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