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 pub matches: Vec<(usize, usize)>,
56}
57
58#[derive(Debug, Clone)]
59pub(crate) struct Collection {
60 pub title: String,
61 pub excerpts: Vec<Excerpt>,
62 pub pending_saves: usize,
65 pub close_when_saved: bool,
66 pub shadow: String,
69 pub revision: BufferRevision,
72 pub rows: Vec<CollectionRow>,
74}
75
76pub(crate) type CollectionHit = (std::path::PathBuf, usize, Option<(usize, usize)>);
79
80type LineHit = (usize, Option<(usize, usize)>);
82type DocHits = Vec<LineHit>;
84type SpanWithHits = (usize, usize, Vec<Option<(usize, usize)>>);
86
87#[derive(Debug)]
90
91pub(crate) struct CollectionBuild {
92 pub title: String,
93 pub hits: Vec<CollectionHit>,
95 pub remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)>,
97 pub waiting: usize,
98}
99
100fn fingerprint(text: &str) -> u64 {
101 let mut hash: u64 = 0xcbf29ce484222325;
102 for byte in text.as_bytes() {
103 hash = (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
104 }
105 hash
106}
107
108fn render(
115 docs: &Arena<DocumentKind, Document>,
116 cwd: &std::path::Path,
117 collection: &mut Collection,
118) -> String {
119 let files = {
120 let mut seen = Vec::new();
121 for excerpt in &collection.excerpts {
122 if !seen.contains(&excerpt.source) {
123 seen.push(excerpt.source);
124 }
125 }
126 seen.len()
127 };
128 let matches = collection.excerpts.len();
129 let dirty = {
130 let mut n = 0;
131 let mut seen = Vec::new();
132 for excerpt in &collection.excerpts {
133 if !seen.contains(&excerpt.source) {
134 seen.push(excerpt.source);
135 if docs.get(excerpt.source).is_some_and(|d| d.buf.dirty) {
136 n += 1;
137 }
138 }
139 }
140 n
141 };
142 let mut text = format!(
143 "collection: {} — {} match(es) · {} file(s){}{}\n",
144 collection.title,
145 matches,
146 files,
147 if dirty > 0 { " · " } else { "" },
148 if dirty > 0 {
149 format!("{dirty} modified")
150 } else {
151 String::new()
152 },
153 );
154 let mut rows = vec![CollectionRow::Title];
155 let mut line = 1;
156 let mut at = 0;
157 while at < collection.excerpts.len() {
158 let source = collection.excerpts[at].source;
159 let mut card_end = at;
160 while card_end + 1 < collection.excerpts.len()
161 && collection.excerpts[card_end + 1].source == source
162 {
163 card_end += 1;
164 }
165 let doc = docs.get(source).unwrap();
166 let path = doc
167 .buf
168 .path
169 .as_ref()
170 .map(|path| path.strip_prefix(cwd).unwrap_or(path).display().to_string())
171 .unwrap_or_else(|| "[scratch]".into());
172 let lang = doc
173 .buf
174 .path
175 .as_ref()
176 .and_then(|p| p.extension().map(|e| format!(".{}", e.to_string_lossy())))
177 .and_then(|ext| strop_lsp::registry::language_for_extension(&ext));
178 let count = card_end - at + 1;
179 let modified = doc.buf.dirty;
180 let lang_badge = lang.map(|l| format!("{l} · ")).unwrap_or_default();
181 let dirty_badge = if modified { " · modified" } else { "" };
182 text.push_str(&format!(
183 "╭─ {path} ── {lang_badge}{count} excerpt(s){dirty_badge}\n"
184 ));
185 rows.push(CollectionRow::CardTop(at));
186 line += 1;
187 for i in at..=card_end {
188 if i > at {
189 let prev = &collection.excerpts[i - 1];
190 let here = &collection.excerpts[i];
191 let gap_lines = doc
192 .buf
193 .line_of(here.start)
194 .saturating_sub(doc.buf.line_of(prev.end));
195 text.push_str(&format!("⋮ {gap_lines} source lines omitted\n"));
196 rows.push(CollectionRow::Gap);
197 line += 1;
198 }
199 let start = collection.excerpts[i].start;
200 let end = collection.excerpts[i].end;
201 let body = doc.buf.text().byte_slice(start..end).to_string();
202 let excerpt = &mut collection.excerpts[i];
203 excerpt.view_line = line - 1; excerpt.view_start = text.len();
205 excerpt.view_lines = body.lines().count().max(1);
206 excerpt.fingerprint = fingerprint(&body);
207 text.push_str(&body);
208 if !body.ends_with('\n') {
209 text.push('\n');
210 }
211 excerpt.view_end = text.len();
212 for _ in 0..excerpt.view_lines {
213 rows.push(CollectionRow::Body);
214 }
215 line += excerpt.view_lines;
216 }
217 text.push_str("╰\n");
218 rows.push(CollectionRow::CardBottom);
219 line += 1;
220 at = card_end + 1;
221 }
222 collection.rows = rows;
223 let _ = line;
224 text
225}
226
227impl Editor {
228 pub(crate) fn open_collection_from_picker(&mut self) {
233 let Some(glue) = &self.picker else {
234 return;
235 };
236 let kind = glue.picker.kind;
237 if !matches!(
238 kind,
239 strop_picker::Kind::Locations
240 | strop_picker::Kind::Diagnostics
241 | strop_picker::Kind::Grep
242 ) {
243 self.message = "collections come from a results list".into();
244 return;
245 }
246 let mut hits: Vec<CollectionHit> = Vec::new();
249 let mut remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)> =
250 Vec::new();
251 for item in glue.picker.items.iter() {
252 match &item.payload {
253 strop_picker::Payload::Grep {
254 path,
255 line,
256 col,
257 match_len,
258 ..
259 } => {
260 let hit = (kind == strop_picker::Kind::Grep)
261 .then(|| (col.saturating_sub(1), *match_len));
262 hits.push((path.clone(), line.saturating_sub(1), hit));
263 }
264 strop_picker::Payload::Remote {
265 endpoint,
266 path,
267 line,
268 ..
269 } => remote_hits.push((endpoint.clone(), path.clone(), line.saturating_sub(1))),
270 _ => {}
271 }
272 }
273 let title = kind.title().trim().to_string();
274 self.close_picker();
275 let mut to_load: Vec<std::path::PathBuf> = Vec::new();
278 for (path, ..) in &hits {
279 let absolute = if path.is_absolute() {
280 path.clone()
281 } else {
282 self.cwd.join(path)
283 };
284 let open = self.docs.iter().any(|(_, doc)| {
285 doc.matches_target(&crate::files::FileTarget::Local(absolute.clone()))
286 });
287 if !open && !to_load.contains(&absolute) {
288 to_load.push(absolute);
289 }
290 }
291 let waiting = to_load.len();
292 let build = CollectionBuild {
293 title,
294 hits,
295 remote_hits,
296 waiting,
297 };
298 if to_load.is_empty() {
299 self.build_collection(build);
300 return;
301 }
302 self.collection_build = Some(build);
303 self.message = format!("collection: loading {waiting} source(s)…");
304 for path in to_load {
305 self.request_open(path, crate::editor::io::OpenIntent::Background);
306 }
307 }
308
309 pub(crate) fn collection_source_ready(&mut self, _document: DocumentId) {
312 let Some(build) = &mut self.collection_build else {
313 strop_trace::record_with(
314 strop_trace::EventKind::JobFinished,
315 || serde_json::json!({"service":"collection","result":"ready-without-build"}),
316 );
317 return;
318 };
319 build.waiting = build.waiting.saturating_sub(1);
320 strop_trace::record_with(
321 strop_trace::EventKind::JobFinished,
322 || serde_json::json!({"service":"collection","result":"source-ready","waiting":build.waiting}),
323 );
324 if build.waiting == 0 {
325 let build = self.collection_build.take().unwrap();
326 self.build_collection(build);
327 }
328 }
329
330 fn build_collection(&mut self, build: CollectionBuild) {
331 let CollectionBuild {
332 title,
333 hits,
334 remote_hits,
335 ..
336 } = build;
337 #[allow(clippy::type_complexity)]
338 let mut by_doc: HashMap<DocumentId, DocHits> = HashMap::new();
339 let mut skipped = 0;
340 for (path, line, hit) in hits {
341 let absolute = if path.is_absolute() {
342 path
343 } else {
344 self.cwd.join(path)
345 };
346 let Some(document) = self.docs.iter().find_map(|(id, doc)| {
347 doc.matches_target(&crate::files::FileTarget::Local(absolute.clone()))
348 .then_some(id)
349 }) else {
350 skipped += 1;
351 continue;
352 };
353 by_doc.entry(document).or_default().push((line, hit));
354 }
355 for (endpoint, path, line) in remote_hits {
356 let document = self.docs.iter().find_map(|(id, doc)| {
357 doc.remote_metadata().and_then(|source| {
358 (source.file.endpoint() == &endpoint && source.file.path() == path.as_path())
359 .then_some(id)
360 })
361 });
362 match document {
363 Some(id) => by_doc.entry(id).or_default().push((line, None)),
364 None => skipped += 1,
365 }
366 }
367 if by_doc.is_empty() {
368 self.message = "no open buffers among the results — open them first".into();
369 return;
370 }
371 let mut excerpts = Vec::new();
372 for (source, mut lines) in by_doc {
373 lines.sort_unstable();
374 lines.dedup_by_key(|(line, _)| *line);
375 let buf = &self.docs.get(source).unwrap().buf;
376 let mut spans: Vec<SpanWithHits> = Vec::new();
379 for (line, hit) in lines {
380 if line >= buf.len_lines() {
381 continue;
382 }
383 match spans.last_mut() {
384 Some((_, end, hits)) if line <= *end => {
385 *end = line + 1;
386 hits.push(hit);
387 }
388 _ => spans.push((line, line + 1, vec![hit])),
389 }
390 }
391 for (start_line, end_line, hits) in spans {
392 let start = buf.line_start(start_line);
393 let end = if end_line >= buf.len_lines() {
394 buf.len_bytes()
395 } else {
396 buf.line_start(end_line)
397 };
398 let text = buf.text().byte_slice(start..end).to_string();
399 let matches = hits
402 .into_iter()
403 .enumerate()
404 .filter_map(|(i, hit)| {
405 hit.map(|(col, len)| (buf.line_start(start_line + i) + col, len))
406 })
407 .collect();
408 excerpts.push(Excerpt {
409 source,
410 start,
411 end,
412 fingerprint: fingerprint(&text),
413 view_line: 0,
414 view_lines: 0,
415 view_start: 0,
416 view_end: 0,
417 matches,
418 });
419 }
420 }
421 excerpts.sort_by_key(|excerpt| {
423 let path = self
424 .docs
425 .get(excerpt.source)
426 .and_then(|d| d.buf.path.clone())
427 .map(|p| p.display().to_string())
428 .unwrap_or_default();
429 (path, excerpt.start)
430 });
431 let excerpt_count = excerpts.len();
432 let title_for_trace = title.clone();
433 let id = self.docs.insert(Document::output(Buffer::from_text("")));
434 self.docs.get_mut(id).unwrap().buf.name = Some(format!("collection: {title}"));
436 let mut collection = Collection {
437 title,
438 excerpts,
439 pending_saves: 0,
440 close_when_saved: false,
441 rows: Vec::new(),
442 shadow: String::new(),
443 revision: BufferRevision::new(0),
444 };
445 let text = render(&self.docs, &self.cwd, &mut collection);
446 collection.shadow = text.clone();
447 self.collections.insert(id, collection);
448 let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
449 self.docs.get_mut(id).unwrap().buf.readonly = false;
450 let revision = self.docs.get(id).unwrap().buf.revision();
451 self.collections.get_mut(&id).unwrap().revision = revision;
452 strop_trace::record_with(strop_trace::EventKind::JobFinished, || {
453 serde_json::json!({
454 "service":"collection","result":"built","excerpts":excerpt_count,
455 "skipped":skipped,"title":title_for_trace,
456 })
457 });
458 self.drop_stale_scratch(id);
459 self.switch_to(id);
460 self.set_head(0);
461 self.message = match skipped {
462 0 => format!("collection: {excerpt_count} excerpt(s)"),
463 _ => format!("collection built; {skipped} hit(s) skipped (not open local buffers)"),
464 };
465 }
466
467 pub(crate) fn maybe_sync_collection(&mut self) {
471 if self.docs.is_empty() {
472 return;
473 }
474 let id = self.current();
475 let revision = self.buf().revision();
476 let Some(stored) = self.collections.get(&id).map(|c| c.revision) else {
477 return;
478 };
479 if revision == stored {
480 return;
481 }
482 let current = self.buf().text().to_string();
483 if current == self.collections[&id].shadow {
484 self.collections.get_mut(&id).unwrap().revision = revision;
485 return;
486 }
487 let working = self.collections[&id].clone();
490 if let Err(reason) = self.collection_write_back(&working, ¤t) {
491 self.message = reason;
492 }
493 let open = working
494 .excerpts
495 .iter()
496 .all(|excerpt| self.docs.get(excerpt.source).is_some());
497 if !open {
498 self.message = "collection: a source buffer was closed — view dropped".into();
499 self.collections.remove(&id);
500 return;
501 }
502 self.collection_render_view(id);
505 }
506
507 pub(crate) fn collection_render_view(&mut self, id: DocumentId) {
510 let caret = if self.current() == id {
513 Some((
514 self.buf().line_of(self.head()),
515 self.buf().col_of(self.head()),
516 ))
517 } else {
518 None
519 };
520 let text = {
521 let entry = self.collections.get_mut(&id).unwrap();
522 render(&self.docs, &self.cwd, entry)
523 };
524 {
525 let entry = self.collections.get_mut(&id).unwrap();
526 entry.shadow = text.clone();
527 }
528 let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
529 if let Some((line, col)) = caret {
530 let line = line.min(self.docs.get(id).unwrap().buf.len_lines().saturating_sub(1));
531 let head = self.docs.get(id).unwrap().buf.clamp_boundary(
532 self.docs
533 .get(id)
534 .unwrap()
535 .buf
536 .line_start(line)
537 .saturating_add(col),
538 );
539 if self.current() == id {
540 self.set_head(head);
541 self.clamp_cursor();
542 }
543 }
544 let revision = self.docs.get(id).unwrap().buf.revision();
545 self.collections.get_mut(&id).unwrap().revision = revision;
546 self.docs.get_mut(id).unwrap().buf.dirty = false;
549 }
550
551 pub(crate) fn collection_splice_excerpt(&mut self, id: DocumentId, index: usize) {
556 let (source, view_start, view_end, view_lines) = {
557 let entry = &self.collections[&id];
558 let excerpt = &entry.excerpts[index];
559 (
560 excerpt.source,
561 excerpt.view_start,
562 excerpt.view_end,
563 excerpt.view_lines,
564 )
565 };
566 let Some(source_doc) = self.docs.get(source) else {
567 return;
568 };
569 let excerpt_span = {
570 let entry = &self.collections[&id];
571 let excerpt = &entry.excerpts[index];
572 (excerpt.start, excerpt.end)
573 };
574 let mut body = source_doc
575 .buf
576 .text()
577 .byte_slice(excerpt_span.0..excerpt_span.1)
578 .to_string();
579 #[cfg(test)]
580 eprintln!("splice coll={id:?} ex={index} span={excerpt_span:?} view={view_start}..{view_end} body={body:?}");
581 if !body.ends_with('\n') {
582 body.push('\n');
583 }
584 let new_lines = body.lines().count().max(1);
585 {
588 let doc = self.docs.get_mut(id).unwrap();
589 let _ = doc
590 .buf
591 .system_edit()
592 .replace(Range::charwise(view_start, view_end), &body);
593 }
594 {
597 let doc = self.docs.get_mut(id).unwrap();
598 let changes: Vec<_> = doc.buf.changes().to_vec();
599 self.analysis.edits(id, &changes);
600 doc.buf.clear_changes();
601 }
602 let byte_delta = body.len() as isize - (view_end - view_start) as isize;
603 let line_delta = new_lines as isize - view_lines as isize;
604 let entry = self.collections.get_mut(&id).unwrap();
605 entry.shadow.replace_range(view_start..view_end, &body);
607 let mut seen = false;
608 for excerpt in &mut entry.excerpts {
609 if seen {
610 excerpt.view_start = (excerpt.view_start as isize + byte_delta) as usize;
611 excerpt.view_end = (excerpt.view_end as isize + byte_delta) as usize;
612 excerpt.view_line = (excerpt.view_line as isize + line_delta) as usize;
613 } else if excerpt.view_start == view_start {
614 seen = true;
615 excerpt.view_lines = new_lines;
616 excerpt.view_end = view_start + body.len();
617 excerpt.fingerprint = fingerprint(&body);
618 }
619 }
620 let revision = self.docs.get(id).unwrap().buf.revision();
621 self.collections.get_mut(&id).unwrap().revision = revision;
622 }
623 fn diff_lines(
629 shadow: &[&str],
630 current: &[&str],
631 ) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
632 let prefix = shadow
633 .iter()
634 .zip(current.iter())
635 .take_while(|(a, b)| a == b)
636 .count();
637 let suffix = shadow[prefix..]
638 .iter()
639 .rev()
640 .zip(current[prefix.min(current.len())..].iter().rev())
641 .take_while(|(a, b)| a == b)
642 .count();
643 let smid = &shadow[prefix..shadow.len() - suffix.min(shadow.len() - prefix)];
644 let cmid = ¤t[prefix..current.len() - suffix.min(current.len() - prefix)];
645 if smid.is_empty() && cmid.is_empty() {
646 return Vec::new();
647 }
648 Self::diff_lines_middle(
649 &shadow[prefix..prefix + smid.len()],
650 ¤t[prefix..prefix + cmid.len()],
651 )
652 .into_iter()
653 .map(|(old, new)| {
654 (
655 old.start + prefix..old.end + prefix,
656 new.start + prefix..new.end + prefix,
657 )
658 })
659 .collect()
660 }
661
662 fn diff_lines_middle(
664 shadow: &[&str],
665 current: &[&str],
666 ) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
667 let (n, m) = (shadow.len(), current.len());
668 let mut lcs = vec![vec![0usize; m + 1]; n + 1];
670 for i in (0..n).rev() {
671 for j in (0..m).rev() {
672 lcs[i][j] = if shadow[i] == current[j] {
673 lcs[i + 1][j + 1] + 1
674 } else {
675 lcs[i + 1][j].max(lcs[i][j + 1])
676 };
677 }
678 }
679 let mut hunks = Vec::new();
680 let (mut i, mut j) = (0, 0);
681 while i < n || j < m {
682 if i < n && j < m && shadow[i] == current[j] {
683 i += 1;
684 j += 1;
685 continue;
686 }
687 let (si, sj) = (i, j);
688 while i < n || j < m {
689 if i < n && j < m && shadow[i] == current[j] {
690 break;
691 }
692 if i < n && (j == m || lcs[i + 1][j] >= lcs[i][j + 1]) {
693 i += 1;
694 } else {
695 j += 1;
696 }
697 }
698 hunks.push((si..i, sj..j));
699 }
700 hunks
701 }
702
703 fn map_line(
707 hunks: &[(std::ops::Range<usize>, std::ops::Range<usize>)],
708 line: usize,
709 count_at_boundary: bool,
710 ) -> usize {
711 let mut current = line;
712 for (old, new) in hunks {
713 let counts =
714 old.end < line || (old.end == line && (!old.is_empty() || count_at_boundary));
715 if counts {
716 current += new.len() - old.len();
717 } else {
718 break;
719 }
720 }
721 current
722 }
723
724 fn collection_write_back(
729 &mut self,
730 collection: &Collection,
731 current: &str,
732 ) -> Result<(), String> {
733 let shadow_lines: Vec<&str> = collection.shadow.split_inclusive('\n').collect();
734 let current_lines: Vec<&str> = current.split_inclusive('\n').collect();
735 let hunks = Self::diff_lines(&shadow_lines, ¤t_lines);
736 if hunks.is_empty() {
737 return Ok(());
738 }
739 let mut touched: Vec<usize> = Vec::new();
741 for (old, _) in &hunks {
742 let mut owner = None;
743 for (index, excerpt) in collection.excerpts.iter().enumerate() {
744 let lo = excerpt.view_line + 1;
745 let hi = excerpt.view_line + excerpt.view_lines + 1;
746 let inside = if old.is_empty() {
747 old.start >= lo && old.start < hi
749 } else {
750 old.start >= lo && old.end <= hi
751 };
752 if inside {
753 owner = Some(index);
754 break;
755 }
756 if !old.is_empty() && old.start < hi && old.end > lo {
758 return Err(
759 "edit touches a header or spans excerpts — refused; view refreshed".into(),
760 );
761 }
762 }
763 let Some(index) = owner else {
764 return Err(
765 "edit touches the title, a header, or the collection's structure — refused; view refreshed"
766 .into(),
767 );
768 };
769 if !touched.contains(&index) {
770 touched.push(index);
771 }
772 }
773 let mut by_source: Vec<(DocumentId, Vec<strop_core::Replacement>, ResourceLocation)> =
776 Vec::new();
777 for index in touched {
778 let excerpt = &collection.excerpts[index];
779 let source = self
780 .docs
781 .get(excerpt.source)
782 .ok_or_else(|| "collection: a source buffer was closed".to_string())?;
783 let present = source
784 .buf
785 .text()
786 .byte_slice(excerpt.start..excerpt.end)
787 .to_string();
788 if fingerprint(&present) != excerpt.fingerprint {
789 return Err(
790 "collection: a source changed elsewhere — refused; view refreshed".into(),
791 );
792 }
793 if source.buf.readonly {
794 return Err(
795 "collection: a source is read-only (remote sources need :remote edit first) — refused; view refreshed"
796 .into(),
797 );
798 }
799 let lo = excerpt.view_line + 1;
800 let hi = excerpt.view_line + excerpt.view_lines + 1;
801 let cur_lo = Self::map_line(&hunks, lo, false);
802 let cur_hi = Self::map_line(&hunks, hi, true);
803 let mut replacement: String = current_lines[cur_lo..cur_hi].concat();
804 if !replacement.is_empty() && !replacement.ends_with('\n') {
805 replacement.push('\n');
806 }
807 let edit = strop_core::Replacement::new(
808 Range::charwise(excerpt.start, excerpt.end),
809 replacement,
810 );
811 let location = match &source.source {
812 super::document::DocumentSource::Remote(remote) => ResourceLocation::remote(
813 remote.file.endpoint().clone(),
814 remote.file.path().to_path_buf(),
815 ),
816 _ => ResourceLocation::local(source.buf.path.clone().unwrap_or_default()),
817 };
818 match by_source
819 .iter_mut()
820 .find(|(id, _, _)| *id == excerpt.source)
821 {
822 Some((_, edits, _)) => edits.push(edit),
823 None => by_source.push((excerpt.source, vec![edit], location)),
824 }
825 }
826 let documents = by_source
827 .into_iter()
828 .map(|(document, edits, location)| PlannedDocument {
829 location,
830 document,
831 base: self.docs.get(document).unwrap().buf.revision(),
832 edits,
833 })
834 .collect();
835 let plan = ChangePlan {
836 producer: ChangeProducer::CollectionEdit,
837 documents,
838 refused: Vec::new(),
839 };
840 self.apply_change_plan(plan);
841 Ok(())
842 }
843}
844
845impl Editor {
846 pub(crate) fn collection_undo(&mut self) {
851 let id = self.current();
852 let Some(sources) = self
853 .collections
854 .get(&id)
855 .map(|c| c.excerpts.iter().map(|e| e.source).collect::<Vec<_>>())
856 else {
857 return;
858 };
859 let Some((index, mut receipt)) = self.changes.take_newest_matching(|receipt| {
860 receipt.producer == "collection edit"
861 && receipt
862 .applied
863 .iter()
864 .any(|(document, ..)| sources.contains(document))
865 }) else {
866 self.message = "already at oldest change".into();
867 return;
868 };
869 let mut moved = 0;
870 for (document, _before, after) in &receipt.applied {
871 match self.docs.get(*document) {
872 Some(doc) if doc.buf.revision() == *after => {}
873 Some(_) => {
874 let name = self
875 .docs
876 .get(*document)
877 .and_then(|d| d.buf.path.clone())
878 .map(|p| p.display().to_string())
879 .unwrap_or_else(|| "a source".into());
880 self.changes.restore(index, receipt);
881 self.message =
882 format!("collection undo refused: {name} changed since — resolve it first");
883 return;
884 }
885 None => {
886 self.changes.restore(index, receipt);
887 self.message = "collection undo refused: a source buffer was closed".into();
888 return;
889 }
890 }
891 }
892 let mut depths = Vec::with_capacity(receipt.applied.len());
893 for (document, ..) in &receipt.applied {
894 let undone_ok = matches!(self.doc_mut(*document).buf.undo(), Ok(Some(_)));
895 if undone_ok {
896 moved += 1;
897 }
898 depths.push(
899 self.docs
900 .get(*document)
901 .map(|doc| doc.buf.history().depth())
902 .unwrap_or(0),
903 );
904 }
905 receipt.redo_depths = Some(depths);
906 self.changes.push_undone(receipt);
909 self.message = format!("undid collection edit across {moved} buffer(s)");
910 }
911
912 pub(crate) fn collection_redo(&mut self) {
915 let id = self.current();
916 let Some(sources) = self
917 .collections
918 .get(&id)
919 .map(|c| c.excerpts.iter().map(|e| e.source).collect::<Vec<_>>())
920 else {
921 return;
922 };
923 let Some(receipt) = self.changes.take_undone_matching(|receipt| {
924 receipt
925 .applied
926 .iter()
927 .any(|(document, ..)| sources.contains(document))
928 }) else {
929 self.message = "nothing to redo".into();
930 return;
931 };
932 let depths = receipt.redo_depths.clone();
935 for (member, (document, ..)) in receipt.applied.iter().enumerate() {
936 let at = self
937 .docs
938 .get(*document)
939 .map(|doc| doc.buf.history().depth());
940 if at != depths.as_ref().map(|d| d[member]) {
941 self.changes.push_undone(receipt);
942 self.message = "collection redo refused: a source changed since the undo".into();
943 return;
944 }
945 }
946 let mut moved = 0;
947 for (document, ..) in &receipt.applied {
948 if matches!(self.doc_mut(*document).buf.redo(), Ok(Some(_))) {
949 moved += 1;
950 }
951 }
952 self.changes.push_receipt_back(receipt);
953 self.message = format!("redid collection edit across {moved} buffer(s)");
954 }
955}
956
957impl Editor {
958 pub(crate) fn collection_save(&mut self, target: Option<PathBuf>, force: bool, close: bool) {
962 let id = self.current();
963 if target.is_some() {
964 self.message = "a collection has no file of its own — :w saves its sources; exporting the view is unsupported"
965 .into();
966 return;
967 }
968 let Some(sources) = self.collections.get(&id).map(|c| {
969 let mut seen: Vec<DocumentId> = c.excerpts.iter().map(|e| e.source).collect();
970 seen.dedup();
971 seen
972 }) else {
973 return;
974 };
975 let mut queued = 0;
976 let mut refused: Vec<String> = Vec::new();
977 for source in sources {
978 let Some(doc) = self.docs.get(source) else {
979 continue;
980 };
981 if !doc.buf.dirty {
982 continue;
983 }
984 let name = doc
985 .buf
986 .path
987 .as_ref()
988 .map(|p| p.display().to_string())
989 .unwrap_or_else(|| "[scratch]".into());
990 if doc.buf.readonly {
991 refused.push(name);
992 continue;
993 }
994 self.request_save_document(source, None, force, false);
997 queued += 1;
998 }
999 if let Some(collection) = self.collections.get_mut(&id) {
1000 collection.pending_saves = queued;
1001 collection.close_when_saved = close && refused.is_empty() && queued > 0;
1002 }
1003 if queued == 0 && refused.is_empty() {
1004 if close {
1005 self.close_pane_or_buffer(false);
1006 } else {
1007 self.message = "collection: all sources are saved".into();
1008 }
1009 return;
1010 }
1011 if !refused.is_empty() {
1012 self.message = format!(
1013 "collection: saving {queued} source(s); read-only skipped: {}",
1014 refused.join(", ")
1015 );
1016 } else {
1017 self.message = format!("collection: saving {queued} source(s)");
1018 }
1019 }
1020
1021 pub(crate) fn collection_save_progress(&mut self, document: DocumentId, saved: bool) {
1025 let mut close: Option<DocumentId> = None;
1026 for (id, collection) in self.collections.iter_mut() {
1027 if collection.pending_saves == 0
1028 || !collection.excerpts.iter().any(|e| e.source == document)
1029 {
1030 continue;
1031 }
1032 if !saved {
1033 collection.pending_saves = 0;
1034 collection.close_when_saved = false;
1035 continue;
1036 }
1037 collection.pending_saves = collection.pending_saves.saturating_sub(1);
1038 if collection.pending_saves == 0 && collection.close_when_saved {
1039 close = Some(*id);
1040 }
1041 }
1042 if let Some(id) = close {
1043 if self.current() == id {
1044 self.close_pane_or_buffer(false);
1045 } else if let Some(collection) = self.collections.get_mut(&id) {
1046 collection.close_when_saved = false;
1047 self.message = "collection: sources saved".into();
1048 }
1049 }
1050 }
1051}
1052
1053impl Editor {
1054 pub fn collection_open_source_pub(&mut self) {
1061 self.collection_open_source();
1062 }
1063
1064 pub(crate) fn collection_open_source(&mut self) {
1065 let id = self.current();
1066 let cursor_line = self.buf().line_of(self.head());
1067 let cursor_col = self.buf().col_of(self.head());
1068 let Some(collection) = self.collections.get(&id) else {
1069 return;
1070 };
1071 let mut target: Option<(DocumentId, usize)> = None;
1072 for excerpt in &collection.excerpts {
1073 if cursor_line == excerpt.view_line {
1074 target = Some((excerpt.source, excerpt.start));
1076 break;
1077 }
1078 if cursor_line > excerpt.view_line
1079 && cursor_line <= excerpt.view_line + excerpt.view_lines
1080 {
1081 let Some(source) = self.docs.get(excerpt.source) else {
1083 break;
1084 };
1085 let source_line =
1086 source.buf.line_of(excerpt.start) + (cursor_line - excerpt.view_line - 1);
1087 let line = source_line.min(source.buf.len_lines().saturating_sub(1));
1088 target = Some((
1089 excerpt.source,
1090 source
1091 .buf
1092 .clamp_boundary(source.buf.line_start(line).saturating_add(cursor_col)),
1093 ));
1094 break;
1095 }
1096 }
1097 let Some((document, head)) = target else {
1098 self.message = "not on an excerpt".into();
1099 return;
1100 };
1101 if self.docs.get(document).is_none() {
1102 self.message = "collection: that source was closed".into();
1103 return;
1104 }
1105 self.push_jump();
1106 self.switch_to(document);
1107 self.set_head(head);
1108 self.clamp_cursor();
1109 self.scroll_to_cursor(self.view_rows());
1110 }
1111}
1112
1113impl Editor {
1114 pub fn collection_row_info(
1119 &self,
1120 doc: strop_core::id::DocumentId,
1121 line: usize,
1122 ) -> Option<CollectionRowInfo> {
1123 let collection = self.collections.get(&doc)?;
1124 let kind = *collection.rows.get(line)?;
1125 let caret_line = if doc == self.current() {
1126 self.buf().line_of(self.head())
1127 } else {
1128 usize::MAX
1129 };
1130 let mut info = CollectionRowInfo {
1131 kind,
1132 source: None,
1133 source_matches: Vec::new(),
1134 card_active: false,
1135 };
1136 for (index, excerpt) in collection.excerpts.iter().enumerate() {
1137 let body_lo = excerpt.view_line + 1;
1138 let body_hi = excerpt.view_line + excerpt.view_lines + 1;
1139 if kind == CollectionRow::CardTop(index) {
1142 let mut rows_end = collection.rows.len();
1144 for (later, next) in collection.excerpts.iter().enumerate().skip(index + 1) {
1145 if next.source != excerpt.source {
1146 rows_end = next.view_line;
1147 break;
1148 }
1149 let _ = later;
1150 }
1151 if let Some(b) = collection
1152 .rows
1153 .iter()
1154 .position(|r| *r == CollectionRow::CardTop(index))
1155 {
1156 let _ = b;
1157 }
1158 info.card_active = caret_line >= excerpt.view_line && caret_line < rows_end;
1159 }
1160 if kind == CollectionRow::Body && line >= body_lo && line < body_hi {
1161 let source = self.docs.get(excerpt.source)?;
1162 let source_line = source.buf.line_of(excerpt.start) + (line - body_lo);
1163 let s = source.buf.line_start(source_line);
1164 let e = source.buf.line_end(source_line);
1165 info.source = Some((excerpt.source, s, e));
1166 info.source_matches = excerpt
1167 .matches
1168 .iter()
1169 .copied()
1170 .filter(|(m, len)| *m >= s && m + len <= e)
1171 .collect();
1172 info.card_active = caret_line >= excerpt.view_line && caret_line < body_hi;
1174 }
1175 }
1176 Some(info)
1177 }
1178
1179 pub fn collection_row_kind(
1182 &self,
1183 doc: strop_core::id::DocumentId,
1184 line: usize,
1185 ) -> Option<CollectionRow> {
1186 self.collections.get(&doc)?.rows.get(line).copied()
1187 }
1188
1189 pub fn collection_source_lineno(
1194 &self,
1195 doc: strop_core::id::DocumentId,
1196 line: usize,
1197 ) -> Option<Option<usize>> {
1198 let collection = self.collections.get(&doc)?;
1199 for excerpt in &collection.excerpts {
1200 if line == excerpt.view_line {
1201 return Some(None); }
1203 if line > excerpt.view_line && line <= excerpt.view_line + excerpt.view_lines {
1204 let source = self.docs.get(excerpt.source)?;
1205 let first = source.buf.line_of(excerpt.start);
1206 return Some(Some(first + (line - excerpt.view_line - 1) + 1));
1207 }
1208 }
1209 Some(None) }
1211}
1212
1213pub struct CollectionRowInfo {
1215 pub kind: CollectionRow,
1216 pub source: Option<(strop_core::id::DocumentId, usize, usize)>,
1218 pub source_matches: Vec<(usize, usize)>,
1220 pub card_active: bool,
1222}
1223
1224impl Editor {
1225 pub fn collection_file_step_pub(&mut self, forward: bool) {
1228 self.collection_file_step(forward);
1229 }
1230
1231 pub(crate) fn collection_file_step(&mut self, forward: bool) {
1232 let id = self.current();
1233 let Some(collection) = self.collections.get(&id) else {
1234 self.message = "file cards live in collections".into();
1235 return;
1236 };
1237 let caret = self.buf().line_of(self.head());
1238 let mut tops: Vec<usize> = Vec::new();
1239 for (row, kind) in collection.rows.iter().enumerate() {
1240 if matches!(kind, CollectionRow::CardTop(_)) {
1241 tops.push(row);
1242 }
1243 }
1244 let target = if forward {
1245 tops.iter().copied().find(|row| *row > caret)
1246 } else {
1247 tops.iter().copied().rev().find(|row| *row < caret)
1248 };
1249 let Some(row) = target else {
1250 self.message = if forward { "last card" } else { "first card" }.into();
1251 return;
1252 };
1253 self.push_jump();
1254 self.set_head(self.buf().line_start(row));
1255 self.clamp_cursor();
1256 self.scroll_to_cursor(self.view_rows());
1257 }
1258}