Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
is_prerelease: ${{ steps.tag.outputs.is_prerelease }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Extract version from Cargo.toml
id: version
Expand Down Expand Up @@ -103,7 +103,7 @@ jobs:
suffix: linux-arm64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
Expand All @@ -123,7 +123,7 @@ jobs:
mkdir -p dist
mv target/${{ matrix.target }}/release/local-dev-proxy dist/local-dev-proxy-${{ matrix.suffix }}

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.suffix }}
path: dist/local-dev-proxy-${{ matrix.suffix }}
Expand All @@ -133,7 +133,7 @@ jobs:
needs: setup
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
Expand All @@ -160,7 +160,7 @@ jobs:
hdiutil create -volname "Local Dev Proxy" -srcfolder "$staging" -ov -format UDZO \
"dist/local-dev-proxy-macos-arm64.dmg"

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: release-macos-arm64
path: dist/local-dev-proxy-macos-arm64.dmg
Expand All @@ -172,7 +172,7 @@ jobs:
if: needs.setup.outputs.is_prerelease == 'false'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
Expand All @@ -186,7 +186,7 @@ jobs:
New-Item -ItemType Directory -Force dist | Out-Null
Move-Item target\x86_64-pc-windows-msvc\release\local-dev-proxy.exe dist\local-dev-proxy-windows-amd64.exe

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: release-windows-amd64
path: dist/local-dev-proxy-windows-amd64.exe
Expand All @@ -198,7 +198,7 @@ jobs:
if: always() && needs.setup.result == 'success' && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' && (needs.build-windows.result == 'success' || needs.build-windows.result == 'skipped')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
pattern: release-*
path: artifacts
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "local-dev-proxy"
version = "0.0.18"
version = "0.0.19"
edition = "2024"
description = "Local development process orchestration with a built-in reverse proxy"
authors = ["Andrew Chen <hello@andrewtheguy.com>"]
Expand Down
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ directory:
| Linux | `$XDG_CONFIG_HOME/andrewtheguy/local-dev-proxy/` (or `~/.config/...`) |

The application does not seed `services.toml`. Without one, the manager window opens in
the configuration editor; save a configuration there (or run Start All) to create the
the configuration editor; enter a configuration there and run Start All to create the
file. `--headless` instead exits with an error that names the expected path.
`local-dev-proxy --sample-config` prints a reference configuration; it is never written
into the profile and is not intended to run unchanged.
Expand All @@ -98,8 +98,8 @@ the active file (`.log.1` is newest). This bounds each log to about 60 MiB. Rota
renames completed files rather than truncating a file while it is being written; once
the retention limit is reached, only the oldest backup is removed.

Edit the configuration in the manager window (**Edit Config**, then **Validate**,
**Save**, or **Apply**) while everything keeps running, or edit the file by hand, check
Edit the configuration in the manager window (**Edit Config**, then **Validate** or
**Apply**) while everything keeps running, or edit the file by hand, check
it with `local-dev-proxy --check-config`, and apply it from the editor (or restart the
application).

