Skip to main content

strop_engine/editor/picker/
preview.rs

1//! Picker preview: bounded worker-prepared ropes; open documents use their live
2//! snapshots. Syntax belongs to the display-analysis actor, not the UI. Reads run as
3//! worker requests — registration precedes launch, every miss is a
4//! typed failure (never an empty success), and a failure never poisons
5//! the path forever.
6
7use std::io::Read;
8use std::path::Path;
9
10use strop_core::worker::{self, CancelReason, Failure, FailureKind, Load, Outcome, Ticket};
11use strop_picker::Payload;
12
13use super::super::Editor;
14use super::{PreviewKey, PreviewResult, PreviewSource};
15
16/// Live delivery already owns a rope. Replay reconstructs the same pure snapshot
17/// from the recorded text; no parser or filesystem access crosses this boundary.
18#[derive(Debug, Clone)]
19pub struct PreparedPreview {
20    pub rope: ropey::Rope,
21}
22impl From<String> for PreparedPreview {
23    fn from(text: String) -> Self {
24        Self {
25            rope: ropey::Rope::from_str(&text),
26        }
27    }
28}
29impl serde::Serialize for PreparedPreview {
30    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
31        serializer.collect_str(&self.rope)
32    }
33}
34impl<'de> serde::Deserialize<'de> for PreparedPreview {
35    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
36        String::deserialize(deserializer).map(Self::from)
37    }
38}
39
40/// One exact witness check per source revision/dataset/selected hit. Replacement
41/// typing and repaints must not compare a megabyte source line again.
42pub(super) struct WitnessCheck {
43    item: usize,
44    dataset: u64,
45    document: Option<(strop_core::id::DocumentId, strop_core::id::BufferRevision)>,
46    path: Option<std::path::PathBuf>,
47    range: Option<strop_core::Range>,
48}
49
50impl Editor {
51    pub fn picker_preview_range(
52        &mut self,
53        source: &PreviewSource,
54    ) -> Result<Option<strop_core::Range>, &'static str> {
55        let Some(glue) = self
56            .picker
57            .as_ref()
58            .filter(|glue| glue.picker.kind == strop_picker::Kind::Search)
59        else {
60            return Ok(None);
61        };
62        let Some(context) = glue.search.as_ref() else {
63            return Ok(None);
64        };
65        let Some(row) = glue.picker.rows.get(glue.picker.selected) else {
66            return Ok(None);
67        };
68        let (rope, document, path) = match source {
69            PreviewSource::Buffer(id) => {
70                let Some(doc) = self.docs.get(*id) else {
71                    return Err("source closed — refresh Search");
72                };
73                (doc.buf.text(), Some((*id, doc.buf.revision())), None)
74            }
75            PreviewSource::Cached(path) => {
76                let Some(entry) = self.previews.get(path) else {
77                    return Ok(None);
78                };
79                (&entry.rope, None, Some(path))
80            }
81            _ => return Ok(None),
82        };
83        let result = |range: Option<strop_core::Range>| {
84            range.map(Some).ok_or("source changed — refresh Search")
85        };
86        if let Some(cached) = glue.preview_witness.as_ref().filter(|cached| {
87            cached.item == row.item
88                && cached.dataset == context.stamp.dataset
89                && cached.document == document
90                && cached.path.as_ref() == path
91        }) {
92            return result(cached.range);
93        }
94        let Payload::Grep {
95            line,
96            col,
97            match_len,
98            line_text,
99            ..
100        } = &glue.picker.items[row.item].payload
101        else {
102            return Ok(None);
103        };
104        let range = super::checked_hit_range(
105            rope,
106            &super::ReplacementHit {
107                line: *line,
108                col: *col,
109                match_len: *match_len,
110                text: line_text.clone(),
111            },
112        );
113        let checked = WitnessCheck {
114            item: row.item,
115            dataset: context.stamp.dataset,
116            document,
117            path: path.cloned(),
118            range,
119        };
120        if let Some(glue) = self.picker.as_mut() {
121            glue.preview_witness = Some(checked);
122        }
123        result(range)
124    }
125
126    pub fn picker_preview(&mut self) -> Option<(String, Option<usize>, PreviewSource)> {
127        let item = self.picker.as_ref()?.picker.current()?;
128        let (path, focus_line) = match &item.payload {
129            Payload::RemoteDirectory(_)
130            | Payload::RemoteConnect
131            | Payload::Jump { .. }
132            | Payload::SearchOption(_)
133            | Payload::CodeAction(_)
134            | Payload::Container(_)
135            | Payload::IndentChoice(_) => return None,
136            Payload::Buffer(document) => {
137                let name = self.docs.get(*document)?.label(&self.cwd);
138                return Some((name, None, PreviewSource::Buffer(*document)));
139            }
140            Payload::File(path) => (path.clone(), None),
141            Payload::Grep { path, line, .. } => (path.clone(), Some(*line)),
142            // A remote hit never previews from the local disk: the
143            // analogous path is another machine's file (0036). The
144            // endpoint-labelled title says where it lives; accepting
145            // opens it remotely.
146            Payload::Remote {
147                endpoint,
148                path,
149                line,
150                ..
151            } => {
152                return Some((
153                    format!("{endpoint}{}", path.display()),
154                    Some(*line),
155                    PreviewSource::Failed("remote hit — accept to open".into()),
156                ));
157            }
158        };
159        let full = self.picker_path(&path);
160        // 0050: the header identifies filename + line first; the parent
161        // directory follows only while it fits the card.
162        let title = {
163            let name = path
164                .file_name()
165                .map(|n| n.to_string_lossy().into_owned())
166                .unwrap_or_else(|| path.display().to_string());
167            let parent = path
168                .parent()
169                .map(|p| p.display().to_string())
170                .unwrap_or_default();
171            match focus_line {
172                Some(line) => format!("{name}:{line}  {parent}"),
173                None => format!("{name}  {parent}"),
174            }
175            .trim_end()
176            .to_string()
177        };
178        let target = crate::files::FileTarget::Local(full.clone());
179        if let Some((document, _)) = self
180            .docs
181            .iter()
182            .find(|(_, document)| document.matches_target(&target))
183        {
184            return Some((title, focus_line, PreviewSource::Buffer(document)));
185        }
186        match self.preview_loads.get(&full) {
187            Some(Load::Failed { failure, .. }) => {
188                return Some((
189                    title,
190                    focus_line,
191                    PreviewSource::Failed(failure.message.clone()),
192                ));
193            }
194            Some(Load::Cancelled { reason, .. }) => {
195                return Some((title, focus_line, PreviewSource::Cancelled(*reason)));
196            }
197            _ => {}
198        }
199        if !self.preview_ready(&full) {
200            return Some((title, focus_line, PreviewSource::Loading));
201        }
202        Some((title, focus_line, PreviewSource::Cached(full)))
203    }
204
205    /// True when the preview is cached. Otherwise registers an owned
206    /// request for this picker instance (cancelling any stale one) and
207    /// launches the bounded read; the next tick picks the result up.
208    fn preview_ready(&mut self, path: &Path) -> bool {
209        let Some(picker) = self.picker.as_ref().map(|glue| glue.id) else {
210            return false;
211        };
212        let key = PreviewKey {
213            picker,
214            path: path.to_path_buf(),
215        };
216        if self.previews.contains_key(path)
217            && matches!(self.preview_loads.get(path), Some(Load::Ready(owner)) if owner == &key)
218        {
219            return true;
220        }
221        // a request from this instance — running, failed or cancelled —
222        // owns the path: frames never silently retry
223        if self
224            .preview_loads
225            .get(path)
226            .is_some_and(|load| load.covers(&key))
227        {
228            return false;
229        }
230        // a stale Running ticket (older instance) must not block
231        if let Some(Load::Running(old)) = self.preview_loads.get(path).cloned() {
232            if let Some(handle) = self.worker_handles.remove(&old.request) {
233                handle.cancel(CancelReason::Superseded);
234            }
235        }
236        let request = match self.worker_ids.allocate() {
237            Ok(request) => request,
238            Err(error) => {
239                self.message = error.message;
240                return false;
241            }
242        };
243        let ticket = Ticket {
244            request,
245            key: key.clone(),
246        };
247        // registration precedes launch — replay mode stops here and
248        // only injected results populate the cache
249        self.preview_loads
250            .insert(path.to_path_buf(), Load::Running(ticket.clone()));
251        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
252            serde_json::json!({
253                "service":"preview","request":request.get(),
254                "picker":picker.0.get(),"path":path.to_string_lossy(),
255            })
256        });
257        match self
258            .tape
259            .request("strop-preview", &serde_json::json!({"ticket":ticket}))
260        {
261            Ok(false) => return false,
262            Ok(true) => {}
263            Err(error) => {
264                self.handle_preview(PreviewResult {
265                    ticket,
266                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
267                });
268                return false;
269            }
270        }
271        let launch_path = path.to_path_buf();
272        let tx = self.preview_tx.clone();
273        let handle = worker::spawn(
274            "strop-preview",
275            move |outcome| {
276                let _ = tx.send(PreviewResult { ticket, outcome });
277            },
278            move |_| read_preview(&launch_path),
279        );
280        self.worker_handles.insert(request, handle);
281        false
282    }
283}
284
285/// The bounded preview read: at most 512 KiB, real files only, valid
286/// UTF-8 — every miss is a typed failure, never an empty success. The
287/// read stays capped even if the file grows after metadata.
288pub(crate) fn read_preview(path: &Path) -> Outcome<PreparedPreview> {
289    const LIMIT: u64 = 512 * 1024;
290    let read = || -> Result<String, Failure> {
291        let meta =
292            std::fs::metadata(path).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
293        if !meta.is_file() {
294            return Err(Failure::new(
295                FailureKind::Unavailable,
296                "preview target is not a file",
297            ));
298        }
299        if meta.len() > LIMIT {
300            return Err(Failure::new(FailureKind::Unavailable, "preview too large"));
301        }
302        let file =
303            std::fs::File::open(path).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
304        let mut bytes = Vec::new();
305        file.take(LIMIT + 1)
306            .read_to_end(&mut bytes)
307            .map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
308        if bytes.len() as u64 > LIMIT {
309            return Err(Failure::new(FailureKind::Unavailable, "preview too large"));
310        }
311        String::from_utf8(bytes).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))
312    };
313    match read() {
314        Ok(text) => Outcome::Success(text.into()),
315        Err(failure) => Outcome::Failed {
316            failure,
317            partial: None,
318        },
319    }
320}