diff --git a/docs/architecture.md b/docs/architecture.md index b687ef9..76944af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,12 +24,14 @@ One editor crate; the workspace also includes its patched Crossterm dependency. - `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Focused diff text supports native LSP navigation, hover, and diagnostics; read-only buffers still reject asynchronous editing effects, and selectors/file lists are not document-action targets. - `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. -- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. +- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. On restart, it restores the terminal and drops the entire `EditorSession`, including workers and plugin hosts, before relaunching the same executable with the original CLI arguments. Unix replaces the process with `exec`; other platforms spawn a fresh process and return. Startup rereads configuration and the original startup file, or creates an unnamed buffer; current buffers, tabs, views, and session state are not restored. ## Frontend interface Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()` even without input, and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `poll()` reports whether background activity requires redrawing. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point. +`Editor::should_quit()` tells the frontend to exit its input/render loop for either quit or restart; `Editor::should_restart()` distinguishes a requested executable relaunch from an ordinary exit. Normal-mode `core.restart` / `:restart` first closes clean buffers across all tabs, panes, and historical views. Remaining dirty buffers retain unsaved text and undo/redo history, block the exit/relaunch request with a warning, and leave the editor interactive; already closed clean buffers remain closed. `core.force_restart` / `:force_restart` (alias `:restart!`) requests relaunch without saving, discarding unsaved text. Both commands take no arguments and have no default binding. Force restart uses palette confirmation, while configured action bindings execute directly. + Session polling schedules synchronous `Editor::refresh_files()` metadata scans every 250 ms; the editor method itself is unthrottled and reports whether a redraw is needed. Scans include active and parked live buffers across inactive tabs/panes, but never immutable historical snapshots, and read content only after metadata changes. Valid changed disk text replaces only clean buffers, updates the saved baseline and text revision, clears stale undo/redo, preserves/clamps every associated view, invalidates annotations and stale requests, and refreshes live comparisons. Dirty buffers keep edits; missing, unreadable, or invalid changed files preserve text and report a warning. Every save independently re-reads disk content and compares its LF-normalized text with the saved baseline, rather than trusting polling or modification timestamps. Normal writes refuse external changes/deletion; newly opened nonexistent paths can be created normally. Typed `core.force_save` / `core.force_save_and_quit` expose canonical `:force_save [path]` / `:force_save_and_quit [path]` and aliases `:w!` / `:wq!`. Force bypasses only the external-change check, retaining snapshot read-only, target-symlink, and another-buffer path-ownership protections. Both save-and-quit variants quit only after a successful save and only when all buffers are clean. diff --git a/docs/configuration.md b/docs/configuration.md index 8571a0a..c3e870f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -148,7 +148,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con | Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` | | Normal | `join_lines`, `undo`, `redo`, `command` | | Normal | `delete_line`, `change_line` | -| Normal | `save`, `force_save`, `quit`, `force_quit`, `save_and_quit`, `force_save_and_quit`, `plugins` | +| Normal | `save`, `force_save`, `quit`, `force_quit`, `restart`, `force_restart`, `save_and_quit`, `force_save_and_quit`, `plugins` | | Normal | `edit`, `buffers`, `buffer`, `buffer_next`, `buffer_previous`, `buffer_delete`, `buffer_force_delete` | | Normal | `follow_link` | | Normal | `diff` | @@ -186,6 +186,10 @@ Live clean buffers reload external changes automatically, including inactive tab `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, and `core.tab.force_close` keys act immediately, without confirmation. +`core.restart` and `core.force_restart` have no default keybindings. Their no-argument commands are `:restart` and `:force_restart`, with `:restart!` as an alias for force restart. Normal restart closes clean buffers across every tab, pane, and historical view; any dirty buffer blocks the relaunch with a warning, retains its text and undo/redo history, and leaves the editor interactive without restoring the clean buffers already closed. Force restart discards unsaved text without writing it. The palette confirms force restart, while a configured `config.core.force_restart` binding executes directly. + +Restart relaunches the same executable with the original CLI arguments after terminal restoration and full session teardown. It rereads configuration and the original startup file (or opens an unnamed buffer when no file was supplied), rather than restoring current buffers, tabs, views, or session state. + ## Binding overrides and failures Adding a binding keeps the original key. Pass `nil` to disable a key, including literal typing in Insert/Command mode: diff --git a/docs/editing.md b/docs/editing.md index 2621924..86978dc 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -154,7 +154,7 @@ The latest reported operation result appears in a non-focusable popup at the wor Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands. -Commands: `:save [path]`, `:force_save [path]`, `:quit`, `:force_quit`, `:save_and_quit [path]`, `:force_save_and_quit [path]`, `:plugins`, and `:plugin-command [arguments]`. Short names remain aliases of these canonical commands, with identical argument and safety rules: +Commands: `:save [path]`, `:force_save [path]`, `:quit`, `:force_quit`, `:restart`, `:force_restart`, `:save_and_quit [path]`, `:force_save_and_quit [path]`, `:plugins`, and `:plugin-command [arguments]`. Short names remain aliases of these canonical commands, with identical argument and safety rules: | Alias | Canonical command | | --- | --- | @@ -162,6 +162,7 @@ Commands: `:save [path]`, `:force_save [path]`, `:quit`, `:force_quit`, `:save_a | `:w!` | `:force_save` | | `:q` | `:quit` | | `:q!` | `:force_quit` | +| `:restart!` | `:force_restart` | | `:wq` | `:save_and_quit` | | `:wq!` | `:force_save_and_quit` | | `:bnext` | `:buffer_next` | @@ -171,6 +172,10 @@ Commands: `:save [path]`, `:force_save [path]`, `:quit`, `:force_quit`, `:save_a `:q` refuses unsaved changes in any open buffer; `:q!` exits without saving them. `:wq` saves the active buffer and quits only if every buffer is then clean; `:wq!` applies the same quit rule after a force save. The typed actions are `core.save`, `core.force_save`, `core.save_and_quit`, and `core.force_save_and_quit`. Normal saves re-read actual disk content and compare it with the saved baseline after CRLF/CR-to-LF normalization, independently of polling or timestamps. External changes or deletion refuse the write with a warning; a new buffer opened at a nonexistent path may save normally. Use `:w!` / `:force_save [path]` or `:wq!` / `:force_save_and_quit [path]` to explicitly overwrite external changes. Force bypasses only the external-change check: historical snapshots remain read-only, target symlinks are rejected, and another loaded buffer's path cannot be overwritten. +`:restart` closes clean buffers across all tabs, panes, and historical views. If any dirty buffer remains, it preserves that buffer's unsaved text and undo/redo history, warns, and keeps the editor interactive; clean buffers already closed stay closed. With no dirty buffers, it restarts the executable. `:force_restart` (alias `:restart!`) restarts regardless of dirty buffers, discarding unsaved text without writing it. Both commands take no arguments; their Normal-mode actions are `core.restart` and `core.force_restart`, with no default keybindings. Selecting force restart in the command palette requires confirmation; an explicitly configured force-restart binding executes directly. + +Restart restores the terminal and tears down the entire editor session, including workers and plugins, before relaunching the same executable with its original CLI arguments. The new process rereads configuration and reopens the original startup file from disk, or starts with an unnamed buffer if no file was supplied. Current buffers, tabs, views, and other session state are not restored. + All saves use a same-directory temporary file and rename and preserve existing permissions. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode. Live file buffers automatically check disk metadata every 250 ms, including buffers in inactive tabs/panes and parked views; historical snapshots are excluded. Content is read only when metadata changes. A clean buffer reloads validated changed text, clears stale undo/redo, preserves caret/selection and view positions where possible, and clamps them to the new text. Reloads invalidate annotations and stale asynchronous requests and refresh live diffs. Dirty buffers retain their edits. Missing, unreadable, or invalid changed files preserve buffer text and report a warning rather than replacing it. diff --git a/src/editor.rs b/src/editor.rs index 4a52b7b..2d9f8d4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -736,6 +736,15 @@ core_actions![ false, true ), + ("core.restart", Action::Restart, &[Mode::Normal], true), + ( + "core.force_restart", + Action::ForceRestart, + &[Mode::Normal], + true, + false, + true + ), ( "core.save_and_quit", Action::SaveAndQuit, @@ -788,6 +797,7 @@ const COMMAND_ALIASES: &[(&str, Action)] = &[ ("w!", Action::ForceSave), ("q", Action::Quit), ("q!", Action::ForceQuit), + ("restart!", Action::ForceRestart), ("wq", Action::SaveAndQuit), ("wq!", Action::ForceSaveAndQuit), ("bnext", Action::BufferNext), @@ -1609,6 +1619,7 @@ pub struct Editor { lsp_names: Vec, lsp_label: String, quit: bool, + restart: bool, capabilities: Vec, features: HashMap, bindings: HashMap>, @@ -1705,6 +1716,7 @@ impl Editor { lsp_names: Vec::new(), lsp_label: String::new(), quit: false, + restart: false, capabilities, features: HashMap::new(), bindings: default_bindings(), @@ -3113,6 +3125,8 @@ impl Editor { | Action::WordWrapToggle | Action::Quit | Action::ForceQuit + | Action::Restart + | Action::ForceRestart | Action::Plugins | Action::Buffers ) @@ -4549,7 +4563,12 @@ impl Editor { self.active.dirty() } pub fn should_quit(&self) -> bool { - self.quit + self.quit || self.restart + } + + /// Whether frontend shutdown should relaunch the executable rather than exit. + pub fn should_restart(&self) -> bool { + self.restart } pub fn context(&self) -> Context { @@ -5848,6 +5867,8 @@ impl Editor { | Action::ForceSave | Action::Quit | Action::ForceQuit + | Action::Restart + | Action::ForceRestart | Action::SaveAndQuit | Action::ForceSaveAndQuit | Action::Plugins @@ -6523,6 +6544,42 @@ impl Editor { self.active.dirty() || self.parked.values().any(Buffer::dirty) } + fn restart(&mut self, force: bool) { + if force || !self.any_dirty() { + self.restart = true; + return; + } + let owners: Vec<_> = self + .diff_owner + .into_iter() + .chain(self.tabs.values().filter_map(|tab| tab.diff_owner)) + .collect(); + for owner in owners { + self.with_pane(owner, |editor| { + editor.execute_action(Action::DiffClose.entry(), ""); + }); + } + if !self.active.dirty() { + let dirty = self.buffers().find(|buffer| buffer.dirty).unwrap().id; + self.switch_buffer(dirty); + } + let clean: Vec<_> = self + .parked + .values() + .filter(|buffer| !buffer.dirty()) + .map(|buffer| buffer.id) + .collect(); + for id in clean { + if self.has_buffer(id) { + self.delete_buffer(id, false); + } + } + let count = self.buffers().filter(|buffer| buffer.dirty).count(); + self.set_message(format!( + "Restart blocked: {count} buffer(s) have unsaved changes; clean buffers closed. Save them or use :restart!" + )); + } + pub fn execute(&mut self, command: &str) -> Option { self.pending_lsp_navigation = None; self.finish_diff_prompt(); @@ -6748,6 +6805,12 @@ impl Editor { } Action::Quit if self.any_dirty() => self.set_message("Unsaved changes; use :w or :q!"), Action::Quit | Action::ForceQuit => self.quit = true, + Action::Restart | Action::ForceRestart if !args.is_empty() => { + self.set_message("Restart commands take no arguments"); + } + Action::Restart | Action::ForceRestart => { + self.restart(action == Action::ForceRestart); + } Action::Plugins if !args.is_empty() => { self.set_message("The plugins command takes no arguments") } @@ -9888,6 +9951,41 @@ mod tests { assert_eq!(fs::read_to_string(path).unwrap(), "new disk"); } + #[test] + fn guarded_restart_closes_historical_views_without_losing_dirty_source_history() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "disk").unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("unsaved".into())]) + .unwrap(); + let dirty = editor.buffer_id(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into()], + vec!["after".into()], + ); + editor.execute("tab.new"); + let snapshot = editor.buffer_id(); + assert!(editor.active.diff_snapshot.is_some()); + editor.execute("restart"); + assert!(!editor.should_restart()); + assert!(!editor.has_buffer(snapshot)); + assert_eq!(editor.buffer_id(), dirty); + assert_eq!(editor.lines(), ["unsaved"]); + assert!(editor.diff().is_none()); + assert!(editor.tabs.values().all(|tab| tab.diff().is_none())); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["disk"]); + assert!(!editor.dirty()); + assert_eq!(fs::read_to_string(path).unwrap(), "disk"); + editor.execute("restart"); + assert!(editor.should_restart()); + assert!(editor.should_quit()); + } + #[test] fn notification_escape_preserves_carets_then_ordinary_escape_collapses_them() { let mut editor = editor(); diff --git a/src/main.rs b/src/main.rs index 1c95a27..af87fc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,10 @@ Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_an Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N] Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged :quit refuses any dirty buffer; both save-and-quit commands require all buffers clean +Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning + :force_restart / :restart! discards unsaved text without writing + Actions: core.restart, core.force_restart (no default binding) + Relaunches the executable with original CLI args/config/startup file; tabs/views reset Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits Markdown links: :follow_link (primary caret; local files only) @@ -134,7 +138,35 @@ fn run() -> Result<(), String> { options.config.as_deref(), ) .map_err(|error| format!("cannot open file: {error}"))?; - tui::run(&mut session).map_err(|error| format!("terminal error: {error}")) + tui::run(&mut session).map_err(|error| format!("terminal error: {error}"))?; + let restart = session.editor().should_restart(); + drop(session); + if restart { + restart_editor()?; + } + Ok(()) +} + +fn restart_editor() -> Result<(), String> { + let executable = env::current_exe().map_err(|error| format!("Restart failed: {error}"))?; + let mut command = std::process::Command::new(executable); + command.args(env::args_os().skip(1)); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + Err(format!("Restart failed: {}", command.exec())) + } + #[cfg(not(unix))] + { + let status = command + .status() + .map_err(|error| format!("Restart failed: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("Restarted editor exited with {status}")) + } + } } fn main() { diff --git a/tests/commands.rs b/tests/commands.rs index 9350731..cd53b93 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -150,6 +150,144 @@ fn buffer_next_and_bnext_preserve_each_buffers_dirty_text_and_undo() { } } +#[test] +fn restart_closes_clean_buffers_across_tabs_and_panes_but_preserves_dirty_history() { + let fixture = Fixture::new(); + let mut editor = fixture.editor("first.txt", "first\n"); + let first_id = editor.buffer_id(); + insert(&mut editor, "changed "); + editor.handle(Input::Char('0')); + insert(&mut editor, "extra "); + let first_redo_text = editor.context().text; + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "changed first\n"); + + editor.execute("pane.split_vertical"); + let clean_path = fixture.0.join("clean.txt"); + fs::write(&clean_path, "clean\n").unwrap(); + editor.execute(&format!("edit {}", clean_path.display())); + let clean_id = editor.buffer_id(); + editor.execute("tab.new"); + let second_path = fixture.0.join("second.txt"); + fs::write(&second_path, "second\n").unwrap(); + editor.execute(&format!("edit {}", second_path.display())); + let second_id = editor.buffer_id(); + insert(&mut editor, "edited "); + editor.execute("pane.split_horizontal"); + let active_clean_path = fixture.0.join("active-clean.txt"); + fs::write(&active_clean_path, "active clean\n").unwrap(); + editor.execute(&format!("edit {}", active_clean_path.display())); + let active_clean_id = editor.buffer_id(); + assert_eq!(editor.tab_count(), 2); + assert_eq!(editor.pane_layout().len(), 2); + + editor.execute(":restart"); + + assert!(!editor.should_restart()); + assert!(!editor.should_quit()); + assert!(editor.message().to_lowercase().contains("unsaved")); + assert_eq!(editor.buffer_count(), 2); + assert!(!editor.has_buffer(clean_id)); + assert!(!editor.has_buffer(active_clean_id)); + assert!(editor.has_buffer(first_id)); + assert!(editor.has_buffer(second_id)); + editor.execute(&format!("buffer {first_id}")); + assert_eq!(editor.buffer_id(), first_id); + assert_eq!(editor.context().text, "changed first\n"); + assert!(editor.dirty()); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, first_redo_text); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "changed first\n"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "first\n"); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "changed first\n"); + editor.execute("save"); + editor.execute(&format!("buffer {second_id}")); + assert_eq!(editor.buffer_id(), second_id); + assert_eq!(editor.context().text, "edited second\n"); + assert!(editor.dirty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "second\n"); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "edited second\n"); + editor.execute("save"); + + editor.execute(":restart"); + + assert!(editor.should_restart()); + assert!(editor.should_quit()); + assert_eq!( + fs::read_to_string(fixture.0.join("first.txt")).unwrap(), + "changed first\n" + ); + assert_eq!(fs::read_to_string(second_path).unwrap(), "edited second\n"); +} + +#[test] +fn restart_commands_validate_arguments_before_closing_or_discarding_buffers() { + for command in ["restart", "force_restart", "restart!"] { + let fixture = Fixture::new(); + let mut editor = fixture.editor("dirty.txt", "original\n"); + let dirty_id = editor.buffer_id(); + insert(&mut editor, "changed "); + let clean_path = fixture.0.join("clean.txt"); + fs::write(&clean_path, "clean\n").unwrap(); + editor.execute(&format!("edit {}", clean_path.display())); + let clean_id = editor.buffer_id(); + + editor.execute(&format!(":{command} unexpected argument")); + + assert!(!editor.should_restart(), "{command}"); + assert!(!editor.should_quit(), "{command}"); + assert_eq!(editor.buffer_count(), 2, "{command}"); + assert_eq!(editor.buffer_id(), clean_id, "{command}"); + assert!(editor.has_buffer(dirty_id), "{command}"); + editor.execute(&format!("buffer {dirty_id}")); + assert_eq!(editor.context().text, "changed original\n", "{command}"); + assert!(editor.dirty(), "{command}"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "original\n", "{command}"); + editor.handle(Input::Char('U')); + assert_eq!(editor.context().text, "changed original\n", "{command}"); + + if command != "restart" { + editor.execute(&format!(":{command}")); + assert!(editor.should_restart(), "{command}"); + assert!(editor.should_quit(), "{command}"); + } + assert_eq!( + fs::read_to_string(fixture.0.join("dirty.txt")).unwrap(), + "original\n", + "{command}" + ); + assert_eq!(fs::read_to_string(clean_path).unwrap(), "clean\n"); + } +} + +#[test] +fn configured_restart_bindings_execute_directly_without_force_confirmation() { + for action in ["core.restart", "core.force_restart"] { + let fixture = Fixture::new(); + let mut editor = fixture.editor("file.txt", "original\n"); + editor.bind("normal", "x", Some(action.into())).unwrap(); + if action == "core.force_restart" { + insert(&mut editor, "changed "); + } + + editor.handle(Input::Char('x')); + + assert!(editor.should_restart(), "{action}"); + assert!(editor.should_quit(), "{action}"); + assert_eq!( + fs::read_to_string(fixture.0.join("file.txt")).unwrap(), + "original\n", + "{action}" + ); + } +} + #[test] fn builtin_canonical_and_alias_collisions_reject_the_entire_feature() { for reserved in ["save", "w", "quit", "q"] {