strop_engine/editor/picker/
preview.rs1use 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#[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
40pub(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 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 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 if self
242 .preview_loads
243 .get(path)
244 .is_some_and(|load| load.covers(&key))
245 {
246 return false;
247 }
248 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 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
339pub(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}