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<strop_workspace::ResourceLocation>,
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 Payload::Grep { location, .. } = &glue.picker.items[row.item].payload else {
69            return Ok(None);
70        };
71        let target = crate::files::FileTarget::from_location(location)
72            .map_err(|_| "invalid source identity")?;
73        let (rope, document, path) = match source {
74            PreviewSource::Buffer(id) => {
75                let Some(doc) = self.docs.get(*id) else {
76                    return Err("source closed — refresh Search");
77                };
78                if !doc.matches_target(&target) {
79                    return Err("preview source belongs to another resource");
80                }
81                (doc.buf.text(), Some((*id, doc.buf.revision())), None)
82            }
83            PreviewSource::Cached(path) => {
84                if path != location {
85                    return Err("preview source belongs to another resource");
86                }
87                let Some(entry) = self.previews.get(path) else {
88                    return Ok(None);
89                };
90                (&entry.rope, None, Some(path))
91            }
92            _ => return Ok(None),
93        };
94        let result = |range: Option<strop_core::Range>| {
95            range.map(Some).ok_or("source changed — refresh Search")
96        };
97        if let Some(cached) = glue.preview_witness.as_ref().filter(|cached| {
98            cached.item == row.item
99                && cached.dataset == context.stamp.dataset
100                && cached.document == document
101                && cached.path.as_ref() == path
102        }) {
103            return result(cached.range);
104        }
105        let Payload::Grep {
106            line,
107            col,
108            match_len,
109            line_text,
110            ..
111        } = &glue.picker.items[row.item].payload
112        else {
113            return Ok(None);
114        };
115        let range = super::checked_hit_range(
116            rope,
117            &super::ReplacementHit {
118                line: *line,
119                col: *col,
120                match_len: *match_len,
121                text: line_text.clone(),
122            },
123        );
124        let checked = WitnessCheck {
125            item: row.item,
126            dataset: context.stamp.dataset,
127            document,
128            path: path.cloned(),
129            range,
130        };
131        if let Some(glue) = self.picker.as_mut() {
132            glue.preview_witness = Some(checked);
133        }
134        result(range)
135    }
136
137    pub fn picker_preview(&mut self) -> Option<(String, Option<usize>, PreviewSource)> {
138        let item = self.picker.as_ref()?.picker.current()?;
139        let (full, focus_line) = match &item.payload {
140            Payload::RemoteDirectory(_)
141            | Payload::RemoteConnect
142            | Payload::Jump { .. }
143            | Payload::SearchOption(_)
144            | Payload::CodeAction(_)
145            | Payload::Container(_)
146            | Payload::FilesystemAction(_)
147            | Payload::IndentChoice(_) => return None,
148            Payload::Buffer(document) => {
149                let name = self.docs.get(*document)?.label(&self.cwd);
150                return Some((name, None, PreviewSource::Buffer(*document)));
151            }
152            Payload::File(path) => (
153                strop_workspace::ResourceLocation::local(self.picker_path(path)),
154                None,
155            ),
156            Payload::Grep { location, line, .. } => (location.clone(), Some(*line)),
157            Payload::Remote {
158                endpoint,
159                path,
160                line,
161                ..
162            } => (
163                strop_workspace::ResourceLocation::remote(endpoint.clone(), path.clone()),
164                Some(*line),
165            ),
166        };
167        let path = &full.path;
168        // 0050: the header identifies filename + line first; the parent
169        // directory follows only while it fits the card.
170        let title = {
171            let name = path
172                .file_name()
173                .map(|n| n.to_string_lossy().into_owned())
174                .unwrap_or_else(|| path.display().to_string());
175            let parent = path
176                .parent()
177                .map(|p| p.display().to_string())
178                .unwrap_or_default();
179            match focus_line {
180                Some(line) => format!("{name}:{line}  {parent}"),
181                None => format!("{name}  {parent}"),
182            }
183            .trim_end()
184            .to_string()
185        };
186        let title = if full.local_path().is_none() {
187            format!("{} · {title}", full.filesystem.label())
188        } else {
189            title
190        };
191        let target = match crate::files::FileTarget::from_location(&full) {
192            Ok(target) => target,
193            Err(error) => {
194                return Some((title, focus_line, PreviewSource::Failed(error.to_string())))
195            }
196        };
197        if let Some((document, _)) = self
198            .docs
199            .iter()
200            .find(|(_, document)| document.matches_target(&target))
201        {
202            return Some((title, focus_line, PreviewSource::Buffer(document)));
203        }
204        match self.preview_loads.get(&full) {
205            Some(Load::Failed { failure, .. }) => {
206                return Some((
207                    title,
208                    focus_line,
209                    PreviewSource::Failed(failure.message.clone()),
210                ));
211            }
212            Some(Load::Cancelled { reason, .. }) => {
213                return Some((title, focus_line, PreviewSource::Cancelled(*reason)));
214            }
215            _ => {}
216        }
217        if !self.preview_ready(&full) {
218            return Some((title, focus_line, PreviewSource::Loading));
219        }
220        Some((title, focus_line, PreviewSource::Cached(full)))
221    }
222
223    /// True when the preview is cached. Otherwise registers an owned
224    /// request for this picker instance (cancelling any stale one) and
225    /// launches the bounded read; the next tick picks the result up.
226    fn preview_ready(&mut self, path: &strop_workspace::ResourceLocation) -> bool {
227        let Some(picker) = self.picker.as_ref().map(|glue| glue.id) else {
228            return false;
229        };
230        let key = PreviewKey {
231            picker,
232            path: path.clone(),
233        };
234        if self.previews.contains_key(path)
235            && matches!(self.preview_loads.get(path), Some(Load::Ready(owner)) if owner == &key)
236        {
237            return true;
238        }
239        // a request from this instance — running, failed or cancelled —
240        // owns the path: frames never silently retry
241        if self
242            .preview_loads
243            .get(path)
244            .is_some_and(|load| load.covers(&key))
245        {
246            return false;
247        }
248        // a stale Running ticket (older instance) must not block
249        if let Some(Load::Running(old)) = self.preview_loads.get(path).cloned() {
250            if let Some(handle) = self.worker_handles.remove(&old.request) {
251                handle.cancel(CancelReason::Superseded);
252            }
253        }
254        let request = match self.worker_ids.allocate() {
255            Ok(request) => request,
256            Err(error) => {
257                self.message = error.message;
258                return false;
259            }
260        };
261        let ticket = Ticket {
262            request,
263            key: key.clone(),
264        };
265        // registration precedes launch — replay mode stops here and
266        // only injected results populate the cache
267        self.preview_loads
268            .insert(path.clone(), Load::Running(ticket.clone()));
269        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
270            serde_json::json!({
271                "service":"preview","request":request.get(),
272                "picker":picker.0.get(),"location":path,
273            })
274        });
275        match self
276            .tape
277            .request("strop-preview", &serde_json::json!({"ticket":ticket}))
278        {
279            Ok(false) => return false,
280            Ok(true) => {}
281            Err(error) => {
282                self.handle_preview(PreviewResult {
283                    ticket,
284                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
285                });
286                return false;
287            }
288        }
289        let launch_path = path.clone();
290        let client = self.remote_client();
291        let tx = self.preview_tx.clone();
292        let handle = worker::spawn(
293            "strop-preview",
294            move |outcome| {
295                let _ = tx.send(PreviewResult { ticket, outcome });
296            },
297            move |token| read_resource_preview(&launch_path, &client, &token),
298        );
299        self.worker_handles.insert(request, handle);
300        false
301    }
302}
303
304fn read_resource_preview(
305    location: &strop_workspace::ResourceLocation,
306    client: &strop_remote::RemoteClient,
307    token: &worker::CancelToken,
308) -> Outcome<PreparedPreview> {
309    match crate::files::FileTarget::from_location(location) {
310        Ok(crate::files::FileTarget::Local(path)) => read_preview(&path),
311        Ok(crate::files::FileTarget::Remote(remote)) => {
312            let length = match strop_remote::ReadLimit::new(512 * 1024) {
313                Ok(length) => length,
314                Err(error) => return Outcome::failed(FailureKind::Protocol, error.to_string()),
315            };
316            let selection = strop_remote::ReadSelection::Range {
317                start: strop_remote::RemoteOffset::new(0),
318                length,
319            };
320            match client.read(&remote, selection, token) {
321                Ok(snapshot) if snapshot.window.is_complete() => {
322                    Outcome::Success(PreparedPreview {
323                        rope: snapshot.buffer.text().clone(),
324                    })
325                }
326                Ok(_) => Outcome::failed(FailureKind::Unavailable, "preview too large"),
327                Err(_) if token.is_cancelled() => Outcome::Cancelled(CancelReason::Superseded),
328                Err(error) => Outcome::failed(FailureKind::Io, error.to_string()),
329            }
330        }
331        Ok(crate::files::FileTarget::Container { .. }) => Outcome::failed(
332            FailureKind::Unavailable,
333            "container search preview is not supported",
334        ),
335        Err(error) => Outcome::failed(FailureKind::Protocol, error.to_string()),
336    }
337}
338
339/// The bounded preview read: at most 512 KiB, real files only, valid
340/// UTF-8 — every miss is a typed failure, never an empty success. The
341/// read stays capped even if the file grows after metadata.
342pub(crate) fn read_preview(path: &Path) -> Outcome<PreparedPreview> {
343    const LIMIT: u64 = 512 * 1024;
344    let read = || -> Result<String, Failure> {
345        let meta =
346            std::fs::metadata(path).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
347        if !meta.is_file() {
348            return Err(Failure::new(
349                FailureKind::Unavailable,
350                "preview target is not a file",
351            ));
352        }
353        if meta.len() > LIMIT {
354            return Err(Failure::new(FailureKind::Unavailable, "preview too large"));
355        }
356        let file =
357            std::fs::File::open(path).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
358        let mut bytes = Vec::new();
359        file.take(LIMIT + 1)
360            .read_to_end(&mut bytes)
361            .map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
362        if bytes.len() as u64 > LIMIT {
363            return Err(Failure::new(FailureKind::Unavailable, "preview too large"));
364        }
365        String::from_utf8(bytes).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))
366    };
367    match read() {
368        Ok(text) => Outcome::Success(text.into()),
369        Err(failure) => Outcome::Failed {
370            failure,
371            partial: None,
372        },
373    }
374}