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
81#[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#[derive(Debug)]
99
100pub(crate) struct CollectionBuild {
101 pub title: String,
102 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 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 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 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 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 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
413pub struct CollectionRowInfo {
415 pub kind: CollectionRow,
416 pub source: Option<(strop_core::id::DocumentId, usize, usize)>,
418 pub source_matches: Vec<(usize, usize)>,
420 pub card_active: bool,
422 pub source_dirty: bool,
423 pub source_readonly: bool,
424}