Expand All @@ -123,12 +123,12 @@ The manager window has three tabs:
- **Services** — each service's status, PID, restart count, and last exit code. Select a
row to start, stop, or restart it; double-click a row to open its log. **Edit Config**
opens the configuration editor (with TOML syntax highlighting) without stopping
anything; **Cancel** discards unsaved edits and goes back to the service list. **Save**
only writes the file; **Apply** validates and saves it, then restarts the proxy and
every service only if the configuration differs from the running one (comment and
formatting changes do not count). While stopped the button is **Start All** instead. If
the configuration fails to start, everything stays stopped and the editor opens with
the error.
anything; **Cancel** discards unsaved edits and goes back to the service list.
**Validate** only checks the edits; nothing is written until **Apply**, which
validates and saves them, then restarts the proxy and every service only if the
configuration differs from the running one (comment and formatting changes do not
count). While stopped the button is **Start All** instead. If the configuration fails
to start, everything stays stopped and the editor opens with the error.
- **Logs** — the tail of a service's log, with a line count and **Follow** to keep it
updating.
- **Routes** — every service's hosts and targets. Click a URL to open it in the browser.
Expand All @@ -145,7 +145,7 @@ absent or disabled:
| Ctrl+L | Open the selected service's log |
| Ctrl+E | Edit Config |
| Escape | Cancel: discard unsaved edits and go back to the service list |
| Ctrl+K / Ctrl+S | Validate / Save the configuration being edited |
| Ctrl+K | Validate the configuration being edited |
| Ctrl+Enter | Apply (validate, save, and restart if it changed), or Start All while stopped |
| Ctrl+R | Reload what the current tab shows: the log or the routes |
| Ctrl+Q | Quit |
Expand Down Expand Up @@ -308,7 +308,7 @@ disagree, the workflow is right and the script is stale.
`ci/unix/e2e.sh` launches the real binary against a throwaway profile, on the headless
labwc session of the development host, and drives the manager window through its
keyboard shortcuts: select, stop, start and restart a service, open its log and the
routes, edit the configuration while it keeps running, save, validate, apply it unchanged
routes, edit the configuration while it keeps running, validate, apply it unchanged
(nothing restarts) and changed (everything restarts), and quit. After each step it checks the proxy's answer over HTTP and the text on screen,
read from a screenshot with tesseract. `ci/unix/ci.sh` runs it on a Linux host where the
session is up and skips it elsewhere; `ci/unix/e2e.sh --available` says which. It needs
Expand Down
7 changes: 4 additions & 3 deletions ci/unix/e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# The scenario launches the manager against a throwaway profile whose one
# service is a busybox httpd, then walks every keyboard shortcut: select the
# service, stop, start and restart it, open its log and the routes, edit the
# configuration while it keeps running, cancel, edit again, save, validate,
# configuration while it keeps running, cancel, edit again, validate,
# apply it unchanged (nothing restarts), apply a change (everything
# restarts), and quit. After each step it checks what a user would check: the
# proxy's answer over HTTP, and the text on screen (a screenshot of the
Expand Down Expand Up @@ -215,11 +215,12 @@ key Escape
expect_screen cancelled 'Edit Config'
key ctrl+e
expect_screen editing-again 'services keep running'
key ctrl+s
expect_screen saved '\bsaved\b'
key ctrl+k
# Word-bounded: the banner says "validates".
expect_screen valid '\bvalid\b'
# Apply from a focused control (Tab lands on Validate): leaving the editor
# removes it, and the shortcuts must keep working afterwards.
for _ in 1 2 3 4 5; do key Tab; done
key ctrl+Return
expect_screen unchanged 'no changes'
expect_http 'still up after an unchanged apply' web.localhost 200
Expand Down
11 changes: 3 additions & 8 deletions src/desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,6 @@ impl Controller {
window.on_validate(self.handler(|c| {
c.validate();
}));
window.on_save(self.handler(|c| {
if c.persist() {
c.set_status("saved ✓", Level::Success);
}
}));
window.on_start_service(self.handler(|c| c.service_action(ServiceAction::Start)));
window.on_stop_service(self.handler(|c| c.service_action(ServiceAction::Stop)));
window.on_restart_service(self.handler(|c| c.service_action(ServiceAction::Restart)));
Expand Down Expand Up @@ -443,9 +438,9 @@ impl Controller {
ServiceView::Edit => {
self.window.set_current_tab(SERVICES_TAB);
if !self.load_config() {
"No services.toml exists yet. Enter a configuration, then Save or Start All \
to create it."
.to_owned()
"No services.toml exists yet. Enter a configuration, then Start All to create \
it."
.to_owned()
} else if self.window.get_running() {
"Editing configuration — services keep running. Apply validates and saves \
it, and restarts everything only if the configuration changed."
Expand Down
94 changes: 75 additions & 19 deletions ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ global Colors {
out property <color> muted: Palette.color-scheme == ColorScheme.dark ? #85888e : #98a2b3;
out property <color> link: Palette.color-scheme == ColorScheme.dark ? #84adff : #175cd3;
out property <color> warning: Palette.color-scheme == ColorScheme.dark ? #fdb022 : #b54708;
out property <color> check: Palette.color-scheme == ColorScheme.dark ? #2ed3b7 : #107569;
out property <color> on-check: Palette.color-scheme == ColorScheme.dark ? #0c111d : #ffffff;

public pure function level(level: Level) -> brush {
if level == Level.success {
Expand Down Expand Up @@ -102,6 +104,70 @@ global Colors {
}
}

/// A filled button in its own color, set apart from the accent-colored
/// primary button.
component TintedButton inherits Rectangle {
in property <string> text;
in property <bool> enabled: true;
in property <color> tint;
in property <color> on-tint;
callback clicked();

accessible-role: button;
accessible-label: root.text;
accessible-enabled: root.enabled;
accessible-action-default => {
if (root.enabled) {
root.clicked();
}
}

min-height: 32px;
min-width: label.preferred-width + 24px;
horizontal-stretch: 0;
border-radius: 4px;
background: !root.enabled ? root.tint.transparentize(60%) : touch.pressed ? root.tint.darker(20%) : touch.has-hover ? root.tint.darker(10%) : root.tint;

label := Text {
text: root.text;
color: root.on-tint;
horizontal-alignment: center;
vertical-alignment: center;
}

touch := TouchArea {
enabled: root.enabled;
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}

forward-focus: focus;
focus := FocusScope {
enabled: root.enabled;
// Unmodified only, so window shortcuts such as Ctrl+Return bubble up.
key-pressed(event) => {
if (!event.modifiers.control && !event.modifiers.meta && !event.modifiers.alt && (event.text == Key.Return || event.text == Key.Space)) {
root.clicked();
return accept;
}
reject
}
}

// Focus ring, outside the fill so it shows against any tint.
if focus.has-focus: Rectangle {
x: -3px;
y: -3px;
width: root.width + 6px;
height: root.height + 6px;
border-radius: root.border-radius + 3px;
border-width: 2px;
border-color: Palette.accent-background;
}
}

component Banner inherits Rectangle {
in property <string> text;
in property <Level> level;
Expand Down Expand Up @@ -484,7 +550,6 @@ export component ManagerWindow inherits Window {
callback cancel-edit();
callback apply();
callback validate();
callback save();
callback editor-edited(string);
callback service-selected(int);
callback start-service();
Expand All @@ -500,6 +565,12 @@ export component ManagerWindow inherits Window {
root.tab-changed(root.current-tab);
}

// Switching views removes the focused control with the old view, and
// with nothing focused no shortcut fires; hand focus back to them.
changed view => {
shortcuts.focus();
}

private property <bool> has-selection: selected-service >= 0 && selected-service < services.length;
private property <ServiceRow> selection: services[selected-service];
private property <bool> can-control: has-selection && selection.controllable && !busy;
Expand Down Expand Up @@ -624,15 +695,6 @@ export component ManagerWindow inherits Window {
}
}

KeyBinding {
keys: @keys(Control + "s");
activated => {
if (root.editing && !root.busy) {
root.save();
}
}
}

KeyBinding {
keys: @keys(Control + "r");
activated => {
Expand Down Expand Up @@ -825,22 +887,16 @@ export component ManagerWindow inherits Window {

HorizontalLayout {
spacing: 8px;
Button {
TintedButton {
text: "Validate";
tint: Colors.check;
on-tint: Colors.on-check;
enabled: !root.busy;
clicked => {
root.validate();
}
}

Button {
text: "Save";
enabled: !root.busy;
clicked => {
root.save();
}
}

Button {
text: "Cancel";
enabled: !root.busy;
Expand Down
Loading