strop_engine/editor/collections/
mod.rs1mod 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CollectionRow {
28 Title,
29 CardTop(usize),
31 Gap,
33 CardBottom,
35 Body,
36}
37
38#[derive(Debug, Clone)]
41pub(crate) struct Excerpt {
42 pub source: DocumentId,
43 pub start: usize,
45 pub end: usize,
46 pub context: usize,
48 pub hit_anchors: Vec<usize>,
50 pub view_line: usize,
52 pub view_lines: usize,
54 pub view_start: usize,
56 pub view_end: usize,
57 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 pub pending_saves: HashSet<DocumentId>,
70 pub close_when_saved: bool,
71 pub match_count: usize,
73 pub revision: BufferRevision,
76 pub rows: Vec<CollectionRow>,
78 pub pending_commit: Vec<(DocumentId, BufferRevision)>,
79}
80
81pub(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#[derive(Debug)]
94
95pub(crate) struct CollectionBuild {
96 pub title: String,
97 pub hits: Vec<CollectionHit>,
99 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 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 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 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 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 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 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
402pub struct CollectionRowInfo {
404 pub kind: CollectionRow,
405 pub source: Option<(strop_core::id::DocumentId, usize, usize)>,
407 pub source_matches: Vec<(usize, usize)>,
409 pub card_active: bool,
411 pub source_dirty: bool,
412 pub source_readonly: bool,
413}