Skip to main content

strop_engine/editor/collections/
mod.rs

1//! Editable code collections (0044): picker results as one real buffer of
2//! source excerpts. Edits to an excerpt write back to its source document
3//! through the change-plan gateway at action boundaries; generated headers
4//! are protected, and a source that moved on refuses by name.
5
6#[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/// What a collection view row IS (0049 §6): the renderer styles chrome
21/// from this — never by parsing row text.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CollectionRow {
24    Title,
25    /// A file card's top border; carries the excerpt index it precedes.
26    CardTop(usize),
27    /// An omitted-lines gap between two excerpts of one file.
28    Gap,
29    /// A file card's bottom border.
30    CardBottom,
31    Body,
32}
33
34/// One excerpt: a whole-line span of a source document, remapped through
35/// the source's change journal like any other saved anchor.
36#[derive(Debug, Clone)]
37pub(crate) struct Excerpt {
38    pub source: DocumentId,
39    /// Source byte span (whole lines), remapped on every source mutation.
40    pub start: usize,
41    pub end: usize,
42    /// FNV-1a of the span's bytes at build/regeneration — the write-back
43    /// staleness check.
44    pub fingerprint: u64,
45    /// Header line index in the shadow text (the title is line 0).
46    pub view_line: usize,
47    /// Source line count as rendered into the shadow.
48    pub view_lines: usize,
49    /// Body byte span in the view/shadow text (0049 §5 invalidation:
50    /// source changes splice this span directly).
51    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    /// Source saves in flight from a collection `:w`/`:wq`; the view
60    /// closes only when every one confirms (0049 §5).
61    pub pending_saves: usize,
62    pub close_when_saved: bool,
63    /// The canonical rendering as of the last sync. The sync diff is
64    /// shadow vs current — no hidden state.
65    pub shadow: String,
66    /// The buffer revision at last sync — the cheap no-change check that
67    /// keeps motions from materializing rope text on the input path.
68    pub revision: BufferRevision,
69    /// Row roles parallel to the view text (0049 §6), rebuilt at render.
70    pub rows: Vec<CollectionRow>,
71}
72
73/// An in-flight collection build: hits plus the count of background
74/// source loads still outstanding (0044 v2 async source loading).
75#[derive(Debug)]
76pub(crate) struct CollectionBuild {
77    pub title: String,
78    pub hits: Vec<(std::path::PathBuf, usize)>,
79    /// Remote hits resolve against open remote documents at build.
80    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
92/// The canonical rendering (0049 §6): a title row with counts, then ONE
93/// CARD PER FILE — top border with path and badges, excerpt bodies with
94/// a "⋮ N source lines omitted" gap between disjoint spans, and a bottom
95/// border. Row roles are recorded in `collection.rows` for the renderer;
96/// chrome rows are protected (write-back refuses them) and keep the
97/// excerpt's `view_line` invariant: the row before the body.
98fn 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; // the chrome row before the body
188            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    /// `ctrl-o` in a result picker: open the listed hits as an editable
213    /// collection. Hits whose files are not open local documents are
214    /// counted and skipped with a message; remote hits carry no local
215    /// payload, so they are skipped the same way.
216    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        // Local hits and remote hits alike; remote ones resolve against
231        // open remote documents (0040 permits gate their write-back).
232        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        // Unopened sources load in the background (never switching focus);
252        // the build assembles when the last one lands.
253        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    /// A background source load landed (or failed): the pending build
286    /// counts down and assembles when its sources are all in.
287    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            // Merge adjacent lines into one excerpt so an edit never
352            // applies twice to overlapping spans.
353            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        // Cards present in path order, not document-id order (0049 §6).
384        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        // The modeline names the collection, never [scratch] (0049 §6).
397        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    /// Every normal-mode action boundary in a collection buffer is a
430    /// write-back attempt — gated on the buffer revision so motions and
431    /// in-progress insert typing never materialize rope text.
432    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        // Write-back against a working copy; the entry stays in the map
450        // so the change-journal remap still tracks its anchors mid-apply.
451        let working = self.collections[&id].clone();
452        if let Err(reason) = self.collection_write_back(&working, &current) {
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        // The source is authoritative: regenerate the view and reset the
465        // shadow whether or not the write-back landed.
466        self.collection_render_view(id);
467    }
468
469    /// Re-render the collection view from its sources and reset shadow +
470    /// revision together (0049 §5: no stale text may be presented).
471    pub(crate) fn collection_render_view(&mut self, id: DocumentId) {
472        // Preserve the logical caret across the regeneration (0049 §5):
473        // same row/column clamped into the new text.
474        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        // The view is a presentation: its dirty bit is never the story
509        // (0049 §5 — the sources own unsaved state).
510        self.docs.get_mut(id).unwrap().buf.dirty = false;
511    }
512
513    /// A source change strictly inside one excerpt (0049 §5): splice the
514    /// excerpt's view span with the new source body instead of
515    /// re-rendering the whole view. The caller gates on a clean view
516    /// (no unsynced user edit) — the spans assume shadow == view.
517    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        // Splice the collection buffer at the excerpt's view span; the
548        // view is clean, so the spans index it directly.
549        {
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        // The splice's own journal entry feeds the view analysis and is
557        // then consumed: the write-back diff must never see it.
558        {
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        // Shadow moves with the view, byte-identical region.
568        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    /// Line-level diff: common prefix/suffix lines trim first (0049 §5 —
586    /// the 2,000-excerpt audit stall was the full-view O(n²) LCS on a
587    /// one-line edit); the dynamic table only ever sees the changed
588    /// middle. Disjoint edit regions in one batch fall back to the LCS
589    /// over that middle only.
590    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 = &current[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            &current[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    /// The LCS table over the changed middle only.
625    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        // lcs[i][j] = LCS length of shadow[i..] vs current[j..]
631        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    /// A shadow line's position in the current text, given the hunks.
666    /// An insertion exactly AT the line attaches forward: span starts map
667    /// without it (the inserted text joins the span), span ends with it.
668    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    /// Diff shadow vs current and write back every touched excerpt as one
687    /// change plan (0044 v2: multiple regions across excerpts, one batch
688    /// per source document). Structure lines (title, headers) are never
689    /// editable; a hunk touching one refuses the whole sync.
690    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, &current_lines);
698        if hunks.is_empty() {
699            return Ok(());
700        }
701        // Every hunk must sit fully inside one excerpt's body span.
702        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                    // an insertion belongs to a body only inside it
710                    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                // Overlap without containment crosses a boundary.
719                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        // One replacement per touched excerpt: its whole body span as it
736        // currently reads — partial hunks carry their unchanged context.
737        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    /// `u` in a collection (0049 §5): undo the newest edit group that
809    /// came FROM this collection, across its actual sources. Preflight
810    /// every member — a source edited since refuses the whole group by
811    /// name, and the receipt is never consumed on refusal.
812    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        // The undo's own journal refreshes the dependent views (0049 §5
869        // invalidation) — no explicit render here.
870        self.changes.push_undone(receipt);
871        self.message = format!("undid collection edit across {moved} buffer(s)");
872    }
873
874    /// `ctrl-r` in a collection: redo the newest undone group of this
875    /// collection, same preflight rules as undo.
876    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        // Revisions are monotonic — an undo never returns to one — so
895        // preflight the undone position by history depth (0049 §5).
896        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    /// `:w` in a collection (0049 §5): save the dirty SOURCES through
921    /// their own save paths — never the presentation. `:w PATH` refuses:
922    /// the view is not a file and exporting it is not this operation.
923    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            // Readonly/remote-permit refusals stay authoritative inside
957            // the save path itself (0040); :w! grants no new capability.
958            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    /// A source save completed (0049 §5): count down; the `:wq` view
984    /// closes only when every save confirmed. A failure cancels the
985    /// close and stays visible.
986    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    /// `g<Space>` in a collection, Enter on a header row, and
1017    /// `:collection source` (0049 §5): open the full source under the
1018    /// caret — the live document with its unsaved edits, never a disk
1019    /// reload. A body row maps to the exact source position; a header
1020    /// row opens the file at that excerpt's first line. The jump is
1021    /// recorded so Ctrl-O returns to the collection working context.
1022    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                // header row: the file, at this excerpt's first line
1037                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                // body row: same line-in-excerpt, same column
1044                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    /// A collection view row's role (0049 §6) for the renderer —
1077    /// structure as data, never text parsing.
1078    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    /// The SOURCE line number for a collection view row (0049 §6):
1087    /// Some(Some(n)) for body rows, Some(None) for chrome (title,
1088    /// headers — the gutter stays blank there), None for ordinary
1089    /// buffers.
1090    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); // header row
1099            }
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) // title row
1107    }
1108}