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
//! 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.buffer.path.is_none() && self.current_scratch_id == Some(id) {
return Ok(());
}
self.lsp.detach_current();
// Look in `parked_buffers` first — another pane may
// already be displaying this scratch 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.
let next = if let Some(parked) = self.parked_buffers.remove(&BufferRef::Scratch(id))
{
parked
} else {
match self.sleeping.remove(&BufferRef::Scratch(id)) {
Some(b) => b.thaw(),
None => Buffer::new(),
}
};
self.stash_and_install(next);
self.current_scratch_id = Some(id);
self.open_gen = self.open_gen.wrapping_add(1);
self.lsp.set_last_synced_version(self.buffer.version);
self.record_opened(BufferRef::Scratch(id));
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
.buffer
.path
.as_ref()
.and_then(|p| p.canonicalize().ok());
if current.as_ref() == Some(&path) {
return Ok(());
}
self.open_path(&path)
}
}
}
/// Move the currently-active buffer out of `App.buffer` and
/// install `next` in its place. Two destinations for the outgoing
/// buffer, depending on whether any inactive pane still needs it:
///
/// - **In use by another pane:** park it into
/// `App.parked_buffers[ref]`, alive and uncompressed. Dropping
/// it to `sleeping` would freeze it (and lose the highlighter),
/// breaking the other pane's render.
/// - **Not in use anywhere:** stash it into `sleeping`
/// (compressed; highlighter dropped, rebuilt on restore). The
/// version counter is preserved so LSP `didChange` sequencing
/// re-anchors cleanly when the buffer wakes up again.
pub(super) fn stash_and_install(&mut self, next: Buffer) {
let key = self.active_ref();
let prev = std::mem::replace(&mut self.buffer, next);
if self.ref_used_by_inactive_pane(&key) {
self.parked_buffers.insert(key, prev);
} else {
let mut prev = prev;
prev.highlighter = None;
self.sleeping.insert(key, SleepingBuffer::freeze(prev));
}
}
/// Install `next` as the active buffer without stashing the
/// previous one. Used by `:bd` where the deleted buffer is
/// supposed to vanish entirely. Callers must have already cleaned
/// up any MRU / sleeping entries that refer to the outgoing
/// buffer.
pub(super) fn install_buffer(&mut self, next: Buffer) {
let _ = std::mem::replace(&mut self.buffer, next);
}
/// [`BufferRef`] for the currently-active buffer. Unnamed buffers
/// carry the scratch id tracked on `App` so multiple scratches
/// stay distinguishable; falls back to `Scratch(0)` if the id is
/// somehow missing (shouldn't happen — the field is always set
/// in sync with `buffer.path`).
pub(super) fn active_ref(&self) -> BufferRef {
match &self.buffer.path {
Some(p) => BufferRef::File(p.canonicalize().unwrap_or_else(|_| p.clone())),
None => BufferRef::Scratch(self.current_scratch_id.unwrap_or(0)),
}
}
/// Open `path`. Lookup order:
///
/// 1. **Parked** (`App.parked_buffers`): another pane already
/// shows this buffer. Pull it out, the active pane now shares
/// that live buffer.
/// 2. **Sleeping**: the user previously visited and switched
/// away. Wake the compressed snapshot.
/// 3. **Fresh disk read** as fallback.
pub fn open_path(&mut self, path: &Path) -> Result<()> {
let path = self.absolutize(path);
let canon = path.canonicalize().unwrap_or_else(|_| path.clone());
let key = BufferRef::File(canon);
if let Some(parked) = self.parked_buffers.remove(&key) {
self.lsp.detach_current();
self.stash_and_install(parked);
self.current_scratch_id = None;
self.record_opened(key);
self.lsp.set_last_synced_version(self.buffer.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 let Some(restored) = self.sleeping.remove(&key) {
self.lsp.detach_current();
self.stash_and_install(restored.thaw());
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.buffer.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());
// Tell the previous LSP client we're done with that document so
// it can drop diagnostics and stop watching it.
self.lsp.detach_current();
self.stash_and_install(loaded);
self.current_scratch_id = None;
// Re-loading a path drops any previously-sleeping copy of it
// — the user explicitly asked for the disk version.
self.sleeping.remove(&BufferRef::File(canon.clone()));
self.record_opened(BufferRef::File(canon));
// 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.buffer.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).
if let Some(entry) = self.preview_lru.borrow_mut().take(path) {
self.buffer.highlighter = None;
let mut h = entry.highlighter;
let source = self.buffer.lines.join("\n");
h.refresh(&source, self.buffer.version);
self.buffer.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: the active buffer's path, each parked buffer's path
/// and its hashmap key, every sleeping buffer's stored path and
/// hashmap key, the MRU list, and the per-pane buffer-ref map.
/// 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) {
if let Some(p) = self.buffer.path.as_ref()
&& let Some(updated) = remap_path(p, old, new)
{
self.buffer.path = Some(updated);
}
// Parked buffers — rekey + update each buffer's own `path`.
let parked_remap: Vec<(BufferRef, PathBuf)> = self
.parked_buffers
.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 parked_remap {
if let Some(mut buf) = self.parked_buffers.remove(&old_ref) {
buf.path = Some(new_path.clone());
self.parked_buffers.insert(BufferRef::File(new_path), buf);
}
}
// Sleeping buffers — same shape as parked, but 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);
}
}
// Inactive pane refs.
for r in self.pane_refs.values_mut() {
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))
}