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
15pub struct Document {
19 pub buf: Buffer,
20 pub source: DocumentSource,
23}
24
25impl Document {
26 pub fn new(buf: Buffer) -> Self {
27 Self {
28 buf,
29 source: DocumentSource::File,
30 }
31 }
32
33 pub fn scratch(buf: Buffer) -> Self {
35 Self {
36 buf,
37 source: DocumentSource::Scratch,
38 }
39 }
40
41 pub fn surface(mut buf: Buffer, surface: Surface, context: strop_git::GitContext) -> Self {
44 buf.readonly = true;
45 Self {
46 buf,
47 source: DocumentSource::Surface(Box::new(surfaces::GitSurface {
48 context,
49 content: surface,
50 })),
51 }
52 }
53
54 pub fn output(mut buf: Buffer) -> Self {
57 buf.readonly = true;
58 Self {
59 buf,
60 source: DocumentSource::Output,
61 }
62 }
63
64 pub fn container_file(
67 mut buf: Buffer,
68 container: strop_workspace::ContainerId,
69 path: std::path::PathBuf,
70 ) -> Self {
71 buf.readonly = true;
72 Self {
73 buf,
74 source: DocumentSource::Container { container, path },
75 }
76 }
77
78 pub fn syntax_path(&self) -> Option<&std::path::Path> {
80 match &self.source {
81 DocumentSource::Remote(source) => Some(source.file.path()),
82 DocumentSource::Surface(source) => match &source.content {
83 Surface::Diff {
84 commit: Some(commit),
85 ..
86 } => Some(&commit.current),
87 Surface::Diff { hunks, .. } => Some(std::path::Path::new(hunks.label())),
88 _ => None,
89 },
90 DocumentSource::File | DocumentSource::Scratch => self.buf.path.as_deref(),
91 DocumentSource::Container { path, .. } => Some(path),
92 DocumentSource::RemoteDirectory(_) | DocumentSource::Output => None,
93 }
94 }
95
96 pub fn matches_target(&self, target: &crate::files::FileTarget) -> bool {
97 match (&self.source, target) {
98 (DocumentSource::Remote(source), crate::files::FileTarget::Remote(location)) => {
99 location.absolute_file() == Some(&source.file)
100 }
101 (
102 DocumentSource::RemoteDirectory(source),
103 crate::files::FileTarget::Remote(location),
104 ) => location.absolute_file() == Some(&source.directory),
105 (DocumentSource::File, crate::files::FileTarget::Local(path)) => {
106 self.buf.path.as_ref() == Some(path)
107 || self.buf.file_identity() == Some(path.as_path())
108 }
109 _ => false,
110 }
111 }
112
113 pub(crate) fn file_target(&self, cwd: &std::path::Path) -> Option<crate::files::FileTarget> {
114 use crate::files::FileTarget;
115 match &self.source {
116 DocumentSource::Remote(source) => Some(FileTarget::Remote(source.file.clone().into())),
117 DocumentSource::RemoteDirectory(source) => {
118 Some(FileTarget::Remote(source.directory.clone().into()))
119 }
120 _ => self
121 .buf
122 .file_identity()
123 .or(self.buf.path.as_deref())
124 .map(|path| FileTarget::Local(cwd.join(path))),
125 }
126 }
127
128 pub fn surface_payload(&self) -> Option<&Surface> {
130 match &self.source {
131 DocumentSource::Surface(s) => Some(&s.content),
132 _ => None,
133 }
134 }
135
136 pub fn surface_payload_mut(&mut self) -> Option<&mut Surface> {
138 match &mut self.source {
139 DocumentSource::Surface(s) => Some(&mut s.content),
140 _ => None,
141 }
142 }
143
144 pub(crate) fn git_context(&self) -> Option<&strop_git::GitContext> {
145 match &self.source {
146 DocumentSource::Surface(surface) => Some(&surface.context),
147 _ => None,
148 }
149 }
150}
151
152impl Editor {
153 pub fn touch_mru(&mut self, i: strop_core::id::DocumentId) {
155 self.mru.retain(|&x| x != i);
156 self.mru.insert(0, i);
157 }
158
159 pub fn cur(&self) -> &Document {
162 self.docs
163 .get(self.current())
164 .expect("invariant: current document is live")
165 }
166
167 pub(crate) fn cur_mut(&mut self) -> super::transact::DocumentEdit<'_> {
168 self.doc_mut(self.current())
169 }
170
171 pub fn buf(&self) -> &Buffer {
172 &self.cur().buf
173 }
174
175 pub fn buf_mut(&mut self) -> super::transact::BufferEdit<'_> {
176 super::transact::BufferEdit::new(self.cur_mut())
177 }
178
179 pub fn doc(&self, id: strop_core::id::DocumentId) -> &Document {
183 self.docs.get(id).expect("stale document id")
184 }
185
186 pub(crate) fn doc_mut(
187 &mut self,
188 id: strop_core::id::DocumentId,
189 ) -> super::transact::DocumentEdit<'_> {
190 super::transact::DocumentEdit::new(self, id)
191 }
192
193 #[cfg(any(test, feature = "test-support"))]
196 pub fn first_doc(&self) -> strop_core::id::DocumentId {
197 self.docs
198 .iter()
199 .next()
200 .map(|(id, _)| id)
201 .expect("test document")
202 }
203
204 pub(crate) fn drop_stale_scratch(&mut self, replacement: strop_core::id::DocumentId) {
209 let scratch = self.docs.iter().find_map(|(id, d)| {
213 let b = &d.buf;
214 (b.path.is_none()
215 && !b.dirty
216 && b.len_bytes() == 0
217 && b.name.is_none()
218 && id != replacement)
219 .then_some(id)
220 });
221 let Some(scratch) = scratch else {
222 return;
223 };
224 if self
225 .pending
226 .prompt()
227 .is_some_and(|prompt| prompt.origin().pane.doc == scratch)
228 {
229 self.cancel_pending();
230 }
231 for pane in &mut self.panes {
232 if pane.doc == scratch {
233 pane.doc = replacement;
234 }
235 }
236 self.lsp_close_document(scratch);
237 self.docs.remove(scratch);
238 self.mru.retain(|&x| x != scratch);
239 if self.view().doc == scratch {
240 self.view_mut().doc = replacement;
241 }
242 }
243
244 #[inline]
246 pub fn current(&self) -> strop_core::id::DocumentId {
247 self.view().doc
248 }
249
250 pub fn switch_to(&mut self, id: strop_core::id::DocumentId) {
252 self.cancel_pending();
253 self.cancel_open(strop_core::worker::CancelReason::Superseded);
254 self.focus_epoch += 1;
255 self.view_mut().doc = id;
256 self.touch_mru(id);
257 }
258
259 #[inline]
261 pub fn sels(&self) -> &strop_core::selection::SelectionSet {
262 &self.view().sels
263 }
264
265 #[inline]
266 pub fn sels_mut(&mut self) -> &mut strop_core::selection::SelectionSet {
267 &mut self.view_mut().sels
268 }
269
270 #[inline]
272 pub fn view_rows(&self) -> usize {
274 self.view_rows
275 }
276
277 pub fn view_top(&self) -> usize {
278 self.view().view_top
279 }
280
281 pub fn close_buffer(&mut self, force: bool) -> bool {
285 if self.buf().dirty && !force {
286 self.message = "unsaved changes — :q! to force".into();
287 return false;
288 }
289 self.cancel_pending();
290 self.cancel_open(strop_core::worker::CancelReason::OwnerClosed);
291 if self.docs.len() == 1 {
292 self.request_session_save();
293 }
294 let closed = self.current();
295 self.revoke_remote_write(closed);
296 self.analysis
297 .forget(super::analysis::AnalysisTarget::Document(closed));
298 self.stop_remote_follow(closed);
299 self.cancel_remote_filter(closed);
300 self.lsp_close_document(closed);
301 self.shell_document_closed(closed);
302 self.revoke_git_requests_for(closed);
303 self.blame_gutters.remove(&closed);
304 self.collections.remove(&closed);
305 self.containers.buffers.remove(&closed);
306 self.containers.entries.remove(&closed);
307 let return_to = self
308 .docs
309 .remove(closed)
310 .and_then(|document| document.return_point().cloned());
311 if self.docs.is_empty() {
312 self.panes.clear();
313 self.active_pane = 0;
314 self.should_quit = true;
315 } else {
316 self.mru.retain(|&x| x != closed);
317 self.generation += 1; let next = self.mru.first().copied().unwrap_or_else(|| {
319 self.docs
320 .iter()
321 .next()
322 .map(|(id, _)| id)
323 .expect("docs non-empty")
324 });
325 for pane in &mut self.panes {
326 if pane.doc == closed {
327 pane.doc = next;
328 pane.sels = Default::default();
329 pane.view_top = 0;
330 pane.hscroll = strop_core::id::DisplayColumn::new(0);
331 pane.desired_column = None;
332 }
333 }
334 self.switch_to(next);
335 self.set_head(0);
336 self.view_mut().view_top = 0;
337 self.view_mut().hscroll = strop_core::id::DisplayColumn::new(0);
338 if let Some(ret) = return_to {
341 if self.docs.get(ret.buffer).is_some() {
342 if ret.buffer != self.current() {
343 self.view_mut().doc = ret.buffer;
344 self.touch_mru(ret.buffer);
345 }
346 self.set_head(ret.cursor.min(self.buf().len_bytes()));
347 self.view_mut().view_top = ret.view_top;
348 self.view_mut().hscroll = ret.hscroll;
349 }
350 }
351 }
352 self.lsp_retire_remote_servers();
353 true
354 }
355 pub fn any_dirty(&self) -> bool {
357 self.docs.iter().any(|(_, d)| d.buf.dirty)
358 }
359
360 pub fn ctrl_c_quit(&mut self) -> bool {
363 if self.ctrl_c_armed || !self.any_dirty() {
364 return true;
365 }
366 self.ctrl_c_armed = true;
367 self.message = "unsaved changes — ctrl-c again to force-quit".into();
368 false
369 }
370
371 #[cfg(any(test, feature = "test-support"))]
373 pub fn open_fixture(
374 &mut self,
375 path: &std::path::Path,
376 ) -> Result<strop_core::id::DocumentId, String> {
377 self.request_open(
378 path.to_owned(),
379 super::io::OpenIntent::Switch { readonly: false },
380 );
381 self.wait_io()?;
382 Ok(self.current())
383 }
384}
385
386#[cfg(test)]
387mod pathbuf_tests {
388 use super::*;
390 use std::os::unix::ffi::OsStrExt;
391
392 #[test]
393 fn non_utf8_filename_opens_highlights_and_saves() {
394 let dir = tempfile::tempdir().unwrap();
395 let path = dir.path().join(std::ffi::OsStr::from_bytes(b"\xff\xfe.rs"));
396 std::fs::write(&path, "fn main() {}\n").unwrap();
397 let mut e = Editor::new(Buffer::from_text("x\n"));
398 let id = e.open_fixture(&path).expect("opens by bytes");
399 e.switch_to(id);
400 assert!(e.analysis_fixture().spans.iter().any(|span| span.start == 0
402 && span.end == 2
403 && span.class == strop_syntax::Class::Keyword));
404 e.feed_text("dd"); e.feed_text(":w\r");
406 e.wait_io().unwrap();
407 assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
408 }
409}