strop_engine/editor/document/
mod.rs1use strop_core::Buffer;
6
7pub(crate) mod remote;
8pub mod surfaces;
9pub use remote::{RemoteDirectory, RemoteDocument};
10
11pub use surfaces::{DiffRow, DocumentSource, ReturnPoint, Surface};
12
13use super::Editor;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Indent {
23 pub style: crate::config::IndentStyle,
24 pub width: usize,
25}
26
27impl Default for Indent {
28 fn default() -> Self {
31 Self {
32 style: crate::config::IndentStyle::Spaces,
33 width: 4,
34 }
35 }
36}
37
38impl Indent {
39 pub fn unit(&self) -> String {
41 match self.style {
42 crate::config::IndentStyle::Spaces => " ".repeat(self.width),
43 crate::config::IndentStyle::Tabs => "\t".into(),
44 }
45 }
46}
47
48pub fn detect_indent(text: &ropey::Rope) -> Option<Indent> {
54 use crate::config::IndentStyle;
55 let mut tabs = 0usize;
56 let mut spaced = 0usize;
57 let mut hist = [0usize; 9]; for line in text.lines().take(1000) {
59 let mut bytes = line.bytes();
60 match bytes.next() {
61 Some(b'\t') => {
62 tabs += 1;
63 continue;
64 }
65 Some(b' ') => {}
66 _ => continue,
67 }
68 let spaces = 1 + bytes.take_while(|b| *b == b' ').count();
69 if line
71 .bytes()
72 .nth(spaces)
73 .is_some_and(|b| b != b'\n' && b != b'\r')
74 {
75 spaced += 1;
76 if spaces <= 8 {
77 hist[spaces] += 1;
78 }
79 }
80 }
81 if tabs == 0 && spaced == 0 {
82 return None;
83 }
84 if tabs > spaced {
85 return Some(Indent {
86 style: IndentStyle::Tabs,
87 width: 4, });
89 }
90 let (covered, unit) = [2usize, 3, 4, 8]
91 .into_iter()
92 .map(|unit| {
93 let covered: usize = (unit..=8).filter(|n| n % unit == 0).map(|n| hist[n]).sum();
94 (covered, unit)
95 })
96 .max()?;
97 (covered * 5 >= spaced * 3).then_some(Indent {
98 style: IndentStyle::Spaces,
99 width: unit,
100 })
101}
102
103pub struct Document {
104 pub buf: Buffer,
105 pub indent: Indent,
107 pub source: DocumentSource,
110}
111
112impl Document {
113 pub fn new(buf: Buffer) -> Self {
114 Self {
115 buf,
116 indent: Indent::default(),
117 source: DocumentSource::File,
118 }
119 }
120
121 pub fn scratch(buf: Buffer) -> Self {
123 Self {
124 buf,
125 indent: Indent::default(),
126 source: DocumentSource::Scratch,
127 }
128 }
129
130 pub fn surface(mut buf: Buffer, surface: Surface, context: strop_git::GitContext) -> Self {
133 buf.readonly = true;
134 Self {
135 buf,
136 indent: Indent::default(),
137 source: DocumentSource::Surface(Box::new(surfaces::GitSurface {
138 context,
139 content: surface,
140 })),
141 }
142 }
143
144 pub fn output(mut buf: Buffer) -> Self {
147 buf.readonly = true;
148 Self {
149 buf,
150 indent: Indent::default(),
151 source: DocumentSource::Output,
152 }
153 }
154
155 pub fn container_file(
158 mut buf: Buffer,
159 container: strop_workspace::ContainerId,
160 path: std::path::PathBuf,
161 ) -> Self {
162 buf.readonly = true;
163 Self {
164 buf,
165 indent: Indent::default(),
166 source: DocumentSource::Container { container, path },
167 }
168 }
169
170 pub fn syntax_path(&self) -> Option<&std::path::Path> {
172 match &self.source {
173 DocumentSource::Remote(source) => Some(source.file.path()),
174 DocumentSource::Surface(source) => match &source.content {
175 Surface::Diff {
176 commit: Some(commit),
177 ..
178 } => Some(&commit.current),
179 Surface::Diff { hunks, .. } => Some(std::path::Path::new(hunks.label())),
180 _ => None,
181 },
182 DocumentSource::File | DocumentSource::Scratch => self.buf.path.as_deref(),
183 DocumentSource::Container { path, .. } => Some(path),
184 DocumentSource::RemoteDirectory(_) | DocumentSource::Output => None,
185 }
186 }
187
188 pub fn matches_target(&self, target: &crate::files::FileTarget) -> bool {
189 match (&self.source, target) {
190 (DocumentSource::Remote(source), crate::files::FileTarget::Remote(location)) => {
191 location.absolute_file() == Some(&source.file)
192 }
193 (
194 DocumentSource::RemoteDirectory(source),
195 crate::files::FileTarget::Remote(location),
196 ) => location.absolute_file() == Some(&source.directory),
197 (DocumentSource::File, crate::files::FileTarget::Local(path)) => {
198 self.buf.path.as_ref() == Some(path)
199 || self.buf.file_identity() == Some(path.as_path())
200 }
201 _ => false,
202 }
203 }
204
205 pub(crate) fn file_target(&self, cwd: &std::path::Path) -> Option<crate::files::FileTarget> {
206 use crate::files::FileTarget;
207 match &self.source {
208 DocumentSource::Remote(source) => Some(FileTarget::Remote(source.file.clone().into())),
209 DocumentSource::RemoteDirectory(source) => {
210 Some(FileTarget::Remote(source.directory.clone().into()))
211 }
212 _ => self
213 .buf
214 .file_identity()
215 .or(self.buf.path.as_deref())
216 .map(|path| FileTarget::Local(cwd.join(path))),
217 }
218 }
219
220 pub fn surface_payload(&self) -> Option<&Surface> {
222 match &self.source {
223 DocumentSource::Surface(s) => Some(&s.content),
224 _ => None,
225 }
226 }
227
228 pub fn surface_payload_mut(&mut self) -> Option<&mut Surface> {
230 match &mut self.source {
231 DocumentSource::Surface(s) => Some(&mut s.content),
232 _ => None,
233 }
234 }
235
236 pub(crate) fn git_context(&self) -> Option<&strop_git::GitContext> {
237 match &self.source {
238 DocumentSource::Surface(surface) => Some(&surface.context),
239 _ => None,
240 }
241 }
242}
243
244impl Editor {
245 pub fn touch_mru(&mut self, i: strop_core::id::DocumentId) {
247 self.mru.retain(|&x| x != i);
248 self.mru.insert(0, i);
249 }
250
251 pub fn cur(&self) -> &Document {
254 self.docs
255 .get(self.current())
256 .expect("invariant: current document is live")
257 }
258
259 pub(crate) fn cur_mut(&mut self) -> super::transact::DocumentEdit<'_> {
260 self.doc_mut(self.current())
261 }
262
263 pub fn buf(&self) -> &Buffer {
264 &self.cur().buf
265 }
266
267 pub fn buf_mut(&mut self) -> super::transact::BufferEdit<'_> {
268 super::transact::BufferEdit::new(self.cur_mut())
269 }
270
271 pub fn doc(&self, id: strop_core::id::DocumentId) -> &Document {
275 self.docs.get(id).expect("stale document id")
276 }
277
278 pub(crate) fn doc_mut(
279 &mut self,
280 id: strop_core::id::DocumentId,
281 ) -> super::transact::DocumentEdit<'_> {
282 super::transact::DocumentEdit::new(self, id)
283 }
284
285 #[cfg(any(test, feature = "test-support"))]
288 pub fn first_doc(&self) -> strop_core::id::DocumentId {
289 self.docs
290 .iter()
291 .next()
292 .map(|(id, _)| id)
293 .expect("test document")
294 }
295
296 pub(crate) fn drop_stale_scratch(&mut self, replacement: strop_core::id::DocumentId) {
301 let scratch = self.docs.iter().find_map(|(id, d)| {
305 let b = &d.buf;
306 (b.path.is_none()
307 && !b.dirty
308 && b.len_bytes() == 0
309 && b.name.is_none()
310 && id != replacement)
311 .then_some(id)
312 });
313 let Some(scratch) = scratch else {
314 return;
315 };
316 if self
317 .pending
318 .prompt()
319 .is_some_and(|prompt| prompt.origin().pane.doc == scratch)
320 {
321 self.cancel_pending();
322 }
323 for pane in &mut self.panes {
324 if pane.doc == scratch {
325 pane.doc = replacement;
326 }
327 }
328 self.lsp_close_document(scratch);
329 self.docs.remove(scratch);
330 self.mru.retain(|&x| x != scratch);
331 if self.view().doc == scratch {
332 self.view_mut().doc = replacement;
333 }
334 }
335
336 #[inline]
338 pub fn current(&self) -> strop_core::id::DocumentId {
339 self.view().doc
340 }
341
342 pub fn switch_to(&mut self, id: strop_core::id::DocumentId) {
344 self.cancel_pending();
345 self.cancel_open(strop_core::worker::CancelReason::Superseded);
346 self.focus_epoch += 1;
347 self.view_mut().doc = id;
348 self.touch_mru(id);
349 }
350
351 #[inline]
353 pub fn sels(&self) -> &strop_core::selection::SelectionSet {
354 &self.view().sels
355 }
356
357 #[inline]
358 pub fn sels_mut(&mut self) -> &mut strop_core::selection::SelectionSet {
359 &mut self.view_mut().sels
360 }
361
362 #[inline]
364 pub fn view_rows(&self) -> usize {
366 self.view_rows
367 }
368
369 pub fn view_top(&self) -> usize {
370 self.view().view_top
371 }
372
373 pub fn close_buffer(&mut self, force: bool) -> bool {
377 let view_only = self.collections.contains_key(&self.current());
380 if self.buf().dirty && !force && !view_only {
381 self.message = "unsaved changes — :q! to force".into();
382 return false;
383 }
384 self.cancel_pending();
385 self.cancel_open(strop_core::worker::CancelReason::OwnerClosed);
386 if self.docs.len() == 1 {
387 self.request_session_save();
388 }
389 let closed = self.current();
390 self.revoke_remote_write(closed);
391 self.analysis
392 .forget(super::analysis::AnalysisTarget::Document(closed));
393 self.stop_remote_follow(closed);
394 self.cancel_remote_filter(closed);
395 self.lsp_close_document(closed);
396 self.shell_document_closed(closed);
397 self.revoke_git_requests_for(closed);
398 self.blame_gutters.remove(&closed);
399 self.collections.remove(&closed);
400 self.containers.buffers.remove(&closed);
401 self.containers.entries.remove(&closed);
402 let return_to = self
403 .docs
404 .remove(closed)
405 .and_then(|document| document.return_point().cloned());
406 if self.docs.is_empty() {
407 self.panes.clear();
408 self.active_pane = 0;
409 self.should_quit = true;
410 } else {
411 self.mru.retain(|&x| x != closed);
412 self.generation += 1; let next = self.mru.first().copied().unwrap_or_else(|| {
414 self.docs
415 .iter()
416 .next()
417 .map(|(id, _)| id)
418 .expect("docs non-empty")
419 });
420 for pane in &mut self.panes {
421 if pane.doc == closed {
422 pane.doc = next;
423 pane.sels = Default::default();
424 pane.view_top = 0;
425 pane.hscroll = strop_core::id::DisplayColumn::new(0);
426 pane.desired_column = None;
427 }
428 }
429 self.switch_to(next);
430 self.set_head(0);
431 self.view_mut().view_top = 0;
432 self.view_mut().hscroll = strop_core::id::DisplayColumn::new(0);
433 if let Some(ret) = return_to {
436 if self.docs.get(ret.buffer).is_some() {
437 if ret.buffer != self.current() {
438 self.view_mut().doc = ret.buffer;
439 self.touch_mru(ret.buffer);
440 }
441 self.set_head(ret.cursor.min(self.buf().len_bytes()));
442 self.view_mut().view_top = ret.view_top;
443 self.view_mut().hscroll = ret.hscroll;
444 }
445 }
446 }
447 self.lsp_retire_remote_servers();
448 true
449 }
450 pub fn any_dirty(&self) -> bool {
452 self.docs.iter().any(|(_, d)| d.buf.dirty)
453 }
454
455 pub fn ctrl_c_quit(&mut self) -> bool {
458 if self.ctrl_c_armed || !self.any_dirty() {
459 return true;
460 }
461 self.ctrl_c_armed = true;
462 self.message = "unsaved changes — ctrl-c again to force-quit".into();
463 false
464 }
465
466 #[cfg(any(test, feature = "test-support"))]
468 pub fn open_fixture(
469 &mut self,
470 path: &std::path::Path,
471 ) -> Result<strop_core::id::DocumentId, String> {
472 self.request_open(
473 path.to_owned(),
474 super::io::OpenIntent::Switch { readonly: false },
475 );
476 self.wait_io()?;
477 Ok(self.current())
478 }
479}
480
481#[cfg(test)]
482mod pathbuf_tests {
483 use super::*;
485 use std::os::unix::ffi::OsStrExt;
486
487 #[test]
488 fn non_utf8_filename_opens_highlights_and_saves() {
489 let dir = tempfile::tempdir().unwrap();
490 let path = dir.path().join(std::ffi::OsStr::from_bytes(b"\xff\xfe.rs"));
491 std::fs::write(&path, "fn main() {}\n").unwrap();
492 let mut e = Editor::new(Buffer::from_text("x\n"));
493 let id = e.open_fixture(&path).expect("opens by bytes");
494 e.switch_to(id);
495 assert!(e.analysis_fixture().spans.iter().any(|span| span.start == 0
497 && span.end == 2
498 && span.class == strop_syntax::Class::Keyword));
499 e.feed_text("dd"); e.feed_text(":w\r");
501 e.wait_io().unwrap();
502 assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
503 }
504}