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