feat: reload clean buffers and guard external-change writes

This commit is contained in:
2026-10-12 22:27:08 +02:00
parent bb5c247ab2
commit 298d586c3e
7 changed files with 556 additions and 41 deletions
+4
View File
@@ -29,6 +29,10 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
## 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.
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.
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. `visual_linewise()` is true only for the whole-row shape in `Mode::Visual`; it changes neither binding modes nor Ruby mode names. Linewise ranges expand to complete rows. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. `linewise_rows()` exposes inclusive selected row ranges only in linewise Visual, including an empty final row without changing `selections()` cardinality. The TUI highlights the copied text on those rows and one cell for each copied newline, never unused viewport space or the gutter, and labels their mode VISUAL LINE. Selection normalization, clipboard shape and contents, edit planning, and history remain inside the editor module.
The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness.
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
+6 -2
View File
@@ -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`, `quit`, `force_quit`, `save_and_quit`, `plugins` |
| Normal | `save`, `force_save`, `quit`, `force_quit`, `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` |
@@ -180,7 +180,11 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con
`core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets.
`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `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.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. Both re-read disk text and compare its LF-normalized content with the saved baseline, refusing external changes or deletion with a warning. `core.force_save` and `core.force_save_and_quit` explicitly bypass that external-change check, but never historical-snapshot read-only protection, target-symlink rejection, or another loaded buffer's path ownership. Their canonical commands are `:force_save [path]` and `:force_save_and_quit [path]`, with aliases `:w!` and `:wq!`; guarded `:save [path]` / `:w` and `:save_and_quit [path]` / `:wq` remain unchanged. A new buffer opened at a nonexistent path may save normally. Configured force-save actions act immediately, without confirmation.
Live clean buffers reload external changes automatically, including inactive tabs/panes, using 250 ms metadata checks and reading content only when metadata changes. Historical snapshots are excluded; dirty buffers keep their edits. Reload clears stale undo/redo, preserves and clamps view positions, and invalidates outdated annotations/requests. Missing, unreadable, or invalid changed files preserve text and report a warning. This polling does not replace the actual-content recheck on save.
`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.
## Binding overrides and failures
+10 -4
View File
@@ -154,20 +154,26 @@ 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]`, `:quit`, `:force_quit`, `: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`, `: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 |
| --- | --- |
| `:w` | `:save` |
| `:w!` | `:force_save` |
| `:q` | `:quit` |
| `:q!` | `:force_quit` |
| `:wq` | `:save_and_quit` |
| `:wq!` | `:force_save_and_quit` |
| `:bnext` | `:buffer_next` |
| `:bprev` | `:buffer_previous` |
| `:bdelete` | `:buffer_delete` |
| `:bdelete!` | `:buffer_force_delete` |
`: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. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. Writing to a path owned by another loaded buffer refuses before modifying the file. 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.
`: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.
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.
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
@@ -254,7 +260,7 @@ Each buffer retains its shared text, path, saved state, and undo/redo history. V
| `:buffer_delete [N]` | Close the active or named buffer; refuse unsaved changes |
| `:buffer_force_delete [N]` | Explicitly discard changes and close the active or named buffer |
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save or discard hidden edits; automatic disk checks also cover hidden clean buffers. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion.
Switching from Insert commits its pending undo group and parks that view in Normal. Explicit buffer switching from Visual retains the pane's directed ranges and Visual mode for that buffer; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. Buffer commands appear in the command palette and can be bound by their typed core actions in Normal mode. When several buffers are open, status shows the active ID and total count.
@@ -364,7 +370,7 @@ Ctrl+p opens a searchable overlay without leaving the current mode. It lists eac
Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, canonical Ex name, a shorthand alias, human-readable label, or owner. Aliases find the same action rather than creating duplicate entries. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme; Ctrl+u clears the query without editing the underlying buffer. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open.
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit`, `core.buffer_force_delete`, and `core.pane.force_close` actions also open canonical Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch.
Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit`, `core.buffer_force_delete`, `core.pane.force_close`, `core.force_save`, and `core.force_save_and_quit` actions also open canonical Ex prompts, requiring another Enter before discarding or overwriting changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch.
Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals.