strop_engine/editor/document/
mod.rs1use strop_core::Buffer;
6
7mod indentation;
8pub(crate) mod remote;
9pub mod surfaces;
10pub use indentation::{detect_indent, Detection, Indent, IndentOverride, IndentSource};
11mod directory;
12pub use directory::Directory;
13pub use remote::RemoteDocument;
14mod snapshot;
15pub(crate) use snapshot::last_position;
16
17pub use super::jumps::JumpRecord;
18pub use surfaces::{DiffRow, DocumentSource, Surface};
19
20use super::Editor;
21
22pub struct Document {
26 pub buf: Buffer,
27 pub(crate) syntax_hint: Option<std::path::PathBuf>,
28 pub indent: Indent,
31 pub indent_override: IndentOverride,
34 pub detection: Option<Detection>,
37 pub source: DocumentSource,
40}
41
42impl Document {
43 pub fn label(&self, cwd: &std::path::Path) -> String {
44 if let Some(remote) = self.remote_metadata() {
45 remote.file.to_string()
46 } else if let DocumentSource::Container { container, path } = &self.source {
47 strop_workspace::ResourceLocation {
48 filesystem: strop_workspace::Filesystem::Container(container.clone()),
49 path: path.clone(),
50 }
51 .label()
52 } else {
53 self.buf
54 .path
55 .as_ref()
56 .map(|path| path.strip_prefix(cwd).unwrap_or(path).display().to_string())
57 .or_else(|| self.buf.name.clone())
58 .unwrap_or_else(|| "[scratch]".into())
59 }
60 }
61
62 pub(crate) fn documentation(mut buffer: Buffer) -> Self {
63 buffer.name = Some("documentation".into());
64 let mut document = Self::output(buffer);
65 document.syntax_hint = Some(std::path::PathBuf::from("documentation.md"));
66 document
67 }
68 pub fn new(buf: Buffer) -> Self {
69 let detection = Some(detect_indent(buf.text()));
70 Self {
71 buf,
72 syntax_hint: None,
73 indent: Indent::default(),
74 indent_override: IndentOverride::default(),
75 detection,
76 source: DocumentSource::File,
77 }
78 }
79
80 pub fn scratch(buf: Buffer) -> Self {
82 let detection = Some(detect_indent(buf.text()));
83 Self {
84 buf,
85 syntax_hint: None,
86 indent: Indent::default(),
87 indent_override: IndentOverride::default(),
88 detection,
89 source: DocumentSource::Scratch,
90 }
91 }
92
93 pub fn surface(mut buf: Buffer, surface: Surface, context: strop_git::GitContext) -> Self {
96 buf.readonly = true;
97 Self {
98 buf,
99 syntax_hint: None,
100 indent: Indent::default(),
101 source: DocumentSource::Surface(Box::new(surfaces::GitSurface {
102 context,
103 content: surface,
104 })),
105 indent_override: IndentOverride::default(),
106 detection: None,
107 }
108 }
109
110 pub fn output(mut buf: Buffer) -> Self {
115 buf.readonly = true;
116 Self {
117 buf,
118 syntax_hint: None,
119 indent: Indent::default(),
120 indent_override: IndentOverride::default(),
121 detection: None,
122 source: DocumentSource::Output { return_to: None },
123 }
124 }
125
126 pub fn container_file(
129 mut buf: Buffer,
130 container: strop_workspace::ContainerId,
131 path: std::path::PathBuf,
132 ) -> Self {
133 buf.readonly = true;
134 let detection = Some(detect_indent(buf.text()));
135 Self {
136 buf,
137 syntax_hint: None,
138 indent: Indent::default(),
139 indent_override: IndentOverride::default(),
140 detection,
141 source: DocumentSource::Container { container, path },
142 }
143 }
144
145 pub fn syntax_path(&self) -> Option<&std::path::Path> {
147 if let Some(path) = &self.syntax_hint {
148 return Some(path);
149 }
150 match &self.source {
151 DocumentSource::Remote(source) => Some(source.file.path()),
152 DocumentSource::Surface(source) => match &source.content {
153 Surface::Diff {
154 commit: Some(commit),
155 ..
156 } => Some(&commit.current),
157 Surface::Diff { hunks, .. } => Some(std::path::Path::new(hunks.label())),
158 _ => None,
159 },
160 DocumentSource::File | DocumentSource::Scratch => self.buf.path.as_deref(),
161 DocumentSource::Container { path, .. } => Some(path),
162 DocumentSource::Directory(_) | DocumentSource::Output { .. } => None,
163 }
164 }
165
166 pub fn matches_target(&self, target: &crate::files::FileTarget) -> bool {
167 match (&self.source, target) {
168 (DocumentSource::Remote(source), crate::files::FileTarget::Remote(location)) => {
169 location.absolute_file() == Some(&source.file)
170 }
171 (DocumentSource::Directory(source), target) => {
172 target.matches_location(&source.location)
173 }
174 (
175 DocumentSource::Container { container, path },
176 crate::files::FileTarget::Container {
177 container: expected,
178 path: requested,
179 },
180 ) => container == expected && path == requested,
181 (DocumentSource::File, crate::files::FileTarget::Local(path)) => {
182 self.buf.path.as_ref() == Some(path)
183 || self.buf.file_identity() == Some(path.as_path())
184 }
185 _ => false,
186 }
187 }
188
189 pub(crate) fn file_target(&self, cwd: &std::path::Path) -> Option<crate::files::FileTarget> {
190 use crate::files::FileTarget;
191 match &self.source {
192 DocumentSource::Remote(source) => Some(FileTarget::Remote(source.file.clone().into())),
193 DocumentSource::Directory(source) => FileTarget::from_location(&source.location).ok(),
194 DocumentSource::Container { container, path } => Some(FileTarget::Container {
195 container: container.clone(),
196 path: path.clone(),
197 }),
198 _ => self
199 .buf
200 .file_identity()
201 .or(self.buf.path.as_deref())
202 .map(|path| FileTarget::Local(cwd.join(path))),
203 }
204 }
205
206 pub fn surface_payload(&self) -> Option<&Surface> {
208 match &self.source {
209 DocumentSource::Surface(s) => Some(&s.content),
210 _ => None,
211 }
212 }
213
214 pub fn surface_payload_mut(&mut self) -> Option<&mut Surface> {
216 match &mut self.source {
217 DocumentSource::Surface(s) => Some(&mut s.content),
218 _ => None,
219 }
220 }
221
222 pub(crate) fn git_context(&self) -> Option<&strop_git::GitContext> {
223 match &self.source {
224 DocumentSource::Surface(surface) => Some(&surface.context),
225 _ => None,
226 }
227 }
228}
229
230impl Editor {
231 pub fn touch_mru(&mut self, i: strop_core::id::DocumentId) {
233 self.mru.retain(|&x| x != i);
234 self.mru.insert(0, i);
235 }
236
237 pub fn cur(&self) -> &Document {
240 self.docs
241 .get(self.current())
242 .expect("invariant: current document is live")
243 }
244
245 pub(crate) fn cur_mut(&mut self) -> super::transact::DocumentEdit<'_> {
246 self.doc_mut(self.current())
247 }
248
249 pub fn buf(&self) -> &Buffer {
250 &self.cur().buf
251 }
252
253 pub fn buf_mut(&mut self) -> super::transact::BufferEdit<'_> {
254 super::transact::BufferEdit::new(self.cur_mut())
255 }
256
257 pub fn doc(&self, id: strop_core::id::DocumentId) -> &Document {
261 self.docs.get(id).expect("stale document id")
262 }
263
264 pub(crate) fn doc_mut(
265 &mut self,
266 id: strop_core::id::DocumentId,
267 ) -> super::transact::DocumentEdit<'_> {
268 super::transact::DocumentEdit::new(self, id)
269 }
270
271 #[cfg(any(test, feature = "test-support"))]
274 pub fn first_doc(&self) -> strop_core::id::DocumentId {
275 self.docs
276 .iter()
277 .next()
278 .map(|(id, _)| id)
279 .expect("test document")
280 }
281
282 pub(crate) fn drop_stale_scratch(&mut self, replacement: strop_core::id::DocumentId) {
287 let scratch = self.docs.iter().find_map(|(id, d)| {
291 let b = &d.buf;
292 (b.path.is_none()
293 && !b.dirty
294 && b.len_bytes() == 0
295 && b.name.is_none()
296 && id != replacement)
297 .then_some(id)
298 });
299 let Some(scratch) = scratch else {
300 return;
301 };
302 if self
303 .pending
304 .prompt()
305 .is_some_and(|prompt| prompt.origin().pane.doc == scratch)
306 {
307 self.cancel_pending();
308 }
309 for pane in &mut self.panes {
310 if pane.doc == scratch {
311 pane.doc = replacement;
312 }
313 }
314 self.lsp_close_document(scratch);
315 self.docs.remove(scratch);
316 self.mru.retain(|&x| x != scratch);
317 if self.view().doc == scratch {
318 self.view_mut().doc = replacement;
319 }
320 }
321
322 #[inline]
324 pub fn current(&self) -> strop_core::id::DocumentId {
325 self.view().doc
326 }
327
328 pub fn switch_to(&mut self, id: strop_core::id::DocumentId) {
330 if self.current() != id {
331 self.remember_directory_view();
332 }
333 self.cancel_pending();
334 self.cancel_open(strop_core::worker::CancelReason::Superseded);
335 self.focus_epoch += 1;
336 if self.current() != id {
337 let view = self.view_mut();
338 view.sels = strop_core::selection::SelectionSet::default();
339 view.view_top = 0;
340 view.hscroll = strop_core::id::DisplayColumn::new(0);
341 view.desired_column = None;
342 }
343 self.view_mut().doc = id;
344 self.touch_mru(id);
345 }
346
347 pub(crate) fn open_temporary_output(
355 &mut self,
356 buf: strop_core::Buffer,
357 ) -> strop_core::id::DocumentId {
358 self.push_jump();
359 let mut document = Document::output(buf);
360 document.set_return_point(self.jump_record());
361 let id = self.docs.insert(document);
362 self.drop_stale_scratch(id);
363 self.switch_to(id);
364 self.set_head(0);
365 id
366 }
367
368 #[inline]
370 pub fn sels(&self) -> &strop_core::selection::SelectionSet {
371 &self.view().sels
372 }
373
374 #[inline]
375 pub fn sels_mut(&mut self) -> &mut strop_core::selection::SelectionSet {
376 &mut self.view_mut().sels
377 }
378
379 #[inline]
381 pub fn view_rows(&self) -> usize {
383 self.view_rows
384 }
385
386 pub fn view_top(&self) -> usize {
387 self.view().view_top
388 }
389
390 pub fn close_buffer(&mut self, force: bool) -> bool {
394 self.remember_directory_view();
395 if !force && self.docs.len() == 1 && self.filesystem.unconfirmed() > 0 {
396 self.message =
397 "filesystem outcomes are unconfirmed; :fs verify before quitting, or :q! to force"
398 .into();
399 return false;
400 }
401 let view_only = self.collections.contains_key(&self.current());
404 if self.buf().dirty && !force && !view_only {
405 self.message = "unsaved changes — :q! to force".into();
406 return false;
407 }
408 self.cancel_pending();
409 self.cancel_open(strop_core::worker::CancelReason::OwnerClosed);
410 if self.docs.len() == 1 {
411 self.request_session_save();
412 }
413 let closed = self.current();
414 let mut affected_collections = Vec::new();
415 for (id, collection) in &mut self.collections {
416 let before = collection.excerpts.len();
417 collection
418 .excerpts
419 .retain(|excerpt| excerpt.source != closed);
420 collection
421 .pending_commit
422 .retain(|(source, _)| *source != closed);
423 if collection.excerpts.len() != before {
424 collection.match_count = collection
425 .excerpts
426 .iter()
427 .map(|excerpt| excerpt.matches.len().max(excerpt.hit_anchors.len()))
428 .sum();
429 affected_collections.push(*id);
430 }
431 }
432 self.revoke_remote_write(closed);
433 self.analysis
434 .forget(super::analysis::AnalysisTarget::Document(closed));
435 self.stop_remote_follow(closed);
436 self.cancel_directory_filter(closed);
437 self.lsp_close_document(closed);
438 self.shell_document_closed(closed);
439 self.revoke_git_requests_for(closed);
440 self.blame_gutters.remove(&closed);
441 self.collections.remove(&closed);
442 self.review.forget(closed);
443 self.filesystem_forget_view(closed);
444 let return_to = self
445 .docs
446 .remove(closed)
447 .and_then(|document| document.return_point().cloned());
448 if self.docs.is_empty() {
449 self.collection_build = None;
450 self.panes.clear();
451 self.active_pane = 0;
452 self.should_quit = true;
453 } else {
454 self.mru.retain(|&x| x != closed);
455 self.generation += 1; let next = self.mru.first().copied().unwrap_or_else(|| {
457 self.docs
458 .iter()
459 .next()
460 .map(|(id, _)| id)
461 .expect("docs non-empty")
462 });
463 for pane in &mut self.panes {
464 if pane.doc == closed {
465 pane.doc = next;
466 pane.sels = Default::default();
467 pane.view_top = 0;
468 pane.hscroll = strop_core::id::DisplayColumn::new(0);
469 pane.desired_column = None;
470 }
471 }
472 self.switch_to(next);
473 self.set_head(0);
474 self.view_mut().view_top = 0;
475 self.view_mut().hscroll = strop_core::id::DisplayColumn::new(0);
476 if let Some(ret) = return_to.filter(|ret| self.docs.get(ret.document).is_some()) {
479 self.jump_to(ret);
480 } else if self.doc(next).directory_metadata_ref().is_some() {
481 self.restore_directory_view(next);
482 }
483 }
484 for collection in affected_collections {
485 self.collection_render_view(collection);
486 }
487 self.lsp_retire_remote_servers();
488 true
489 }
490 pub fn any_dirty(&self) -> bool {
492 self.docs.iter().any(|(_, d)| d.buf.dirty)
493 }
494
495 pub fn ctrl_c_quit(&mut self) -> bool {
498 if self.ctrl_c_armed || (!self.any_dirty() && self.filesystem.unconfirmed() == 0) {
499 return true;
500 }
501 self.ctrl_c_armed = true;
502 self.message = if self.filesystem.unconfirmed() > 0 {
503 "filesystem outcomes are unconfirmed — ctrl-c again to force-quit".into()
504 } else {
505 "unsaved changes — ctrl-c again to force-quit".into()
506 };
507 false
508 }
509
510 #[cfg(any(test, feature = "test-support"))]
512 pub fn open_fixture(
513 &mut self,
514 path: &std::path::Path,
515 ) -> Result<strop_core::id::DocumentId, String> {
516 self.request_open(
517 path.to_owned(),
518 super::io::OpenIntent::Switch { readonly: false },
519 );
520 self.wait_io()?;
521 Ok(self.current())
522 }
523}
524
525#[cfg(test)]
526mod pathbuf_tests {
527 use super::*;
529 use std::os::unix::ffi::OsStrExt;
530
531 #[test]
532 fn non_utf8_filename_opens_highlights_and_saves() {
533 let dir = tempfile::tempdir().unwrap();
534 let path = dir.path().join(std::ffi::OsStr::from_bytes(b"\xff\xfe.rs"));
535 std::fs::write(&path, "fn main() {}\n").unwrap();
536 let mut e = Editor::new(Buffer::from_text("x\n"));
537 let id = e.open_fixture(&path).expect("opens by bytes");
538 e.switch_to(id);
539 assert!(e.analysis_fixture().spans.iter().any(|span| span.start == 0
541 && span.end == 2
542 && span.class == strop_syntax::Class::Keyword));
543 e.feed_text("dd"); e.feed_text(":w\r");
545 e.wait_io().unwrap();
546 assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
547 }
548}