1mod 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 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#[derive(Debug)]
99
100pub(crate) struct CollectionBuild {
101 pub title: String,
102 pub hits: Vec<CollectionHit>,
104 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 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 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 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 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 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 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
432pub struct CollectionRowInfo {
434 pub kind: CollectionRow,
435 pub source: Option<(strop_core::id::DocumentId, usize, usize)>,
437 pub source_matches: Vec<(usize, usize)>,
439 pub card_active: bool,
441 pub source_dirty: bool,
442 pub source_readonly: bool,
443}