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)]
23pub(crate) struct Excerpt {
24 pub source: DocumentId,
25 pub start: usize,
27 pub end: usize,
28 pub fingerprint: u64,
31 pub view_line: usize,
33 pub view_lines: usize,
35 pub view_start: usize,
38 pub view_end: usize,
39}
40
41#[derive(Debug, Clone)]
42pub(crate) struct Collection {
43 pub title: String,
44 pub excerpts: Vec<Excerpt>,
45 pub pending_saves: usize,
48 pub close_when_saved: bool,
49 pub shadow: String,
52 pub revision: BufferRevision,
55}
56
57#[derive(Debug)]
60pub(crate) struct CollectionBuild {
61 pub title: String,
62 pub hits: Vec<(std::path::PathBuf, usize)>,
63 pub remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)>,
65 pub waiting: usize,
66}
67
68fn fingerprint(text: &str) -> u64 {
69 let mut hash: u64 = 0xcbf29ce484222325;
70 for byte in text.as_bytes() {
71 hash = (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
72 }
73 hash
74}
75
76fn render(
81 docs: &Arena<DocumentKind, Document>,
82 cwd: &std::path::Path,
83 collection: &mut Collection,
84) -> String {
85 let mut text = format!(
86 "collection: {} — {} excerpt(s) (edits write back at action boundaries; g<Space> opens source)\n",
87 collection.title,
88 collection.excerpts.len()
89 );
90 let mut line = 1;
91 for excerpt in &mut collection.excerpts {
92 let source = &docs.get(excerpt.source).unwrap().buf;
93 let path = source
94 .path
95 .as_ref()
96 .map(|path| path.strip_prefix(cwd).unwrap_or(path).display().to_string())
97 .unwrap_or_else(|| "[scratch]".into());
98 let first = source.line_of(excerpt.start) + 1;
99 text.push_str(&format!("── {path}:{first} ──\n"));
100 let body = source
101 .text()
102 .byte_slice(excerpt.start..excerpt.end)
103 .to_string();
104 #[cfg(test)]
105 eprintln!(
106 "render excerpt src={:?} span={}..{} body={body:?}",
107 excerpt.source, excerpt.start, excerpt.end
108 );
109 excerpt.view_line = line;
110 excerpt.view_start = text.len();
111 excerpt.view_lines = body.lines().count().max(1);
112 excerpt.fingerprint = fingerprint(&body);
113 text.push_str(&body);
114 if !body.ends_with('\n') {
115 text.push('\n');
116 }
117 excerpt.view_end = text.len();
118 line += 1 + excerpt.view_lines;
119 }
120 text
121}
122
123impl Editor {
124 pub(crate) fn open_collection_from_picker(&mut self) {
129 let Some(glue) = &self.picker else {
130 return;
131 };
132 let kind = glue.picker.kind;
133 if !matches!(
134 kind,
135 strop_picker::Kind::Locations
136 | strop_picker::Kind::Diagnostics
137 | strop_picker::Kind::Grep
138 ) {
139 self.message = "collections come from a results list".into();
140 return;
141 }
142 let mut hits: Vec<(std::path::PathBuf, usize)> = Vec::new();
145 let mut remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)> =
146 Vec::new();
147 for item in glue.picker.items.iter() {
148 match &item.payload {
149 strop_picker::Payload::Grep { path, line, .. } => {
150 hits.push((path.clone(), line.saturating_sub(1)));
151 }
152 strop_picker::Payload::Remote {
153 endpoint,
154 path,
155 line,
156 ..
157 } => remote_hits.push((endpoint.clone(), path.clone(), line.saturating_sub(1))),
158 _ => {}
159 }
160 }
161 let title = kind.title().trim().to_string();
162 self.close_picker();
163 let mut to_load: Vec<std::path::PathBuf> = Vec::new();
166 for (path, _) in &hits {
167 let absolute = if path.is_absolute() {
168 path.clone()
169 } else {
170 self.cwd.join(path)
171 };
172 let open = self.docs.iter().any(|(_, doc)| {
173 doc.matches_target(&crate::files::FileTarget::Local(absolute.clone()))
174 });
175 if !open && !to_load.contains(&absolute) {
176 to_load.push(absolute);
177 }
178 }
179 let waiting = to_load.len();
180 let build = CollectionBuild {
181 title,
182 hits,
183 remote_hits,
184 waiting,
185 };
186 if to_load.is_empty() {
187 self.build_collection(build);
188 return;
189 }
190 self.collection_build = Some(build);
191 self.message = format!("collection: loading {waiting} source(s)…");
192 for path in to_load {
193 self.request_open(path, crate::editor::io::OpenIntent::Background);
194 }
195 }
196
197 pub(crate) fn collection_source_ready(&mut self, _document: DocumentId) {
200 let Some(build) = &mut self.collection_build else {
201 strop_trace::record_with(
202 strop_trace::EventKind::JobFinished,
203 || serde_json::json!({"service":"collection","result":"ready-without-build"}),
204 );
205 return;
206 };
207 build.waiting = build.waiting.saturating_sub(1);
208 strop_trace::record_with(
209 strop_trace::EventKind::JobFinished,
210 || serde_json::json!({"service":"collection","result":"source-ready","waiting":build.waiting}),
211 );
212 if build.waiting == 0 {
213 let build = self.collection_build.take().unwrap();
214 self.build_collection(build);
215 }
216 }
217
218 fn build_collection(&mut self, build: CollectionBuild) {
219 let CollectionBuild {
220 title,
221 hits,
222 remote_hits,
223 ..
224 } = build;
225 let mut by_doc: HashMap<DocumentId, Vec<usize>> = HashMap::new();
226 let mut skipped = 0;
227 for (path, line) in hits {
228 let absolute = if path.is_absolute() {
229 path
230 } else {
231 self.cwd.join(path)
232 };
233 let Some(document) = self.docs.iter().find_map(|(id, doc)| {
234 doc.matches_target(&crate::files::FileTarget::Local(absolute.clone()))
235 .then_some(id)
236 }) else {
237 skipped += 1;
238 continue;
239 };
240 by_doc.entry(document).or_default().push(line);
241 }
242 for (endpoint, path, line) in remote_hits {
243 let document = self.docs.iter().find_map(|(id, doc)| {
244 doc.remote_metadata().and_then(|source| {
245 (source.file.endpoint() == &endpoint && source.file.path() == path.as_path())
246 .then_some(id)
247 })
248 });
249 match document {
250 Some(id) => by_doc.entry(id).or_default().push(line),
251 None => skipped += 1,
252 }
253 }
254 if by_doc.is_empty() {
255 self.message = "no open buffers among the results — open them first".into();
256 return;
257 }
258 let mut excerpts = Vec::new();
259 for (source, mut lines) in by_doc {
260 lines.sort_unstable();
261 lines.dedup();
262 let buf = &self.docs.get(source).unwrap().buf;
263 let mut spans: Vec<(usize, usize)> = Vec::new();
266 for line in lines {
267 if line >= buf.len_lines() {
268 continue;
269 }
270 match spans.last_mut() {
271 Some((_, end)) if line <= *end => *end = line + 1,
272 _ => spans.push((line, line + 1)),
273 }
274 }
275 for (start_line, end_line) in spans {
276 let start = buf.line_start(start_line);
277 let end = if end_line >= buf.len_lines() {
278 buf.len_bytes()
279 } else {
280 buf.line_start(end_line)
281 };
282 let text = buf.text().byte_slice(start..end).to_string();
283 excerpts.push(Excerpt {
284 source,
285 start,
286 end,
287 fingerprint: fingerprint(&text),
288 view_line: 0,
289 view_lines: 0,
290 view_start: 0,
291 view_end: 0,
292 });
293 }
294 }
295 excerpts.sort_by_key(|excerpt| (excerpt.source, excerpt.start));
296 let excerpt_count = excerpts.len();
297 let title_for_trace = title.clone();
298 let id = self.docs.insert(Document::output(Buffer::from_text("")));
299 self.docs.get_mut(id).unwrap().buf.name = Some(format!("collection: {title}"));
301 let mut collection = Collection {
302 title,
303 excerpts,
304 pending_saves: 0,
305 close_when_saved: false,
306 shadow: String::new(),
307 revision: BufferRevision::new(0),
308 };
309 let text = render(&self.docs, &self.cwd, &mut collection);
310 collection.shadow = text.clone();
311 self.collections.insert(id, collection);
312 let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
313 self.docs.get_mut(id).unwrap().buf.readonly = false;
314 let revision = self.docs.get(id).unwrap().buf.revision();
315 self.collections.get_mut(&id).unwrap().revision = revision;
316 strop_trace::record_with(strop_trace::EventKind::JobFinished, || {
317 serde_json::json!({
318 "service":"collection","result":"built","excerpts":excerpt_count,
319 "skipped":skipped,"title":title_for_trace,
320 })
321 });
322 self.drop_stale_scratch(id);
323 self.switch_to(id);
324 self.set_head(0);
325 self.message = match skipped {
326 0 => format!("collection: {excerpt_count} excerpt(s)"),
327 _ => format!("collection built; {skipped} hit(s) skipped (not open local buffers)"),
328 };
329 }
330
331 pub(crate) fn maybe_sync_collection(&mut self) {
335 if self.docs.is_empty() {
336 return;
337 }
338 let id = self.current();
339 let revision = self.buf().revision();
340 let Some(stored) = self.collections.get(&id).map(|c| c.revision) else {
341 return;
342 };
343 if revision == stored {
344 return;
345 }
346 let current = self.buf().text().to_string();
347 if current == self.collections[&id].shadow {
348 self.collections.get_mut(&id).unwrap().revision = revision;
349 return;
350 }
351 let working = self.collections[&id].clone();
354 if let Err(reason) = self.collection_write_back(&working, ¤t) {
355 self.message = reason;
356 }
357 let open = working
358 .excerpts
359 .iter()
360 .all(|excerpt| self.docs.get(excerpt.source).is_some());
361 if !open {
362 self.message = "collection: a source buffer was closed — view dropped".into();
363 self.collections.remove(&id);
364 return;
365 }
366 self.collection_render_view(id);
369 }
370
371 pub(crate) fn collection_render_view(&mut self, id: DocumentId) {
374 let caret = if self.current() == id {
377 Some((
378 self.buf().line_of(self.head()),
379 self.buf().col_of(self.head()),
380 ))
381 } else {
382 None
383 };
384 let text = {
385 let entry = self.collections.get_mut(&id).unwrap();
386 render(&self.docs, &self.cwd, entry)
387 };
388 {
389 let entry = self.collections.get_mut(&id).unwrap();
390 entry.shadow = text.clone();
391 }
392 let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
393 if let Some((line, col)) = caret {
394 let line = line.min(self.docs.get(id).unwrap().buf.len_lines().saturating_sub(1));
395 let head = self.docs.get(id).unwrap().buf.clamp_boundary(
396 self.docs
397 .get(id)
398 .unwrap()
399 .buf
400 .line_start(line)
401 .saturating_add(col),
402 );
403 if self.current() == id {
404 self.set_head(head);
405 self.clamp_cursor();
406 }
407 }
408 let revision = self.docs.get(id).unwrap().buf.revision();
409 self.collections.get_mut(&id).unwrap().revision = revision;
410 self.docs.get_mut(id).unwrap().buf.dirty = false;
413 }
414
415 pub(crate) fn collection_splice_excerpt(&mut self, id: DocumentId, index: usize) {
420 let (source, view_start, view_end, view_lines) = {
421 let entry = &self.collections[&id];
422 let excerpt = &entry.excerpts[index];
423 (
424 excerpt.source,
425 excerpt.view_start,
426 excerpt.view_end,
427 excerpt.view_lines,
428 )
429 };
430 let Some(source_doc) = self.docs.get(source) else {
431 return;
432 };
433 let excerpt_span = {
434 let entry = &self.collections[&id];
435 let excerpt = &entry.excerpts[index];
436 (excerpt.start, excerpt.end)
437 };
438 let mut body = source_doc
439 .buf
440 .text()
441 .byte_slice(excerpt_span.0..excerpt_span.1)
442 .to_string();
443 #[cfg(test)]
444 eprintln!("splice coll={id:?} ex={index} span={excerpt_span:?} view={view_start}..{view_end} body={body:?}");
445 if !body.ends_with('\n') {
446 body.push('\n');
447 }
448 let new_lines = body.lines().count().max(1);
449 {
452 let doc = self.docs.get_mut(id).unwrap();
453 let _ = doc
454 .buf
455 .system_edit()
456 .replace(Range::charwise(view_start, view_end), &body);
457 }
458 {
461 let doc = self.docs.get_mut(id).unwrap();
462 let changes: Vec<_> = doc.buf.changes().to_vec();
463 self.analysis.edits(id, &changes);
464 doc.buf.clear_changes();
465 }
466 let byte_delta = body.len() as isize - (view_end - view_start) as isize;
467 let line_delta = new_lines as isize - view_lines as isize;
468 let entry = self.collections.get_mut(&id).unwrap();
469 entry.shadow.replace_range(view_start..view_end, &body);
471 let mut seen = false;
472 for excerpt in &mut entry.excerpts {
473 if seen {
474 excerpt.view_start = (excerpt.view_start as isize + byte_delta) as usize;
475 excerpt.view_end = (excerpt.view_end as isize + byte_delta) as usize;
476 excerpt.view_line = (excerpt.view_line as isize + line_delta) as usize;
477 } else if excerpt.view_start == view_start {
478 seen = true;
479 excerpt.view_lines = new_lines;
480 excerpt.view_end = view_start + body.len();
481 excerpt.fingerprint = fingerprint(&body);
482 }
483 }
484 let revision = self.docs.get(id).unwrap().buf.revision();
485 self.collections.get_mut(&id).unwrap().revision = revision;
486 }
487 fn diff_lines(
493 shadow: &[&str],
494 current: &[&str],
495 ) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
496 let prefix = shadow
497 .iter()
498 .zip(current.iter())
499 .take_while(|(a, b)| a == b)
500 .count();
501 let suffix = shadow[prefix..]
502 .iter()
503 .rev()
504 .zip(current[prefix.min(current.len())..].iter().rev())
505 .take_while(|(a, b)| a == b)
506 .count();
507 let smid = &shadow[prefix..shadow.len() - suffix.min(shadow.len() - prefix)];
508 let cmid = ¤t[prefix..current.len() - suffix.min(current.len() - prefix)];
509 if smid.is_empty() && cmid.is_empty() {
510 return Vec::new();
511 }
512 Self::diff_lines_middle(
513 &shadow[prefix..prefix + smid.len()],
514 ¤t[prefix..prefix + cmid.len()],
515 )
516 .into_iter()
517 .map(|(old, new)| {
518 (
519 old.start + prefix..old.end + prefix,
520 new.start + prefix..new.end + prefix,
521 )
522 })
523 .collect()
524 }
525
526 fn diff_lines_middle(
528 shadow: &[&str],
529 current: &[&str],
530 ) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
531 let (n, m) = (shadow.len(), current.len());
532 let mut lcs = vec![vec![0usize; m + 1]; n + 1];
534 for i in (0..n).rev() {
535 for j in (0..m).rev() {
536 lcs[i][j] = if shadow[i] == current[j] {
537 lcs[i + 1][j + 1] + 1
538 } else {
539 lcs[i + 1][j].max(lcs[i][j + 1])
540 };
541 }
542 }
543 let mut hunks = Vec::new();
544 let (mut i, mut j) = (0, 0);
545 while i < n || j < m {
546 if i < n && j < m && shadow[i] == current[j] {
547 i += 1;
548 j += 1;
549 continue;
550 }
551 let (si, sj) = (i, j);
552 while i < n || j < m {
553 if i < n && j < m && shadow[i] == current[j] {
554 break;
555 }
556 if i < n && (j == m || lcs[i + 1][j] >= lcs[i][j + 1]) {
557 i += 1;
558 } else {
559 j += 1;
560 }
561 }
562 hunks.push((si..i, sj..j));
563 }
564 hunks
565 }
566
567 fn map_line(
571 hunks: &[(std::ops::Range<usize>, std::ops::Range<usize>)],
572 line: usize,
573 count_at_boundary: bool,
574 ) -> usize {
575 let mut current = line;
576 for (old, new) in hunks {
577 let counts =
578 old.end < line || (old.end == line && (!old.is_empty() || count_at_boundary));
579 if counts {
580 current += new.len() - old.len();
581 } else {
582 break;
583 }
584 }
585 current
586 }
587
588 fn collection_write_back(
593 &mut self,
594 collection: &Collection,
595 current: &str,
596 ) -> Result<(), String> {
597 let shadow_lines: Vec<&str> = collection.shadow.split_inclusive('\n').collect();
598 let current_lines: Vec<&str> = current.split_inclusive('\n').collect();
599 let hunks = Self::diff_lines(&shadow_lines, ¤t_lines);
600 if hunks.is_empty() {
601 return Ok(());
602 }
603 let mut touched: Vec<usize> = Vec::new();
605 for (old, _) in &hunks {
606 let mut owner = None;
607 for (index, excerpt) in collection.excerpts.iter().enumerate() {
608 let lo = excerpt.view_line + 1;
609 let hi = excerpt.view_line + excerpt.view_lines + 1;
610 let inside = if old.is_empty() {
611 old.start >= lo && old.start < hi
613 } else {
614 old.start >= lo && old.end <= hi
615 };
616 if inside {
617 owner = Some(index);
618 break;
619 }
620 if !old.is_empty() && old.start < hi && old.end > lo {
622 return Err(
623 "edit touches a header or spans excerpts — refused; view refreshed".into(),
624 );
625 }
626 }
627 let Some(index) = owner else {
628 return Err(
629 "edit touches the title, a header, or the collection's structure — refused; view refreshed"
630 .into(),
631 );
632 };
633 if !touched.contains(&index) {
634 touched.push(index);
635 }
636 }
637 let mut by_source: Vec<(DocumentId, Vec<strop_core::Replacement>, ResourceLocation)> =
640 Vec::new();
641 for index in touched {
642 let excerpt = &collection.excerpts[index];
643 let source = self
644 .docs
645 .get(excerpt.source)
646 .ok_or_else(|| "collection: a source buffer was closed".to_string())?;
647 let present = source
648 .buf
649 .text()
650 .byte_slice(excerpt.start..excerpt.end)
651 .to_string();
652 if fingerprint(&present) != excerpt.fingerprint {
653 return Err(
654 "collection: a source changed elsewhere — refused; view refreshed".into(),
655 );
656 }
657 if source.buf.readonly {
658 return Err(
659 "collection: a source is read-only (remote sources need :remote edit first) — refused; view refreshed"
660 .into(),
661 );
662 }
663 let lo = excerpt.view_line + 1;
664 let hi = excerpt.view_line + excerpt.view_lines + 1;
665 let cur_lo = Self::map_line(&hunks, lo, false);
666 let cur_hi = Self::map_line(&hunks, hi, true);
667 let mut replacement: String = current_lines[cur_lo..cur_hi].concat();
668 if !replacement.is_empty() && !replacement.ends_with('\n') {
669 replacement.push('\n');
670 }
671 let edit = strop_core::Replacement::new(
672 Range::charwise(excerpt.start, excerpt.end),
673 replacement,
674 );
675 let location = match &source.source {
676 super::document::DocumentSource::Remote(remote) => ResourceLocation::remote(
677 remote.file.endpoint().clone(),
678 remote.file.path().to_path_buf(),
679 ),
680 _ => ResourceLocation::local(source.buf.path.clone().unwrap_or_default()),
681 };
682 match by_source
683 .iter_mut()
684 .find(|(id, _, _)| *id == excerpt.source)
685 {
686 Some((_, edits, _)) => edits.push(edit),
687 None => by_source.push((excerpt.source, vec![edit], location)),
688 }
689 }
690 let documents = by_source
691 .into_iter()
692 .map(|(document, edits, location)| PlannedDocument {
693 location,
694 document,
695 base: self.docs.get(document).unwrap().buf.revision(),
696 edits,
697 })
698 .collect();
699 let plan = ChangePlan {
700 producer: ChangeProducer::CollectionEdit,
701 documents,
702 refused: Vec::new(),
703 };
704 self.apply_change_plan(plan);
705 Ok(())
706 }
707}
708
709impl Editor {
710 pub(crate) fn collection_undo(&mut self) {
715 let id = self.current();
716 let Some(sources) = self
717 .collections
718 .get(&id)
719 .map(|c| c.excerpts.iter().map(|e| e.source).collect::<Vec<_>>())
720 else {
721 return;
722 };
723 let Some((index, mut receipt)) = self.changes.take_newest_matching(|receipt| {
724 receipt.producer == "collection edit"
725 && receipt
726 .applied
727 .iter()
728 .any(|(document, ..)| sources.contains(document))
729 }) else {
730 self.message = "already at oldest change".into();
731 return;
732 };
733 let mut moved = 0;
734 for (document, _before, after) in &receipt.applied {
735 match self.docs.get(*document) {
736 Some(doc) if doc.buf.revision() == *after => {}
737 Some(_) => {
738 let name = self
739 .docs
740 .get(*document)
741 .and_then(|d| d.buf.path.clone())
742 .map(|p| p.display().to_string())
743 .unwrap_or_else(|| "a source".into());
744 self.changes.restore(index, receipt);
745 self.message =
746 format!("collection undo refused: {name} changed since — resolve it first");
747 return;
748 }
749 None => {
750 self.changes.restore(index, receipt);
751 self.message = "collection undo refused: a source buffer was closed".into();
752 return;
753 }
754 }
755 }
756 let mut depths = Vec::with_capacity(receipt.applied.len());
757 for (document, ..) in &receipt.applied {
758 let undone_ok = matches!(self.doc_mut(*document).buf.undo(), Ok(Some(_)));
759 if undone_ok {
760 moved += 1;
761 }
762 depths.push(
763 self.docs
764 .get(*document)
765 .map(|doc| doc.buf.history().depth())
766 .unwrap_or(0),
767 );
768 }
769 receipt.redo_depths = Some(depths);
770 self.changes.push_undone(receipt);
773 self.message = format!("undid collection edit across {moved} buffer(s)");
774 }
775
776 pub(crate) fn collection_redo(&mut self) {
779 let id = self.current();
780 let Some(sources) = self
781 .collections
782 .get(&id)
783 .map(|c| c.excerpts.iter().map(|e| e.source).collect::<Vec<_>>())
784 else {
785 return;
786 };
787 let Some(receipt) = self.changes.take_undone_matching(|receipt| {
788 receipt
789 .applied
790 .iter()
791 .any(|(document, ..)| sources.contains(document))
792 }) else {
793 self.message = "nothing to redo".into();
794 return;
795 };
796 let depths = receipt.redo_depths.clone();
799 for (member, (document, ..)) in receipt.applied.iter().enumerate() {
800 let at = self
801 .docs
802 .get(*document)
803 .map(|doc| doc.buf.history().depth());
804 if at != depths.as_ref().map(|d| d[member]) {
805 self.changes.push_undone(receipt);
806 self.message = "collection redo refused: a source changed since the undo".into();
807 return;
808 }
809 }
810 let mut moved = 0;
811 for (document, ..) in &receipt.applied {
812 if matches!(self.doc_mut(*document).buf.redo(), Ok(Some(_))) {
813 moved += 1;
814 }
815 }
816 self.changes.push_receipt_back(receipt);
817 self.message = format!("redid collection edit across {moved} buffer(s)");
818 }
819}
820
821impl Editor {
822 pub(crate) fn collection_save(&mut self, target: Option<PathBuf>, force: bool, close: bool) {
826 let id = self.current();
827 if target.is_some() {
828 self.message = "a collection has no file of its own — :w saves its sources; exporting the view is unsupported"
829 .into();
830 return;
831 }
832 let Some(sources) = self.collections.get(&id).map(|c| {
833 let mut seen: Vec<DocumentId> = c.excerpts.iter().map(|e| e.source).collect();
834 seen.dedup();
835 seen
836 }) else {
837 return;
838 };
839 let mut queued = 0;
840 let mut refused: Vec<String> = Vec::new();
841 for source in sources {
842 let Some(doc) = self.docs.get(source) else {
843 continue;
844 };
845 if !doc.buf.dirty {
846 continue;
847 }
848 let name = doc
849 .buf
850 .path
851 .as_ref()
852 .map(|p| p.display().to_string())
853 .unwrap_or_else(|| "[scratch]".into());
854 if doc.buf.readonly {
855 refused.push(name);
856 continue;
857 }
858 self.request_save_document(source, None, force, false);
861 queued += 1;
862 }
863 if let Some(collection) = self.collections.get_mut(&id) {
864 collection.pending_saves = queued;
865 collection.close_when_saved = close && refused.is_empty() && queued > 0;
866 }
867 if queued == 0 && refused.is_empty() {
868 if close {
869 self.close_pane_or_buffer(false);
870 } else {
871 self.message = "collection: all sources are saved".into();
872 }
873 return;
874 }
875 if !refused.is_empty() {
876 self.message = format!(
877 "collection: saving {queued} source(s); read-only skipped: {}",
878 refused.join(", ")
879 );
880 } else {
881 self.message = format!("collection: saving {queued} source(s)");
882 }
883 }
884
885 pub(crate) fn collection_save_progress(&mut self, document: DocumentId, saved: bool) {
889 let mut close: Option<DocumentId> = None;
890 for (id, collection) in self.collections.iter_mut() {
891 if collection.pending_saves == 0
892 || !collection.excerpts.iter().any(|e| e.source == document)
893 {
894 continue;
895 }
896 if !saved {
897 collection.pending_saves = 0;
898 collection.close_when_saved = false;
899 continue;
900 }
901 collection.pending_saves = collection.pending_saves.saturating_sub(1);
902 if collection.pending_saves == 0 && collection.close_when_saved {
903 close = Some(*id);
904 }
905 }
906 if let Some(id) = close {
907 if self.current() == id {
908 self.close_pane_or_buffer(false);
909 } else if let Some(collection) = self.collections.get_mut(&id) {
910 collection.close_when_saved = false;
911 self.message = "collection: sources saved".into();
912 }
913 }
914 }
915}
916
917impl Editor {
918 pub fn collection_open_source_pub(&mut self) {
925 self.collection_open_source();
926 }
927
928 pub(crate) fn collection_open_source(&mut self) {
929 let id = self.current();
930 let cursor_line = self.buf().line_of(self.head());
931 let cursor_col = self.buf().col_of(self.head());
932 let Some(collection) = self.collections.get(&id) else {
933 return;
934 };
935 let mut target: Option<(DocumentId, usize)> = None;
936 for excerpt in &collection.excerpts {
937 if cursor_line == excerpt.view_line {
938 target = Some((excerpt.source, excerpt.start));
940 break;
941 }
942 if cursor_line > excerpt.view_line
943 && cursor_line <= excerpt.view_line + excerpt.view_lines
944 {
945 let Some(source) = self.docs.get(excerpt.source) else {
947 break;
948 };
949 let source_line =
950 source.buf.line_of(excerpt.start) + (cursor_line - excerpt.view_line - 1);
951 let line = source_line.min(source.buf.len_lines().saturating_sub(1));
952 target = Some((
953 excerpt.source,
954 source
955 .buf
956 .clamp_boundary(source.buf.line_start(line).saturating_add(cursor_col)),
957 ));
958 break;
959 }
960 }
961 let Some((document, head)) = target else {
962 self.message = "not on an excerpt".into();
963 return;
964 };
965 if self.docs.get(document).is_none() {
966 self.message = "collection: that source was closed".into();
967 return;
968 }
969 self.push_jump();
970 self.switch_to(document);
971 self.set_head(head);
972 self.clamp_cursor();
973 self.scroll_to_cursor(self.view_rows());
974 }
975}
976
977impl Editor {
978 pub fn collection_source_lineno(
983 &self,
984 doc: strop_core::id::DocumentId,
985 line: usize,
986 ) -> Option<Option<usize>> {
987 let collection = self.collections.get(&doc)?;
988 for excerpt in &collection.excerpts {
989 if line == excerpt.view_line {
990 return Some(None); }
992 if line > excerpt.view_line && line <= excerpt.view_line + excerpt.view_lines {
993 let source = self.docs.get(excerpt.source)?;
994 let first = source.buf.line_of(excerpt.start);
995 return Some(Some(first + (line - excerpt.view_line - 1) + 1));
996 }
997 }
998 Some(None) }
1000}