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    location: strop_workspace::ResourceLocation,
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    pub waiting: usize,
105    pub owner: strop_core::worker::WorkerId,
106    pub origin: DocumentId,
107    pub revision: BufferRevision,
108    pub focus_on_ready: bool,
109}
110
111impl Editor {
112    fn collection_sources(&self) -> HashMap<strop_workspace::ResourceLocation, DocumentId> {
113        let mut sources = HashMap::new();
114        for (id, document) in self.docs.iter() {
115            if matches!(document.source, super::document::DocumentSource::File) {
116                for path in document
117                    .buf
118                    .path
119                    .as_deref()
120                    .into_iter()
121                    .chain(document.buf.file_identity())
122                {
123                    sources.insert(
124                        strop_workspace::ResourceLocation::local(self.cwd.join(path)),
125                        id,
126                    );
127                }
128            }
129            if let Some(location) = document
130                .file_target(&self.cwd)
131                .and_then(|target| target.resource_location())
132            {
133                sources.insert(location, id);
134            }
135        }
136        sources
137    }
138
139    /// `ctrl-o` in a result picker: open the listed hits as an editable
140    /// collection. Unopened sources load in their captured filesystem namespace.
141    pub(crate) fn open_collection_from_picker(&mut self) {
142        let Some(glue) = &self.picker else {
143            return;
144        };
145        if let Some(error) = &glue.picker.error {
146            self.message = format!("collection refused: {error}");
147            return;
148        }
149        if glue.picker.streaming || glue.rank_pending.is_some() {
150            self.message = "results are still updating — retry Ctrl-O when ready".into();
151            return;
152        }
153        let kind = glue.picker.kind;
154        if !matches!(
155            kind,
156            strop_picker::Kind::Locations
157                | strop_picker::Kind::Diagnostics
158                | strop_picker::Kind::Search
159        ) {
160            self.message = "collections come from a results list".into();
161            return;
162        }
163        let mut hits: Vec<CollectionHit> = Vec::new();
164        for item in glue.picker.accepted() {
165            match &item.payload {
166                strop_picker::Payload::Grep {
167                    location,
168                    line,
169                    col,
170                    match_len,
171                    line_text,
172                } => {
173                    let hit = (kind == strop_picker::Kind::Search)
174                        .then(|| (col.saturating_sub(1), *match_len));
175                    hits.push(CollectionHit {
176                        location: location.clone(),
177                        line: line.saturating_sub(1),
178                        span: hit,
179                        witness: hit.map(|_| super::picker::ReplacementHit {
180                            line: *line,
181                            col: *col,
182                            match_len: *match_len,
183                            text: line_text.clone(),
184                        }),
185                    });
186                }
187                strop_picker::Payload::Remote {
188                    endpoint,
189                    path,
190                    line,
191                    ..
192                } => hits.push(CollectionHit {
193                    location: strop_workspace::ResourceLocation::remote(
194                        endpoint.clone(),
195                        path.clone(),
196                    ),
197                    line: line.saturating_sub(1),
198                    span: None,
199                    witness: None,
200                }),
201                _ => {}
202            }
203        }
204        if hits.is_empty() {
205            self.message = "collection: no included source matches".into();
206            return;
207        }
208        let title = kind.title().trim().to_string();
209        let owner = glue.id.0;
210        self.close_picker();
211        // Unopened sources load in the background (never switching focus);
212        // the build assembles when the last one lands.
213        let mut to_load = Vec::new();
214        let open_sources = self.collection_sources();
215        let mut requested = std::collections::HashSet::new();
216        for hit in &hits {
217            if !open_sources.contains_key(&hit.location) && requested.insert(hit.location.clone()) {
218                match crate::files::FileTarget::from_location(&hit.location) {
219                    Ok(target) => to_load.push(target),
220                    Err(error) => {
221                        self.message = format!("collection refused: {error}");
222                        return;
223                    }
224                }
225            }
226        }
227        let waiting = to_load.len();
228        let build = CollectionBuild {
229            title,
230            hits,
231            waiting,
232            owner,
233            origin: self.current(),
234            revision: self.buf().revision(),
235            focus_on_ready: true,
236        };
237        if to_load.is_empty() {
238            self.build_collection(build);
239            return;
240        }
241        self.collection_build = Some(build);
242        self.message = format!("collection: loading {waiting} source(s)…");
243        for target in to_load {
244            self.request_target(
245                target,
246                crate::editor::io::OpenIntent::CollectionSource { owner },
247            );
248        }
249    }
250
251    /// A background source load landed (or failed): the pending build
252    /// counts down and assembles when its sources are all in.
253    pub(crate) fn collection_source_ready(&mut self, owner: strop_core::worker::WorkerId) {
254        let Some(build) = self
255            .collection_build
256            .as_mut()
257            .filter(|build| build.owner == owner)
258        else {
259            strop_trace::record_with(
260                strop_trace::EventKind::JobFinished,
261                || serde_json::json!({"service":"collection","result":"ready-without-build"}),
262            );
263            return;
264        };
265        build.waiting = build.waiting.saturating_sub(1);
266        strop_trace::record_with(
267            strop_trace::EventKind::JobFinished,
268            || serde_json::json!({"service":"collection","result":"source-ready","waiting":build.waiting}),
269        );
270        if build.waiting == 0 {
271            let build = self.collection_build.take().unwrap();
272            self.build_collection(build);
273        }
274    }
275
276    fn build_collection(&mut self, build: CollectionBuild) {
277        let take_focus = build.focus_on_ready
278            && self
279                .panes
280                .get(self.active_pane)
281                .is_some_and(|pane| pane.doc == build.origin)
282            && self
283                .docs
284                .get(build.origin)
285                .is_some_and(|document| document.buf.revision() == build.revision);
286        let CollectionBuild { title, hits, .. } = build;
287        let mut by_doc: HashMap<DocumentId, Vec<SourceHit>> = HashMap::new();
288        let open_sources = self.collection_sources();
289        let mut skipped = 0;
290        for CollectionHit {
291            location,
292            line,
293            span: hit,
294            witness,
295        } in hits
296        {
297            let Some(&document) = open_sources.get(&location) else {
298                skipped += 1;
299                continue;
300            };
301            if witness.as_ref().is_some_and(|witness| {
302                super::picker::checked_hit_range(self.doc(document).buf.text(), witness).is_none()
303            }) {
304                skipped += 1;
305                continue;
306            }
307            by_doc
308                .entry(document)
309                .or_default()
310                .push(SourceHit { line, span: hit });
311        }
312        if by_doc.is_empty() {
313            self.message = format!("collection refused: {skipped} source match(es) stale or unavailable; refresh Search");
314            return;
315        }
316        let mut excerpts: Vec<Excerpt> = Vec::new();
317        let mut match_count = 0;
318        for (source, mut hits) in by_doc {
319            hits.sort_unstable();
320            hits.dedup();
321            let buf = &self.docs.get(source).unwrap().buf;
322            for SourceHit { line, span: hit } in hits {
323                if line >= buf.len_lines() {
324                    continue;
325                }
326                match_count += 1;
327                let hit_start = buf.line_start(line);
328                let start = buf.line_start(line.saturating_sub(2));
329                let end_line = line.saturating_add(3);
330                let end = if end_line >= buf.len_lines() {
331                    buf.len_bytes()
332                } else {
333                    buf.line_start(end_line)
334                };
335                let span = hit.map(|(column, length)| (hit_start + column, length));
336                if let Some(previous) = excerpts
337                    .last_mut()
338                    .filter(|e| e.source == source && e.end >= start)
339                {
340                    previous.end = previous.end.max(end);
341                    if !previous.hit_anchors.contains(&hit_start) {
342                        previous.hit_anchors.push(hit_start);
343                    }
344                    previous.matches.extend(span);
345                } else {
346                    excerpts.push(Excerpt {
347                        source,
348                        start,
349                        end,
350                        context: 2,
351                        hit_anchors: vec![hit_start],
352                        view_line: 0,
353                        view_lines: 0,
354                        view_start: 0,
355                        view_end: 0,
356                        matches: span.into_iter().collect(),
357                    });
358                }
359            }
360        }
361        // Cards present in path order, not document-id order (0049 §6).
362        excerpts.sort_by_cached_key(|excerpt| {
363            let label = self
364                .docs
365                .get(excerpt.source)
366                .map(|document| document.label(&self.cwd))
367                .unwrap_or_default();
368            (label, excerpt.source.index(), excerpt.start)
369        });
370        let excerpt_count = excerpts.len();
371        let title_for_trace = title.clone();
372        let id = self.docs.insert(Document::output(Buffer::from_text("")));
373        // The modeline names the collection, never [scratch] (0049 §6).
374        self.docs.get_mut(id).unwrap().buf.name = Some(format!("collection: {title}"));
375        let mut collection = Collection {
376            title,
377            excerpts,
378            pending_saves: HashSet::new(),
379            close_when_saved: false,
380            rows: Vec::new(),
381            pending_commit: Vec::new(),
382            match_count,
383            skipped,
384            revision: BufferRevision::new(0),
385        };
386        let text = render(&self.docs, &self.cwd, &mut collection);
387        self.collections.insert(id, collection);
388        let _ = self.doc_mut(id).buf.system_edit().replace_all(&text);
389        self.docs.get_mut(id).unwrap().buf.readonly = false;
390        let revision = self.docs.get(id).unwrap().buf.revision();
391        self.collections.get_mut(&id).unwrap().revision = revision;
392        strop_trace::record_with(strop_trace::EventKind::JobFinished, || {
393            serde_json::json!({
394                "service":"collection","result":"built","excerpts":excerpt_count,
395                "skipped":skipped,"title":title_for_trace,
396            })
397        });
398        if take_focus {
399            self.drop_stale_scratch(id);
400            self.switch_to(id);
401            self.set_head(0);
402        }
403        self.message = match skipped {
404            0 => format!("collection: {excerpt_count} excerpt(s)"),
405            _ => format!("collection built; {skipped} hit(s) skipped (not open local buffers)"),
406        };
407        if !take_focus {
408            self.message.push_str(" — ready in Space b");
409        }
410    }
411}
412
413/// What the renderer needs per collection row (0049 §6).
414pub struct CollectionRowInfo {
415    pub kind: CollectionRow,
416    /// Body rows: (source document, source byte start, source byte end).
417    pub source: Option<(strop_core::id::DocumentId, usize, usize)>,
418    /// Query hit spans within the row, in SOURCE bytes (0050 §7).
419    pub source_matches: Vec<(usize, usize)>,
420    /// The caret sits inside this row's card (focus chrome).
421    pub card_active: bool,
422    pub source_dirty: bool,
423    pub source_readonly: bool,
424}