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