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/// One excerpt: a whole-line span of a source document, remapped through
21/// the source's change journal like any other saved anchor.
22#[derive(Debug, Clone)]
23pub(crate) struct Excerpt {
24    pub source: DocumentId,
25    /// Source byte span (whole lines), remapped on every source mutation.
26    pub start: usize,
27    pub end: usize,
28    /// FNV-1a of the span's bytes at build/regeneration — the write-back
29    /// staleness check.
30    pub fingerprint: u64,
31    /// Header line index in the shadow text (the title is line 0).
32    pub view_line: usize,
33    /// Source line count as rendered into the shadow.
34    pub view_lines: usize,
35    /// Body byte span in the view/shadow text (0049 §5 invalidation:
36    /// source changes splice this span directly).
37    pub view_start: usize,
38    pub view_end: usize,
39}
40
41#[derive(Debug, Clone)]
42pub(crate) struct Collection {
43    pub title: String,
44    pub excerpts: Vec<Excerpt>,
45    /// Source saves in flight from a collection `:w`/`:wq`; the view
46    /// closes only when every one confirms (0049 §5).
47    pub pending_saves: usize,
48    pub close_when_saved: bool,
49    /// The canonical rendering as of the last sync. The sync diff is
50    /// shadow vs current — no hidden state.
51    pub shadow: String,
52    /// The buffer revision at last sync — the cheap no-change check that
53    /// keeps motions from materializing rope text on the input path.
54    pub revision: BufferRevision,
55}
56
57/// An in-flight collection build: hits plus the count of background
58/// source loads still outstanding (0044 v2 async source loading).
59#[derive(Debug)]
60pub(crate) struct CollectionBuild {
61    pub title: String,
62    pub hits: Vec<(std::path::PathBuf, usize)>,
63    /// Remote hits resolve against open remote documents at build.
64    pub remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)>,
65    pub waiting: usize,
66}
67
68fn fingerprint(text: &str) -> u64 {
69    let mut hash: u64 = 0xcbf29ce484222325;
70    for byte in text.as_bytes() {
71        hash = (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3);
72    }
73    hash
74}
75
76/// The canonical rendering: title, then per excerpt a header line and its
77/// source text. Re-anchors each excerpt's view span as it is emitted. An
78/// associated function over disjoint fields, so the map entry can be
79/// re-anchored while the editor borrows `docs` and `cwd`.
80fn render(
81    docs: &Arena<DocumentKind, Document>,
82    cwd: &std::path::Path,
83    collection: &mut Collection,
84) -> String {
85    let mut text = format!(
86        "collection: {} — {} excerpt(s) (edits write back at action boundaries; g<Space> opens source)\n",
87        collection.title,
88        collection.excerpts.len()
89    );
90    let mut line = 1;
91    for excerpt in &mut collection.excerpts {
92        let source = &docs.get(excerpt.source).unwrap().buf;
93        let path = source
94            .path
95            .as_ref()
96            .map(|path| path.strip_prefix(cwd).unwrap_or(path).display().to_string())
97            .unwrap_or_else(|| "[scratch]".into());
98        let first = source.line_of(excerpt.start) + 1;
99        text.push_str(&format!("── {path}:{first} ──\n"));
100        let body = source
101            .text()
102            .byte_slice(excerpt.start..excerpt.end)
103            .to_string();
104        #[cfg(test)]
105        eprintln!(
106            "render excerpt src={:?} span={}..{} body={body:?}",
107            excerpt.source, excerpt.start, excerpt.end
108        );
109        excerpt.view_line = line;
110        excerpt.view_start = text.len();
111        excerpt.view_lines = body.lines().count().max(1);
112        excerpt.fingerprint = fingerprint(&body);
113        text.push_str(&body);
114        if !body.ends_with('\n') {
115            text.push('\n');
116        }
117        excerpt.view_end = text.len();
118        line += 1 + excerpt.view_lines;
119    }
120    text
121}
122
123impl Editor {
124    /// `ctrl-o` in a result picker: open the listed hits as an editable
125    /// collection. Hits whose files are not open local documents are
126    /// counted and skipped with a message; remote hits carry no local
127    /// payload, so they are skipped the same way.
128    pub(crate) fn open_collection_from_picker(&mut self) {
129        let Some(glue) = &self.picker else {
130            return;
131        };
132        let kind = glue.picker.kind;
133        if !matches!(
134            kind,
135            strop_picker::Kind::Locations
136                | strop_picker::Kind::Diagnostics
137                | strop_picker::Kind::Grep
138        ) {
139            self.message = "collections come from a results list".into();
140            return;
141        }
142        // Local hits and remote hits alike; remote ones resolve against
143        // open remote documents (0040 permits gate their write-back).
144        let mut hits: Vec<(std::path::PathBuf, usize)> = Vec::new();
145        let mut remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)> =
146            Vec::new();
147        for item in glue.picker.items.iter() {
148            match &item.payload {
149                strop_picker::Payload::Grep { path, line, .. } => {
150                    hits.push((path.clone(), line.saturating_sub(1)));
151                }
152                strop_picker::Payload::Remote {
153                    endpoint,
154                    path,
155                    line,
156                    ..
157                } => remote_hits.push((endpoint.clone(), path.clone(), line.saturating_sub(1))),
158                _ => {}
159            }
160        }
161        let title = kind.title().trim().to_string();
162        self.close_picker();
163        // Unopened sources load in the background (never switching focus);
164        // the build assembles when the last one lands.
165        let mut to_load: Vec<std::path::PathBuf> = Vec::new();
166        for (path, _) in &hits {
167            let absolute = if path.is_absolute() {
168                path.clone()
169            } else {
170                self.cwd.join(path)
171            };
172            let open = self.docs.iter().any(|(_, doc)| {
173                doc.matches_target(&crate::files::FileTarget::Local(absolute.clone()))
174            });
175            if !open && !to_load.contains(&absolute) {
176                to_load.push(absolute);
177            }
178        }
179        let waiting = to_load.len();
180        let build = CollectionBuild {
181            title,
182            hits,
183            remote_hits,
184            waiting,
185        };
186        if to_load.is_empty() {
187            self.build_collection(build);
188            return;
189        }
190        self.collection_build = Some(build);
191        self.message = format!("collection: loading {waiting} source(s)…");
192        for path in to_load {
193            self.request_open(path, crate::editor::io::OpenIntent::Background);
194        }
195    }
196
197    /// A background source load landed (or failed): the pending build
198    /// counts down and assembles when its sources are all in.
199    pub(crate) fn collection_source_ready(&mut self, _document: DocumentId) {
200        let Some(build) = &mut self.collection_build else {
201            strop_trace::record_with(
202                strop_trace::EventKind::JobFinished,
203                || serde_json::json!({"service":"collection","result":"ready-without-build"}),
204            );
205            return;
206        };
207        build.waiting = build.waiting.saturating_sub(1);
208        strop_trace::record_with(
209            strop_trace::EventKind::JobFinished,
210            || serde_json::json!({"service":"collection","result":"source-ready","waiting":build.waiting}),
211        );
212        if build.waiting == 0 {
213            let build = self.collection_build.take().unwrap();
214            self.build_collection(build);
215        }
216    }
217
218    fn build_collection(&mut self, build: CollectionBuild) {
219        let CollectionBuild {
220            title,
221            hits,
222            remote_hits,
223            ..
224        } = build;
225        let mut by_doc: HashMap<DocumentId, Vec<usize>> = HashMap::new();
226        let mut skipped = 0;
227        for (path, line) in hits {
228            let absolute = if path.is_absolute() {
229                path
230            } else {
231                self.cwd.join(path)
232            };
233            let Some(document) = self.docs.iter().find_map(|(id, doc)| {
234                doc.matches_target(&crate::files::FileTarget::Local(absolute.clone()))
235                    .then_some(id)
236            }) else {
237                skipped += 1;
238                continue;
239            };
240            by_doc.entry(document).or_default().push(line);
241        }
242        for (endpoint, path, line) in remote_hits {
243            let document = self.docs.iter().find_map(|(id, doc)| {
244                doc.remote_metadata().and_then(|source| {
245                    (source.file.endpoint() == &endpoint && source.file.path() == path.as_path())
246                        .then_some(id)
247                })
248            });
249            match document {
250                Some(id) => by_doc.entry(id).or_default().push(line),
251                None => skipped += 1,
252            }
253        }
254        if by_doc.is_empty() {
255            self.message = "no open buffers among the results — open them first".into();
256            return;
257        }
258        let mut excerpts = Vec::new();
259        for (source, mut lines) in by_doc {
260            lines.sort_unstable();
261            lines.dedup();
262            let buf = &self.docs.get(source).unwrap().buf;
263            // Merge adjacent lines into one excerpt so an edit never
264            // applies twice to overlapping spans.
265            let mut spans: Vec<(usize, usize)> = Vec::new();
266            for line in lines {
267                if line >= buf.len_lines() {
268                    continue;
269                }
270                match spans.last_mut() {
271                    Some((_, end)) if line <= *end => *end = line + 1,
272                    _ => spans.push((line, line + 1)),
273                }
274            }
275            for (start_line, end_line) in spans {
276                let start = buf.line_start(start_line);
277                let end = if end_line >= buf.len_lines() {
278                    buf.len_bytes()
279                } else {
280                    buf.line_start(end_line)
281                };
282                let text = buf.text().byte_slice(start..end).to_string();
283                excerpts.push(Excerpt {
284                    source,
285                    start,
286                    end,
287                    fingerprint: fingerprint(&text),
288                    view_line: 0,
289                    view_lines: 0,
290                    view_start: 0,
291                    view_end: 0,
292                });
293            }
294        }
295        excerpts.sort_by_key(|excerpt| (excerpt.source, excerpt.start));
296        let excerpt_count = excerpts.len();
297        let title_for_trace = title.clone();
298        let id = self.docs.insert(Document::output(Buffer::from_text("")));
299        // The modeline names the collection, never [scratch] (0049 §6).
300        self.docs.get_mut(id).unwrap().buf.name = Some(format!("collection: {title}"));
301        let mut collection = Collection {
302            title,
303            excerpts,
304            pending_saves: 0,
305            close_when_saved: false,
306            shadow: String::new(),
307            revision: BufferRevision::new(0),
308        };
309        let text = render(&self.docs, &self.cwd, &mut collection);
310        collection.shadow = text.clone();
311        self.collections.insert(id, collection);
312        let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
313        self.docs.get_mut(id).unwrap().buf.readonly = false;
314        let revision = self.docs.get(id).unwrap().buf.revision();
315        self.collections.get_mut(&id).unwrap().revision = revision;
316        strop_trace::record_with(strop_trace::EventKind::JobFinished, || {
317            serde_json::json!({
318                "service":"collection","result":"built","excerpts":excerpt_count,
319                "skipped":skipped,"title":title_for_trace,
320            })
321        });
322        self.drop_stale_scratch(id);
323        self.switch_to(id);
324        self.set_head(0);
325        self.message = match skipped {
326            0 => format!("collection: {excerpt_count} excerpt(s)"),
327            _ => format!("collection built; {skipped} hit(s) skipped (not open local buffers)"),
328        };
329    }
330
331    /// Every normal-mode action boundary in a collection buffer is a
332    /// write-back attempt — gated on the buffer revision so motions and
333    /// in-progress insert typing never materialize rope text.
334    pub(crate) fn maybe_sync_collection(&mut self) {
335        if self.docs.is_empty() {
336            return;
337        }
338        let id = self.current();
339        let revision = self.buf().revision();
340        let Some(stored) = self.collections.get(&id).map(|c| c.revision) else {
341            return;
342        };
343        if revision == stored {
344            return;
345        }
346        let current = self.buf().text().to_string();
347        if current == self.collections[&id].shadow {
348            self.collections.get_mut(&id).unwrap().revision = revision;
349            return;
350        }
351        // Write-back against a working copy; the entry stays in the map
352        // so the change-journal remap still tracks its anchors mid-apply.
353        let working = self.collections[&id].clone();
354        if let Err(reason) = self.collection_write_back(&working, &current) {
355            self.message = reason;
356        }
357        let open = working
358            .excerpts
359            .iter()
360            .all(|excerpt| self.docs.get(excerpt.source).is_some());
361        if !open {
362            self.message = "collection: a source buffer was closed — view dropped".into();
363            self.collections.remove(&id);
364            return;
365        }
366        // The source is authoritative: regenerate the view and reset the
367        // shadow whether or not the write-back landed.
368        self.collection_render_view(id);
369    }
370
371    /// Re-render the collection view from its sources and reset shadow +
372    /// revision together (0049 §5: no stale text may be presented).
373    pub(crate) fn collection_render_view(&mut self, id: DocumentId) {
374        // Preserve the logical caret across the regeneration (0049 §5):
375        // same row/column clamped into the new text.
376        let caret = if self.current() == id {
377            Some((
378                self.buf().line_of(self.head()),
379                self.buf().col_of(self.head()),
380            ))
381        } else {
382            None
383        };
384        let text = {
385            let entry = self.collections.get_mut(&id).unwrap();
386            render(&self.docs, &self.cwd, entry)
387        };
388        {
389            let entry = self.collections.get_mut(&id).unwrap();
390            entry.shadow = text.clone();
391        }
392        let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
393        if let Some((line, col)) = caret {
394            let line = line.min(self.docs.get(id).unwrap().buf.len_lines().saturating_sub(1));
395            let head = self.docs.get(id).unwrap().buf.clamp_boundary(
396                self.docs
397                    .get(id)
398                    .unwrap()
399                    .buf
400                    .line_start(line)
401                    .saturating_add(col),
402            );
403            if self.current() == id {
404                self.set_head(head);
405                self.clamp_cursor();
406            }
407        }
408        let revision = self.docs.get(id).unwrap().buf.revision();
409        self.collections.get_mut(&id).unwrap().revision = revision;
410        // The view is a presentation: its dirty bit is never the story
411        // (0049 §5 — the sources own unsaved state).
412        self.docs.get_mut(id).unwrap().buf.dirty = false;
413    }
414
415    /// A source change strictly inside one excerpt (0049 §5): splice the
416    /// excerpt's view span with the new source body instead of
417    /// re-rendering the whole view. The caller gates on a clean view
418    /// (no unsynced user edit) — the spans assume shadow == view.
419    pub(crate) fn collection_splice_excerpt(&mut self, id: DocumentId, index: usize) {
420        let (source, view_start, view_end, view_lines) = {
421            let entry = &self.collections[&id];
422            let excerpt = &entry.excerpts[index];
423            (
424                excerpt.source,
425                excerpt.view_start,
426                excerpt.view_end,
427                excerpt.view_lines,
428            )
429        };
430        let Some(source_doc) = self.docs.get(source) else {
431            return;
432        };
433        let excerpt_span = {
434            let entry = &self.collections[&id];
435            let excerpt = &entry.excerpts[index];
436            (excerpt.start, excerpt.end)
437        };
438        let mut body = source_doc
439            .buf
440            .text()
441            .byte_slice(excerpt_span.0..excerpt_span.1)
442            .to_string();
443        #[cfg(test)]
444        eprintln!("splice coll={id:?} ex={index} span={excerpt_span:?} view={view_start}..{view_end} body={body:?}");
445        if !body.ends_with('\n') {
446            body.push('\n');
447        }
448        let new_lines = body.lines().count().max(1);
449        // Splice the collection buffer at the excerpt's view span; the
450        // view is clean, so the spans index it directly.
451        {
452            let doc = self.docs.get_mut(id).unwrap();
453            let _ = doc
454                .buf
455                .system_edit()
456                .replace(Range::charwise(view_start, view_end), &body);
457        }
458        // The splice's own journal entry feeds the view analysis and is
459        // then consumed: the write-back diff must never see it.
460        {
461            let doc = self.docs.get_mut(id).unwrap();
462            let changes: Vec<_> = doc.buf.changes().to_vec();
463            self.analysis.edits(id, &changes);
464            doc.buf.clear_changes();
465        }
466        let byte_delta = body.len() as isize - (view_end - view_start) as isize;
467        let line_delta = new_lines as isize - view_lines as isize;
468        let entry = self.collections.get_mut(&id).unwrap();
469        // Shadow moves with the view, byte-identical region.
470        entry.shadow.replace_range(view_start..view_end, &body);
471        let mut seen = false;
472        for excerpt in &mut entry.excerpts {
473            if seen {
474                excerpt.view_start = (excerpt.view_start as isize + byte_delta) as usize;
475                excerpt.view_end = (excerpt.view_end as isize + byte_delta) as usize;
476                excerpt.view_line = (excerpt.view_line as isize + line_delta) as usize;
477            } else if excerpt.view_start == view_start {
478                seen = true;
479                excerpt.view_lines = new_lines;
480                excerpt.view_end = view_start + body.len();
481                excerpt.fingerprint = fingerprint(&body);
482            }
483        }
484        let revision = self.docs.get(id).unwrap().buf.revision();
485        self.collections.get_mut(&id).unwrap().revision = revision;
486    }
487    /// Line-level diff: common prefix/suffix lines trim first (0049 §5 —
488    /// the 2,000-excerpt audit stall was the full-view O(n²) LCS on a
489    /// one-line edit); the dynamic table only ever sees the changed
490    /// middle. Disjoint edit regions in one batch fall back to the LCS
491    /// over that middle only.
492    fn diff_lines(
493        shadow: &[&str],
494        current: &[&str],
495    ) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
496        let prefix = shadow
497            .iter()
498            .zip(current.iter())
499            .take_while(|(a, b)| a == b)
500            .count();
501        let suffix = shadow[prefix..]
502            .iter()
503            .rev()
504            .zip(current[prefix.min(current.len())..].iter().rev())
505            .take_while(|(a, b)| a == b)
506            .count();
507        let smid = &shadow[prefix..shadow.len() - suffix.min(shadow.len() - prefix)];
508        let cmid = &current[prefix..current.len() - suffix.min(current.len() - prefix)];
509        if smid.is_empty() && cmid.is_empty() {
510            return Vec::new();
511        }
512        Self::diff_lines_middle(
513            &shadow[prefix..prefix + smid.len()],
514            &current[prefix..prefix + cmid.len()],
515        )
516        .into_iter()
517        .map(|(old, new)| {
518            (
519                old.start + prefix..old.end + prefix,
520                new.start + prefix..new.end + prefix,
521            )
522        })
523        .collect()
524    }
525
526    /// The LCS table over the changed middle only.
527    fn diff_lines_middle(
528        shadow: &[&str],
529        current: &[&str],
530    ) -> Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> {
531        let (n, m) = (shadow.len(), current.len());
532        // lcs[i][j] = LCS length of shadow[i..] vs current[j..]
533        let mut lcs = vec![vec![0usize; m + 1]; n + 1];
534        for i in (0..n).rev() {
535            for j in (0..m).rev() {
536                lcs[i][j] = if shadow[i] == current[j] {
537                    lcs[i + 1][j + 1] + 1
538                } else {
539                    lcs[i + 1][j].max(lcs[i][j + 1])
540                };
541            }
542        }
543        let mut hunks = Vec::new();
544        let (mut i, mut j) = (0, 0);
545        while i < n || j < m {
546            if i < n && j < m && shadow[i] == current[j] {
547                i += 1;
548                j += 1;
549                continue;
550            }
551            let (si, sj) = (i, j);
552            while i < n || j < m {
553                if i < n && j < m && shadow[i] == current[j] {
554                    break;
555                }
556                if i < n && (j == m || lcs[i + 1][j] >= lcs[i][j + 1]) {
557                    i += 1;
558                } else {
559                    j += 1;
560                }
561            }
562            hunks.push((si..i, sj..j));
563        }
564        hunks
565    }
566
567    /// A shadow line's position in the current text, given the hunks.
568    /// An insertion exactly AT the line attaches forward: span starts map
569    /// without it (the inserted text joins the span), span ends with it.
570    fn map_line(
571        hunks: &[(std::ops::Range<usize>, std::ops::Range<usize>)],
572        line: usize,
573        count_at_boundary: bool,
574    ) -> usize {
575        let mut current = line;
576        for (old, new) in hunks {
577            let counts =
578                old.end < line || (old.end == line && (!old.is_empty() || count_at_boundary));
579            if counts {
580                current += new.len() - old.len();
581            } else {
582                break;
583            }
584        }
585        current
586    }
587
588    /// Diff shadow vs current and write back every touched excerpt as one
589    /// change plan (0044 v2: multiple regions across excerpts, one batch
590    /// per source document). Structure lines (title, headers) are never
591    /// editable; a hunk touching one refuses the whole sync.
592    fn collection_write_back(
593        &mut self,
594        collection: &Collection,
595        current: &str,
596    ) -> Result<(), String> {
597        let shadow_lines: Vec<&str> = collection.shadow.split_inclusive('\n').collect();
598        let current_lines: Vec<&str> = current.split_inclusive('\n').collect();
599        let hunks = Self::diff_lines(&shadow_lines, &current_lines);
600        if hunks.is_empty() {
601            return Ok(());
602        }
603        // Every hunk must sit fully inside one excerpt's body span.
604        let mut touched: Vec<usize> = Vec::new();
605        for (old, _) in &hunks {
606            let mut owner = None;
607            for (index, excerpt) in collection.excerpts.iter().enumerate() {
608                let lo = excerpt.view_line + 1;
609                let hi = excerpt.view_line + excerpt.view_lines + 1;
610                let inside = if old.is_empty() {
611                    // an insertion belongs to a body only inside it
612                    old.start >= lo && old.start < hi
613                } else {
614                    old.start >= lo && old.end <= hi
615                };
616                if inside {
617                    owner = Some(index);
618                    break;
619                }
620                // Overlap without containment crosses a boundary.
621                if !old.is_empty() && old.start < hi && old.end > lo {
622                    return Err(
623                        "edit touches a header or spans excerpts — refused; view refreshed".into(),
624                    );
625                }
626            }
627            let Some(index) = owner else {
628                return Err(
629                    "edit touches the title, a header, or the collection's structure — refused; view refreshed"
630                        .into(),
631                );
632            };
633            if !touched.contains(&index) {
634                touched.push(index);
635            }
636        }
637        // One replacement per touched excerpt: its whole body span as it
638        // currently reads — partial hunks carry their unchanged context.
639        let mut by_source: Vec<(DocumentId, Vec<strop_core::Replacement>, ResourceLocation)> =
640            Vec::new();
641        for index in touched {
642            let excerpt = &collection.excerpts[index];
643            let source = self
644                .docs
645                .get(excerpt.source)
646                .ok_or_else(|| "collection: a source buffer was closed".to_string())?;
647            let present = source
648                .buf
649                .text()
650                .byte_slice(excerpt.start..excerpt.end)
651                .to_string();
652            if fingerprint(&present) != excerpt.fingerprint {
653                return Err(
654                    "collection: a source changed elsewhere — refused; view refreshed".into(),
655                );
656            }
657            if source.buf.readonly {
658                return Err(
659                    "collection: a source is read-only (remote sources need :remote edit first) — refused; view refreshed"
660                        .into(),
661                );
662            }
663            let lo = excerpt.view_line + 1;
664            let hi = excerpt.view_line + excerpt.view_lines + 1;
665            let cur_lo = Self::map_line(&hunks, lo, false);
666            let cur_hi = Self::map_line(&hunks, hi, true);
667            let mut replacement: String = current_lines[cur_lo..cur_hi].concat();
668            if !replacement.is_empty() && !replacement.ends_with('\n') {
669                replacement.push('\n');
670            }
671            let edit = strop_core::Replacement::new(
672                Range::charwise(excerpt.start, excerpt.end),
673                replacement,
674            );
675            let location = match &source.source {
676                super::document::DocumentSource::Remote(remote) => ResourceLocation::remote(
677                    remote.file.endpoint().clone(),
678                    remote.file.path().to_path_buf(),
679                ),
680                _ => ResourceLocation::local(source.buf.path.clone().unwrap_or_default()),
681            };
682            match by_source
683                .iter_mut()
684                .find(|(id, _, _)| *id == excerpt.source)
685            {
686                Some((_, edits, _)) => edits.push(edit),
687                None => by_source.push((excerpt.source, vec![edit], location)),
688            }
689        }
690        let documents = by_source
691            .into_iter()
692            .map(|(document, edits, location)| PlannedDocument {
693                location,
694                document,
695                base: self.docs.get(document).unwrap().buf.revision(),
696                edits,
697            })
698            .collect();
699        let plan = ChangePlan {
700            producer: ChangeProducer::CollectionEdit,
701            documents,
702            refused: Vec::new(),
703        };
704        self.apply_change_plan(plan);
705        Ok(())
706    }
707}
708
709impl Editor {
710    /// `u` in a collection (0049 §5): undo the newest edit group that
711    /// came FROM this collection, across its actual sources. Preflight
712    /// every member — a source edited since refuses the whole group by
713    /// name, and the receipt is never consumed on refusal.
714    pub(crate) fn collection_undo(&mut self) {
715        let id = self.current();
716        let Some(sources) = self
717            .collections
718            .get(&id)
719            .map(|c| c.excerpts.iter().map(|e| e.source).collect::<Vec<_>>())
720        else {
721            return;
722        };
723        let Some((index, mut receipt)) = self.changes.take_newest_matching(|receipt| {
724            receipt.producer == "collection edit"
725                && receipt
726                    .applied
727                    .iter()
728                    .any(|(document, ..)| sources.contains(document))
729        }) else {
730            self.message = "already at oldest change".into();
731            return;
732        };
733        let mut moved = 0;
734        for (document, _before, after) in &receipt.applied {
735            match self.docs.get(*document) {
736                Some(doc) if doc.buf.revision() == *after => {}
737                Some(_) => {
738                    let name = self
739                        .docs
740                        .get(*document)
741                        .and_then(|d| d.buf.path.clone())
742                        .map(|p| p.display().to_string())
743                        .unwrap_or_else(|| "a source".into());
744                    self.changes.restore(index, receipt);
745                    self.message =
746                        format!("collection undo refused: {name} changed since — resolve it first");
747                    return;
748                }
749                None => {
750                    self.changes.restore(index, receipt);
751                    self.message = "collection undo refused: a source buffer was closed".into();
752                    return;
753                }
754            }
755        }
756        let mut depths = Vec::with_capacity(receipt.applied.len());
757        for (document, ..) in &receipt.applied {
758            let undone_ok = matches!(self.doc_mut(*document).buf.undo(), Ok(Some(_)));
759            if undone_ok {
760                moved += 1;
761            }
762            depths.push(
763                self.docs
764                    .get(*document)
765                    .map(|doc| doc.buf.history().depth())
766                    .unwrap_or(0),
767            );
768        }
769        receipt.redo_depths = Some(depths);
770        // The undo's own journal refreshes the dependent views (0049 §5
771        // invalidation) — no explicit render here.
772        self.changes.push_undone(receipt);
773        self.message = format!("undid collection edit across {moved} buffer(s)");
774    }
775
776    /// `ctrl-r` in a collection: redo the newest undone group of this
777    /// collection, same preflight rules as undo.
778    pub(crate) fn collection_redo(&mut self) {
779        let id = self.current();
780        let Some(sources) = self
781            .collections
782            .get(&id)
783            .map(|c| c.excerpts.iter().map(|e| e.source).collect::<Vec<_>>())
784        else {
785            return;
786        };
787        let Some(receipt) = self.changes.take_undone_matching(|receipt| {
788            receipt
789                .applied
790                .iter()
791                .any(|(document, ..)| sources.contains(document))
792        }) else {
793            self.message = "nothing to redo".into();
794            return;
795        };
796        // Revisions are monotonic — an undo never returns to one — so
797        // preflight the undone position by history depth (0049 §5).
798        let depths = receipt.redo_depths.clone();
799        for (member, (document, ..)) in receipt.applied.iter().enumerate() {
800            let at = self
801                .docs
802                .get(*document)
803                .map(|doc| doc.buf.history().depth());
804            if at != depths.as_ref().map(|d| d[member]) {
805                self.changes.push_undone(receipt);
806                self.message = "collection redo refused: a source changed since the undo".into();
807                return;
808            }
809        }
810        let mut moved = 0;
811        for (document, ..) in &receipt.applied {
812            if matches!(self.doc_mut(*document).buf.redo(), Ok(Some(_))) {
813                moved += 1;
814            }
815        }
816        self.changes.push_receipt_back(receipt);
817        self.message = format!("redid collection edit across {moved} buffer(s)");
818    }
819}
820
821impl Editor {
822    /// `:w` in a collection (0049 §5): save the dirty SOURCES through
823    /// their own save paths — never the presentation. `:w PATH` refuses:
824    /// the view is not a file and exporting it is not this operation.
825    pub(crate) fn collection_save(&mut self, target: Option<PathBuf>, force: bool, close: bool) {
826        let id = self.current();
827        if target.is_some() {
828            self.message = "a collection has no file of its own — :w saves its sources;                             exporting the view is unsupported"
829                .into();
830            return;
831        }
832        let Some(sources) = self.collections.get(&id).map(|c| {
833            let mut seen: Vec<DocumentId> = c.excerpts.iter().map(|e| e.source).collect();
834            seen.dedup();
835            seen
836        }) else {
837            return;
838        };
839        let mut queued = 0;
840        let mut refused: Vec<String> = Vec::new();
841        for source in sources {
842            let Some(doc) = self.docs.get(source) else {
843                continue;
844            };
845            if !doc.buf.dirty {
846                continue;
847            }
848            let name = doc
849                .buf
850                .path
851                .as_ref()
852                .map(|p| p.display().to_string())
853                .unwrap_or_else(|| "[scratch]".into());
854            if doc.buf.readonly {
855                refused.push(name);
856                continue;
857            }
858            // Readonly/remote-permit refusals stay authoritative inside
859            // the save path itself (0040); :w! grants no new capability.
860            self.request_save_document(source, None, force, false);
861            queued += 1;
862        }
863        if let Some(collection) = self.collections.get_mut(&id) {
864            collection.pending_saves = queued;
865            collection.close_when_saved = close && refused.is_empty() && queued > 0;
866        }
867        if queued == 0 && refused.is_empty() {
868            if close {
869                self.close_pane_or_buffer(false);
870            } else {
871                self.message = "collection: all sources are saved".into();
872            }
873            return;
874        }
875        if !refused.is_empty() {
876            self.message = format!(
877                "collection: saving {queued} source(s); read-only skipped: {}",
878                refused.join(", ")
879            );
880        } else {
881            self.message = format!("collection: saving {queued} source(s)");
882        }
883    }
884
885    /// A source save completed (0049 §5): count down; the `:wq` view
886    /// closes only when every save confirmed. A failure cancels the
887    /// close and stays visible.
888    pub(crate) fn collection_save_progress(&mut self, document: DocumentId, saved: bool) {
889        let mut close: Option<DocumentId> = None;
890        for (id, collection) in self.collections.iter_mut() {
891            if collection.pending_saves == 0
892                || !collection.excerpts.iter().any(|e| e.source == document)
893            {
894                continue;
895            }
896            if !saved {
897                collection.pending_saves = 0;
898                collection.close_when_saved = false;
899                continue;
900            }
901            collection.pending_saves = collection.pending_saves.saturating_sub(1);
902            if collection.pending_saves == 0 && collection.close_when_saved {
903                close = Some(*id);
904            }
905        }
906        if let Some(id) = close {
907            if self.current() == id {
908                self.close_pane_or_buffer(false);
909            } else if let Some(collection) = self.collections.get_mut(&id) {
910                collection.close_when_saved = false;
911                self.message = "collection: sources saved".into();
912            }
913        }
914    }
915}
916
917impl Editor {
918    /// `g<Space>` in a collection, Enter on a header row, and
919    /// `:collection source` (0049 §5): open the full source under the
920    /// caret — the live document with its unsaved edits, never a disk
921    /// reload. A body row maps to the exact source position; a header
922    /// row opens the file at that excerpt's first line. The jump is
923    /// recorded so Ctrl-O returns to the collection working context.
924    pub fn collection_open_source_pub(&mut self) {
925        self.collection_open_source();
926    }
927
928    pub(crate) fn collection_open_source(&mut self) {
929        let id = self.current();
930        let cursor_line = self.buf().line_of(self.head());
931        let cursor_col = self.buf().col_of(self.head());
932        let Some(collection) = self.collections.get(&id) else {
933            return;
934        };
935        let mut target: Option<(DocumentId, usize)> = None;
936        for excerpt in &collection.excerpts {
937            if cursor_line == excerpt.view_line {
938                // header row: the file, at this excerpt's first line
939                target = Some((excerpt.source, excerpt.start));
940                break;
941            }
942            if cursor_line > excerpt.view_line
943                && cursor_line <= excerpt.view_line + excerpt.view_lines
944            {
945                // body row: same line-in-excerpt, same column
946                let Some(source) = self.docs.get(excerpt.source) else {
947                    break;
948                };
949                let source_line =
950                    source.buf.line_of(excerpt.start) + (cursor_line - excerpt.view_line - 1);
951                let line = source_line.min(source.buf.len_lines().saturating_sub(1));
952                target = Some((
953                    excerpt.source,
954                    source
955                        .buf
956                        .clamp_boundary(source.buf.line_start(line).saturating_add(cursor_col)),
957                ));
958                break;
959            }
960        }
961        let Some((document, head)) = target else {
962            self.message = "not on an excerpt".into();
963            return;
964        };
965        if self.docs.get(document).is_none() {
966            self.message = "collection: that source was closed".into();
967            return;
968        }
969        self.push_jump();
970        self.switch_to(document);
971        self.set_head(head);
972        self.clamp_cursor();
973        self.scroll_to_cursor(self.view_rows());
974    }
975}
976
977impl Editor {
978    /// The SOURCE line number for a collection view row (0049 §6):
979    /// Some(Some(n)) for body rows, Some(None) for chrome (title,
980    /// headers — the gutter stays blank there), None for ordinary
981    /// buffers.
982    pub fn collection_source_lineno(
983        &self,
984        doc: strop_core::id::DocumentId,
985        line: usize,
986    ) -> Option<Option<usize>> {
987        let collection = self.collections.get(&doc)?;
988        for excerpt in &collection.excerpts {
989            if line == excerpt.view_line {
990                return Some(None); // header row
991            }
992            if line > excerpt.view_line && line <= excerpt.view_line + excerpt.view_lines {
993                let source = self.docs.get(excerpt.source)?;
994                let first = source.buf.line_of(excerpt.start);
995                return Some(Some(first + (line - excerpt.view_line - 1) + 1));
996            }
997        }
998        Some(None) // title row
999    }
1000}