From d03a866e0971846e616014112307ee72c06b943f Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 16:19:42 -0700 Subject: [PATCH 1/6] Edit the configuration while services run; restart only on a changed apply Edit Config opens the editor without stopping anything. Apply validates and saves it, then restarts the proxy and services only if the parsed configuration differs from the running one (comment and formatting edits do not count); while stopped the same button is Start All. Save no longer requires stopping. The read-only view and Stop All & Edit Config (with Ctrl+Shift+E) are gone, and the Routes tab lists the running configuration. Co-Authored-By: Claude Opus 5.5 --- README.md | 31 +++++----- ci/unix/e2e.sh | 33 +++++++---- src/desktop.rs | 157 ++++++++++++++++++++++++++++++++----------------- src/manager.rs | 74 +++++++++++++++++------ ui/app.slint | 67 +++++++-------------- 5 files changed, 220 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index 0f48612..51b6ba9 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,10 @@ 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 (**View Config → Stop All & Edit Config**, -then **Validate**, **Save**, or **Start All**), or edit the file by hand while the -application is stopped, check it with `local-dev-proxy --check-config`, and start the -application again. +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 +it with `local-dev-proxy --check-config`, and apply it from the editor (or restart the +application). ## Usage @@ -121,11 +121,13 @@ service's combined stdout/stderr goes to `logs/.log`. 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. **View Config** - shows the running configuration; **Stop All & Edit Config** stops everything and opens - the editor, where **Start All** validates, saves, and launches the edited file. If the - configuration fails to start, the editor opens with the error. Both views highlight - the TOML syntax. + 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) while the proxy and + services keep running. **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. - **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. @@ -140,10 +142,9 @@ absent or disabled: | Ctrl+Up / Ctrl+Down | Select the previous / next service | | Ctrl+Shift+S / Ctrl+Shift+X / Ctrl+Shift+R | Start / Stop / Restart the selected service | | Ctrl+L | Open the selected service's log | -| Ctrl+E | View Config, or back to the service list | -| Ctrl+Shift+E | Stop All & Edit Config | +| Ctrl+E | Edit Config, or back to the service list | | Ctrl+K / Ctrl+S | Validate / Save the configuration being edited | -| Ctrl+Enter | Start All (validate, save, and launch the edited configuration) | +| Ctrl+Enter | Apply (validate, save, and restart if it changed), or Start All while stopped | | Ctrl+R | Reload what the current tab shows: the editor from disk, the log, or the routes | | Ctrl+Q | Quit | @@ -303,9 +304,9 @@ 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, the routes -and the read-only configuration, stop everything to edit, save, validate, start again, -and quit. After each step it checks the proxy's answer over HTTP and the text on screen, +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 +(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 the labwc session's `DISPLAY` in the systemd user environment (or diff --git a/ci/unix/e2e.sh b/ci/unix/e2e.sh index 8b419c7..a680c34 100755 --- a/ci/unix/e2e.sh +++ b/ci/unix/e2e.sh @@ -7,9 +7,9 @@ # # 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, the routes and the -# read-only configuration, stop everything to edit, save, validate, start -# again, and quit. After each step it checks what a user would check: the +# service, stop, start and restart it, open its log and the routes, edit the +# configuration while it keeps running, save, 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 # session, read back with tesseract). tmp/e2e/ keeps the numbered screenshots # and the app's logs, for looking at after a failure. @@ -205,21 +205,32 @@ key ctrl+3 # The target column; tesseract skips the blue URL text. expect_screen routes "localhost:$web_port" key ctrl+1 -key ctrl+e -expect_screen config 'Viewing configuration' -# Stop everything and edit, then start it all again. -key ctrl+shift+e -expect_screen editing 'Editing configuration' -expect_http 'proxy down while editing' web.localhost 000 +# Edit while everything keeps running; applying it unchanged restarts nothing. +key ctrl+e +expect_screen editing 'services keep running' +expect_http 'proxy up while editing' web.localhost 200 key ctrl+s expect_screen saved '\bsaved\b' key ctrl+k # Word-bounded: the banner says "validates". expect_screen valid '\bvalid\b' key ctrl+Return -expect_screen restarted-all 'saved.{1,3}started' -expect_http 'started again' web.localhost 200 +expect_screen unchanged 'no changes' +expect_http 'still up after an unchanged apply' web.localhost 200 +grep -c 'web starting on' "$profile/logs/web.log" | grep -qx 3 || fail 'an unchanged apply restarted the service' + +# A changed configuration (written to disk; the editor loads it) restarts +# everything with it. +new_web_port=$(free_port) +sed -i "s/WEB_PORT = \"$web_port\"/WEB_PORT = \"$new_web_port\"/" "$profile/services.toml" +key ctrl+e +expect_screen edited "$new_web_port" +key ctrl+Return +expect_screen restarted-all 'saved.{1,3}restarted' +expect_http 'restarted with the change' web.localhost 200 +grep -q "web starting on $new_web_port" "$profile/logs/web.log" || fail 'the service did not restart on the new port' +web_port=$new_web_port # Quit takes the service down with it. key ctrl+q diff --git a/src/desktop.rs b/src/desktop.rs index 9d3376d..ad1c6bf 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -16,7 +16,7 @@ use slint::{ }; use crate::frontend::{AppEvent, Frontend}; -use crate::manager::{Manager, ManagerError}; +use crate::manager::{Applied, Manager, ManagerError}; use crate::process::{ServiceSnapshot, ServiceStatus}; use crate::routes::{RouteGroup, ServiceKind}; @@ -187,16 +187,15 @@ impl Controller { fn bind(self: &Rc) { let window = &self.window; window.on_quit(self.handler(|c| c.quit())); - window.on_toggle_view_config(self.handler(|c| { + window.on_toggle_edit(self.handler(|c| { let view = c.state.borrow().view; - c.set_view(if view == ServiceView::Readonly { - ServiceView::Services - } else { - ServiceView::Readonly - }); + if view == ServiceView::Services { + c.set_view(ServiceView::Edit); + } else if c.window.get_running() { + c.set_view(ServiceView::Services); + } })); - window.on_stop_to_edit(self.handler(|c| c.stop_to_edit())); - window.on_start_all(self.handler(|c| c.start_all())); + window.on_apply(self.handler(|c| c.apply())); window.on_validate(self.handler(|c| { c.validate(); })); @@ -425,6 +424,7 @@ impl Controller { changed }; if changed { + self.window.set_running(running); self.set_view(if running { ServiceView::Services } else { @@ -436,19 +436,25 @@ impl Controller { fn set_view(&self, view: ServiceView) { let banner = match view { ServiceView::Services => String::new(), - ServiceView::Readonly => { - self.load_config(); - "Viewing configuration (read-only) — services are still running.".to_owned() - } ServiceView::Edit => { self.window.set_current_tab(SERVICES_TAB); - if self.load_config() { - "Editing configuration — Start All validates, saves, and launches it." - .to_owned() + // Unsaved edits survive a trip back to the service list. + let exists = if self.window.get_dirty() { + self.with_manager(Manager::has_config).unwrap_or(true) } else { + self.load_config() + }; + if !exists { "No services.toml exists yet. Enter a configuration, then Save or 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." + .to_owned() + } else { + "Editing configuration — Start All validates, saves, and launches it." + .to_owned() } } }; @@ -517,29 +523,36 @@ impl Controller { // --- lifecycle -------------------------------------------------------------- - fn stop_to_edit(self: &Rc) { - self.job("stopping…".to_owned(), |manager| { - manager.stop(); - Ok("stopped — editing".to_owned()) - }); - } - - fn start_all(self: &Rc) { + /// Save the editor text, then start it, or restart with it only if it + /// differs from the running configuration. + fn apply(self: &Rc) { if !self.persist() { return; } - self.job("starting…".to_owned(), |manager| { + if self.window.get_running() { + // A failed restart leaves everything stopped, which brings the + // editor back. + self.set_view(ServiceView::Services); + } + self.job("applying…".to_owned(), |manager| { manager - .start() - .map(|_| "saved & started ✓".to_owned()) - .map_err(|err| format!("start failed: {err}")) + .apply() + .map(|applied| { + match applied { + Applied::Started => "saved & started ✓", + Applied::Restarted => "saved & restarted ✓", + Applied::Unchanged => "saved — no changes, nothing restarted", + } + .to_owned() + }) + .map_err(|err| format!("apply failed: {err}")) }); } // --- configuration -------------------------------------------------------------- - /// Load the configuration on disk into both config views; returns - /// whether a file exists. + /// Load the configuration on disk into the editor; returns whether a + /// file exists. fn load_config(&self) -> bool { let Some(result) = self.with_manager(Manager::read_config) else { return false; @@ -556,7 +569,6 @@ impl Controller { } }; self.window.set_editor_text(text.as_str().into()); - self.window.set_config_text(text.as_str().into()); self.window.set_dirty(false); self.state.borrow_mut().loaded_config = text; exists @@ -607,10 +619,6 @@ impl Controller { self.set_banner(&err.to_string(), Level::Error); false } - Err(ManagerError::StillRunning) => { - self.set_status("stop services first", Level::Error); - false - } Err(err) => { self.set_status(&err.to_string(), Level::Error); false @@ -880,7 +888,7 @@ mod tests { /// is the only test in the crate that creates Slint components. #[cfg(unix)] #[test] - fn manager_window_edits_starts_controls_and_stops_services() { + fn manager_window_edits_starts_controls_and_applies_services() { use i_slint_backend_testing::ElementHandle; use slint::Model; @@ -935,12 +943,20 @@ mod tests { .local_addr() .unwrap() .port(); - let config = format!( - "http_port = {port}\nbind = [\"127.0.0.1\"]\n\n\ - [services.app]\ncommand = [\"sh\", \"-c\", \"echo hello from app; exec sleep 30\"]\n\n\ - [[services.app.routes]]\nid = \"app\"\nhosts = [\"app.localhost\"]\ntarget_port = 1\n\n\ - [services.external]\n" - ); + let config_on = |port: u16| { + format!( + "http_port = {port}\nbind = [\"127.0.0.1\"]\n\n\ + [services.app]\ncommand = [\"sh\", \"-c\", \"echo hello from app; exec sleep 30\"]\n\n\ + [[services.app.routes]]\nid = \"app\"\nhosts = [\"app.localhost\"]\ntarget_port = 1\n\n\ + [services.external]\n" + ) + }; + let config = config_on(port); + let new_port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); let controller = Controller::new(Arc::clone(&manager), ManagerWindow::new().unwrap(), None); controller.bind(); @@ -1026,17 +1042,49 @@ mod tests { }) .await; + // Editing leaves the services running. + let pid = window.get_services().row_data(0).unwrap().pid; + window.set_current_tab(LOGS_TAB); window.set_current_tab(SERVICES_TAB); - click(window, "View Config"); - assert_eq!(window.get_view(), ServiceView::Readonly); - assert_eq!(window.get_config_text(), config.as_str()); - - click(window, "Stop All & Edit Config"); - wait_until("stop", || !window.get_busy()).await; - assert_eq!(window.get_status_text(), "stopped — editing"); + click(window, "Edit Config"); assert_eq!(window.get_view(), ServiceView::Edit); - assert_eq!(window.get_current_tab(), SERVICES_TAB); - assert_eq!(window.get_services().row_data(0).unwrap().status, "stopped"); + assert_eq!(window.get_editor_text(), config.as_str()); + assert!(window.get_banner().contains("services keep running")); + assert!(window.get_running()); + + // Unsaved edits survive going back to the list. + type_config(window, &format!("# only a comment\n{config}")); + click(window, "Back to Services"); + assert_eq!(window.get_view(), ServiceView::Services); + click(window, "Edit Config"); + assert!(window.get_dirty()); + assert!(window.get_editor_text().starts_with("# only a comment")); + + // A comment is no change: saved, but nothing restarts. + click(window, "Apply"); + wait_until("apply", || !window.get_busy()).await; + assert_eq!( + window.get_status_text(), + "saved — no changes, nothing restarted" + ); + assert_eq!(window.get_view(), ServiceView::Services); + assert!( + std::fs::read_to_string(&services_file) + .unwrap() + .starts_with("# only a comment") + ); + assert_eq!(window.get_services().row_data(0).unwrap().pid, pid); + + // A real change restarts everything with it. + click(window, "Edit Config"); + type_config(window, &config_on(new_port)); + click(window, "Apply"); + wait_until("apply", || !window.get_busy()).await; + assert_eq!(window.get_status_text(), "saved & restarted ✓"); + assert_eq!(window.get_view(), ServiceView::Services); + assert_ne!(window.get_services().row_data(0).unwrap().pid, pid); + std::net::TcpStream::connect(("127.0.0.1", new_port)).unwrap(); + assert!(std::net::TcpStream::connect(("127.0.0.1", port)).is_err()); click(window, "Quit"); } @@ -1044,8 +1092,11 @@ mod tests { slint::spawn_local(script).unwrap(); slint::run_event_loop_until_quit().unwrap(); - let manager = lock(&manager); - assert!(!manager.is_running()); + // Quitting only ends the event loop; `Desktop::run` then stops + // everything, as here. + let mut manager = lock(&manager); + assert!(manager.is_running()); + manager.stop(); assert!( manager .services() diff --git a/src/manager.rs b/src/manager.rs index c09b2bd..7b8d440 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -24,8 +24,6 @@ pub enum ManagerError { Service(#[from] ServiceError), #[error("services are not running")] NotRunning, - #[error("stop services before changing the configuration")] - StillRunning, #[error("could not write {path}: {source}")] Write { path: String, @@ -41,6 +39,20 @@ pub struct Manager { /// Kept after a stop so the last known service states stay visible. services: Option, proxy: Option, + /// The configuration the proxy and services were started from; `Some` + /// exactly while running. + running: Option, +} + +/// What [`Manager::apply`] did with the configuration on disk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Applied { + /// Nothing was running, so it was started. + Started, + /// It differed from the running configuration, so everything restarted. + Restarted, + /// It matches the running configuration; nothing was touched. + Unchanged, } impl Manager { @@ -55,6 +67,7 @@ impl Manager { log_limits: LogLimits::default(), services: None, proxy: None, + running: None, }) } @@ -107,6 +120,7 @@ impl Manager { )) { Ok(proxy) => { self.proxy = Some(proxy); + self.running = Some(manifest.clone()); Ok(manifest) } Err(err) => { @@ -116,8 +130,24 @@ impl Manager { } } + /// Start the configuration on disk, restarting everything only if it + /// differs from the running one. Comment and formatting changes do not + /// count as differences. + pub fn apply(&mut self) -> Result { + let Some(running) = &self.running else { + self.start()?; + return Ok(Applied::Started); + }; + if *running == load_manifest(&self.paths.services_file())? { + return Ok(Applied::Unchanged); + } + self.start()?; + Ok(Applied::Restarted) + } + /// Stop the proxy and every managed service. pub fn stop(&mut self) { + self.running = None; if let Some(proxy) = self.proxy.take() { self.runtime.block_on(proxy.shutdown()); } @@ -167,9 +197,13 @@ impl Manager { Ok(tail_file(&services.log_path(name)?, lines)) } - /// Route listing for the configuration on disk. + /// Route listing for the running configuration, or the one on disk + /// while stopped. pub fn routes(&self) -> Result, ConfigError> { - Ok(route_listing(&load_manifest(&self.paths.services_file())?)) + match &self.running { + Some(manifest) => Ok(route_listing(manifest)), + None => Ok(route_listing(&load_manifest(&self.paths.services_file())?)), + } } /// The configuration text on disk, or `None` if none exists yet. @@ -189,12 +223,9 @@ impl Manager { Ok(manifest) } - /// Validate and atomically replace the configuration. Only allowed while - /// stopped, so the running state always matches the file it came from. + /// Validate and atomically replace the configuration. The running state + /// is untouched until [`Manager::apply`]. pub fn save_config(&self, text: &str) -> Result<(), ManagerError> { - if self.is_running() { - return Err(ManagerError::StillRunning); - } self.validate_config(text)?; write_atomically(&self.paths.services_file(), text) } @@ -247,7 +278,7 @@ mod tests { } #[test] - fn config_editing_is_only_allowed_while_stopped() { + fn config_edits_while_running_apply_only_when_they_change_something() { let (_dir, mut manager) = manager(); assert_eq!(manager.read_config().unwrap(), None); assert!(matches!( @@ -262,14 +293,23 @@ mod tests { manager.read_config().unwrap().as_deref(), Some(text.as_str()) ); + assert_eq!(manager.apply().unwrap(), Applied::Started); + let pid = manager.services()[0].pid; - manager.start().unwrap(); - assert!(matches!( - manager.save_config(&text), - Err(ManagerError::StillRunning) - )); - manager.stop(); - manager.save_config(&text).unwrap(); + // Saving while running leaves the running services alone. + let commented = format!("# a comment\n{text}"); + manager.save_config(&commented).unwrap(); + assert!(manager.is_running()); + assert_eq!(manager.apply().unwrap(), Applied::Unchanged); + assert_eq!(manager.services()[0].pid, pid); + + let port = free_port(); + manager.save_config(&config(port)).unwrap(); + assert_eq!(manager.services()[0].pid, pid); + assert_eq!(manager.apply().unwrap(), Applied::Restarted); + assert!(manager.is_running()); + assert_ne!(manager.services()[0].pid, pid); + std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); } #[test] diff --git a/ui/app.slint b/ui/app.slint index 0ad7abe..c0f4e7e 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -1,6 +1,6 @@ import { Button, CheckBox, ComboBox, ListView, Palette, ScrollView, SpinBox, TabWidget, TextEdit } from "std-widgets.slint"; -export enum ServiceView { services, readonly, edit } +export enum ServiceView { services, edit } export enum Level { neutral, success, error } @@ -456,6 +456,8 @@ export component ManagerWindow inherits Window { in property mono-font; /// A lifecycle or service action is running in the background. in property busy; + /// The proxy and services are running. + in property running; in-out property current-tab; in property view; @@ -465,7 +467,6 @@ export component ManagerWindow inherits Window { in property status-level; in property <[ServiceRow]> services; in property selected-service: -1; - in property config-text; in-out property editor-text; in property dirty; @@ -479,9 +480,8 @@ export component ManagerWindow inherits Window { in property routes-banner; callback quit(); - callback toggle-view-config(); - callback stop-to-edit(); - callback start-all(); + callback toggle-edit(); + callback apply(); callback validate(); callback save(); callback reload-config(); @@ -518,7 +518,6 @@ export component ManagerWindow inherits Window { private property on-services-tab: current-tab == 0; private property in-service-list: on-services-tab && view == ServiceView.services; private property editing: on-services-tab && view == ServiceView.edit; - private property viewing-config: on-services-tab && view == ServiceView.readonly; function move-selection(delta: int) { if (in-service-list && services.length > 0) { @@ -530,9 +529,9 @@ export component ManagerWindow inherits Window { function refresh-current-tab() { if (editing && !busy) { root.reload-config(); - } else if (current-tab == 1 && view != ServiceView.edit) { + } else if (current-tab == 1 && running) { root.refresh-logs(); - } else if (current-tab == 2 && view != ServiceView.edit) { + } else if (current-tab == 2 && running) { root.reload-routes(); } } @@ -594,17 +593,8 @@ export component ManagerWindow inherits Window { KeyBinding { keys: @keys(Control + "e"); activated => { - if ((root.in-service-list || root.viewing-config) && !root.busy) { - root.toggle-view-config(); - } - } - } - - KeyBinding { - keys: @keys(Control + Shift + "e"); - activated => { - if (root.viewing-config && !root.busy) { - root.stop-to-edit(); + if ((root.in-service-list || (root.editing && root.running)) && !root.busy) { + root.toggle-edit(); } } } @@ -613,7 +603,7 @@ export component ManagerWindow inherits Window { keys: @keys(Control + Return); activated => { if (root.editing && !root.busy) { - root.start-all(); + root.apply(); } } } @@ -731,26 +721,19 @@ export component ManagerWindow inherits Window { HorizontalLayout { spacing: 8px; - if root.view != ServiceView.edit: Button { - text: root.view == ServiceView.readonly ? "Back to Services" : "View Config"; - enabled: !root.busy; - clicked => { - root.toggle-view-config(); - } - } - if root.view == ServiceView.readonly: Button { - text: "Stop All & Edit Config"; + if root.view == ServiceView.services || root.running: Button { + text: root.view == ServiceView.edit ? "Back to Services" : "Edit Config"; enabled: !root.busy; clicked => { - root.stop-to-edit(); + root.toggle-edit(); } } if root.view == ServiceView.edit: Button { - text: "Start All"; + text: root.running ? "Apply" : "Start All"; primary: true; enabled: !root.busy; clicked => { - root.start-all(); + root.apply(); } } Rectangle { } @@ -820,14 +803,6 @@ export component ManagerWindow inherits Window { } } - if root.view == ServiceView.readonly: TomlEdit { - accessible-label: "Configuration"; - vertical-stretch: 1; - read-only: true; - font-family: root.mono-font; - text: root.config-text; - } - if root.view == ServiceView.edit: VerticalLayout { spacing: 8px; TomlEdit { @@ -881,14 +856,14 @@ export component ManagerWindow inherits Window { Tab { title: "Logs"; - if root.view == ServiceView.edit: Text { - text: "Unavailable while editing the configuration."; + if !root.running: Text { + text: "Unavailable while services are stopped."; color: Colors.muted; horizontal-alignment: center; vertical-alignment: center; } - if root.view != ServiceView.edit: VerticalLayout { + if root.running: VerticalLayout { padding: 12px; spacing: 8px; HorizontalLayout { @@ -954,14 +929,14 @@ export component ManagerWindow inherits Window { Tab { title: "Routes"; - if root.view == ServiceView.edit: Text { - text: "Unavailable while editing the configuration."; + if !root.running: Text { + text: "Unavailable while services are stopped."; color: Colors.muted; horizontal-alignment: center; vertical-alignment: center; } - if root.view != ServiceView.edit: VerticalLayout { + if root.running: VerticalLayout { padding: 12px; spacing: 8px; Text { From 364f98065cccfd3d736b746bc9420ca2265a92a0 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 16:24:01 -0700 Subject: [PATCH 2/6] Replace Reload from disk and Back to Services with Cancel Cancel (Escape) discards unsaved edits and returns to the service list; it is shown only while running, since the editor is the only view while stopped. Ctrl+E now only opens the editor, and Ctrl+R no longer reloads it. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +++++--- ci/unix/e2e.sh | 9 +++++++-- src/desktop.rs | 44 ++++++++++++++++++-------------------------- ui/app.slint | 34 +++++++++++++++++++++------------- 4 files changed, 51 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 51b6ba9..9ba6ad8 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,8 @@ 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) while the proxy and - services keep running. **Save** only writes the file; **Apply** validates and saves it, + services keep running; **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 @@ -142,10 +143,11 @@ absent or disabled: | Ctrl+Up / Ctrl+Down | Select the previous / next service | | Ctrl+Shift+S / Ctrl+Shift+X / Ctrl+Shift+R | Start / Stop / Restart the selected service | | Ctrl+L | Open the selected service's log | -| Ctrl+E | Edit Config, or back to the service list | +| Ctrl+E | Edit Config | +| Escape | Cancel editing: discard unsaved edits and go back to the service list | | Ctrl+K / Ctrl+S | Validate / Save 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 editor from disk, the log, or the routes | +| Ctrl+R | Reload what the current tab shows: the log or the routes | | Ctrl+Q | Quit | Only one instance runs per profile. Launching it again while it is running brings the diff --git a/ci/unix/e2e.sh b/ci/unix/e2e.sh index a680c34..73ba1dc 100755 --- a/ci/unix/e2e.sh +++ b/ci/unix/e2e.sh @@ -8,8 +8,9 @@ # 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, save, validate, apply it unchanged -# (nothing restarts), apply a change (everything restarts), and quit. After each step it checks what a user would check: the +# configuration while it keeps running, cancel, edit again, save, 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 # session, read back with tesseract). tmp/e2e/ keeps the numbered screenshots # and the app's logs, for looking at after a failure. @@ -210,6 +211,10 @@ key ctrl+1 key ctrl+e expect_screen editing 'services keep running' expect_http 'proxy up while editing' web.localhost 200 +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 diff --git a/src/desktop.rs b/src/desktop.rs index ad1c6bf..5ea23a7 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -187,14 +187,8 @@ impl Controller { fn bind(self: &Rc) { let window = &self.window; window.on_quit(self.handler(|c| c.quit())); - window.on_toggle_edit(self.handler(|c| { - let view = c.state.borrow().view; - if view == ServiceView::Services { - c.set_view(ServiceView::Edit); - } else if c.window.get_running() { - c.set_view(ServiceView::Services); - } - })); + window.on_edit_config(self.handler(|c| c.set_view(ServiceView::Edit))); + window.on_cancel_edit(self.handler(|c| c.cancel_edit())); window.on_apply(self.handler(|c| c.apply())); window.on_validate(self.handler(|c| { c.validate(); @@ -204,7 +198,6 @@ impl Controller { c.set_status("saved ✓", Level::Success); } })); - window.on_reload_config(self.handler(|c| c.reload_config())); 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))); @@ -438,13 +431,7 @@ impl Controller { ServiceView::Services => String::new(), ServiceView::Edit => { self.window.set_current_tab(SERVICES_TAB); - // Unsaved edits survive a trip back to the service list. - let exists = if self.window.get_dirty() { - self.with_manager(Manager::has_config).unwrap_or(true) - } else { - self.load_config() - }; - if !exists { + if !self.load_config() { "No services.toml exists yet. Enter a configuration, then Save or Start All \ to create it." .to_owned() @@ -574,12 +561,13 @@ impl Controller { exists } - fn reload_config(&self) { - let exists = self.load_config(); - self.restore_mode_banner(); - if exists { - self.set_status("reloaded from disk", Level::Neutral); + /// Discard unsaved edits and go back to the service list. + fn cancel_edit(&self) { + if self.window.get_dirty() { + self.set_status("edits discarded", Level::Neutral); } + self.load_config(); + self.set_view(ServiceView::Services); } fn validate(&self) -> bool { @@ -1052,13 +1040,17 @@ mod tests { assert!(window.get_banner().contains("services keep running")); assert!(window.get_running()); - // Unsaved edits survive going back to the list. - type_config(window, &format!("# only a comment\n{config}")); - click(window, "Back to Services"); + // Cancel discards unsaved edits and goes back to the list. + type_config(window, "# discarded\n"); + assert!(window.get_dirty()); + click(window, "Cancel"); assert_eq!(window.get_view(), ServiceView::Services); + assert_eq!(window.get_status_text(), "edits discarded"); + assert_eq!(std::fs::read_to_string(&services_file).unwrap(), config); click(window, "Edit Config"); - assert!(window.get_dirty()); - assert!(window.get_editor_text().starts_with("# only a comment")); + assert!(!window.get_dirty()); + assert_eq!(window.get_editor_text(), config.as_str()); + type_config(window, &format!("# only a comment\n{config}")); // A comment is no change: saved, but nothing restarts. click(window, "Apply"); diff --git a/ui/app.slint b/ui/app.slint index c0f4e7e..24870b5 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -480,11 +480,11 @@ export component ManagerWindow inherits Window { in property routes-banner; callback quit(); - callback toggle-edit(); + callback edit-config(); + callback cancel-edit(); callback apply(); callback validate(); callback save(); - callback reload-config(); callback editor-edited(string); callback service-selected(int); callback start-service(); @@ -527,9 +527,7 @@ export component ManagerWindow inherits Window { // Reload whatever the current tab shows. function refresh-current-tab() { - if (editing && !busy) { - root.reload-config(); - } else if (current-tab == 1 && running) { + if (current-tab == 1 && running) { root.refresh-logs(); } else if (current-tab == 2 && running) { root.reload-routes(); @@ -593,8 +591,17 @@ export component ManagerWindow inherits Window { KeyBinding { keys: @keys(Control + "e"); activated => { - if ((root.in-service-list || (root.editing && root.running)) && !root.busy) { - root.toggle-edit(); + if (root.in-service-list && !root.busy) { + root.edit-config(); + } + } + } + + KeyBinding { + keys: @keys(Escape); + activated => { + if (root.editing && root.running && !root.busy) { + root.cancel-edit(); } } } @@ -721,11 +728,11 @@ export component ManagerWindow inherits Window { HorizontalLayout { spacing: 8px; - if root.view == ServiceView.services || root.running: Button { - text: root.view == ServiceView.edit ? "Back to Services" : "Edit Config"; + if root.view == ServiceView.services: Button { + text: "Edit Config"; enabled: !root.busy; clicked => { - root.toggle-edit(); + root.edit-config(); } } if root.view == ServiceView.edit: Button { @@ -834,11 +841,12 @@ export component ManagerWindow inherits Window { } } - Button { - text: "Reload from disk"; + // Nothing to go back to while stopped. + if root.running: Button { + text: "Cancel"; enabled: !root.busy; clicked => { - root.reload-config(); + root.cancel-edit(); } } From 84b0b13bdf79f94ed7c394a508615bfde40a3e0b Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 16:32:13 -0700 Subject: [PATCH 3/6] Show Cancel whether or not services are running Cancel (Escape) always discards unsaved edits and returns to the service list, independent of the run state. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++++++++--------- src/desktop.rs | 8 ++++++++ ui/app.slint | 5 ++--- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9ba6ad8..9d4202e 100644 --- a/README.md +++ b/README.md @@ -121,14 +121,13 @@ service's combined stdout/stderr goes to `logs/.log`. 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) while the proxy and - services keep running; **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. + 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. - **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. @@ -144,7 +143,7 @@ absent or disabled: | Ctrl+Shift+S / Ctrl+Shift+X / Ctrl+Shift+R | Start / Stop / Restart the selected service | | Ctrl+L | Open the selected service's log | | Ctrl+E | Edit Config | -| Escape | Cancel editing: discard unsaved edits and go back to the service list | +| Escape | Cancel: discard unsaved edits and go back to the service list | | Ctrl+K / Ctrl+S | Validate / Save 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 | diff --git a/src/desktop.rs b/src/desktop.rs index 5ea23a7..43838fc 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -963,6 +963,14 @@ mod tests { .starts_with("No services.toml exists yet") ); + // Cancel goes back to the service list while stopped, too. + type_config(window, "# discarded\n"); + click(window, "Cancel"); + assert_eq!(window.get_view(), ServiceView::Services); + click(window, "Edit Config"); + assert_eq!(window.get_view(), ServiceView::Edit); + assert_eq!(window.get_editor_text(), ""); + type_config(window, "http_port = \"x\"\nbind = [\"127.0.0.1\"]"); assert!(window.get_dirty()); click(window, "Validate"); diff --git a/ui/app.slint b/ui/app.slint index 24870b5..06a8bcf 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -600,7 +600,7 @@ export component ManagerWindow inherits Window { KeyBinding { keys: @keys(Escape); activated => { - if (root.editing && root.running && !root.busy) { + if (root.editing && !root.busy) { root.cancel-edit(); } } @@ -841,8 +841,7 @@ export component ManagerWindow inherits Window { } } - // Nothing to go back to while stopped. - if root.running: Button { + Button { text: "Cancel"; enabled: !root.busy; clicked => { From c2d8af75de1b92186e911ed5633247499f9c59fa Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 16:32:22 -0700 Subject: [PATCH 4/6] Rewrap the Services paragraph in the README Co-Authored-By: Claude Opus 5.5 --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9d4202e..647b0dc 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,14 @@ service's combined stdout/stderr goes to `logs/.log`. 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. + 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. - **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. From 92a5bc0d9e2a843c78716c5fa6230f2bac8ec7a6 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 16:52:52 -0700 Subject: [PATCH 5/6] Pick a second test port that differs from the first Dropping the first listener lets the OS hand the same port out again; the changed configuration would then match the running one and not restart. Co-Authored-By: Claude Opus 5.5 --- src/desktop.rs | 16 +++++++++++----- src/manager.rs | 11 +++++++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/desktop.rs b/src/desktop.rs index 43838fc..888d92c 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -940,11 +940,17 @@ mod tests { ) }; let config = config_on(port); - let new_port = std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port(); + // The changed configuration must differ, so the port must too. + let new_port = loop { + let candidate = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + if candidate != port { + break candidate; + } + }; let controller = Controller::new(Arc::clone(&manager), ManagerWindow::new().unwrap(), None); controller.bind(); diff --git a/src/manager.rs b/src/manager.rs index 7b8d440..ce0fe10 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -287,7 +287,8 @@ mod tests { )); assert!(!manager.has_config()); - let text = config(free_port()); + let first_port = free_port(); + let text = config(first_port); manager.save_config(&text).unwrap(); assert_eq!( manager.read_config().unwrap().as_deref(), @@ -303,7 +304,13 @@ mod tests { assert_eq!(manager.apply().unwrap(), Applied::Unchanged); assert_eq!(manager.services()[0].pid, pid); - let port = free_port(); + // The changed configuration must differ, so the port must too. + let port = loop { + let candidate = free_port(); + if candidate != first_port { + break candidate; + } + }; manager.save_config(&config(port)).unwrap(); assert_eq!(manager.services()[0].pid, pid); assert_eq!(manager.apply().unwrap(), Applied::Restarted); From 08e59c57404e8c0e4bb4b1783ccb83e72c8b2d50 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Thu, 24 Sep 2026 16:56:22 -0700 Subject: [PATCH 6/6] Bump version to 0.0.17 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 7e05da6..773b4e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2710,7 +2710,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "local-dev-proxy" -version = "0.0.16" +version = "0.0.17" dependencies = [ "bytes", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 1559041..b6c5f22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "local-dev-proxy" -version = "0.0.16" +version = "0.0.17" edition = "2024" description = "Local development process orchestration with a built-in reverse proxy" authors = ["Andrew Chen "]