1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
//! File-open orchestration: load the buffer synchronously and stash
//! the previous one. The expensive follow-up work (tree-sitter
//! highlighter build, LSP server spawn) is fanned out via
//! [`super::workers`]; multi-buffer cycling and deletion live in
//! [`super::buffer_list`].
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::editor::Buffer;
use crate::buffer_ref::BufferRef;
use super::{App, SleepingBuffer, Toast};
impl App {
/// Dispatch a buffer-picker selection. Scratch and File both go
/// through the same stash-and-restore flow as
/// [`Self::open_path`] — if the target has a sleeping snapshot
/// (with preserved unsaved edits, cursor, undo history), we
/// restore it; otherwise we fall through to a fresh load.
pub fn switch_to_buffer(&mut self, r: BufferRef) -> Result<()> {
match r {
BufferRef::Scratch(id) => {
if self.active_doc().path.is_none() && self.current_scratch_id == Some(id) {
return Ok(());
}
// Switching to a scratch buffer is a jump (the File arm
// records via `open_path`; this arm has no such path).
self.record_jump();
self.lsp.detach_current();
let key = BufferRef::Scratch(id);
// Ensure the target document is in the pool — another
// pane may already show it live; otherwise thaw from
// `sleeping`, or mint a fresh empty buffer.
// `Buffer::new` (one empty line) ≠ `Buffer::default`
// (zero lines), so we can't use `unwrap_or_default`
// here — the wrong default would leave the buffer
// with an empty `lines` Vec and crash motions.
self.ensure_doc_pooled(&key, Buffer::new);
self.stash_and_install(key.clone());
self.current_scratch_id = Some(id);
self.open_gen = self.open_gen.wrapping_add(1);
self.lsp.set_last_synced_version(self.active_doc().version);
self.record_opened(key);
self.push_toast(Toast::info(BufferRef::scratch_label(id)));
Ok(())
}
BufferRef::File(path) => {
// Already on this file? Leave cursor/unsaved state alone.
let current = self
.active_doc()
.path
.as_ref()
.and_then(|p| p.canonicalize().ok());
if current.as_ref() == Some(&path) {
return Ok(());
}
self.open_path(&path)
}
}
}
/// Ensure the document named by `key` lives in the pool. If it's
/// already present (e.g. another pane shows it live) nothing
/// happens; if it's sleeping it's thawed back; otherwise `build`
/// mints a fresh document. The document's session (cursor/mode) is
/// NOT part of the pool — callers wrap it in a fresh [`Editor`].
pub(super) fn ensure_doc_pooled(&mut self, key: &BufferRef, build: impl FnOnce() -> Buffer) {
if self.documents.contains_key(key) {
return;
}
let doc = match self.sleeping.remove(key) {
Some(s) => s.thaw(),
None => build(),
};
self.documents.insert(key.clone(), doc);
}
/// Switch the active session to the document named by `next` (which
/// must already be present in the pool), then retire the document the
/// session was previously editing if nothing else references it.
///
/// The session itself persists across the switch — only its `doc` is
/// re-pointed (see [`Self::swap_active_doc`]) — so its jumplist and
/// per-buffer cursor memory survive, matching vim's per-window
/// jumplist. The outgoing document goes to `sleeping` (compressed;
/// highlighter dropped, rebuilt on restore) when no remaining session
/// — the active one or any inactive pane — names its ref. The version
/// counter is preserved so LSP `didChange` sequencing re-anchors
/// cleanly when the document wakes up again. When some pane still
/// shows it, it stays live in the pool untouched.
pub(super) fn stash_and_install(&mut self, next: BufferRef) {
let prev_ref = self.editor.doc.clone();
self.swap_active_doc(next);
self.retire_doc_if_unreferenced(prev_ref);
}
/// Re-point the active session at `next` (which must already be
/// pooled) without touching document retirement. Stashes the
/// outgoing buffer's cursor into this session's `cursor_memory`, then
/// restores `next`'s remembered cursor — the origin on a first visit
/// — clamped to the incoming document. Multi-cursors and pending
/// command tokens are dropped and the mode reset to Normal, matching
/// a fresh view; the jumplist and cursor memory ride along on the
/// persistent session.
fn swap_active_doc(&mut self, next: BufferRef) {
let prev = self.editor.doc.clone();
if prev != next {
self.editor.cursor_memory.insert(prev, self.editor.cursor);
}
let doc = self
.documents
.get(&next)
.expect("target doc present in pool");
self.editor.adopt_doc(next, doc);
}
/// Move `r`'s document to `sleeping` when no live session (the
/// active editor or any inactive pane) references it. A no-op when
/// the document is still shown somewhere — it stays live in the
/// pool so that pane keeps rendering.
pub(super) fn retire_doc_if_unreferenced(&mut self, r: BufferRef) {
if self.editor.doc == r || self.ref_used_by_inactive_pane(&r) {
return;
}
if let Some(mut doc) = self.documents.remove(&r) {
doc.highlighter = None;
self.sleeping.insert(r, SleepingBuffer::freeze(doc));
}
}
/// Install the document named by `successor` (already pooled) as the
/// active session after a `:bd`, where the deleted buffer is supposed
/// to vanish entirely. Callers must have already removed the outgoing
/// document from the pool and cleaned up any MRU / sleeping entries
/// that refer to it. Unlike [`Self::stash_and_install`] the outgoing
/// document is NOT retired to `sleeping` — it's gone for good.
pub(super) fn install_buffer(&mut self, successor: BufferRef) {
let deleted = self.editor.doc.clone();
self.swap_active_doc(successor.clone());
// The deleted document is gone for good — purge its remembered
// cursor from every session so a later buffer reusing the ref
// can't inherit a stale position. (`swap_active_doc` just stashed
// it; undo that here.)
self.forget_cursor_memory(&deleted);
// Any inactive pane still showing the deleted buffer (e.g. after
// `:split`) would be left with a dangling `doc` ref and panic on
// its next render/focus, so move those sessions onto the
// successor too — matching vim, where a window showing a
// bdeleted buffer switches to the replacement. Route them through
// the same `adopt_doc` normalization as the active session so an
// inactive pane doesn't keep stale `mode`/`tokens` (it gets no
// input until refocused, and `focus_pane` doesn't normalize) and
// restores its own remembered cursor for the successor instead of
// snapping to the origin.
if deleted != successor {
let doc = self
.documents
.get(&successor)
.expect("successor doc present in pool");
for content in self.pane_content.values_mut() {
if let crate::app::PaneContent::Editor(ed) = content
&& ed.doc == deleted
{
ed.adopt_doc(successor.clone(), doc);
}
}
}
}
/// Drop `r`'s remembered cursor from every editor session (the active
/// one and each inactive pane), so a `:bd`-deleted buffer leaves no
/// stale position behind.
fn forget_cursor_memory(&mut self, r: &BufferRef) {
self.editor.cursor_memory.remove(r);
self.editor.fold_memory.remove(r);
for content in self.pane_content.values_mut() {
if let crate::app::PaneContent::Editor(ed) = content {
ed.cursor_memory.remove(r);
ed.fold_memory.remove(r);
}
}
}
/// [`BufferRef`] for the currently-active document — simply the
/// active session's `doc`, the authoritative pool key.
pub(super) fn active_ref(&self) -> BufferRef {
self.editor.doc.clone()
}
/// Open `path`. Lookup order:
///
/// 1. **Already pooled** (`App.documents`): another pane already
/// shows this document. The active session re-points at the same
/// live ref, sharing it.
/// 2. **Sleeping**: the user previously visited and switched
/// away. Wake the compressed snapshot into the pool.
/// 3. **Fresh disk read** as fallback.
pub fn open_path(&mut self, path: &Path) -> Result<()> {
// Leaving the current buffer is a jump — record the origin so
// `Ctrl-O` returns here. No-op while navigating the jumplist.
self.record_jump();
let path = self.absolutize(path);
let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
let key = BufferRef::File(canon);
if self.documents.contains_key(&key) {
// Another pane already shows this document live — share it.
self.lsp.detach_current();
self.stash_and_install(key.clone());
self.current_scratch_id = None;
self.record_opened(key);
self.lsp.set_last_synced_version(self.active_doc().version);
self.push_toast(Toast::info(format!("opened {} (shared)", path.display())));
// Buffer was live — highlighter survives; LSP resync only.
self.spawn_lsp_worker(&path);
return Ok(());
}
if self.sleeping.contains_key(&key) {
self.lsp.detach_current();
self.ensure_doc_pooled(&key, Buffer::new);
self.stash_and_install(key.clone());
self.current_scratch_id = None;
self.record_opened(key);
self.open_gen = self.open_gen.wrapping_add(1);
self.lsp.set_last_synced_version(self.active_doc().version);
self.push_toast(Toast::info(format!("restored {}", path.display())));
self.spawn_engine_worker(&path);
self.spawn_vcs_worker();
self.spawn_lsp_worker(&path);
return Ok(());
}
self.open_path_force(&path)
}
/// Resolve a user-supplied path to an absolute path against
/// `startup_cwd`. Doesn't touch the filesystem — works for files
/// that don't exist yet, which `canonicalize()` rejects. Critical
/// for `:e new_file.rs`: without absolutizing, the relative path
/// flows into [`crate::lsp::path_to_uri`] which produces a broken
/// `file:///new_file.rs` URI (no directory), and the LSP server
/// silently ignores the document.
fn absolutize(&self, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
self.startup_cwd.join(path)
}
}
/// Open `path` from disk, discarding any sleeping copy. Used on
/// the initial command-line load and as the fall-through for
/// `open_path` when there's no sleeping snapshot to restore.
pub fn open_path_force(&mut self, path: &Path) -> Result<()> {
// Load up front — if this fails we want to leave the active
// buffer alone. Missing files are treated as a new, unsaved
// buffer attached to `path` so `:w` materializes the file.
let (loaded, is_new) = match Buffer::load(path) {
Ok(b) => (b, false),
Err(e)
if e.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound) =>
{
let mut b = Buffer::new();
b.path = Some(path.to_path_buf());
(b, true)
}
Err(e) => return Err(e),
};
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let key = BufferRef::File(canon.clone());
// Tell the previous LSP client we're done with that document so
// it can drop diagnostics and stop watching it.
self.lsp.detach_current();
// Re-loading a path drops any previously-sleeping copy of it
// — the user explicitly asked for the disk version.
self.sleeping.remove(&key);
// Pool the freshly-loaded document under its ref, then point a
// new active session at it.
self.documents.insert(key.clone(), loaded);
self.stash_and_install(key.clone());
self.current_scratch_id = None;
self.record_opened(key);
// Bump the generation: any in-flight worker thread from a
// previous `open_path` is now stale. Its result will be dropped
// when it lands instead of clobbering this buffer.
self.open_gen = self.open_gen.wrapping_add(1);
// Pre-seed the LSP sync version so the first `didChange` after
// open is a no-op when nothing has changed since load.
self.lsp.set_last_synced_version(self.active_doc().version);
self.push_toast(if is_new {
Toast::info(format!("{} [new file]", path.display()))
} else {
Toast::info(format!("opened {}", path.display()))
});
// If the fuzzy preview worker already built a highlighter for
// this path, steal it: we're about to render the buffer and the
// tree is ready right now. Saves a worker round-trip and the
// "plain text → highlighted" flash. Re-`refresh` against the
// buffer's source/version so the cached tree's incremental diff
// re-anchors on whatever `Buffer::load` just read (usually a
// no-op because the file hasn't changed since the preview ran).
let preview_entry = self.preview_lru.borrow_mut().take(path);
if let Some(entry) = preview_entry {
let doc = self.active_doc_mut();
doc.highlighter = None;
let mut h = entry.highlighter;
let source = doc.lines.join("\n");
h.refresh(&source, doc.version);
doc.highlighter = Some(h);
} else {
self.spawn_engine_worker(path);
}
self.spawn_vcs_worker();
self.spawn_lsp_worker(path);
Ok(())
}
/// Re-point every buffer that referenced `old` (or anything under
/// it, for a directory move) at `new`. Used after the explorer's
/// rename/move so an open buffer's next save lands at the moved
/// file's new location instead of resurrecting the source.
///
/// Touches: every pooled document's path and its hashmap key, the
/// active session's `doc` ref and every inactive pane session's
/// `doc` ref, every sleeping buffer's stored path and hashmap key,
/// and the MRU list. The order matters only in that we collect
/// remap targets first and apply them in a second pass — mutating a
/// hashmap while iterating it is otherwise rejected by the borrow
/// checker.
pub fn rewrite_buffer_paths(&mut self, old: &Path, new: &Path) {
// Pooled documents — rekey + update each document's own `path`.
let doc_remap: Vec<(BufferRef, PathBuf)> = self
.documents
.keys()
.filter_map(|k| match k {
BufferRef::File(p) => remap_path(p, old, new).map(|np| (k.clone(), np)),
_ => None,
})
.collect();
for (old_ref, new_path) in doc_remap {
let new_ref = BufferRef::File(new_path.clone());
if let Some(mut doc) = self.documents.remove(&old_ref) {
doc.path = Some(new_path);
self.documents.insert(new_ref.clone(), doc);
}
// Re-point any session naming the old ref at the new one.
if self.editor.doc == old_ref {
self.editor.doc = new_ref.clone();
}
for content in self.pane_content.values_mut() {
if let crate::app::PaneContent::Editor(ed) = content
&& ed.doc == old_ref
{
ed.doc = new_ref.clone();
}
}
}
// Sleeping buffers — the path lives
// behind setter/getter so we can keep the freeze-compressed
// payload untouched.
let sleep_remap: Vec<(BufferRef, PathBuf)> = self
.sleeping
.iter()
.filter_map(|(k, s)| match (k, s.path()) {
(BufferRef::File(kp), Some(_)) => {
remap_path(kp, old, new).map(|np| (k.clone(), np))
}
_ => None,
})
.collect();
for (old_ref, new_path) in sleep_remap {
if let Some(mut sleep) = self.sleeping.remove(&old_ref) {
sleep.set_path(Some(new_path.clone()));
self.sleeping.insert(BufferRef::File(new_path), sleep);
}
}
// MRU list — keep order, just rewrite the path inside each ref.
for r in &mut self.opened_paths {
if let BufferRef::File(p) = r
&& let Some(np) = remap_path(p, old, new)
{
*r = BufferRef::File(np);
}
}
}
}
/// Remap a single path against an `old → new` move. Returns `Some(new
/// path)` when `p` is either exactly `old` (a single-file rename) or
/// lies under `old/` (caught by a directory move). Returns `None` when
/// the path is unaffected so the caller can skip it cheaply.
fn remap_path(p: &Path, old: &Path, new: &Path) -> Option<PathBuf> {
if p == old {
return Some(new.to_path_buf());
}
p.strip_prefix(old).ok().map(|suffix| new.join(suffix))
}