Skip to main content

strop_engine/editor/collections/
mod.rs

1//! Source-backed editable collections. Journal edits publish immediately;
2//! generated chrome is protected and source undo groups commit at action boundaries.
3
4mod context;
5mod editing;
6mod history;
7mod journal;
8mod navigation;
9mod projection;
10#[cfg(test)]
11mod tests;
12mod updates;
13mod view_positions;
14
15use projection::render;
16
17use std::collections::{HashMap, HashSet};
18
19use super::document::Document;
20use super::Editor;
21use strop_core::id::{BufferRevision, DocumentId};
22use strop_core::Buffer;
23
24/// What a collection view row IS (0049 §6): the renderer styles chrome
25/// from this — never by parsing row text.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CollectionRow {
28    Title,
29    /// A file card's top border; carries the excerpt index it precedes.
30    CardTop(usize),
31    /// An omitted-lines gap between two excerpts of one file.
32    Gap,
33    /// A file card's bottom border.
34    CardBottom,
35    Body,
36}
37
38/// One excerpt: a whole-line span of a source document, remapped through
39/// the source's change journal like any other saved anchor.
40#[derive(Debug, Clone)]
41pub(crate) struct Excerpt {
42    pub source: DocumentId,
43    /// Source byte span (whole lines), remapped on every source mutation.
44    pub start: usize,
45    pub end: usize,
46    /// Context radius around the query's source hits.
47    pub context: usize,
48    /// Original hit line anchors, remapped with source edits.
49    pub hit_anchors: Vec<usize>,
50    /// Header line index in the view (the title is line 0).
51    pub view_line: usize,
52    /// Source line count as rendered.
53    pub view_lines: usize,
54    /// Editable source body byte span in the view, excluding synthetic newline.
55    pub view_start: usize,
56    pub view_end: usize,
57    /// Query hit spans in SOURCE bytes within this excerpt (the picker's
58    /// match evidence paints in the view, 0050 §7).
59    pub matches: Vec<(usize, usize)>,
60}
61
62#[derive(Debug, Clone)]
63pub(crate) struct Collection {
64    pub title: String,
65    pub excerpts: Vec<Excerpt>,
66    pub skipped: usize,
67    /// Source saves in flight from a collection `:w`/`:wq`; the view
68    /// closes only when every one confirms (0049 §5).
69    pub pending_saves: HashSet<DocumentId>,
70    pub close_when_saved: bool,
71    /// Query provenance: matches are counted independently of merged excerpts.
72    pub match_count: usize,
73    /// The buffer revision at last sync — the cheap no-change check that
74    /// keeps motions from materializing rope text on the input path.
75    pub revision: BufferRevision,
76    /// Row roles parallel to the view text (0049 §6), rebuilt at render.
77    pub rows: Vec<CollectionRow>,
78    pub pending_commit: Vec<(DocumentId, BufferRevision)>,
79}
80
81/// A source location plus the exact search witness when this came from Search.
82#[derive(Debug)]
83pub(crate) struct CollectionHit {
84    path: std::path::PathBuf,
85    line: usize,
86    span: Option<(usize, usize)>,
87    witness: Option<super::picker::ReplacementHit>,
88}
89
90#[derive(PartialEq, Eq, PartialOrd, Ord)]
91struct SourceHit {
92    line: usize,
93    span: Option<(usize, usize)>,
94}
95
96/// An in-flight collection build: hits plus the count of background
97/// source loads still outstanding (0044 v2 async source loading).
98#[derive(Debug)]
99
100pub(crate) struct CollectionBuild {
101    pub title: String,
102    /// (path, line, match col+len in source bytes when known)
103    pub hits: Vec<CollectionHit>,
104    /// Remote hits resolve against open remote documents at build.
105    pub remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)>,
106    pub waiting: usize,
107    pub owner: strop_core::worker::WorkerId,
108    pub origin: DocumentId,
109    pub revision: BufferRevision,
110    pub focus_on_ready: bool,
111}
112
113impl Editor {
114    fn collection_local_sources(&self) -> HashMap<std::path::PathBuf, DocumentId> {
115        let mut sources = HashMap::new();
116        for (id, document) in self.docs.iter() {
117            if matches!(document.source, super::document::DocumentSource::File) {
118                for path in document
119                    .buf
120                    .path
121                    .as_deref()
122                    .into_iter()
123                    .chain(document.buf.file_identity())
124                {
125                    sources.insert(self.cwd.join(path), id);
126                }
127            }
128        }
129        sources
130    }
131
132    /// `ctrl-o` in a result picker: open the listed hits as an editable
133    /// collection. Unopened local sources load as owned background work;
134    /// remote hits resolve only against their already-open endpoint.
135    pub(crate) fn open_collection_from_picker(&mut self) {
136        let Some(glue) = &self.picker else {
137            return;
138        };
139        if let Some(error) = &glue.picker.error {
140            self.message = format!("collection refused: {error}");
141            return;
142        }
143        if glue.picker.streaming || glue.rank_pending.is_some() {
144            self.message = "results are still updating — retry Ctrl-O when ready".into();
145            return;
146        }
147        let kind = glue.picker.kind;
148        if !matches!(
149            kind,
150            strop_picker::Kind::Locations
151                | strop_picker::Kind::Diagnostics
152                | strop_picker::Kind::Search
153        ) {
154            self.message = "collections come from a results list".into();
155            return;
156        }
157        // Local hits and remote hits alike; remote ones resolve against
158        // open remote documents (0040 permits gate their write-back).
159        let root = glue
160            .search
161            .as_ref()
162            .map_or(&self.cwd, |context| &context.scope.root.path);
163        let mut hits: Vec<CollectionHit> = Vec::new();
164        let mut remote_hits: Vec<(strop_workspace::RemoteEndpoint, std::path::PathBuf, usize)> =
165            Vec::new();
166        for item in glue.picker.accepted() {
167            match &item.payload {
168                strop_picker::Payload::Grep {
169                    path,
170                    line,
171                    col,
172                    match_len,
173                    line_text,
174                } => {
175                    let hit = (kind == strop_picker::Kind::Search)
176                        .then(|| (col.saturating_sub(1), *match_len));
177                    hits.push(CollectionHit {
178                        path: root.join(path),
179                        line: line.saturating_sub(1),
180                        span: hit,
181                        witness: hit.map(|_| super::picker::ReplacementHit {
182                            line: *line,
183                            col: *col,
184                            match_len: *match_len,
185                            text: line_text.clone(),
186                        }),
187                    });
188                }
189                strop_picker::Payload::Remote {
190                    endpoint,
191                    path,
192                    line,
193                    ..
194                } => remote_hits.push((endpoint.clone(), path.clone(), line.saturating_sub(1))),
195                _ => {}
196            }
197        }
198        if hits.is_empty() && remote_hits.is_empty() {
199            self.message = "collection: no included source matches".into();
200            return;
201        }
202        let title = kind.title().trim().to_string();
203        let owner = glue.id.0;
204        self.close_picker();
205        // Unopened sources load in the background (never switching focus);
206        // the build assembles when the last one lands.
207        let mut to_load: Vec<std::path::PathBuf> = Vec::new();
208        let open_sources = self.collection_local_sources();
209        let mut requested = std::collections::HashSet::new();
210        for CollectionHit { path, .. } in &hits {
211            let absolute = if path.is_absolute() {
212                path.clone()
213            } else {
214                self.cwd.join(path)
215            };
216            if !open_sources.contains_key(&absolute) && requested.insert(absolute.clone()) {
217                to_load.push(absolute);
218            }
219        }
220        let waiting = to_load.len();
221        let build = CollectionBuild {
222            title,
223            hits,
224            remote_hits,
225            waiting,
226            owner,
227            origin: self.current(),
228            revision: self.buf().revision(),
229            focus_on_ready: true,
230        };
231        if to_load.is_empty() {
232            self.build_collection(build);
233            return;
234        }
235        self.collection_build = Some(build);
236        self.message = format!("collection: loading {waiting} source(s)…");
237        for path in to_load {
238            self.request_open(
239                path,
240                crate::editor::io::OpenIntent::CollectionSource { owner },
241            );
242        }
243    }
244
245    /// A background source load landed (or failed): the pending build
246    /// counts down and assembles when its sources are all in.
247    pub(crate) fn collection_source_ready(&mut self, owner: strop_core::worker::WorkerId) {
248        let Some(build) = self
249            .collection_build
250            .as_mut()
251            .filter(|build| build.owner == owner)
252        else {
253            strop_trace::record_with(
254                strop_trace::EventKind::JobFinished,
255                || serde_json::json!({"service":"collection","result":"ready-without-build"}),
256            );
257            return;
258        };
259        build.waiting = build.waiting.saturating_sub(1);
260        strop_trace::record_with(
261            strop_trace::EventKind::JobFinished,
262            || serde_json::json!({"service":"collection","result":"source-ready","waiting":build.waiting}),
263        );
264        if build.waiting == 0 {
265            let build = self.collection_build.take().unwrap();
266            self.build_collection(build);
267        }
268    }
269
270    fn build_collection(&mut self, build: CollectionBuild) {
271        let take_focus = build.focus_on_ready
272            && self
273                .panes
274                .get(self.active_pane)
275                .is_some_and(|pane| pane.doc == build.origin)
276            && self
277                .docs
278                .get(build.origin)
279                .is_some_and(|document| document.buf.revision() == build.revision);
280        let CollectionBuild {
281            title,
282            hits,
283            remote_hits,
284            ..
285        } = build;
286        let mut by_doc: HashMap<DocumentId, Vec<SourceHit>> = HashMap::new();
287        let open_sources = self.collection_local_sources();
288        let mut skipped = 0;
289        for CollectionHit {
290            path,
291            line,
292            span: hit,
293            witness,
294        } in hits
295        {
296            let absolute = if path.is_absolute() {
297                path
298            } else {
299                self.cwd.join(path)
300            };
301            let Some(&document) = open_sources.get(&absolute) else {
302                skipped += 1;
303                continue;
304            };
305            if witness.as_ref().is_some_and(|witness| {
306                super::picker::checked_hit_range(self.doc(document).buf.text(), witness).is_none()
307            }) {
308                skipped += 1;
309                continue;
310            }
311            by_doc
312                .entry(document)
313                .or_default()
314                .push(SourceHit { line, span: hit });
315        }
316        for (endpoint, path, line) in remote_hits {
317            let document = self.docs.iter().find_map(|(id, doc)| {
318                doc.remote_metadata().and_then(|source| {
319                    (source.file.endpoint() == &endpoint && source.file.path() == path.as_path())
320                        .then_some(id)
321                })
322            });
323            match document {
324                Some(id) => by_doc
325                    .entry(id)
326                    .or_default()
327                    .push(SourceHit { line, span: None }),
328                None => skipped += 1,
329            }
330        }
331        if by_doc.is_empty() {
332            self.message = format!("collection refused: {skipped} source match(es) stale or unavailable; refresh Search");
333            return;
334        }
335        let mut excerpts: Vec<Excerpt> = Vec::new();
336        let mut match_count = 0;
337        for (source, mut hits) in by_doc {
338            hits.sort_unstable();
339            hits.dedup();
340            let buf = &self.docs.get(source).unwrap().buf;
341            for SourceHit { line, span: hit } in hits {
342                if line >= buf.len_lines() {
343                    continue;
344                }
345                match_count += 1;
346                let hit_start = buf.line_start(line);
347                let start = buf.line_start(line.saturating_sub(2));
348                let end_line = line.saturating_add(3);
349                let end = if end_line >= buf.len_lines() {
350                    buf.len_bytes()
351                } else {
352                    buf.line_start(end_line)
353                };
354                let span = hit.map(|(column, length)| (hit_start + column, length));
355                if let Some(previous) = excerpts
356                    .last_mut()
357                    .filter(|e| e.source == source && e.end >= start)
358                {
359                    previous.end = previous.end.max(end);
360                    if !previous.hit_anchors.contains(&hit_start) {
361                        previous.hit_anchors.push(hit_start);
362                    }
363                    previous.matches.extend(span);
364                } else {
365                    excerpts.push(Excerpt {
366                        source,
367                        start,
368                        end,
369                        context: 2,
370                        hit_anchors: vec![hit_start],
371                        view_line: 0,
372                        view_lines: 0,
373                        view_start: 0,
374                        view_end: 0,
375                        matches: span.into_iter().collect(),
376                    });
377                }
378            }
379        }
380        // Cards present in path order, not document-id order (0049 §6).
381        excerpts.sort_by_cached_key(|excerpt| {
382            let label = self
383                .docs
384                .get(excerpt.source)
385                .map(|document| document.label(&self.cwd))
386                .unwrap_or_default();
387            (label, excerpt.source.index(), excerpt.start)
388        });
389        let excerpt_count = excerpts.len();
390        let title_for_trace = title.clone();
391        let id = self.docs.insert(Document::output(Buffer::from_text("")));
392        // The modeline names the collection, never [scratch] (0049 §6).
393        self.docs.get_mut(id).unwrap().buf.name = Some(format!("collection: {title}"));
394        let mut collection = Collection {
395            title,
396            excerpts,
397            pending_saves: HashSet::new(),
398            close_when_saved: false,
399            rows: Vec::new(),
400            pending_commit: Vec::new(),
401            match_count,
402            skipped,
403            revision: BufferRevision::new(0),
404        };
405        let text = render(&self.docs, &self.cwd, &mut collection);
406        self.collections.insert(id, collection);
407        let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
408        self.docs.get_mut(id).unwrap().buf.readonly = false;
409        let revision = self.docs.get(id).unwrap().buf.revision();
410        self.collections.get_mut(&id).unwrap().revision = revision;
411        strop_trace::record_with(strop_trace::EventKind::JobFinished, || {
412            serde_json::json!({
413                "service":"collection","result":"built","excerpts":excerpt_count,
414                "skipped":skipped,"title":title_for_trace,
415            })
416        });
417        if take_focus {
418            self.drop_stale_scratch(id);
419            self.switch_to(id);
420            self.set_head(0);
421        }
422        self.message = match skipped {
423            0 => format!("collection: {excerpt_count} excerpt(s)"),
424            _ => format!("collection built; {skipped} hit(s) skipped (not open local buffers)"),
425        };
426        if !take_focus {
427            self.message.push_str(" — ready in Space b");
428        }
429    }
430}
431
432/// What the renderer needs per collection row (0049 §6).
433pub struct CollectionRowInfo {
434    pub kind: CollectionRow,
435    /// Body rows: (source document, source byte start, source byte end).
436    pub source: Option<(strop_core::id::DocumentId, usize, usize)>,
437    /// Query hit spans within the row, in SOURCE bytes (0050 §7).
438    pub source_matches: Vec<(usize, usize)>,
439    /// The caret sits inside this row's card (focus chrome).
440    pub card_active: bool,
441    pub source_dirty: bool,
442    pub source_readonly: bool,
443}