Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c35abe0f03 | |||
| d85998e1cb | |||
| 298d586c3e |
@@ -13,6 +13,7 @@
|
||||
- [x] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view.
|
||||
- changed symbol ranges stay in the comparison; other targets open ordinary tabs. Historical text stays immutable; source freshness and failure atomicity are preserved.
|
||||
- [x] better syntax highlighting for markdown files. color `-` and `1.` from lists.
|
||||
- [x] automatically reload buffers when underlying file changes outside of vamp if buffer is clean. if buffer is dirty, reject normal write attempts with warning and require forceful write to overwrite underlying file contents.
|
||||
- [ ] diff view: change diff view modes; choose between split and unified diff view (split by default).
|
||||
- [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default.
|
||||
- [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc.
|
||||
|
||||
+10
-2
@@ -24,11 +24,17 @@ 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 inside a terminal guard covering startup, rendering, session teardown, and executable handoff. On restart, it drops the entire `EditorSession`, including workers and plugin hosts, then briefly disables raw mode and input reporting without leaving the alternate screen before relaunching the same executable with the original CLI arguments. Unix replaces the process with `exec`; other platforms spawn a fresh process and wait for it. 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.
|
||||
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.
|
||||
@@ -53,7 +59,9 @@ Frontends borrow `snippet_selections()` for the active argument's end-exclusive
|
||||
|
||||
## Terminal lifecycle and layout
|
||||
|
||||
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, DEC 2031 color-scheme notifications, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
|
||||
The terminal guard owns raw mode, alternate-screen state, bracketed paste, mouse capture, focus reporting, DEC 2031 color-scheme notifications, and its panic hook across startup, rendering, session teardown, and relaunch. An inherited-screen restart installs cleanup and its panic hook before argument, file, or configuration parsing, resumes raw mode and input reporting without entering the alternate screen again, and clears and repaints inherited contents in its first synchronized frame. A private first-argument marker identifies this handoff without changing the original CLI arguments. Final quit, startup or I/O errors, failed relaunch, and unwinding panics restore normal input modes, the normal screen, cursor visibility, and the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while the guard is active.
|
||||
|
||||
`tui::TerminalSession::run(inherited_screen, body)` scopes this ownership; `tui::run(&mut session, inherited_screen)` renders inside that active guard. `TerminalSession::prepare_restart()` resets process-local input state without leaving the alternate screen, and the guard stays armed if relaunch fails.
|
||||
|
||||
Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam.
|
||||
|
||||
|
||||
+10
-2
@@ -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`, `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` |
|
||||
@@ -180,7 +180,15 @@ 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.
|
||||
|
||||
`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 full session teardown. Raw mode and input reporting briefly reset, but the alternate screen remains active across the process handoff; the new process resumes input modes and clears and repaints the screen atomically in its first frame. 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. Final quit, errors, and panics restore normal input modes, the normal screen, cursor visibility, and the prior panic hook.
|
||||
|
||||
## Binding overrides and failures
|
||||
|
||||
|
||||
+15
-4
@@ -154,20 +154,31 @@ 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`, `: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 |
|
||||
| --- | --- |
|
||||
| `:w` | `:save` |
|
||||
| `:w!` | `:force_save` |
|
||||
| `:q` | `:quit` |
|
||||
| `:q!` | `:force_quit` |
|
||||
| `:restart!` | `:force_restart` |
|
||||
| `: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.
|
||||
|
||||
`: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 tears down the entire editor session, including workers and plugins, before relaunching the same executable with its original CLI arguments. It briefly resets raw mode and input reporting but retains the alternate screen across the process handoff; the new process resumes input modes and clears and repaints that screen atomically in its first frame. 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. Final quit, errors, and panics still restore normal input modes, the normal screen, cursor visibility, and the prior panic hook.
|
||||
|
||||
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 +265,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 +375,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.
|
||||
|
||||
|
||||
+533
-32
@@ -14,7 +14,7 @@ use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::ops::Range;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -694,6 +694,14 @@ core_actions![
|
||||
true,
|
||||
),
|
||||
("core.save", Action::Save, &[Mode::Normal], true),
|
||||
(
|
||||
"core.force_save",
|
||||
Action::ForceSave,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
false,
|
||||
true
|
||||
),
|
||||
("core.diff", Action::Diff, &[Mode::Normal], true),
|
||||
(
|
||||
"core.word_wrap.toggle",
|
||||
@@ -728,12 +736,29 @@ 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,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.force_save_and_quit",
|
||||
Action::ForceSaveAndQuit,
|
||||
&[Mode::Normal],
|
||||
true,
|
||||
false,
|
||||
true
|
||||
),
|
||||
("core.plugins", Action::Plugins, &[Mode::Normal], true),
|
||||
("core.edit", Action::Edit, &[Mode::Normal], true, true),
|
||||
("core.buffers", Action::Buffers, &[Mode::Normal], true),
|
||||
@@ -769,9 +794,12 @@ core_actions![
|
||||
// Alias targets stay typed; canonical spelling comes from the action catalog.
|
||||
const COMMAND_ALIASES: &[(&str, Action)] = &[
|
||||
("w", Action::Save),
|
||||
("w!", Action::ForceSave),
|
||||
("q", Action::Quit),
|
||||
("q!", Action::ForceQuit),
|
||||
("restart!", Action::ForceRestart),
|
||||
("wq", Action::SaveAndQuit),
|
||||
("wq!", Action::ForceSaveAndQuit),
|
||||
("bnext", Action::BufferNext),
|
||||
("bprev", Action::BufferPrevious),
|
||||
("bdelete", Action::BufferDelete),
|
||||
@@ -833,7 +861,9 @@ impl Action {
|
||||
| Self::PasteSystemAfter
|
||||
| Self::Newline
|
||||
| Self::Save
|
||||
| Self::ForceSave
|
||||
| Self::SaveAndQuit
|
||||
| Self::ForceSaveAndQuit
|
||||
| Self::BufferDelete
|
||||
| Self::BufferForceDelete
|
||||
| Self::LspCompletion
|
||||
@@ -1285,6 +1315,19 @@ impl ViewState {
|
||||
self.search_match = None;
|
||||
self.visual_motions = None;
|
||||
}
|
||||
|
||||
fn reload(&mut self, lines: &[String]) {
|
||||
self.clamp(lines);
|
||||
self.insert_start = (self.mode == Mode::Insert).then(|| Snapshot {
|
||||
lines: lines.to_vec(),
|
||||
targets: self.targets.clone(),
|
||||
primary: self.primary,
|
||||
});
|
||||
self.pending_edit = (self.mode == Mode::Insert).then(|| EditRecipe {
|
||||
start: EditStart::Insert(Action::Insert),
|
||||
steps: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct Pane {
|
||||
@@ -1313,6 +1356,41 @@ impl Tab {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq)]
|
||||
struct FileStamp {
|
||||
modified: std::time::SystemTime,
|
||||
len: u64,
|
||||
#[cfg(unix)]
|
||||
identity: (u64, u64, i64, i64),
|
||||
}
|
||||
|
||||
impl FileStamp {
|
||||
fn from_metadata(metadata: &fs::Metadata) -> io::Result<Self> {
|
||||
if !metadata.is_file() {
|
||||
return Err(io::Error::other("File is not a regular file"));
|
||||
}
|
||||
Ok(Self {
|
||||
modified: metadata.modified()?,
|
||||
len: metadata.len(),
|
||||
#[cfg(unix)]
|
||||
identity: (
|
||||
metadata.dev(),
|
||||
metadata.ino(),
|
||||
metadata.ctime(),
|
||||
metadata.ctime_nsec(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> io::Result<Option<Self>> {
|
||||
match fs::metadata(path) {
|
||||
Ok(metadata) => Self::from_metadata(&metadata).map(Some),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Buffer {
|
||||
id: usize,
|
||||
diff_snapshot: Option<(u64, usize, Focus)>,
|
||||
@@ -1320,6 +1398,8 @@ struct Buffer {
|
||||
snapshot_root: Option<PathBuf>,
|
||||
lines: Vec<String>,
|
||||
saved_lines: Vec<String>,
|
||||
disk_stamp: Option<FileStamp>,
|
||||
reload_error: Option<String>,
|
||||
text_revision: u64,
|
||||
save_revision: u64,
|
||||
diagnostics: BTreeMap<String, AnnotationSet>,
|
||||
@@ -1339,9 +1419,15 @@ impl Buffer {
|
||||
identity: Option<PathBuf>,
|
||||
allow_missing: bool,
|
||||
) -> io::Result<Self> {
|
||||
let mut disk_stamp = None;
|
||||
let text = match &path {
|
||||
Some(path) => match fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Some(path) => match fs::File::open(path) {
|
||||
Ok(mut file) => {
|
||||
disk_stamp = Some(FileStamp::from_metadata(&file.metadata()?)?);
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
text
|
||||
}
|
||||
Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => {
|
||||
String::new()
|
||||
}
|
||||
@@ -1349,7 +1435,7 @@ impl Buffer {
|
||||
},
|
||||
None => String::new(),
|
||||
};
|
||||
Self::from_text(id, path, identity, &text)
|
||||
Self::from_text(id, path, identity, &text, disk_stamp)
|
||||
}
|
||||
|
||||
fn from_text(
|
||||
@@ -1357,6 +1443,7 @@ impl Buffer {
|
||||
path: Option<PathBuf>,
|
||||
identity: Option<PathBuf>,
|
||||
text: &str,
|
||||
disk_stamp: Option<FileStamp>,
|
||||
) -> io::Result<Self> {
|
||||
validate_text(text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
let lines = split_lines(text);
|
||||
@@ -1366,6 +1453,8 @@ impl Buffer {
|
||||
snapshot_document_path: None,
|
||||
snapshot_root: None,
|
||||
saved_lines: lines.clone(),
|
||||
disk_stamp,
|
||||
reload_error: None,
|
||||
lines,
|
||||
text_revision: 0,
|
||||
save_revision: 0,
|
||||
@@ -1386,6 +1475,52 @@ impl Buffer {
|
||||
self.diff_snapshot.is_none() && self.lines != self.saved_lines
|
||||
}
|
||||
|
||||
fn refresh_file(&mut self) -> io::Result<bool> {
|
||||
if self.diff_snapshot.is_some() || self.dirty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(path) = &self.path else {
|
||||
return Ok(false);
|
||||
};
|
||||
let stamp = FileStamp::read(path)?;
|
||||
if stamp == self.disk_stamp {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(stamp) = stamp else {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "File was removed"));
|
||||
};
|
||||
let mut file = fs::File::open(path)?;
|
||||
if FileStamp::from_metadata(&file.metadata()?)? != stamp {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"File changed while reloading",
|
||||
));
|
||||
}
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text)?;
|
||||
if FileStamp::from_metadata(&file.metadata()?)? != stamp {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"File changed while reloading",
|
||||
));
|
||||
}
|
||||
validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
self.disk_stamp = Some(stamp);
|
||||
if normalize_newlines(&text)
|
||||
.split('\n')
|
||||
.eq(self.lines.iter().map(String::as_str))
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
self.lines = split_lines(&text);
|
||||
self.saved_lines.clone_from(&self.lines);
|
||||
self.undo.clear();
|
||||
self.redo.clear();
|
||||
self.text_changed();
|
||||
self.view.reload(&self.lines);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn info(&self, active: bool) -> BufferInfo<'_> {
|
||||
BufferInfo {
|
||||
id: self.id,
|
||||
@@ -1484,6 +1619,7 @@ pub struct Editor {
|
||||
lsp_names: Vec<String>,
|
||||
lsp_label: String,
|
||||
quit: bool,
|
||||
restart: bool,
|
||||
capabilities: Vec<String>,
|
||||
features: HashMap<String, Feature>,
|
||||
bindings: HashMap<Mode, HashMap<Input, Binding>>,
|
||||
@@ -1580,6 +1716,7 @@ impl Editor {
|
||||
lsp_names: Vec::new(),
|
||||
lsp_label: String::new(),
|
||||
quit: false,
|
||||
restart: false,
|
||||
capabilities,
|
||||
features: HashMap::new(),
|
||||
bindings: default_bindings(),
|
||||
@@ -2988,6 +3125,8 @@ impl Editor {
|
||||
| Action::WordWrapToggle
|
||||
| Action::Quit
|
||||
| Action::ForceQuit
|
||||
| Action::Restart
|
||||
| Action::ForceRestart
|
||||
| Action::Plugins
|
||||
| Action::Buffers
|
||||
)
|
||||
@@ -3408,8 +3547,14 @@ impl Editor {
|
||||
if text.len() > MAX_EDIT_BYTES {
|
||||
return Err("Definition target exceeds 4 MiB".into());
|
||||
}
|
||||
let buffer = Buffer::from_text(self.next_buffer_id, Some(path), Some(identity), &text)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let buffer = Buffer::from_text(
|
||||
self.next_buffer_id,
|
||||
Some(path),
|
||||
Some(identity),
|
||||
&text,
|
||||
Some(FileStamp::from_metadata(&metadata).map_err(|error| error.to_string())?),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((Some(buffer), self.next_buffer_id))
|
||||
}
|
||||
|
||||
@@ -4418,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 {
|
||||
@@ -5714,9 +5864,13 @@ impl Editor {
|
||||
| Action::TabClose
|
||||
| Action::TabForceClose
|
||||
| Action::Save
|
||||
| Action::ForceSave
|
||||
| Action::Quit
|
||||
| Action::ForceQuit
|
||||
| Action::Restart
|
||||
| Action::ForceRestart
|
||||
| Action::SaveAndQuit
|
||||
| Action::ForceSaveAndQuit
|
||||
| Action::Plugins
|
||||
| Action::Edit
|
||||
| Action::Buffers
|
||||
@@ -6390,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<Invocation> {
|
||||
self.pending_lsp_navigation = None;
|
||||
self.finish_diff_prompt();
|
||||
@@ -6592,12 +6782,16 @@ impl Editor {
|
||||
args: String::new(),
|
||||
});
|
||||
}
|
||||
Action::Save | Action::SaveAndQuit => {
|
||||
if self.save(if args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(args))
|
||||
}) && action == Action::SaveAndQuit
|
||||
Action::Save | Action::ForceSave | Action::SaveAndQuit | Action::ForceSaveAndQuit => {
|
||||
let force = matches!(action, Action::ForceSave | Action::ForceSaveAndQuit);
|
||||
if self.save(
|
||||
if args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(args))
|
||||
},
|
||||
force,
|
||||
) && matches!(action, Action::SaveAndQuit | Action::ForceSaveAndQuit)
|
||||
{
|
||||
if self.any_dirty() {
|
||||
self.set_message("Unsaved changes in other buffers; use :w or :q!");
|
||||
@@ -6611,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")
|
||||
}
|
||||
@@ -6729,7 +6929,57 @@ impl Editor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save(&mut self, path: Option<PathBuf>) -> bool {
|
||||
/// Refreshes every live buffer, including views parked in inactive tabs.
|
||||
pub(crate) fn refresh_files(&mut self) -> bool {
|
||||
let active_id = self.active.id;
|
||||
let mut changed = false;
|
||||
let mut active_reloaded = false;
|
||||
let mut notice = None;
|
||||
for buffer in std::iter::once(&mut self.active).chain(self.parked.values_mut()) {
|
||||
match buffer.refresh_file() {
|
||||
Ok(reloaded) => {
|
||||
buffer.reload_error = None;
|
||||
if !reloaded {
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
active_reloaded |= buffer.id == active_id;
|
||||
for pane in self.panes.values_mut() {
|
||||
if let Some(view) = pane.views.get_mut(&buffer.id) {
|
||||
view.reload(&buffer.lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!(
|
||||
"Reload failed for {}: {error}",
|
||||
buffer.path.as_deref().unwrap().display(),
|
||||
);
|
||||
if buffer.reload_error.as_ref() != Some(&message) {
|
||||
buffer.reload_error = Some(message.clone());
|
||||
notice = Some(message);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if active_reloaded {
|
||||
self.completion = None;
|
||||
self.snippet = None;
|
||||
self.clear_hover();
|
||||
self.pending_lsp_navigation = None;
|
||||
self.normalize_targets();
|
||||
}
|
||||
if let Some(notice) = notice {
|
||||
self.set_message(notice);
|
||||
}
|
||||
if changed {
|
||||
self.refresh_live_diff();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn save(&mut self, path: Option<PathBuf>, force: bool) -> bool {
|
||||
if self.mutation_blocked() {
|
||||
self.set_message("Diff pane is read-only");
|
||||
return false;
|
||||
@@ -6756,10 +7006,19 @@ impl Editor {
|
||||
));
|
||||
return false;
|
||||
}
|
||||
match atomic_save(&path, &self.active.lines) {
|
||||
Ok(()) => {
|
||||
let expected = (!force
|
||||
&& (self.active.identity.as_ref() == Some(&identity)
|
||||
|| self.active.path.as_ref() == Some(&path)))
|
||||
.then_some((
|
||||
self.active.saved_lines.as_slice(),
|
||||
self.active.disk_stamp.is_some(),
|
||||
));
|
||||
match atomic_save(&path, &self.active.lines, expected) {
|
||||
Ok(stamp) => {
|
||||
self.active.saved_lines.clone_from(&self.active.lines);
|
||||
self.active.save_revision = self.active.save_revision.wrapping_add(1);
|
||||
self.active.disk_stamp = Some(stamp);
|
||||
self.active.reload_error = None;
|
||||
if self.active.path.as_ref() != Some(&path) {
|
||||
self.active.semantic_tokens = None;
|
||||
}
|
||||
@@ -9406,8 +9665,38 @@ fn target_metadata(path: &Path) -> io::Result<Option<fs::Metadata>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> {
|
||||
fn check_disk_contents(path: &Path, expected: Option<(&[String], bool)>) -> io::Result<()> {
|
||||
let Some((lines, existed)) = expected else {
|
||||
return Ok(());
|
||||
};
|
||||
let matches = match fs::read_to_string(path) {
|
||||
Ok(text) => {
|
||||
existed
|
||||
&& normalize_newlines(&text)
|
||||
.split('\n')
|
||||
.eq(lines.iter().map(String::as_str))
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => !existed,
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidData => false,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if matches {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"File changed outside vamp; use :w! to overwrite",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn atomic_save(
|
||||
path: &Path,
|
||||
lines: &[String],
|
||||
expected: Option<(&[String], bool)>,
|
||||
) -> io::Result<FileStamp> {
|
||||
let metadata = target_metadata(path)?;
|
||||
check_disk_contents(path, expected)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
@@ -9436,13 +9725,15 @@ fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> {
|
||||
file.set_permissions(metadata.permissions())?;
|
||||
}
|
||||
file.sync_all()?;
|
||||
let stamp = FileStamp::from_metadata(&file.metadata()?)?;
|
||||
drop(file);
|
||||
// Recheck immediately before rename: never knowingly replace a symlink.
|
||||
target_metadata(path)?;
|
||||
check_disk_contents(path, expected)?;
|
||||
fs::rename(&temporary, path)?;
|
||||
#[cfg(unix)]
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
Ok(stamp)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
@@ -9490,6 +9781,211 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_reload_clamps_shared_views_and_restarts_clean_insert_history() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.path("file.txt");
|
||||
fs::write(&path, "one\ntwo\nthree").unwrap();
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("old edit".into())])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('u'));
|
||||
type_text(&mut editor, "gb$");
|
||||
editor.execute("tab.new");
|
||||
editor.handle(Input::Char('|'));
|
||||
let pane = editor.pane_id();
|
||||
let revision = editor.text_revision();
|
||||
fs::write(&path, "é").unwrap();
|
||||
assert!(editor.refresh_files());
|
||||
assert_eq!(editor.lines(), ["é"]);
|
||||
assert!(!editor.dirty());
|
||||
assert!(editor.text_revision() > revision);
|
||||
assert_eq!(editor.cursor(), Cursor::default());
|
||||
editor.handle(Input::Alt('h'));
|
||||
assert_ne!(editor.pane_id(), pane);
|
||||
assert_eq!(editor.cursor(), Cursor::default());
|
||||
editor.execute("tab.previous");
|
||||
assert_eq!(editor.cursor(), Cursor::default());
|
||||
editor.handle(Input::Char('U'));
|
||||
assert_eq!(editor.lines(), ["é"]);
|
||||
editor.handle(Input::Char('i'));
|
||||
fs::write(&path, "😀ZZ").unwrap();
|
||||
assert!(editor.refresh_files());
|
||||
assert_eq!(editor.mode(), Mode::Insert);
|
||||
type_text(&mut editor, "X");
|
||||
editor.handle(Input::Escape);
|
||||
editor.handle(Input::Char('u'));
|
||||
assert_eq!(editor.lines(), ["😀ZZ"]);
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_dirty_writes_require_force_even_without_a_poll() {
|
||||
for (normal, force, quits) in [
|
||||
("w", "w!", false),
|
||||
("save", "force_save", false),
|
||||
("wq", "wq!", true),
|
||||
("save_and_quit", "force_save_and_quit", true),
|
||||
] {
|
||||
let directory = Directory::new();
|
||||
let path = directory.path("file.txt");
|
||||
fs::write(&path, "baseline").unwrap();
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("local edits".into())])
|
||||
.unwrap();
|
||||
fs::write(&path, "external").unwrap();
|
||||
assert!(!editor.refresh_files());
|
||||
editor.execute(normal);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "external");
|
||||
assert_eq!(editor.lines(), ["local edits"]);
|
||||
assert!(editor.dirty());
|
||||
assert!(!editor.should_quit());
|
||||
assert!(editor.message().contains(":w!"));
|
||||
editor.execute(force);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "local edits");
|
||||
assert!(!editor.dirty());
|
||||
assert_eq!(editor.should_quit(), quits);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_deletion_is_not_a_new_file_and_invalid_reload_preserves_text() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.path("file.txt");
|
||||
fs::write(&path, "").unwrap();
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
fs::remove_file(&path).unwrap();
|
||||
assert!(editor.refresh_files());
|
||||
assert!(!editor.refresh_files());
|
||||
editor.execute("w");
|
||||
assert!(!path.exists());
|
||||
editor.execute("w!");
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "");
|
||||
fs::write(&path, b"\xff").unwrap();
|
||||
let revision = editor.text_revision();
|
||||
assert!(editor.refresh_files());
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
assert_eq!(editor.lines(), [""]);
|
||||
editor.execute("wq");
|
||||
assert!(!editor.should_quit());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"\xff");
|
||||
fs::write(&path, "recovered\r\n").unwrap();
|
||||
assert!(editor.refresh_files());
|
||||
assert_eq!(editor.lines(), ["recovered", ""]);
|
||||
assert!(!editor.dirty());
|
||||
let missing = directory.path("new.txt");
|
||||
let mut new = Editor::open(Some(missing.clone()), vec![]).unwrap();
|
||||
new.execute("w");
|
||||
assert_eq!(fs::read_to_string(missing).unwrap(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_same_text_keeps_history_and_newline_normalization_allows_save() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.path("file.txt");
|
||||
fs::write(&path, "same\r\n").unwrap();
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("old edit".into())])
|
||||
.unwrap();
|
||||
editor.handle(Input::Char('u'));
|
||||
let revision = editor.text_revision();
|
||||
fs::write(&path, "same\n").unwrap();
|
||||
assert!(!editor.refresh_files());
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
editor.handle(Input::Char('U'));
|
||||
assert_eq!(editor.lines(), ["old edit"]);
|
||||
editor.execute("w");
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "old edit");
|
||||
assert!(!editor.dirty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn external_atomic_replacement_with_same_size_and_mtime_reloads() {
|
||||
let directory = Directory::new();
|
||||
let path = directory.path("file.txt");
|
||||
let replacement = directory.path("replacement");
|
||||
let fixed = std::time::UNIX_EPOCH + std::time::Duration::from_secs(123);
|
||||
fs::write(&path, "before").unwrap();
|
||||
fs::File::open(&path).unwrap().set_modified(fixed).unwrap();
|
||||
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
|
||||
fs::write(&replacement, "after!").unwrap();
|
||||
fs::File::open(&replacement)
|
||||
.unwrap()
|
||||
.set_modified(fixed)
|
||||
.unwrap();
|
||||
fs::rename(&replacement, &path).unwrap();
|
||||
assert!(editor.refresh_files());
|
||||
assert_eq!(editor.lines(), ["after!"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_reload_refreshes_working_diff_but_not_historical_snapshots() {
|
||||
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();
|
||||
working_diff(&mut editor, &directory.0, &["file.txt"]);
|
||||
fs::write(&path, "external working").unwrap();
|
||||
assert!(editor.refresh_files());
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().comparison.as_ref().unwrap().files[0].after,
|
||||
["external working"],
|
||||
);
|
||||
editor.execute("diff.close");
|
||||
historical_diff(
|
||||
&mut editor,
|
||||
&directory.0,
|
||||
vec!["before".into()],
|
||||
vec!["after".into()],
|
||||
);
|
||||
let revision = editor.text_revision();
|
||||
fs::write(&path, "new disk").unwrap();
|
||||
assert!(editor.refresh_files());
|
||||
assert_eq!(editor.lines(), ["before"]);
|
||||
assert_eq!(editor.text_revision(), revision);
|
||||
editor.execute("w!");
|
||||
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();
|
||||
@@ -18788,16 +19284,16 @@ mod tests {
|
||||
editor
|
||||
.apply(vec![Effect::Replace("dirty b".into())])
|
||||
.unwrap();
|
||||
editor.execute(&format!("w {}", directory.0.join("./a").display()));
|
||||
assert!(editor.message().contains("belongs to buffer 1"));
|
||||
assert_eq!(editor.path(), Some(b.as_path()));
|
||||
assert!(editor.dirty());
|
||||
assert_eq!(fs::read_to_string(&a).unwrap(), "a");
|
||||
assert_eq!(fs::read_to_string(&b).unwrap(), "b");
|
||||
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
|
||||
editor.execute(&format!("wq {}", a.display()));
|
||||
assert!(!editor.should_quit());
|
||||
assert!(editor.dirty());
|
||||
for command in ["w", "w!", "wq", "wq!"] {
|
||||
editor.execute(&format!("{command} {}", directory.0.join("./a").display()));
|
||||
assert!(editor.message().contains("belongs to buffer 1"));
|
||||
assert_eq!(editor.path(), Some(b.as_path()));
|
||||
assert!(editor.dirty());
|
||||
assert!(!editor.should_quit());
|
||||
assert_eq!(fs::read_to_string(&a).unwrap(), "a");
|
||||
assert_eq!(fs::read_to_string(&b).unwrap(), "b");
|
||||
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2);
|
||||
}
|
||||
editor.execute(&format!("w {}", directory.0.join("./b").display()));
|
||||
assert!(!editor.dirty());
|
||||
assert_eq!(fs::read_to_string(&b).unwrap(), "dirty b");
|
||||
@@ -18879,6 +19375,10 @@ mod tests {
|
||||
assert!(editor.dirty());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally");
|
||||
editor.execute(&format!("w {}", path.display()));
|
||||
assert!(editor.dirty());
|
||||
assert_eq!(editor.path(), Some(link.as_path()));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "changed externally");
|
||||
editor.execute(&format!("w! {}", path.display()));
|
||||
assert!(!editor.dirty());
|
||||
assert_eq!(editor.path(), Some(path.as_path()));
|
||||
|
||||
@@ -19107,7 +19607,6 @@ mod tests {
|
||||
editor.handle(Input::Backspace);
|
||||
}
|
||||
type_text(&mut editor, "q!");
|
||||
assert_eq!(editor.palette().unwrap().entries.len(), 1);
|
||||
assert!(!palette_entry(&editor, "core.force_quit").enabled);
|
||||
editor.handle(Input::Enter);
|
||||
assert!(!editor.should_quit());
|
||||
@@ -20939,9 +21438,11 @@ mod tests {
|
||||
editor
|
||||
.apply(vec![Effect::Replace("not saved".into())])
|
||||
.unwrap();
|
||||
editor.execute("wq");
|
||||
assert!(editor.dirty());
|
||||
assert!(!editor.should_quit());
|
||||
for command in ["wq", "wq!"] {
|
||||
editor.execute(command);
|
||||
assert!(editor.dirty());
|
||||
assert!(!editor.should_quit());
|
||||
}
|
||||
assert!(
|
||||
fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
|
||||
+73
-9
@@ -1,7 +1,10 @@
|
||||
use std::{env, path::PathBuf};
|
||||
use std::{env, io, path::PathBuf};
|
||||
|
||||
use vamp::{session::EditorSession, tui};
|
||||
|
||||
// Only the first argument can be this process-to-process terminal handoff marker.
|
||||
const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen";
|
||||
|
||||
const HELP: &str = "vamp — modal editor with isolated Ruby plugins
|
||||
|
||||
Usage: vamp [--config PATH | --no-config] [FILE]
|
||||
@@ -40,11 +43,23 @@ Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+
|
||||
Outside snippets Enter inserts newline, Tab inserts tab; Esc keeps text and returns to Normal
|
||||
Insert/Command/Search: Ctrl+u deletes backward to the logical line start
|
||||
Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
|
||||
Commands: :save [PATH], :quit, :force_quit, :save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Aliases: w=save, q=quit, q!=force_quit, wq=save_and_quit
|
||||
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
|
||||
:save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
||||
Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit,
|
||||
:wq=save_and_quit, :wq!=force_save_and_quit
|
||||
Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit
|
||||
Normal saves recheck LF-normalized disk text; external changes/deletion require force
|
||||
Force never bypasses snapshot read-only, symlink, or another-buffer path protection
|
||||
Clean live buffers reload on 250ms metadata checks, including inactive tabs/panes
|
||||
Reload preserves/clamps views and clears stale undo/redo; snapshots never reload
|
||||
Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn
|
||||
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; :save_and_quit saves active and quits only if all are clean
|
||||
: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)
|
||||
@@ -74,8 +89,8 @@ struct Options {
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Option<Options>, String> {
|
||||
let mut args = env::args_os().skip(1);
|
||||
fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
|
||||
let mut args = env::args_os().skip(1 + usize::from(inherited_screen));
|
||||
let mut file = None;
|
||||
let mut config = None;
|
||||
let mut config_selected = false;
|
||||
@@ -117,7 +132,25 @@ fn options() -> Result<Option<Options>, String> {
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let Some(options) = options()? else {
|
||||
let inherited_screen = env::args_os()
|
||||
.nth(1)
|
||||
.is_some_and(|arg| arg == INHERITED_SCREEN);
|
||||
if inherited_screen {
|
||||
return tui::TerminalSession::run(true, || {
|
||||
run_editor(options(true).map_err(io::Error::other)?, true)
|
||||
})
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
let options = options(false)?;
|
||||
if options.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
tui::TerminalSession::run(false, || run_editor(options, false))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn run_editor(options: Option<Options>, inherited_screen: bool) -> io::Result<()> {
|
||||
let Some(options) = options else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut session = EditorSession::open(
|
||||
@@ -125,8 +158,39 @@ fn run() -> Result<(), String> {
|
||||
tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(),
|
||||
options.config.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("cannot open file: {error}"))?;
|
||||
tui::run(&mut session).map_err(|error| format!("terminal error: {error}"))
|
||||
.map_err(|error| io::Error::other(format!("cannot open file: {error}")))?;
|
||||
tui::run(&mut session, inherited_screen)?;
|
||||
let restart = session.editor().should_restart();
|
||||
drop(session);
|
||||
if restart {
|
||||
tui::TerminalSession::prepare_restart()?;
|
||||
restart_editor(inherited_screen)
|
||||
.map_err(|error| io::Error::other(format!("Restart failed: {error}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restart_editor(inherited_screen: bool) -> io::Result<()> {
|
||||
let executable = env::current_exe()?;
|
||||
let mut command = std::process::Command::new(executable);
|
||||
command.arg(INHERITED_SCREEN);
|
||||
command.args(env::args_os().skip(1 + usize::from(inherited_screen)));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
Err(command.exec())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let status = command.status()?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"Restarted editor exited with {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
||||
+90
-1
@@ -8,6 +8,7 @@ use std::{
|
||||
mpsc::{self, Receiver, Sender},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -173,6 +174,7 @@ pub struct EditorSession {
|
||||
diff_workers: BTreeMap<usize, DiffWorker>,
|
||||
battery: Option<crate::battery::Battery>,
|
||||
battery_enabled: bool,
|
||||
last_file_refresh: Instant,
|
||||
}
|
||||
|
||||
impl EditorSession {
|
||||
@@ -272,6 +274,7 @@ impl EditorSession {
|
||||
diff_workers: BTreeMap::new(),
|
||||
battery: None,
|
||||
battery_enabled: false,
|
||||
last_file_refresh: Instant::now(),
|
||||
};
|
||||
session.synchronize_documents();
|
||||
session.poll_battery();
|
||||
@@ -1044,9 +1047,22 @@ impl EditorSession {
|
||||
changed
|
||||
}
|
||||
|
||||
fn refresh_files_at(&mut self, now: Instant) -> bool {
|
||||
if now.saturating_duration_since(self.last_file_refresh) < Duration::from_millis(250) {
|
||||
return false;
|
||||
}
|
||||
self.last_file_refresh = now;
|
||||
self.editor.refresh_files()
|
||||
}
|
||||
|
||||
/// Drains ready plugin work without waiting for command or server responses.
|
||||
pub fn poll(&mut self) -> bool {
|
||||
let mut changed = self.synchronize_documents();
|
||||
self.poll_at(Instant::now())
|
||||
}
|
||||
|
||||
fn poll_at(&mut self, now: Instant) -> bool {
|
||||
let mut changed = self.refresh_files_at(now);
|
||||
changed |= self.synchronize_documents();
|
||||
changed |= self.poll_battery();
|
||||
changed |= self.poll_diff();
|
||||
for update in self.lsp.poll() {
|
||||
@@ -3396,6 +3412,79 @@ end
|
||||
assert_eq!(session.editor().label("battery"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_poll_refreshes_clean_and_parked_files_on_the_interval_but_keeps_dirty_text() {
|
||||
let fixture = Fixture::new();
|
||||
let active = fixture.file("buffer.txt", "old");
|
||||
let parked = fixture.file("parked.txt", "parked");
|
||||
let mut session = fixture.open("");
|
||||
session.execute(&format!("edit {}", parked.display()));
|
||||
session.execute("buffer 1");
|
||||
let now = session.last_file_refresh;
|
||||
let revision = session.editor().text_revision();
|
||||
fs::write(&active, "external replacement").unwrap();
|
||||
fs::write(&parked, "external parked replacement").unwrap();
|
||||
|
||||
session.poll_at(now + Duration::from_millis(249));
|
||||
assert_eq!(session.editor().lines(), &["old"]);
|
||||
session.dispatch(Invocation {
|
||||
plugin: "core".into(),
|
||||
command: "lsp.definition".into(),
|
||||
args: String::new(),
|
||||
});
|
||||
assert!(session.requests["core"].latest.is_some());
|
||||
assert!(session.poll_at(now + Duration::from_millis(250)));
|
||||
assert!(session.requests["core"].latest.is_none());
|
||||
assert_eq!(session.editor().lines(), &["external replacement"]);
|
||||
assert!(session.editor().text_revision() > revision);
|
||||
let documents: Vec<_> = session.editor().documents().collect();
|
||||
assert_eq!(documents[1].lines, ["external parked replacement"]);
|
||||
|
||||
session.handle(Input::Char('x'));
|
||||
let dirty = session.editor().lines().to_vec();
|
||||
let revision = session.editor().text_revision();
|
||||
fs::write(&active, "second external replacement").unwrap();
|
||||
session.poll_at(now + Duration::from_millis(500));
|
||||
assert_eq!(session.editor().lines(), dirty.as_slice());
|
||||
assert_eq!(session.editor().text_revision(), revision);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&active).unwrap(),
|
||||
"second external replacement"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_external_reload_notifies_async_plugin_with_change_without_save() {
|
||||
let fixture = Fixture::new();
|
||||
let file = fixture.file("buffer.txt", "old");
|
||||
let mut session = fixture.asynchronous(Some(file.clone()));
|
||||
barrier(&mut session, "opened");
|
||||
let now = session.last_file_refresh;
|
||||
let revision = session.editor().text_revision();
|
||||
fs::write(&file, "updated external text").unwrap();
|
||||
assert!(session.poll_at(now + Duration::from_millis(250)));
|
||||
assert_eq!(session.editor().lines(), &["updated external text"]);
|
||||
let updated_revision = session.editor().text_revision();
|
||||
assert!(updated_revision > revision);
|
||||
assert_eq!(
|
||||
session.documents[&session.editor().buffer_id()]
|
||||
.snapshot
|
||||
.revision,
|
||||
updated_revision
|
||||
);
|
||||
barrier(&mut session, "reload delivered");
|
||||
|
||||
let events = fixture.events();
|
||||
let changes: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event["event"] == "change")
|
||||
.collect();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[0]["document"]["text"], "updated external text");
|
||||
assert_eq!(changes[0]["document"]["revision"], updated_revision);
|
||||
assert!(!events.iter().any(|event| event["event"] == "save"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_documents_include_unnamed_hidden_versions_real_saves_renames_and_closes() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
+107
-84
@@ -59,7 +59,8 @@ struct HookState {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
struct TerminalSession {
|
||||
/// Owns terminal modes and panic cleanup through application startup and shutdown.
|
||||
pub struct TerminalSession {
|
||||
hook: Arc<Mutex<HookState>>,
|
||||
}
|
||||
|
||||
@@ -91,10 +92,10 @@ fn restore_terminal() {
|
||||
}
|
||||
|
||||
impl TerminalSession {
|
||||
fn run(body: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
|
||||
pub fn run(inherited_screen: bool, body: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
|
||||
let hook = Arc::new(Mutex::new(HookState {
|
||||
previous: Some(panic::take_hook()),
|
||||
active: false,
|
||||
active: inherited_screen,
|
||||
}));
|
||||
let during_session = Arc::clone(&hook);
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
@@ -119,9 +120,11 @@ impl TerminalSession {
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.active = true;
|
||||
if !inherited_screen {
|
||||
execute!(io::stdout(), EnterAlternateScreen)?;
|
||||
}
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EnterAlternateScreen,
|
||||
EnableBracketedPaste,
|
||||
EnableMouseCapture,
|
||||
EnableFocusChange,
|
||||
@@ -135,6 +138,20 @@ impl TerminalSession {
|
||||
Err(payload) => panic::resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores process-local raw/input state without exposing the normal screen.
|
||||
/// The guard remains armed if launching the replacement process fails.
|
||||
pub fn prepare_restart() -> io::Result<()> {
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
io::stdout(),
|
||||
EndSynchronizedUpdate,
|
||||
DisableBracketedPaste,
|
||||
DisableMouseCapture,
|
||||
DisableFocusChange,
|
||||
ColorSchemeNotifications(false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalSession {
|
||||
@@ -737,95 +754,101 @@ fn wheel_kind(mouse: &MouseEvent) -> Option<MouseEventKind> {
|
||||
fn draw_frame<W: io::Write>(
|
||||
terminal: &mut Terminal<CrosstermBackend<Vec<u8>>>,
|
||||
output: &mut W,
|
||||
clear_screen: bool,
|
||||
render: impl FnOnce(&mut Frame),
|
||||
) -> io::Result<()> {
|
||||
// Do not suspend the terminal's presentation while computing the next frame.
|
||||
terminal.backend_mut().writer_mut().clear();
|
||||
crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
|
||||
// Terminals without synchronized updates still hide the painting cursor.
|
||||
let result = terminal
|
||||
.hide_cursor()
|
||||
.and_then(|()| terminal.draw(render).map(|_| ()));
|
||||
let result = terminal.hide_cursor().and_then(|()| {
|
||||
if clear_screen {
|
||||
// Fresh Ratatui buffers are already empty; avoid its blocking cursor query.
|
||||
crossterm::queue!(
|
||||
terminal.backend_mut(),
|
||||
crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
|
||||
)?;
|
||||
}
|
||||
terminal.draw(render).map(|_| ())
|
||||
});
|
||||
let finish = execute!(terminal.backend_mut(), EndSynchronizedUpdate);
|
||||
result.and(finish)?;
|
||||
output.write_all(terminal.backend().writer())?;
|
||||
output.flush()
|
||||
}
|
||||
|
||||
/// Renders session state and translates terminal input; the session owns plugin
|
||||
/// execution and retirement, so the adapter needs no lifecycle callback.
|
||||
pub fn run(session: &mut EditorSession) -> io::Result<()> {
|
||||
TerminalSession::run(|| {
|
||||
let mut output: Stdout = io::stdout();
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
|
||||
let mut viewports = BufferViewports::default();
|
||||
let mut colors = TerminalColors::default();
|
||||
let mut query_theme = true;
|
||||
let mut redraw = true;
|
||||
let mut pending = None;
|
||||
while !session.editor().should_quit() {
|
||||
redraw |= session.poll();
|
||||
colors.expire(Instant::now());
|
||||
if viewports.theme != colors.colors {
|
||||
viewports.set_theme(colors.colors);
|
||||
redraw = true;
|
||||
}
|
||||
if redraw {
|
||||
draw_frame(&mut terminal, &mut output, |frame| {
|
||||
viewports.draw_session(frame, session)
|
||||
})?;
|
||||
redraw = false;
|
||||
}
|
||||
// Start the reply deadline after painting, not before expensive syntax initialization.
|
||||
if query_theme || viewports.diff_visible {
|
||||
let now = Instant::now();
|
||||
if query_theme || colors.refresh_due(now) {
|
||||
colors.query(&mut output, now)?;
|
||||
query_theme = false;
|
||||
}
|
||||
}
|
||||
let input = if let Some(input) = pending.take() {
|
||||
input
|
||||
} else {
|
||||
let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
input
|
||||
};
|
||||
match input {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if session.editor().mode() == Mode::Insert && typing_key(&key) {
|
||||
viewports.panels.follow_files();
|
||||
pending = typing_ready(session, key, || {
|
||||
next_terminal_event(&mut colors, Duration::ZERO)
|
||||
})?;
|
||||
redraw = true;
|
||||
} else if let Some(input) = translate(key) {
|
||||
viewports.panels.follow_files();
|
||||
session.handle(input);
|
||||
redraw = true;
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => {
|
||||
viewports.panels.follow_files();
|
||||
session.handle(Input::Paste(text));
|
||||
redraw = true;
|
||||
}
|
||||
Event::Mouse(mouse) => {
|
||||
let (changed, following) = viewports.scroll_ready(session, mouse, || {
|
||||
next_terminal_event(&mut colors, Duration::ZERO)
|
||||
})?;
|
||||
redraw |= changed;
|
||||
pending = following;
|
||||
}
|
||||
Event::Resize(_, _) => redraw = true,
|
||||
Event::FocusGained | Event::ColorSchemeChanged => query_theme = true,
|
||||
_ => {}
|
||||
/// Renders inside an active `TerminalSession`; inherited contents are cleared
|
||||
/// inside the first synchronized frame rather than exposing a blank screen.
|
||||
pub fn run(session: &mut EditorSession, mut clear_screen: bool) -> io::Result<()> {
|
||||
let mut output: Stdout = io::stdout();
|
||||
let mut terminal = Terminal::new(CrosstermBackend::new(Vec::new()))?;
|
||||
let mut viewports = BufferViewports::default();
|
||||
let mut colors = TerminalColors::default();
|
||||
let mut query_theme = true;
|
||||
let mut redraw = true;
|
||||
let mut pending = None;
|
||||
while !session.editor().should_quit() {
|
||||
redraw |= session.poll();
|
||||
colors.expire(Instant::now());
|
||||
if viewports.theme != colors.colors {
|
||||
viewports.set_theme(colors.colors);
|
||||
redraw = true;
|
||||
}
|
||||
if redraw {
|
||||
draw_frame(&mut terminal, &mut output, clear_screen, |frame| {
|
||||
viewports.draw_session(frame, session)
|
||||
})?;
|
||||
redraw = false;
|
||||
clear_screen = false;
|
||||
}
|
||||
// Start the reply deadline after painting, not before expensive syntax initialization.
|
||||
if query_theme || viewports.diff_visible {
|
||||
let now = Instant::now();
|
||||
if query_theme || colors.refresh_due(now) {
|
||||
colors.query(&mut output, now)?;
|
||||
query_theme = false;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
let input = if let Some(input) = pending.take() {
|
||||
input
|
||||
} else {
|
||||
let Some(input) = next_terminal_event(&mut colors, Duration::from_millis(25))? else {
|
||||
continue;
|
||||
};
|
||||
input
|
||||
};
|
||||
match input {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if session.editor().mode() == Mode::Insert && typing_key(&key) {
|
||||
viewports.panels.follow_files();
|
||||
pending = typing_ready(session, key, || {
|
||||
next_terminal_event(&mut colors, Duration::ZERO)
|
||||
})?;
|
||||
redraw = true;
|
||||
} else if let Some(input) = translate(key) {
|
||||
viewports.panels.follow_files();
|
||||
session.handle(input);
|
||||
redraw = true;
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => {
|
||||
viewports.panels.follow_files();
|
||||
session.handle(Input::Paste(text));
|
||||
redraw = true;
|
||||
}
|
||||
Event::Mouse(mouse) => {
|
||||
let (changed, following) = viewports.scroll_ready(session, mouse, || {
|
||||
next_terminal_event(&mut colors, Duration::ZERO)
|
||||
})?;
|
||||
redraw |= changed;
|
||||
pending = following;
|
||||
}
|
||||
Event::Resize(_, _) => redraw = true,
|
||||
Event::FocusGained | Event::ColorSchemeChanged => query_theme = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_terminal_event(
|
||||
@@ -13927,7 +13950,7 @@ end
|
||||
)
|
||||
.unwrap();
|
||||
for text in ["first frame", "second frame", "third frame"] {
|
||||
draw_frame(&mut terminal, &mut output, |frame| {
|
||||
draw_frame(&mut terminal, &mut output, false, |frame| {
|
||||
assert!(
|
||||
!synchronized.get(),
|
||||
"terminal presentation suspended during frame computation"
|
||||
@@ -13958,7 +13981,7 @@ end
|
||||
let mut viewports = BufferViewports::default();
|
||||
for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) {
|
||||
editor.handle(Input::Char(key));
|
||||
draw_frame(&mut terminal, &mut output, |frame| {
|
||||
draw_frame(&mut terminal, &mut output, false, |frame| {
|
||||
viewports.draw(frame, &mut editor)
|
||||
})
|
||||
.unwrap();
|
||||
@@ -14023,10 +14046,10 @@ end
|
||||
}));
|
||||
if mode == "entry" {
|
||||
let mut editor = EditorSession::open(None, vec![], None).unwrap();
|
||||
assert!(run(&mut editor).is_err());
|
||||
assert!(TerminalSession::run(false, || run(&mut editor, false)).is_err());
|
||||
} else {
|
||||
let result = panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
TerminalSession::run(|| {
|
||||
TerminalSession::run(false, || {
|
||||
assert!(crossterm::terminal::is_raw_mode_enabled().unwrap());
|
||||
match mode.as_str() {
|
||||
"normal" => Ok(()),
|
||||
@@ -14038,7 +14061,7 @@ end
|
||||
viewport: ratatui::Viewport::Fixed(Rect::new(0, 0, 20, 5)),
|
||||
},
|
||||
)?;
|
||||
draw_frame(&mut terminal, &mut io::stdout(), |_| {
|
||||
draw_frame(&mut terminal, &mut io::stdout(), false, |_| {
|
||||
panic::panic_any(73_u64)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"] {
|
||||
@@ -176,3 +314,124 @@ fn builtin_canonical_and_alias_collisions_reject_the_entire_feature() {
|
||||
assert_eq!(invocation.plugin, "example");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn executable_restart_keeps_the_alternate_screen_and_restores_terminal_on_exit() {
|
||||
for scenario in ["quit", "startup-error"] {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.0.join("startup file.txt");
|
||||
fs::write(&path, "SCREEN_HANDOFF_DOCUMENT\n").unwrap();
|
||||
let output =
|
||||
std::process::Command::new(std::env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()))
|
||||
.args([
|
||||
"-rpty",
|
||||
"-rtimeout",
|
||||
"-rio/console",
|
||||
"-e",
|
||||
r#"
|
||||
scenario, binary, path = ARGV
|
||||
captured = +"".b
|
||||
status = nil
|
||||
PTY.spawn({"TERM" => "xterm-256color"}, binary, "--no-config", "--", path) do |output, input, pid|
|
||||
phase = 0
|
||||
begin
|
||||
input.winsize = [24, 100]
|
||||
Timeout.timeout(20) do
|
||||
loop do
|
||||
if IO.select([output], nil, nil, 0.05)
|
||||
begin
|
||||
chunk = output.read_nonblock(65536)
|
||||
captured << chunk
|
||||
STDOUT.write(chunk)
|
||||
rescue IO::WaitReadable
|
||||
rescue EOFError, Errno::EIO
|
||||
end
|
||||
end
|
||||
unless status
|
||||
waited = Process.waitpid2(pid, Process::WNOHANG)
|
||||
status = waited[1] if waited
|
||||
end
|
||||
if status
|
||||
# waitpid can win the race with the final output read.
|
||||
loop do
|
||||
begin
|
||||
chunk = output.read_nonblock(65536)
|
||||
captured << chunk
|
||||
STDOUT.write(chunk)
|
||||
rescue IO::WaitReadable, EOFError, Errno::EIO
|
||||
break
|
||||
end
|
||||
end
|
||||
break
|
||||
end
|
||||
|
||||
# A fresh process re-enables input reporting before its first repaint.
|
||||
# Command-overlay frames must not be mistaken for a relaunched editor.
|
||||
sessions = captured.split("\e[?2004h", -1)
|
||||
next unless sessions.length == phase + 2
|
||||
frame = sessions.last
|
||||
next unless frame.include?("\e[?2026l")
|
||||
visible = frame.gsub(/\e\[[0-?]*[ -\/]*[@-~]/, "")
|
||||
next unless visible.include?("SCREEN_HANDOFF_DOCUMENT")
|
||||
raise "alternate screen re-entered during restart" unless captured.scan("\e[?1049h").length == 1
|
||||
raise "normal screen exposed during restart" if captured.include?("\e[?1049l")
|
||||
|
||||
if scenario == "startup-error"
|
||||
File.binwrite(path, "\xff".b)
|
||||
input.write(":restart\r")
|
||||
else
|
||||
input.write([":restart\r", ":restart!\r", ":q\r"].fetch(phase))
|
||||
end
|
||||
input.flush
|
||||
phase += 1
|
||||
end
|
||||
|
||||
expected_phases = scenario == "quit" ? 3 : 1
|
||||
raise "exited before requested restart/quit" unless phase == expected_phases
|
||||
raise "unexpected exit status: #{status.inspect}" unless status.success? == (scenario == "quit")
|
||||
# Inspect the master: macOS revokes slave descriptors when their session exits.
|
||||
raise "terminal echo was not restored" unless input.echo?
|
||||
settings = IO.popen(["stty", "-a"], in: input, &:read)
|
||||
raise "terminal remained noncanonical: #{settings}" unless settings.match?(/(?:^|[;\s])icanon(?:[;\s]|$)/)
|
||||
end
|
||||
ensure
|
||||
unless status
|
||||
begin
|
||||
Process.kill("KILL", pid)
|
||||
rescue Errno::ESRCH
|
||||
end
|
||||
begin
|
||||
Process.waitpid(pid)
|
||||
rescue Errno::ECHILD
|
||||
end
|
||||
end
|
||||
input.close
|
||||
end
|
||||
end
|
||||
"#,
|
||||
"--",
|
||||
scenario,
|
||||
env!("CARGO_BIN_EXE_vamp"),
|
||||
])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.expect("Ruby with stdlib PTY and io/console is required (set VAMP_RUBY)");
|
||||
let captured = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{scenario}: {}\nterminal output: {captured:?}",
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
assert_eq!(
|
||||
captured.matches("\x1b[?1049h").count(),
|
||||
1,
|
||||
"{scenario}: {captured:?}",
|
||||
);
|
||||
assert_eq!(
|
||||
captured.matches("\x1b[?1049l").count(),
|
||||
1,
|
||||
"{scenario}: {captured:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user