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: 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 "] 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..870b3fb 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,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 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..e2eab41 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,70 @@ 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(); + } + } + + 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 text; in property level; @@ -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(); @@ -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 has-selection: selected-service >= 0 && selected-service < services.length; private property selection: services[selected-service]; private property can-control: has-selection && selection.controllable && !busy; @@ -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 => { @@ -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;