feat: reload clean buffers and guard external-change writes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
+422
-19
@@ -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",
|
||||
@@ -734,6 +742,14 @@ core_actions![
|
||||
&[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 +785,11 @@ 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),
|
||||
("wq", Action::SaveAndQuit),
|
||||
("wq!", Action::ForceSaveAndQuit),
|
||||
("bnext", Action::BufferNext),
|
||||
("bprev", Action::BufferPrevious),
|
||||
("bdelete", Action::BufferDelete),
|
||||
@@ -833,7 +851,9 @@ impl Action {
|
||||
| Self::PasteSystemAfter
|
||||
| Self::Newline
|
||||
| Self::Save
|
||||
| Self::ForceSave
|
||||
| Self::SaveAndQuit
|
||||
| Self::ForceSaveAndQuit
|
||||
| Self::BufferDelete
|
||||
| Self::BufferForceDelete
|
||||
| Self::LspCompletion
|
||||
@@ -1285,6 +1305,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 +1346,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 +1388,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 +1409,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 +1425,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 +1433,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 +1443,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 +1465,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,
|
||||
@@ -3408,7 +3533,13 @@ 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)
|
||||
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))
|
||||
}
|
||||
@@ -5714,9 +5845,11 @@ impl Editor {
|
||||
| Action::TabClose
|
||||
| Action::TabForceClose
|
||||
| Action::Save
|
||||
| Action::ForceSave
|
||||
| Action::Quit
|
||||
| Action::ForceQuit
|
||||
| Action::SaveAndQuit
|
||||
| Action::ForceSaveAndQuit
|
||||
| Action::Plugins
|
||||
| Action::Edit
|
||||
| Action::Buffers
|
||||
@@ -6592,12 +6725,16 @@ impl Editor {
|
||||
args: String::new(),
|
||||
});
|
||||
}
|
||||
Action::Save | Action::SaveAndQuit => {
|
||||
if self.save(if args.is_empty() {
|
||||
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))
|
||||
}) && action == Action::SaveAndQuit
|
||||
},
|
||||
force,
|
||||
) && matches!(action, Action::SaveAndQuit | Action::ForceSaveAndQuit)
|
||||
{
|
||||
if self.any_dirty() {
|
||||
self.set_message("Unsaved changes in other buffers; use :w or :q!");
|
||||
@@ -6729,7 +6866,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 +6943,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 +9602,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 +9662,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 +9718,176 @@ 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 notification_escape_preserves_carets_then_ordinary_escape_collapses_them() {
|
||||
let mut editor = editor();
|
||||
@@ -18788,16 +19186,16 @@ mod tests {
|
||||
editor
|
||||
.apply(vec![Effect::Replace("dirty b".into())])
|
||||
.unwrap();
|
||||
editor.execute(&format!("w {}", directory.0.join("./a").display()));
|
||||
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!("wq {}", a.display()));
|
||||
assert!(!editor.should_quit());
|
||||
assert!(editor.dirty());
|
||||
}
|
||||
editor.execute(&format!("w {}", directory.0.join("./b").display()));
|
||||
assert!(!editor.dirty());
|
||||
assert_eq!(fs::read_to_string(&b).unwrap(), "dirty b");
|
||||
@@ -18879,6 +19277,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 +19509,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 +21340,11 @@ mod tests {
|
||||
editor
|
||||
.apply(vec![Effect::Replace("not saved".into())])
|
||||
.unwrap();
|
||||
editor.execute("wq");
|
||||
for command in ["wq", "wq!"] {
|
||||
editor.execute(command);
|
||||
assert!(editor.dirty());
|
||||
assert!(!editor.should_quit());
|
||||
}
|
||||
assert!(
|
||||
fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
|
||||
+11
-3
@@ -40,11 +40,19 @@ 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
|
||||
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)
|
||||
|
||||
+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();
|
||||
|
||||
Reference in New Issue
Block a user