1#[cfg(test)]
7mod tests;
8
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12use strop_core::id::{Arena, BufferRevision, DocumentId, DocumentKind};
13use strop_core::{Buffer, Range};
14use strop_workspace::ResourceLocation;
15
16use super::changes::{ChangePlan, ChangeProducer, PlannedDocument};
17use super::document::Document;
18use super::Editor;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CollectionRow {
24 Title,
25 CardTop(usize),
27 Gap,
29 CardBottom,
31 Body,
32}
33
34#[derive(Debug, Clone)]
37pub(crate) struct Excerpt {
38 pub source: DocumentId,
39 pub start: usize,
41 pub end: usize,
42 pub fingerprint: u64,
45 pub view_line: usize,
47 pub view_lines: usize,
49 pub view_start: usize,
52 pub view_end: usize,
53}
54
55#[derive(Debug, Clone)]
56pub(crate) struct Collection {
57 pub title: String,
58 pub excerpts: Vec<Excerpt>,
59 pub pending_saves: usize,
62 pub close_when_saved: bool,
63 pub shadow: String,
66 pub revision: BufferRevision,
69 pub rows: Vec<CollectionRow>,
71}
72
73#[derive(Debug)]
76pub(crate) struct CollectionBuild {
77 pub title: String,
78 pub hits: Vec<(std::path::PathBuf, usize)>,
79 pub remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)>,
81 pub waiting: usize,
82}
83
84fn fingerprint(text: &str) -> u64 {
85 let mut hash: u64 = 0xcbf29ce484222325;
86 for byte in text.as_bytes() {
87 hash = (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
88 }
89 hash
90}
91
92fn render(
99 docs: &Arena<DocumentKind, Document>,
100 cwd: &std::path::Path,
101 collection: &mut Collection,
102) -> String {
103 let files = {
104 let mut seen = Vec::new();
105 for excerpt in &collection.excerpts {
106 if !seen.contains(&excerpt.source) {
107 seen.push(excerpt.source);
108 }
109 }
110 seen.len()
111 };
112 let matches = collection.excerpts.len();
113 let dirty = {
114 let mut n = 0;
115 let mut seen = Vec::new();
116 for excerpt in &collection.excerpts {
117 if !seen.contains(&excerpt.source) {
118 seen.push(excerpt.source);
119 if docs.get(excerpt.source).is_some_and(|d| d.buf.dirty) {
120 n += 1;
121 }
122 }
123 }
124 n
125 };
126 let mut text = format!(
127 "collection: {} — {} match(es) · {} file(s){}{}\n",
128 collection.title,
129 matches,
130 files,
131 if dirty > 0 { " · " } else { "" },
132 if dirty > 0 {
133 format!("{dirty} modified")
134 } else {
135 String::new()
136 },
137 );
138 let mut rows = vec![CollectionRow::Title];
139 let mut line = 1;
140 let mut at = 0;
141 while at < collection.excerpts.len() {
142 let source = collection.excerpts[at].source;
143 let mut card_end = at;
144 while card_end + 1 < collection.excerpts.len()
145 && collection.excerpts[card_end + 1].source == source
146 {
147 card_end += 1;
148 }
149 let doc = docs.get(source).unwrap();
150 let path = doc
151 .buf
152 .path
153 .as_ref()
154 .map(|path| path.strip_prefix(cwd).unwrap_or(path).display().to_string())
155 .unwrap_or_else(|| "[scratch]".into());
156 let lang = doc
157 .buf
158 .path
159 .as_ref()
160 .and_then(|p| p.extension().map(|e| format!(".{}", e.to_string_lossy())))
161 .and_then(|ext| strop_lsp::registry::language_for_extension(&ext));
162 let count = card_end - at + 1;
163 let modified = doc.buf.dirty;
164 let lang_badge = lang.map(|l| format!("{l} · ")).unwrap_or_default();
165 let dirty_badge = if modified { " · modified" } else { "" };
166 text.push_str(&format!(
167 "╭─ {path} ── {lang_badge}{count} excerpt(s){dirty_badge}\n"
168 ));
169 rows.push(CollectionRow::CardTop(at));
170 line += 1;
171 for i in at..=card_end {
172 if i > at {
173 let prev = &collection.excerpts[i - 1];
174 let here = &collection.excerpts[i];
175 let gap_lines = doc
176 .buf
177 .line_of(here.start)
178 .saturating_sub(doc.buf.line_of(prev.end));
179 text.push_str(&format!("⋮ {gap_lines} source lines omitted\n"));
180 rows.push(CollectionRow::Gap);
181 line += 1;
182 }
183 let start = collection.excerpts[i].start;
184 let end = collection.excerpts[i].end;
185 let body = doc.buf.text().byte_slice(start..end).to_string();
186 let excerpt = &mut collection.excerpts[i];
187 excerpt.view_line = line - 1; excerpt.view_start = text.len();
189 excerpt.view_lines = body.lines().count().max(1);
190 excerpt.fingerprint = fingerprint(&body);
191 text.push_str(&body);
192 if !body.ends_with('\n') {
193 text.push('\n');
194 }
195 excerpt.view_end = text.len();
196 for _ in 0..excerpt.view_lines {
197 rows.push(CollectionRow::Body);
198 }
199 line += excerpt.view_lines;
200 }
201 text.push_str("╰\n");
202 rows.push(CollectionRow::CardBottom);
203 line += 1;
204 at = card_end + 1;
205 }
206 collection.rows = rows;
207 let _ = line;
208 text
209}
210
211impl Editor {
212 pub(crate) fn open_collection_from_picker(&mut self) {
217 let Some(glue) = &self.picker else {
218 return;
219 };
220 let kind = glue.picker.kind;
221 if !matches!(
222 kind,
223 strop_picker::Kind::Locations
224 | strop_picker::Kind::Diagnostics
225 | strop_picker::Kind::Grep
226 ) {
227 self.message = "collections come from a results list".into();
228 return;
229 }
230 let mut hits: Vec<(std::path::PathBuf, usize)> = Vec::new();
233 let mut remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)> =
234 Vec::new();
235 for item in glue.picker.items.iter() {
236 match &item.payload {
237 strop_picker::Payload::Grep { path, line, .. } => {
238 hits.push((path.clone(), line.saturating_sub(1)));
239 }
240 strop_picker::Payload::Remote {
241 endpoint,
242 path,
243 line,
244 ..
245 } => remote_hits.push((endpoint.clone(), path.clone(), line.saturating_sub(1))),
246 _ => {}
247 }
248 }
249 let title = kind.title().trim().to_string();
250 self.close_picker();
251 let mut to_load: Vec<std::path::PathBuf> = Vec::new();
254 for (path, _) in &hits {
255 let absolute = if path.is_absolute() {
256 path.clone()
257 } else {
258 self.cwd.join(path)
259 };
260 let open = self.docs.iter().any(|(_, doc)| {
261 doc.matches_target(&crate::files::FileTarget::Local(absolute.clone()))
262 });
263 if !open && !to_load.contains(&absolute) {
264 to_load.push(absolute);
265 }
266 }
267 let waiting = to_load.len();
268 let build = CollectionBuild {
269 title,
270 hits,
271 remote_hits,
272 waiting,
273 };
274 if to_load.is_empty() {
275 self.build_collection(build);
276 return;
277 }
278 self.collection_build = Some(build);
279 self.message = format!("collection: loading {waiting} source(s)…");
280 for path in to_load {
281 self.request_open(path, crate::editor::io::OpenIntent::Background);
282 }
283 }
284
285 pub(crate) fn collection_source_ready(&mut self, _document: DocumentId) {
288 let Some(build) = &mut self.collection_build else {
289 strop_trace::record_with(
290 strop_trace::EventKind::JobFinished,
291 || serde_json::json!({"service":"collection","result":"ready-without-build"}),
292 );
293 return;
294 };
295 build.waiting = build.waiting.saturating_sub(1);
296 strop_trace::record_with(
297 strop_trace::EventKind::JobFinished,
298 || serde_json::json!({"service":"collection","result":"source-ready","waiting":build.waiting}),
299 );
300 if build.waiting == 0 {
301 let build = self.collection_build.take().unwrap();
302 self.build_collection(build);
303 }
304 }
305
306 fn build_collection(&mut self, build: CollectionBuild) {
307 let CollectionBuild {
308 title,
309 hits,
310 remote_hits,
311 ..
312 } = build;
313 let mut by_doc: HashMap<DocumentId, Vec<usize>> = HashMap::new();
314 let mut skipped = 0;
315 for (path, line) in hits {
316 let absolute = if path.is_absolute() {
317 path
318 } else {
319 self.cwd.join(path)
320 };
321 let Some(document) = self.docs.iter().find_map(|(id, doc)| {
322 doc.matches_target(&crate::files::FileTarget::Local(absolute.clone()))
323 .then_some(id)
324 }) else {
325 skipped += 1;
326 continue;
327 };
328 by_doc.entry(document).or_default().push(line);
329 }
330 for (endpoint, path, line) in remote_hits {
331 let document = self.docs.iter().find_map(|(id, doc)| {
332 doc.remote_metadata().and_then(|source| {
333 (source.file.endpoint() == &endpoint && source.file.path() == path.as_path())
334 .then_some(id)
335 })
336 });
337 match document {
338 Some(id) => by_doc.entry(id).or_default().push(line),
339 None => skipped += 1,
340 }
341 }
342 if by_doc.is_empty() {
343 self.message = "no open buffers among the results — open them first".into();
344 return;
345 }
346 let mut excerpts = Vec::new();
347 for (source, mut lines) in by_doc {
348 lines.sort_unstable();
349 lines.dedup();
350 let buf = &self.docs.get(source).unwrap().buf;
351 let mut spans: Vec<(usize, usize)> = Vec::new();
354 for line in lines {
355 if line >= buf.len_lines() {
356 continue;
357 }
358 match spans.last_mut() {
359 Some((_, end)) if line <= *end => *end = line + 1,
360 _ => spans.push((line, line + 1)),
361 }
362 }
363 for (start_line, end_line) in spans {
364 let start = buf.line_start(start_line);
365 let end = if end_line >= buf.len_lines() {
366 buf.len_bytes()
367 } else {
368 buf.line_start(end_line)
369 };
370 let text = buf.text().byte_slice(start..end).to_string();
371 excerpts.push(Excerpt {
372 source,
373 start,
374 end,
375 fingerprint: fingerprint(&text),
376 view_line: 0,
377 view_lines: 0,
378 view_start: 0,
379 view_end: 0,
380 });
381 }
382 }
383 excerpts.sort_by_key(|excerpt| {
385 let path = self
386 .docs
387 .get(excerpt.source)
388 .and_then(|d| d.buf.path.clone())
389 .map(|p| p.display().to_string())
390 .unwrap_or_default();
391 (path, excerpt.start)
392 });
393 let excerpt_count = excerpts.len();
394 let title_for_trace = title.clone();
395 let id = self.docs.insert(Document::output(Buffer::from_text("")));
396 self.docs.get_mut(id).unwrap().buf.name = Some(format!("collection: {title}"));
398 let mut collection = Collection {
399 title,
400 excerpts,
401 pending_saves: 0,
402 close_when_saved: false,
403 rows: Vec::new(),
404 shadow: String::new(),
405 revision: BufferRevision::new(0),
406 };
407 let text = render(&self.docs, &self.cwd, &mut collection);
408 collection.shadow = text.clone();
409 self.collections.insert(id, collection);
410 let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
411 self.docs.get_mut(id).unwrap().buf.readonly = false;
412 let revision = self.docs.get(id).unwrap().buf.revision();
413 self.collections.get_mut(&id).unwrap().revision = revision;
414 strop_trace::record_with(strop_trace::EventKind::JobFinished, || {
415 serde_json::json!({
416 "service":"collection","result":"built","excerpts":excerpt_count,
417 "skipped":skipped,"title":title_for_trace,
418 })
419 });
420 self.drop_stale_scratch(id);
421 self.switch_to(id);
422 self.set_head(0);
423 self.message = match skipped {
424 0 => format!("collection: {excerpt_count} excerpt(s)"),
425 _ => format!("collection built; {skipped} hit(s) skipped (not open local buffers)"),
426 };
427 }
428
429 pub(crate) fn maybe_sync_collection(&mut self) {
433 if self.docs.is_empty() {
434 return;
435 }
436 let id = self.current();
437 let revision = self.buf().revision();
438 let Some(stored) = self.collections.get(&id).map(|c| c.revision) else {
439 return;
440 };
441 if revision == stored {
442 return;
443 }
444 let current = self.buf().text().to_string();
445 if current == self.collections[&id].shadow {
446 self.collections.get_mut(&id).unwrap().revision = revision;
447 return;
448 }
449 let working = self.collections[&id].clone();
452 if let Err(reason) = self.collection_write_back(&working, ¤t) {
453 self.message = reason;
454 }
455 let open = working
456 .excerpts
457 .iter()
458 .all(|excerpt| self.docs.get(excerpt.source).is_some());
459 if !open {
460 self.message = "collection: a source buffer was closed — view dropped".into();
461 self.collections.remove(&id);
462 return;
463 }
464 self.collection_render_view(id);
467 }
468
469 pub(crate) fn collection_render_view(&mut self, id: DocumentId) {
472 let caret = if self.current() == id {
475 Some((
476 self.buf().line_of(self.head()),
477 self.buf().col_of(self.head()),
478 ))
479 } else {
480 None
481 };
482 let text = {
483 let entry = self.collections.get_mut(&id).unwrap();
484 render(&self.docs, &self.cwd, entry)
485 };
486 {
487 let entry = self.collections.get_mut(&id).unwrap();
488 entry.shadow = text.clone();
489 }
490 let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
491 if let Some((line, col)) = caret {
492 let line = line.min(self.docs.get(id).unwrap().buf.len_lines().saturating_sub(1));
493 let head = self.docs.get(id).unwrap().buf.clamp_boundary(
494 self.docs
495 .get(id)
496 .unwrap()
497 .buf
498 .line_start(line)
499 .saturating_add(col),
500 );
501 if self.current() == id {
502 self.set_head(head);
503 self.clamp_cursor();
504 }
505 }
506 let revision = self.docs.get(id).unwrap().buf.revision();
507 self.collections.get_mut(&id).unwrap().revision = revision;
508 self.docs.get_mut(id).unwrap().buf.dirty = false;
511 }
512
513 pub(crate) fn collection_splice_excerpt(&mut self, id: DocumentId, index: usize) {
518 let (source, view_start, view_end, view_lines) = {
519 let entry = &self.collections[&id];
520 let excerpt = &entry.excerpts[index];
521 (
522 excerpt.source,
523 excerpt.view_start,
524 excerpt.view_end,
525 excerpt.view_lines,
526 )
527 };
528 let Some(source_doc) = self.docs.get(source) else {
529 return;
530 };
531 let excerpt_span = {
532 let entry = &self.collections[&id];
533 let excerpt = &entry.excerpts[index];
534 (excerpt.start, excerpt.end)
535 };
536 let mut body = source_doc
537 .buf
538 .text()
539 .byte_slice(excerpt_span.0..excerpt_span.1)
540 .to_string();
541 #[cfg(test)]
542 eprintln!("splice coll={id:?} ex={index} span={excerpt_span:?} view={view_start}..{view_end} body={body:?}");
543 if !body.ends_with('\n') {
544 body.push('\n');
545 }
546 let new_lines = body.lines().count().max(1);
547 {
550 let doc = self.docs.get_mut(id).unwrap();
551 let _ = doc
552 .buf
553 .system_edit()
554 .replace(Range::charwise(view_start, view_end), &body);
555 }
556 {
559 let doc = self.docs.get_mut(id).unwrap();
560 let changes: Vec<_> = doc.buf.changes().to_vec();
561 self.analysis.edits(id, &changes);
562 doc.buf.clear_changes();
563 }
564 let byte_delta = body.len() as isize - (view_end - view_start) as isize;
565 let line_delta = new_lines as isize - view_lines as isize;
566 let entry = self.collections.get_mut(&id).unwrap();
567 entry.shadow.replace_range(view_start..view_end, &body);
569 let mut seen = false;
570 for excerpt in &mut entry.excerpts {
571 if seen {
572 excerpt.view_start = (excerpt.view_start as isize + byte_delta) as usize;
573 excerpt.view_end = (excerpt.view_end as isize + byte_delta) as usize;
574 excerpt.view_line = (excerpt.view_line as isize + line_delta) as usize;
575 } else if excerpt.view_start == view_start {
576 seen = true;
577 excerpt.view_lines = new_lines;
578 excerpt.view_end = view_start + body.len();
579 excerpt.fingerprint = fingerprint(&body);
580 }
581 }
582 let revision = self.docs.get(id).unwrap().buf.revision();
583 self.collections.get_mut(&id).unwrap().revision = revision;
584 }
585 fn diff_lines(
591 shadow: &[&str],
592 current: &[&str],
593 ) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
594 let prefix = shadow
595 .iter()
596 .zip(current.iter())
597 .take_while(|(a, b)| a == b)
598 .count();
599 let suffix = shadow[prefix..]
600 .iter()
601 .rev()
602 .zip(current[prefix.min(current.len())..].iter().rev())
603 .take_while(|(a, b)| a == b)
604 .count();
605 let smid = &shadow[prefix..shadow.len() - suffix.min(shadow.len() - prefix)];
606 let cmid = ¤t[prefix..current.len() - suffix.min(current.len() - prefix)];
607 if smid.is_empty() && cmid.is_empty() {
608 return Vec::new();
609 }
610 Self::diff_lines_middle(
611 &shadow[prefix..prefix + smid.len()],
612 ¤t[prefix..prefix + cmid.len()],
613 )
614 .into_iter()
615 .map(|(old, new)| {
616 (
617 old.start + prefix..old.end + prefix,
618 new.start + prefix..new.end + prefix,
619 )
620 })
621 .collect()
622 }
623
624 fn diff_lines_middle(
626 shadow: &[&str],
627 current: &[&str],
628 ) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
629 let (n, m) = (shadow.len(), current.len());
630 let mut lcs = vec![vec![0usize; m + 1]; n + 1];
632 for i in (0..n).rev() {
633 for j in (0..m).rev() {
634 lcs[i][j] = if shadow[i] == current[j] {
635 lcs[i + 1][j + 1] + 1
636 } else {
637 lcs[i + 1][j].max(lcs[i][j + 1])
638 };
639 }
640 }
641 let mut hunks = Vec::new();
642 let (mut i, mut j) = (0, 0);
643 while i < n || j < m {
644 if i < n && j < m && shadow[i] == current[j] {
645 i += 1;
646 j += 1;
647 continue;
648 }
649 let (si, sj) = (i, j);
650 while i < n || j < m {
651 if i < n && j < m && shadow[i] == current[j] {
652 break;
653 }
654 if i < n && (j == m || lcs[i + 1][j] >= lcs[i][j + 1]) {
655 i += 1;
656 } else {
657 j += 1;
658 }
659 }
660 hunks.push((si..i, sj..j));
661 }
662 hunks
663 }
664
665 fn map_line(
669 hunks: &[(std::ops::Range<usize>, std::ops::Range<usize>)],
670 line: usize,
671 count_at_boundary: bool,
672 ) -> usize {
673 let mut current = line;
674 for (old, new) in hunks {
675 let counts =
676 old.end < line || (old.end == line && (!old.is_empty() || count_at_boundary));
677 if counts {
678 current += new.len() - old.len();
679 } else {
680 break;
681 }
682 }
683 current
684 }
685
686 fn collection_write_back(
691 &mut self,
692 collection: &Collection,
693 current: &str,
694 ) -> Result<(), String> {
695 let shadow_lines: Vec<&str> = collection.shadow.split_inclusive('\n').collect();
696 let current_lines: Vec<&str> = current.split_inclusive('\n').collect();
697 let hunks = Self::diff_lines(&shadow_lines, ¤t_lines);
698 if hunks.is_empty() {
699 return Ok(());
700 }
701 let mut touched: Vec<usize> = Vec::new();
703 for (old, _) in &hunks {
704 let mut owner = None;
705 for (index, excerpt) in collection.excerpts.iter().enumerate() {
706 let lo = excerpt.view_line + 1;
707 let hi = excerpt.view_line + excerpt.view_lines + 1;
708 let inside = if old.is_empty() {
709 old.start >= lo && old.start < hi
711 } else {
712 old.start >= lo && old.end <= hi
713 };
714 if inside {
715 owner = Some(index);
716 break;
717 }
718 if !old.is_empty() && old.start < hi && old.end > lo {
720 return Err(
721 "edit touches a header or spans excerpts — refused; view refreshed".into(),
722 );
723 }
724 }
725 let Some(index) = owner else {
726 return Err(
727 "edit touches the title, a header, or the collection's structure — refused; view refreshed"
728 .into(),
729 );
730 };
731 if !touched.contains(&index) {
732 touched.push(index);
733 }
734 }
735 let mut by_source: Vec<(DocumentId, Vec<strop_core::Replacement>, ResourceLocation)> =
738 Vec::new();
739 for index in touched {
740 let excerpt = &collection.excerpts[index];
741 let source = self
742 .docs
743 .get(excerpt.source)
744 .ok_or_else(|| "collection: a source buffer was closed".to_string())?;
745 let present = source
746 .buf
747 .text()
748 .byte_slice(excerpt.start..excerpt.end)
749 .to_string();
750 if fingerprint(&present) != excerpt.fingerprint {
751 return Err(
752 "collection: a source changed elsewhere — refused; view refreshed".into(),
753 );
754 }
755 if source.buf.readonly {
756 return Err(
757 "collection: a source is read-only (remote sources need :remote edit first) — refused; view refreshed"
758 .into(),
759 );
760 }
761 let lo = excerpt.view_line + 1;
762 let hi = excerpt.view_line + excerpt.view_lines + 1;
763 let cur_lo = Self::map_line(&hunks, lo, false);
764 let cur_hi = Self::map_line(&hunks, hi, true);
765 let mut replacement: String = current_lines[cur_lo..cur_hi].concat();
766 if !replacement.is_empty() && !replacement.ends_with('\n') {
767 replacement.push('\n');
768 }
769 let edit = strop_core::Replacement::new(
770 Range::charwise(excerpt.start, excerpt.end),
771 replacement,
772 );
773 let location = match &source.source {
774 super::document::DocumentSource::Remote(remote) => ResourceLocation::remote(
775 remote.file.endpoint().clone(),
776 remote.file.path().to_path_buf(),
777 ),
778 _ => ResourceLocation::local(source.buf.path.clone().unwrap_or_default()),
779 };
780 match by_source
781 .iter_mut()
782 .find(|(id, _, _)| *id == excerpt.source)
783 {
784 Some((_, edits, _)) => edits.push(edit),
785 None => by_source.push((excerpt.source, vec![edit], location)),
786 }
787 }
788 let documents = by_source
789 .into_iter()
790 .map(|(document, edits, location)| PlannedDocument {
791 location,
792 document,
793 base: self.docs.get(document).unwrap().buf.revision(),
794 edits,
795 })
796 .collect();
797 let plan = ChangePlan {
798 producer: ChangeProducer::CollectionEdit,
799 documents,
800 refused: Vec::new(),
801 };
802 self.apply_change_plan(plan);
803 Ok(())
804 }
805}
806
807impl Editor {
808 pub(crate) fn collection_undo(&mut self) {
813 let id = self.current();
814 let Some(sources) = self
815 .collections
816 .get(&id)
817 .map(|c| c.excerpts.iter().map(|e| e.source).collect::<Vec<_>>())
818 else {
819 return;
820 };
821 let Some((index, mut receipt)) = self.changes.take_newest_matching(|receipt| {
822 receipt.producer == "collection edit"
823 && receipt
824 .applied
825 .iter()
826 .any(|(document, ..)| sources.contains(document))
827 }) else {
828 self.message = "already at oldest change".into();
829 return;
830 };
831 let mut moved = 0;
832 for (document, _before, after) in &receipt.applied {
833 match self.docs.get(*document) {
834 Some(doc) if doc.buf.revision() == *after => {}
835 Some(_) => {
836 let name = self
837 .docs
838 .get(*document)
839 .and_then(|d| d.buf.path.clone())
840 .map(|p| p.display().to_string())
841 .unwrap_or_else(|| "a source".into());
842 self.changes.restore(index, receipt);
843 self.message =
844 format!("collection undo refused: {name} changed since — resolve it first");
845 return;
846 }
847 None => {
848 self.changes.restore(index, receipt);
849 self.message = "collection undo refused: a source buffer was closed".into();
850 return;
851 }
852 }
853 }
854 let mut depths = Vec::with_capacity(receipt.applied.len());
855 for (document, ..) in &receipt.applied {
856 let undone_ok = matches!(self.doc_mut(*document).buf.undo(), Ok(Some(_)));
857 if undone_ok {
858 moved += 1;
859 }
860 depths.push(
861 self.docs
862 .get(*document)
863 .map(|doc| doc.buf.history().depth())
864 .unwrap_or(0),
865 );
866 }
867 receipt.redo_depths = Some(depths);
868 self.changes.push_undone(receipt);
871 self.message = format!("undid collection edit across {moved} buffer(s)");
872 }
873
874 pub(crate) fn collection_redo(&mut self) {
877 let id = self.current();
878 let Some(sources) = self
879 .collections
880 .get(&id)
881 .map(|c| c.excerpts.iter().map(|e| e.source).collect::<Vec<_>>())
882 else {
883 return;
884 };
885 let Some(receipt) = self.changes.take_undone_matching(|receipt| {
886 receipt
887 .applied
888 .iter()
889 .any(|(document, ..)| sources.contains(document))
890 }) else {
891 self.message = "nothing to redo".into();
892 return;
893 };
894 let depths = receipt.redo_depths.clone();
897 for (member, (document, ..)) in receipt.applied.iter().enumerate() {
898 let at = self
899 .docs
900 .get(*document)
901 .map(|doc| doc.buf.history().depth());
902 if at != depths.as_ref().map(|d| d[member]) {
903 self.changes.push_undone(receipt);
904 self.message = "collection redo refused: a source changed since the undo".into();
905 return;
906 }
907 }
908 let mut moved = 0;
909 for (document, ..) in &receipt.applied {
910 if matches!(self.doc_mut(*document).buf.redo(), Ok(Some(_))) {
911 moved += 1;
912 }
913 }
914 self.changes.push_receipt_back(receipt);
915 self.message = format!("redid collection edit across {moved} buffer(s)");
916 }
917}
918
919impl Editor {
920 pub(crate) fn collection_save(&mut self, target: Option<PathBuf>, force: bool, close: bool) {
924 let id = self.current();
925 if target.is_some() {
926 self.message = "a collection has no file of its own — :w saves its sources; exporting the view is unsupported"
927 .into();
928 return;
929 }
930 let Some(sources) = self.collections.get(&id).map(|c| {
931 let mut seen: Vec<DocumentId> = c.excerpts.iter().map(|e| e.source).collect();
932 seen.dedup();
933 seen
934 }) else {
935 return;
936 };
937 let mut queued = 0;
938 let mut refused: Vec<String> = Vec::new();
939 for source in sources {
940 let Some(doc) = self.docs.get(source) else {
941 continue;
942 };
943 if !doc.buf.dirty {
944 continue;
945 }
946 let name = doc
947 .buf
948 .path
949 .as_ref()
950 .map(|p| p.display().to_string())
951 .unwrap_or_else(|| "[scratch]".into());
952 if doc.buf.readonly {
953 refused.push(name);
954 continue;
955 }
956 self.request_save_document(source, None, force, false);
959 queued += 1;
960 }
961 if let Some(collection) = self.collections.get_mut(&id) {
962 collection.pending_saves = queued;
963 collection.close_when_saved = close && refused.is_empty() && queued > 0;
964 }
965 if queued == 0 && refused.is_empty() {
966 if close {
967 self.close_pane_or_buffer(false);
968 } else {
969 self.message = "collection: all sources are saved".into();
970 }
971 return;
972 }
973 if !refused.is_empty() {
974 self.message = format!(
975 "collection: saving {queued} source(s); read-only skipped: {}",
976 refused.join(", ")
977 );
978 } else {
979 self.message = format!("collection: saving {queued} source(s)");
980 }
981 }
982
983 pub(crate) fn collection_save_progress(&mut self, document: DocumentId, saved: bool) {
987 let mut close: Option<DocumentId> = None;
988 for (id, collection) in self.collections.iter_mut() {
989 if collection.pending_saves == 0
990 || !collection.excerpts.iter().any(|e| e.source == document)
991 {
992 continue;
993 }
994 if !saved {
995 collection.pending_saves = 0;
996 collection.close_when_saved = false;
997 continue;
998 }
999 collection.pending_saves = collection.pending_saves.saturating_sub(1);
1000 if collection.pending_saves == 0 && collection.close_when_saved {
1001 close = Some(*id);
1002 }
1003 }
1004 if let Some(id) = close {
1005 if self.current() == id {
1006 self.close_pane_or_buffer(false);
1007 } else if let Some(collection) = self.collections.get_mut(&id) {
1008 collection.close_when_saved = false;
1009 self.message = "collection: sources saved".into();
1010 }
1011 }
1012 }
1013}
1014
1015impl Editor {
1016 pub fn collection_open_source_pub(&mut self) {
1023 self.collection_open_source();
1024 }
1025
1026 pub(crate) fn collection_open_source(&mut self) {
1027 let id = self.current();
1028 let cursor_line = self.buf().line_of(self.head());
1029 let cursor_col = self.buf().col_of(self.head());
1030 let Some(collection) = self.collections.get(&id) else {
1031 return;
1032 };
1033 let mut target: Option<(DocumentId, usize)> = None;
1034 for excerpt in &collection.excerpts {
1035 if cursor_line == excerpt.view_line {
1036 target = Some((excerpt.source, excerpt.start));
1038 break;
1039 }
1040 if cursor_line > excerpt.view_line
1041 && cursor_line <= excerpt.view_line + excerpt.view_lines
1042 {
1043 let Some(source) = self.docs.get(excerpt.source) else {
1045 break;
1046 };
1047 let source_line =
1048 source.buf.line_of(excerpt.start) + (cursor_line - excerpt.view_line - 1);
1049 let line = source_line.min(source.buf.len_lines().saturating_sub(1));
1050 target = Some((
1051 excerpt.source,
1052 source
1053 .buf
1054 .clamp_boundary(source.buf.line_start(line).saturating_add(cursor_col)),
1055 ));
1056 break;
1057 }
1058 }
1059 let Some((document, head)) = target else {
1060 self.message = "not on an excerpt".into();
1061 return;
1062 };
1063 if self.docs.get(document).is_none() {
1064 self.message = "collection: that source was closed".into();
1065 return;
1066 }
1067 self.push_jump();
1068 self.switch_to(document);
1069 self.set_head(head);
1070 self.clamp_cursor();
1071 self.scroll_to_cursor(self.view_rows());
1072 }
1073}
1074
1075impl Editor {
1076 pub fn collection_row_kind(
1079 &self,
1080 doc: strop_core::id::DocumentId,
1081 line: usize,
1082 ) -> Option<CollectionRow> {
1083 self.collections.get(&doc)?.rows.get(line).copied()
1084 }
1085
1086 pub fn collection_source_lineno(
1091 &self,
1092 doc: strop_core::id::DocumentId,
1093 line: usize,
1094 ) -> Option<Option<usize>> {
1095 let collection = self.collections.get(&doc)?;
1096 for excerpt in &collection.excerpts {
1097 if line == excerpt.view_line {
1098 return Some(None); }
1100 if line > excerpt.view_line && line <= excerpt.view_line + excerpt.view_lines {
1101 let source = self.docs.get(excerpt.source)?;
1102 let first = source.buf.line_of(excerpt.start);
1103 return Some(Some(first + (line - excerpt.view_line - 1) + 1));
1104 }
1105 }
1106 Some(None) }
1108}