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    /// Query hit spans in SOURCE bytes within this excerpt (the picker's
54    /// match evidence paints in the view, 0050 §7).
55    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    /// Source saves in flight from a collection `:w`/`:wq`; the view
63    /// closes only when every one confirms (0049 §5).
64    pub pending_saves: usize,
65    pub close_when_saved: bool,
66    /// The canonical rendering as of the last sync. The sync diff is
67    /// shadow vs current — no hidden state.
68    pub shadow: String,
69    /// The buffer revision at last sync — the cheap no-change check that
70    /// keeps motions from materializing rope text on the input path.
71    pub revision: BufferRevision,
72    /// Row roles parallel to the view text (0049 §6), rebuilt at render.
73    pub rows: Vec<CollectionRow>,
74}
75
76/// One collection hit: path, 0-based line, and the query submatch's
77/// (byte column, length) when the source was a real rg match.
78pub(crate) type CollectionHit = (std::path::PathBuf, usize, Option<(usize, usize)>);
79
80/// A hit line plus its optional submatch span.
81type LineHit = (usize, Option<(usize, usize)>);
82/// Per-document collected hits.
83type DocHits = Vec<LineHit>;
84/// One merged excerpt span with its hits: (start line, end line, hits).
85type SpanWithHits = (usize, usize, Vec<Option<(usize, usize)>>);
86
87/// An in-flight collection build: hits plus the count of background
88/// source loads still outstanding (0044 v2 async source loading).
89#[derive(Debug)]
90
91pub(crate) struct CollectionBuild {
92    pub title: String,
93    /// (path, line, match col+len in source bytes when known)
94    pub hits: Vec<CollectionHit>,
95    /// Remote hits resolve against open remote documents at build.
96    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
108/// The canonical rendering (0049 §6): a title row with counts, then ONE
109/// CARD PER FILE — top border with path and badges, excerpt bodies with
110/// a "⋮ N source lines omitted" gap between disjoint spans, and a bottom
111/// border. Row roles are recorded in `collection.rows` for the renderer;
112/// chrome rows are protected (write-back refuses them) and keep the
113/// excerpt's `view_line` invariant: the row before the body.
114fn 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; // the chrome row before the body
204            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    /// `ctrl-o` in a result picker: open the listed hits as an editable
229    /// collection. Hits whose files are not open local documents are
230    /// counted and skipped with a message; remote hits carry no local
231    /// payload, so they are skipped the same way.
232    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        // Local hits and remote hits alike; remote ones resolve against
247        // open remote documents (0040 permits gate their write-back).
248        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        // Unopened sources load in the background (never switching focus);
276        // the build assembles when the last one lands.
277        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    /// A background source load landed (or failed): the pending build
310    /// counts down and assembles when its sources are all in.
311    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            // Merge adjacent lines into one excerpt so an edit never
377            // applies twice to overlapping spans; the hits ride along.
378            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                // the hits inside this span, in source bytes (0050 §7:
400                // the query's evidence paints in the view)
401                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        // Cards present in path order, not document-id order (0049 §6).
422        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        // The modeline names the collection, never [scratch] (0049 §6).
435        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    /// Every normal-mode action boundary in a collection buffer is a
468    /// write-back attempt — gated on the buffer revision so motions and
469    /// in-progress insert typing never materialize rope text.
470    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        // Write-back against a working copy; the entry stays in the map
488        // so the change-journal remap still tracks its anchors mid-apply.
489        let working = self.collections[&id].clone();
490        if let Err(reason) = self.collection_write_back(&working, &current) {
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        // The source is authoritative: regenerate the view and reset the
503        // shadow whether or not the write-back landed.
504        self.collection_render_view(id);
505    }
506
507    /// Re-render the collection view from its sources and reset shadow +
508    /// revision together (0049 §5: no stale text may be presented).
509    pub(crate) fn collection_render_view(&mut self, id: DocumentId) {
510        // Preserve the logical caret across the regeneration (0049 §5):
511        // same row/column clamped into the new text.
512        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        // The view is a presentation: its dirty bit is never the story
547        // (0049 §5 — the sources own unsaved state).
548        self.docs.get_mut(id).unwrap().buf.dirty = false;
549    }
550
551    /// A source change strictly inside one excerpt (0049 §5): splice the
552    /// excerpt's view span with the new source body instead of
553    /// re-rendering the whole view. The caller gates on a clean view
554    /// (no unsynced user edit) — the spans assume shadow == view.
555    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        // Splice the collection buffer at the excerpt's view span; the
586        // view is clean, so the spans index it directly.
587        {
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        // The splice's own journal entry feeds the view analysis and is
595        // then consumed: the write-back diff must never see it.
596        {
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        // Shadow moves with the view, byte-identical region.
606        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    /// Line-level diff: common prefix/suffix lines trim first (0049 §5 —
624    /// the 2,000-excerpt audit stall was the full-view O(n²) LCS on a
625    /// one-line edit); the dynamic table only ever sees the changed
626    /// middle. Disjoint edit regions in one batch fall back to the LCS
627    /// over that middle only.
628    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 = &current[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            &current[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    /// The LCS table over the changed middle only.
663    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        // lcs[i][j] = LCS length of shadow[i..] vs current[j..]
669        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    /// A shadow line's position in the current text, given the hunks.
704    /// An insertion exactly AT the line attaches forward: span starts map
705    /// without it (the inserted text joins the span), span ends with it.
706    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    /// Diff shadow vs current and write back every touched excerpt as one
725    /// change plan (0044 v2: multiple regions across excerpts, one batch
726    /// per source document). Structure lines (title, headers) are never
727    /// editable; a hunk touching one refuses the whole sync.
728    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, &current_lines);
736        if hunks.is_empty() {
737            return Ok(());
738        }
739        // Every hunk must sit fully inside one excerpt's body span.
740        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                    // an insertion belongs to a body only inside it
748                    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                // Overlap without containment crosses a boundary.
757                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        // One replacement per touched excerpt: its whole body span as it
774        // currently reads — partial hunks carry their unchanged context.
775        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    /// `u` in a collection (0049 §5): undo the newest edit group that
847    /// came FROM this collection, across its actual sources. Preflight
848    /// every member — a source edited since refuses the whole group by
849    /// name, and the receipt is never consumed on refusal.
850    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        // The undo's own journal refreshes the dependent views (0049 §5
907        // invalidation) — no explicit render here.
908        self.changes.push_undone(receipt);
909        self.message = format!("undid collection edit across {moved} buffer(s)");
910    }
911
912    /// `ctrl-r` in a collection: redo the newest undone group of this
913    /// collection, same preflight rules as undo.
914    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        // Revisions are monotonic — an undo never returns to one — so
933        // preflight the undone position by history depth (0049 §5).
934        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    /// `:w` in a collection (0049 §5): save the dirty SOURCES through
959    /// their own save paths — never the presentation. `:w PATH` refuses:
960    /// the view is not a file and exporting it is not this operation.
961    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            // Readonly/remote-permit refusals stay authoritative inside
995            // the save path itself (0040); :w! grants no new capability.
996            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    /// A source save completed (0049 §5): count down; the `:wq` view
1022    /// closes only when every save confirmed. A failure cancels the
1023    /// close and stays visible.
1024    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    /// `g<Space>` in a collection, Enter on a header row, and
1055    /// `:collection source` (0049 §5): open the full source under the
1056    /// caret — the live document with its unsaved edits, never a disk
1057    /// reload. A body row maps to the exact source position; a header
1058    /// row opens the file at that excerpt's first line. The jump is
1059    /// recorded so Ctrl-O returns to the collection working context.
1060    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                // header row: the file, at this excerpt's first line
1075                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                // body row: same line-in-excerpt, same column
1082                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    /// Per-row source facts for the renderer (0049 §6): the row's role,
1115    /// and for body rows the source document + this row's source byte
1116    /// span + the query hits inside it (source bytes) + whether the
1117    /// caret sits in this card.
1118    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            // card rows: the CardTop before this excerpt through the next
1140            // excerpt's card top (or bottom row) — group by source runs
1141            if kind == CollectionRow::CardTop(index) {
1142                // the card is active when the caret is anywhere within it
1143                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                // body inside the active card
1173                info.card_active = caret_line >= excerpt.view_line && caret_line < body_hi;
1174            }
1175        }
1176        Some(info)
1177    }
1178
1179    /// A collection view row's role (0049 §6) for the renderer —
1180    /// structure as data, never text parsing.
1181    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    /// The SOURCE line number for a collection view row (0049 §6):
1190    /// Some(Some(n)) for body rows, Some(None) for chrome (title,
1191    /// headers — the gutter stays blank there), None for ordinary
1192    /// buffers.
1193    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); // header row
1202            }
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) // title row
1210    }
1211}
1212
1213/// What the renderer needs per collection row (0049 §6).
1214pub struct CollectionRowInfo {
1215    pub kind: CollectionRow,
1216    /// Body rows: (source document, source byte start, source byte end).
1217    pub source: Option<(strop_core::id::DocumentId, usize, usize)>,
1218    /// Query hit spans within the row, in SOURCE bytes (0050 §7).
1219    pub source_matches: Vec<(usize, usize)>,
1220    /// The caret sits inside this row's card (focus chrome).
1221    pub card_active: bool,
1222}
1223
1224impl Editor {
1225    /// `]f` / `[f` in a collection: next / previous file card (0049 §5's
1226    /// excerpt navigation through the command registry).
1227    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}