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
40impl Editor {
41    pub fn picker_preview(&mut self) -> Option<(String, Option<usize>, PreviewSource)> {
42        let item = self.picker.as_ref()?.picker.current()?;
43        let (path, focus_line) = match &item.payload {
44            Payload::RemoteDirectory(_)
45            | Payload::RemoteConnect
46            | Payload::Jump { .. }
47            | Payload::CodeAction(_)
48            | Payload::Container(_) => return None,
49            Payload::Buffer(document) => {
50                let name = self
51                    .docs
52                    .get(*document)?
53                    .buf
54                    .path
55                    .as_ref()
56                    .map(|path| path.to_string_lossy().into_owned())
57                    .unwrap_or_else(|| "[scratch]".into());
58                return Some((name, None, PreviewSource::Buffer(*document)));
59            }
60            Payload::File(path) => (path.clone(), None),
61            Payload::Grep { path, line, .. } => (path.clone(), Some(*line)),
62            // A remote hit never previews from the local disk: the
63            // analogous path is another machine's file (0036). The
64            // endpoint-labelled title says where it lives; accepting
65            // opens it remotely.
66            Payload::Remote {
67                endpoint,
68                path,
69                line,
70                ..
71            } => {
72                return Some((
73                    format!("{endpoint}{}", path.display()),
74                    Some(*line),
75                    PreviewSource::Failed("remote hit — accept to open".into()),
76                ));
77            }
78        };
79        let full = self.cwd.join(&path);
80        // 0050: the header identifies filename + line first; the parent
81        // directory follows only while it fits the card.
82        let title = {
83            let name = path
84                .file_name()
85                .map(|n| n.to_string_lossy().into_owned())
86                .unwrap_or_else(|| path.display().to_string());
87            let parent = path
88                .parent()
89                .map(|p| p.display().to_string())
90                .unwrap_or_default();
91            match focus_line {
92                Some(line) => format!("{name}:{line}  {parent}"),
93                None => format!("{name}  {parent}"),
94            }
95            .trim_end()
96            .to_string()
97        };
98        if let Some((document, _)) = self
99            .docs
100            .iter()
101            .find(|(_, document)| document.buf.path.as_ref() == Some(&full))
102        {
103            return Some((title, focus_line, PreviewSource::Buffer(document)));
104        }
105        match self.preview_loads.get(&full) {
106            Some(Load::Failed { failure, .. }) => {
107                return Some((
108                    title,
109                    focus_line,
110                    PreviewSource::Failed(failure.message.clone()),
111                ));
112            }
113            Some(Load::Cancelled { reason, .. }) => {
114                return Some((title, focus_line, PreviewSource::Cancelled(*reason)));
115            }
116            _ => {}
117        }
118        if !self.preview_ready(&full) {
119            return Some((title, focus_line, PreviewSource::Loading));
120        }
121        Some((title, focus_line, PreviewSource::Cached(full)))
122    }
123
124    /// True when the preview is cached. Otherwise registers an owned
125    /// request for this picker instance (cancelling any stale one) and
126    /// launches the bounded read; the next tick picks the result up.
127    fn preview_ready(&mut self, path: &Path) -> bool {
128        if self.previews.contains_key(path) {
129            return true;
130        }
131        let Some(picker) = self.picker.as_ref().map(|glue| glue.id) else {
132            return false;
133        };
134        let key = PreviewKey {
135            picker,
136            path: path.to_path_buf(),
137        };
138        // a request from this instance — running, failed or cancelled —
139        // owns the path: frames never silently retry
140        if self
141            .preview_loads
142            .get(path)
143            .is_some_and(|load| load.covers(&key))
144        {
145            return false;
146        }
147        // a stale Running ticket (older instance) must not block
148        if let Some(Load::Running(old)) = self.preview_loads.get(path).cloned() {
149            if let Some(handle) = self.worker_handles.remove(&old.request) {
150                handle.cancel(CancelReason::Superseded);
151            }
152        }
153        let request = match self.worker_ids.allocate() {
154            Ok(request) => request,
155            Err(error) => {
156                self.message = error.message;
157                return false;
158            }
159        };
160        let ticket = Ticket {
161            request,
162            key: key.clone(),
163        };
164        // registration precedes launch — replay mode stops here and
165        // only injected results populate the cache
166        self.preview_loads
167            .insert(path.to_path_buf(), Load::Running(ticket.clone()));
168        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
169            serde_json::json!({
170                "service":"preview","request":request.get(),
171                "picker":picker.0.get(),"path":path.to_string_lossy(),
172            })
173        });
174        match self
175            .tape
176            .request("strop-preview", &serde_json::json!({"ticket":ticket}))
177        {
178            Ok(false) => return false,
179            Ok(true) => {}
180            Err(error) => {
181                self.handle_preview(PreviewResult {
182                    ticket,
183                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
184                });
185                return false;
186            }
187        }
188        let launch_path = path.to_path_buf();
189        let tx = self.preview_tx.clone();
190        let handle = worker::spawn(
191            "strop-preview",
192            move |outcome| {
193                let _ = tx.send(PreviewResult { ticket, outcome });
194            },
195            move |_| read_preview(&launch_path),
196        );
197        self.worker_handles.insert(request, handle);
198        false
199    }
200}
201
202/// The bounded preview read: at most 512 KiB, real files only, valid
203/// UTF-8 — every miss is a typed failure, never an empty success. The
204/// read stays capped even if the file grows after metadata.
205pub(crate) fn read_preview(path: &Path) -> Outcome<PreparedPreview> {
206    const LIMIT: u64 = 512 * 1024;
207    let read = || -> Result<String, Failure> {
208        let meta =
209            std::fs::metadata(path).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
210        if !meta.is_file() {
211            return Err(Failure::new(
212                FailureKind::Unavailable,
213                "preview target is not a file",
214            ));
215        }
216        if meta.len() > LIMIT {
217            return Err(Failure::new(FailureKind::Unavailable, "preview too large"));
218        }
219        let file =
220            std::fs::File::open(path).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
221        let mut bytes = Vec::new();
222        file.take(LIMIT + 1)
223            .read_to_end(&mut bytes)
224            .map_err(|e| Failure::new(FailureKind::Io, e.to_string()))?;
225        if bytes.len() as u64 > LIMIT {
226            return Err(Failure::new(FailureKind::Unavailable, "preview too large"));
227        }
228        String::from_utf8(bytes).map_err(|e| Failure::new(FailureKind::Io, e.to_string()))
229    };
230    match read() {
231        Ok(text) => Outcome::Success(text.into()),
232        Err(failure) => Outcome::Failed {
233            failure,
234            partial: None,
235        },
236    }
237}