From 0715abcb7b6531af73be9e5dc0b037d5eaaf7848 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 19:25:12 -0700 Subject: [PATCH 1/5] Drop the editor's Save button and color Validate apart Saving without applying left services.toml out of step with what was running, so the only way to write the configuration is now Apply (or Start All while stopped); the Save button and its Ctrl+S shortcut are gone. Validate becomes a filled teal button, set apart from Apply's accent color. Co-Authored-By: Claude Opus 5.5 --- README.md | 22 ++++++++--------- ci/unix/e2e.sh | 4 +--- src/desktop.rs | 11 +++------ ui/app.slint | 64 +++++++++++++++++++++++++++++++++++--------------- 4 files changed, 60 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 667e604..ed79875 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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). @@ -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. @@ -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 | @@ -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 diff --git a/ci/unix/e2e.sh b/ci/unix/e2e.sh index 73ba1dc..2e450f1 100755 --- a/ci/unix/e2e.sh +++ b/ci/unix/e2e.sh @@ -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 @@ -215,8 +215,6 @@ 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' diff --git a/src/desktop.rs b/src/desktop.rs index 5a4e1e3..7c3b44b 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -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))); @@ -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." diff --git a/ui/app.slint b/ui/app.slint index 06a8bcf..5df5abc 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -54,6 +54,8 @@ global Colors { out property muted: Palette.color-scheme == ColorScheme.dark ? #85888e : #98a2b3; out property link: Palette.color-scheme == ColorScheme.dark ? #84adff : #175cd3; out property warning: Palette.color-scheme == ColorScheme.dark ? #fdb022 : #b54708; + out property check: Palette.color-scheme == ColorScheme.dark ? #2ed3b7 : #107569; + out property on-check: Palette.color-scheme == ColorScheme.dark ? #0c111d : #ffffff; public pure function level(level: Level) -> brush { if level == Level.success { @@ -102,6 +104,46 @@ global Colors { } } +/// A filled button in its own color, set apart from the accent-colored +/// primary button. +component TintedButton inherits Rectangle { + in property text; + in property enabled: true; + in property tint; + in property 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(); + } + } +} + component Banner inherits Rectangle { in property text; in property level; @@ -484,7 +526,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(); @@ -624,15 +665,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 => { @@ -825,22 +857,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; From f48b9d6b7360914ee037cfccf4eae15ea7153b26 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 19:41:24 -0700 Subject: [PATCH 2/5] Make the Validate button keyboard-focusable Tab reaches it, Enter or Space activates it, and a focus ring shows when it has focus, like the std buttons beside it. Modified keys are left to bubble so window shortcuts such as Ctrl+Return still work. Co-Authored-By: Claude Opus 5.5 --- ui/app.slint | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ui/app.slint b/ui/app.slint index 5df5abc..6432cd6 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -142,6 +142,30 @@ component TintedButton inherits Rectangle { 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 { From 45f931f4e58b6dbe77d8e08aec463d8d6fffb0d9 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 20:05:54 -0700 Subject: [PATCH 3/5] Keep shortcuts working after leaving the editor Switching between the service list and the editor removes whichever control had focus, and with nothing focused no shortcut fired until the window was clicked. Hand focus back to the shortcut scope on every view change. The e2e now applies from a focused Validate button, so the Ctrl+E that follows covers it. Co-Authored-By: Claude Opus 5.5 --- ci/unix/e2e.sh | 3 +++ ui/app.slint | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/ci/unix/e2e.sh b/ci/unix/e2e.sh index 2e450f1..870b3fb 100755 --- a/ci/unix/e2e.sh +++ b/ci/unix/e2e.sh @@ -218,6 +218,9 @@ expect_screen editing-again 'services keep running' 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 diff --git a/ui/app.slint b/ui/app.slint index 6432cd6..e2eab41 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -565,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 has-selection: selected-service >= 0 && selected-service < services.length; private property selection: services[selected-service]; private property can-control: has-selection && selection.controllable && !busy; From c7c50013b6fd513e741556a6ac9509d3b5140573 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 19:17:51 -0700 Subject: [PATCH 4/5] Move GitHub actions off the deprecated Node.js 20 runtime actions/checkout v4 -> v7, actions/upload-artifact v4 -> v7 and actions/download-artifact v4 -> v8 all run on Node.js 24. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 16 ++++++++-------- .github/workflows/test.yml | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05c4f1b..945cfcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8364431..88f0b7a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 }} @@ -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 }} @@ -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: @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53293ef..a617a5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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: From 15c22ef1320b2ab4cd72a3705c2f153bc22966af Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 20:35:05 -0700 Subject: [PATCH 5/5] Bump version to 0.0.19 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 150f362..464d346 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2710,7 +2710,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "local-dev-proxy" -version = "0.0.18" +version = "0.0.19" dependencies = [ "block2 0.6.2", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 4afffef..b58a3c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 "]