Skip to main content

strop_engine/editor/picker/
mod.rs

1//! Picker glue: workers post onto the editor event loop, every stream
2//! owned by an exact ticket (R9): registration precedes launch, every
3//! request settles exactly once, and stale streams die at the handler
4//! instead of against the model (0020 §2).
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::mpsc::{channel, Receiver};
9
10use strop_core::worker::{CancelHandle, CancelReason, Load, Ticket, WorkerId};
11use strop_picker::{Item, Kind, Payload, Picker, PickerMsg};
12
13use super::{Editor, Key};
14
15mod accept;
16mod drain;
17mod preview;
18mod query;
19#[cfg(test)]
20mod query_tests;
21pub(crate) mod ranking;
22mod replace;
23pub use replace::checked_hit_range;
24pub use replace::ReplacementHit;
25pub(crate) mod search;
26pub use search::SearchScope;
27#[cfg(test)]
28mod search_tests;
29#[cfg(test)]
30mod tests;
31
32/// One picker instance's identity, allocated from the editor's worker
33/// id pool when the picker opens. Every streaming request and preview
34/// read binds to it — closing the picker invalidates them all at once.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
36#[serde(transparent)]
37pub struct PickerId(pub WorkerId);
38
39/// What one streaming picker request owns: which instance, against
40/// which working directory.
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct PickerKey {
43    pub picker: PickerId,
44    #[serde(with = "strop_core::path_serde")]
45    pub cwd: PathBuf,
46}
47
48/// A worker message stamped with the request that produced it. Both
49/// the TUI's forwarded events and the headless drain deliver these;
50/// only the owning ticket may touch the model.
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
52pub struct PickerEvent {
53    pub ticket: Ticket<PickerKey>,
54    pub msg: PickerMsg,
55}
56
57/// One supervised preview read: the picker instance it serves and the
58/// namespace-qualified resource being read.
59#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60pub struct PreviewKey {
61    pub picker: PickerId,
62    pub path: strop_workspace::ResourceLocation,
63}
64
65/// The terminal result of a preview request.
66pub type PreviewResult = strop_core::worker::Completion<PreviewKey, preview::PreparedPreview>;
67
68pub struct PickerGlue {
69    pub picker: Picker,
70    pub id: PickerId,
71    /// The request owning the stream: set at launch, cleared by its
72    /// terminal Finished event or by cancellation.
73    pub(crate) active: Option<Ticket<PickerKey>>,
74    /// Headless only: the active request's raw stream (the TUI gets a
75    /// ticket-stamping bridge at launch instead).
76    pub(crate) rx: Option<(Ticket<PickerKey>, Receiver<PickerMsg>)>,
77    pub(crate) worker: Option<CancelHandle>,
78    pub(crate) lsp_context: Option<strop_lsp::ReplyContext>,
79    pub(crate) rank_worker: Option<strop_picker::RankingWorker<ranking::Key>>,
80    pub rank_pending: Option<Ticket<ranking::Key>>,
81    /// Coalesce catalog/query changes while one rank snapshot is in flight.
82    pub(crate) rank_dirty: bool,
83    pub(crate) ranked_query: Option<String>,
84    pub(crate) rank_alive: bool,
85    pub(crate) accept_when_ranked: bool,
86    /// The manual query-suggestion list (0051 R02): ctrl-space opens
87    /// it; it owns accept/cancel keys until dismissed.
88    pub suggestions: Option<SuggestionList>,
89    pub query_highlights: Vec<strop_picker::query::HighlightSpan>,
90    pub query_summary: String,
91    pub(crate) query: Option<std::sync::Arc<strop_picker::query::SearchQuery>>,
92    file_scope: Option<std::sync::Arc<strop_picker::query::SearchQuery>>,
93    pub(crate) indent_target: Option<strop_core::id::DocumentId>,
94    pub(crate) search: Option<search::SearchContext>,
95    preview_witness: Option<preview::WitnessCheck>,
96}
97
98/// The visible suggestion list: static candidates from the query's own
99/// parse position — never an LSP or a filesystem scan.
100pub struct SuggestionList {
101    pub items: Vec<strop_picker::query::suggest::Suggestion>,
102    pub selected: usize,
103}
104
105impl PickerGlue {
106    /// A picker with no request yet: `Editor::set_picker` allocates the
107    /// instance identity before the glue is installed; Files/grep
108    /// requests are launched afterwards by `Editor::open_picker` and
109    /// `picker_input_changed`. (LSP location lists never launch one.)
110    pub fn diagnostics(picker: Picker) -> Self {
111        Self {
112            picker,
113            id: PickerId(WorkerId::new(0)), // replaced on install
114            active: None,
115            rx: None,
116            worker: None,
117            lsp_context: None,
118            rank_worker: None,
119            rank_pending: None,
120            rank_dirty: false,
121            ranked_query: None,
122            rank_alive: false,
123            accept_when_ranked: false,
124            suggestions: None,
125            query_highlights: Vec::new(),
126            query_summary: String::new(),
127            file_scope: None,
128            query: None,
129            indent_target: None,
130            search: None,
131            preview_witness: None,
132        }
133    }
134
135    /// Revoke the active request without touching the model: cancel
136    /// the worker (a queued terminal event is rejected later — it
137    /// cannot regain authority) and drop the raw stream.
138    fn revoke(&mut self, reason: CancelReason) {
139        self.rx = None;
140        if self.active.take().is_some() {
141            if let Some(worker) = self.worker.take() {
142                worker.cancel(reason);
143            }
144        }
145    }
146}
147
148impl Editor {
149    /// Install a picker: tears down any previous instance (revoking
150    /// its streams and previews) and allocates a fresh identity from
151    /// the worker id pool.
152    pub(crate) fn set_picker(&mut self, mut glue: PickerGlue) {
153        self.cancel_pending();
154        self.close_picker();
155        let id = match self.worker_ids.allocate() {
156            Ok(id) => id,
157            Err(error) => {
158                self.message = error.message;
159                return;
160            }
161        };
162        glue.preview_witness = None;
163        glue.id = PickerId(id);
164        if glue.picker.kind == Kind::Search && glue.search.is_none() {
165            match self.new_search_context(SearchScope {
166                root: strop_workspace::ResourceLocation::local(self.cwd.clone()),
167            }) {
168                Ok(context) => glue.search = Some(context),
169                Err(error) => {
170                    self.message = error;
171                    return;
172                }
173            }
174        }
175        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
176            serde_json::json!({
177                "service":"picker","id":id.get(),
178                "kind":glue.picker.kind.title().trim(),"streaming":glue.picker.streaming,
179            })
180        });
181        self.picker = Some(glue);
182        if self
183            .picker
184            .as_ref()
185            .is_some_and(|glue| glue.picker.kind != Kind::RemoteAddress)
186        {
187            self.start_picker_ranking();
188        }
189    }
190
191    pub fn open_picker(&mut self, kind: Kind) {
192        if kind == Kind::FilesystemActions {
193            self.open_filesystem_actions();
194            return;
195        }
196        if kind == Kind::Search {
197            self.open_search(false);
198            return;
199        }
200        if kind == Kind::RemoteHosts {
201            self.open_remote_picker();
202            return;
203        }
204        if kind == Kind::Jumps {
205            self.open_jumps_picker();
206            return;
207        }
208        if kind == Kind::RemoteAddress {
209            self.open_remote_address();
210            return;
211        }
212        if kind == Kind::TabSize {
213            self.open_tab_size_picker();
214            return;
215        }
216        let items = match kind {
217            Kind::Buffers => self
218                .mru
219                .iter()
220                .map(|&i| {
221                    let name = match self.doc(i).buf.path.as_ref() {
222                        Some(path) => path.to_string_lossy().into_owned(),
223                        None => "[scratch]".into(),
224                    };
225                    Item {
226                        badge: None,
227                        text: name,
228                        payload: Payload::Buffer(i),
229                    }
230                })
231                .collect(),
232            // Grep/Replace stream only once input registers a request;
233            // Files launches its walk right after install.
234            Kind::Files
235            | Kind::Search
236            | Kind::RemoteHosts
237            | Kind::RemoteAddress
238            | Kind::CodeActions
239            | Kind::Containers => vec![],
240            Kind::Jumps => unreachable!("the jumplist builds its own items"),
241            Kind::SearchOptions => unreachable!("search options build their own items"),
242            Kind::TabSize => unreachable!("the tab-size selector builds its own items"),
243            Kind::FilesystemActions => {
244                unreachable!("filesystem actions build their own captured selector")
245            }
246            Kind::Symbols => vec![],
247            Kind::Diagnostics | Kind::Locations => {
248                unreachable!("location lists use PickerGlue::diagnostics")
249            }
250        };
251        self.set_picker(PickerGlue::diagnostics(Picker::new(kind, items, false)));
252        if kind == Kind::Files {
253            self.picker_input_changed();
254        }
255    }
256
257    /// `:search-options` (0051 R03): the hidden/ignore controls with
258    /// their live values; Enter toggles and the row updates in place.
259    pub(crate) fn open_search_options(&mut self) {
260        let item = |setting: strop_picker::SearchSetting, on: bool| strop_picker::Item {
261            badge: None,
262            text: format!(
263                "{}: {}",
264                match setting {
265                    strop_picker::SearchSetting::Hidden => "hidden (dotfiles)",
266                    strop_picker::SearchSetting::RespectIgnore => "ignored entries",
267                },
268                match (setting, on) {
269                    (strop_picker::SearchSetting::Hidden, true)
270                    | (strop_picker::SearchSetting::RespectIgnore, false) => "include",
271                    _ => "exclude",
272                },
273            ),
274            payload: strop_picker::Payload::SearchOption(setting),
275        };
276        let items = vec![
277            item(
278                strop_picker::SearchSetting::Hidden,
279                self.config.search_show_hidden,
280            ),
281            item(
282                strop_picker::SearchSetting::RespectIgnore,
283                self.config.search_respect_ignore,
284            ),
285        ];
286        self.set_picker(PickerGlue::diagnostics(Picker::new(
287            Kind::SearchOptions,
288            items,
289            false,
290        )));
291    }
292
293    /// The jumplist as a menu (0047 §2): past newest-first, the current
294    /// position marked, then the future; dead documents are filtered.
295    pub(crate) fn open_jumps_picker(&mut self) {
296        let mut items = Vec::new();
297        for entry in self.jumplist_past.iter().rev() {
298            items.extend(jump_row(self, entry, "  "));
299        }
300        items.extend(jump_row(self, &self.jump_record(), "> "));
301        for entry in self.jumplist_future.iter().rev() {
302            items.extend(jump_row(self, entry, "  "));
303        }
304        self.set_picker(PickerGlue::diagnostics(Picker::new(
305            Kind::Jumps,
306            items,
307            false,
308        )));
309    }
310
311    /// Register headless delivery before launch, or stamp directly onto the live
312    /// app queue. Source restarts never allocate per-request bridge threads.
313    fn picker_source_sink(&mut self, ticket: Ticket<PickerKey>) -> strop_picker::SourceSink {
314        if let Some(app_tx) = self.app_tx.clone() {
315            return strop_picker::SourceSink::new(move |msg| {
316                app_tx
317                    .send(super::events::AppEvent::Picker(PickerEvent {
318                        ticket: ticket.clone(),
319                        msg,
320                    }))
321                    .is_ok()
322            });
323        }
324        let (tx, rx) = channel();
325        if let Some(glue) = self.picker.as_mut() {
326            glue.rx = Some((ticket, rx));
327        }
328        tx.into()
329    }
330
331    /// Connect-time: hand any already-registered headless stream to
332    /// the app channel (normally requests attach at launch).
333    pub(crate) fn connect_picker_stream(&mut self, tx: &super::events::EventSender) {
334        if let Some(glue) = &mut self.picker {
335            if let Some((ticket, rx)) = glue.rx.take() {
336                if let Err(error) = drain::forward_picker_stream(rx, ticket.clone(), tx.clone()) {
337                    let _ = tx.send(super::events::AppEvent::Picker(PickerEvent {
338                        ticket,
339                        msg: PickerMsg::Finished(strop_core::worker::Outcome::failed(
340                            strop_core::worker::FailureKind::ThreadStart,
341                            format!("picker bridge: {error}"),
342                        )),
343                    }));
344                }
345            }
346        }
347    }
348
349    /// Close the picker: revoke its active request, stop its worker,
350    /// and cancel/forget the previews it owns (failed reads become
351    /// retryable on reopen; retained successful bytes must be revalidated).
352    pub fn close_picker(&mut self) {
353        let Some(mut glue) = self.picker.take() else {
354            return;
355        };
356        glue.revoke(CancelReason::OwnerClosed);
357        self.revoke_remote_chooser(glue.id);
358        self.revoke_filesystem_actions(glue.id);
359        self.revoke_picker_previews(glue.id);
360        if glue.rank_alive {
361            self.picker_ranking.retiring.insert(glue.id);
362        }
363        if glue.picker.kind == Kind::Search {
364            drop(glue.rank_worker.take());
365            self.retain_search(glue);
366        } else if let Some(worker) = glue.rank_worker.take() {
367            if let Err(error) = worker.retire(glue.picker) {
368                self.message = format!("picker cleanup failed: {error}");
369            }
370        }
371    }
372
373    pub fn picker_open(&self) -> bool {
374        self.picker.is_some()
375    }
376
377    /// Cancel/forget every preview this picker instance owns. Running
378    /// requests are cancelled; Failed/Cancelled loads and their blank
379    /// cache entries are removed so an explicit reopen retries; Ready
380    /// bytes stay within the cache bound but are not current in a new picker.
381    fn revoke_picker_previews(&mut self, picker: PickerId) {
382        let mut cancelled = Vec::new();
383        let mut forgotten = Vec::new();
384        self.preview_loads.retain(|path, load| match load {
385            Load::Running(ticket) if ticket.key.picker == picker => {
386                cancelled.push(ticket.request);
387                false
388            }
389            Load::Failed { key, .. } | Load::Cancelled { key, .. } if key.picker == picker => {
390                forgotten.push(path.clone());
391                false
392            }
393            _ => true,
394        });
395        for request in cancelled {
396            if let Some(handle) = self.worker_handles.remove(&request) {
397                handle.cancel(CancelReason::OwnerClosed);
398            }
399        }
400        for path in forgotten {
401            self.previews.remove(&path);
402            self.analysis
403                .forget(super::analysis::AnalysisTarget::Preview(path));
404        }
405    }
406
407    pub(crate) fn feed_picker(&mut self, key: Key) {
408        let Some(glue) = &mut self.picker else {
409            return;
410        };
411        if key != Key::Enter {
412            glue.accept_when_ranked = false;
413        }
414        let search = glue.picker.kind == Kind::Search;
415        let replace = search && glue.picker.replacement_visible;
416        // the suggestion list owns accept/cancel while open (0051 R02)
417        if glue.suggestions.is_some() {
418            match key {
419                Key::Up => {
420                    let list = glue.suggestions.as_mut().unwrap();
421                    list.selected = list.selected.saturating_sub(1);
422                    return;
423                }
424                Key::Down | Key::Tab => {
425                    let list = glue.suggestions.as_mut().unwrap();
426                    list.selected = (list.selected + 1).min(list.items.len().saturating_sub(1));
427                    return;
428                }
429                Key::Enter => {
430                    self.accept_suggestion();
431                    return;
432                }
433                Key::Esc => {
434                    glue.suggestions = None;
435                    return;
436                }
437                _ => {
438                    glue.suggestions = None;
439                }
440            }
441        }
442        match key {
443            Key::CtrlSpace => {
444                self.open_suggestions();
445            }
446            Key::Esc => {
447                if glue.picker.input_normal() {
448                    let origin = glue.search.as_ref().map(|context| context.origin.clone());
449                    self.close_picker();
450                    if let Some(origin) =
451                        origin.filter(|origin| self.docs.get(origin.document).is_some())
452                    {
453                        self.jump_to(origin);
454                    }
455                } else {
456                    glue.picker.enter_normal();
457                }
458            }
459            Key::Enter => self.accept_current_picker(),
460            Key::Tab | Key::Backtab if replace => {
461                if glue.search.as_ref().is_some_and(|context| {
462                    context.scope.root.filesystem != strop_workspace::Filesystem::Local
463                }) {
464                    self.message =
465                        "SSH Search is read-only; With and Review are unavailable".into();
466                } else {
467                    glue.picker.toggle_field();
468                }
469            }
470            // ctrl-o: the listed hits become an editable collection (0044).
471            Key::CtrlO => self.open_collection_from_picker(),
472            Key::CtrlD if search => {
473                if glue.picker.toggle_file_excluded() {
474                    self.search_intent_changed();
475                } else {
476                    self.message = "no source match selected".into();
477                }
478            }
479            Key::CtrlX if search => {
480                if glue.picker.toggle_excluded() {
481                    self.search_intent_changed();
482                } else {
483                    self.message = "no source match selected".into();
484                }
485            }
486            Key::CtrlD | Key::CtrlX => {}
487            Key::Backspace => {
488                if glue.picker.input_normal() {
489                    glue.picker.normal_key('h');
490                } else if replace && glue.picker.field == strop_picker::Field::Replace {
491                    glue.picker.pop_replace_char();
492                    self.search_intent_changed();
493                } else {
494                    glue.picker.pop_char();
495                    self.picker_input_changed();
496                }
497            }
498            Key::CtrlL => self.needs_repaint = true,
499            Key::CtrlR if search => self.toggle_search_replacement(),
500            Key::CtrlR | Key::CtrlW => {}
501            Key::CtrlU | Key::CtrlF | Key::CtrlB | Key::CtrlV | Key::CtrlCaret => {}
502            Key::Up => glue.picker.move_by(-1),
503            Key::Down => glue.picker.move_by(1),
504            Key::Tab => glue.picker.move_by(1),
505            Key::Backtab => glue.picker.move_by(-1),
506            Key::Left => glue.picker.caret_left(),
507            Key::Right => glue.picker.caret_right(),
508            Key::Char('j') if glue.picker.input_normal() => glue.picker.move_by(1),
509            Key::Char('k') if glue.picker.input_normal() => glue.picker.move_by(-1),
510            Key::Char(c) => {
511                if glue.picker.input_normal() {
512                    if glue.picker.normal_key(c) {
513                        if replace && glue.picker.field == strop_picker::Field::Replace {
514                            self.search_intent_changed();
515                        } else {
516                            self.picker_input_changed();
517                        }
518                    }
519                } else if replace && glue.picker.field == strop_picker::Field::Replace {
520                    glue.picker.push_replace_char(c);
521                    self.search_intent_changed();
522                } else {
523                    glue.picker.push_char(c);
524                    self.picker_input_changed();
525                }
526            }
527        }
528    }
529
530    /// Bracketed paste while a picker is open edits the focused field
531    /// (query, replacement or remote address); it never reaches the
532    /// document behind the card. Multi-line payloads are rejected with
533    /// a message — a dropped keystroke with no feedback reads as a
534    /// broken terminal, not as an editor decision.
535    pub(crate) fn paste_picker(&mut self, text: &str) {
536        if text.is_empty() {
537            return;
538        }
539        let Some(glue) = &mut self.picker else {
540            return;
541        };
542        if text.contains(['\r', '\n']) {
543            self.message = "picker input cannot contain a newline".into();
544            return;
545        }
546        if glue.picker.paste(text) {
547            self.picker_input_changed();
548        } else {
549            self.search_intent_changed();
550        }
551    }
552
553    pub(crate) fn accept_current_picker(&mut self) {
554        if self
555            .picker
556            .as_ref()
557            .is_some_and(|glue| glue.picker.kind == Kind::RemoteAddress)
558        {
559            self.accept_remote_address();
560            return;
561        }
562        let Some(glue) = self.picker.as_mut() else {
563            return;
564        };
565        if matches!(glue.picker.kind, Kind::Files | Kind::Search) {
566            if let Some(error) = &glue.picker.error {
567                self.message = error.clone();
568                return;
569            }
570        }
571        let replacing = glue.picker.kind == Kind::Search
572            && glue.picker.replacement_visible
573            && glue.picker.field == strop_picker::Field::Replace;
574        if glue.rank_pending.is_some()
575            || ((replacing || glue.picker.current().is_none()) && glue.picker.streaming)
576        {
577            glue.accept_when_ranked = true;
578            return;
579        }
580        glue.accept_when_ranked = false;
581        if replacing {
582            self.prepare_search_review();
583            return;
584        }
585        let payload = glue.picker.current().map(|item| item.payload.clone());
586        // RemoteHosts: the pinned "Add a host…" row keeps one meaning —
587        // open the address box. Typed filter text comes along as the
588        // draft, so a hostname that matched no listed destination isn't
589        // lost (0.21.0 field report), and typing "Add" can't connect to
590        // a host literally named "add".
591        if glue.picker.kind == Kind::RemoteHosts && matches!(payload, Some(Payload::RemoteConnect))
592        {
593            let draft = {
594                let text = glue.picker.input.text.trim();
595                // Filter text that matches the pinned row's own label was
596                // aimed AT the row ("Add"); only text that matched
597                // nothing — a bare hostname — becomes the address draft.
598                let aimed_at_row = glue
599                    .picker
600                    .current()
601                    .is_some_and(|item| strop_picker::fuzzy_score(text, &item.text).is_some());
602                (!aimed_at_row).then(|| text.to_string())
603            };
604            self.close_picker();
605            self.open_remote_address();
606            if let Some(draft) = draft.filter(|draft| !draft.is_empty()) {
607                if let Some(glue) = self.picker.as_mut() {
608                    glue.picker.paste(&draft);
609                }
610            }
611            return;
612        }
613        // TabSize: every row is an IndentChoice; the typed filter text
614        // rides along so the pinned custom row can validate it as a
615        // width (the RemoteHosts draft pattern, 0.21.0).
616        if glue.picker.kind == Kind::TabSize {
617            let draft = glue.picker.input.text.trim().to_string();
618            let Some(Payload::IndentChoice(choice)) = payload else {
619                self.message = "no matching entries".into();
620                return;
621            };
622            let Some(document) = glue.indent_target else {
623                self.message = "indentation selector lost its source — reopen :tab-size".into();
624                return;
625            };
626            self.close_picker();
627            self.accept_indent_choice(document, choice, &draft);
628            return;
629        }
630        if glue.picker.kind == Kind::FilesystemActions {
631            let Some(Payload::FilesystemAction(index)) = payload else {
632                self.message = "no matching filesystem action".into();
633                return;
634            };
635            let picker = glue.id;
636            self.accept_filesystem_action(picker, index);
637            return;
638        }
639        let Some(payload) = payload else {
640            self.message = "no matching entries".into();
641            return;
642        };
643        if glue.picker.kind == Kind::Search {
644            self.open_search_hit(payload);
645            return;
646        }
647        let context = glue.lsp_context;
648        self.close_picker();
649        self.accept_picker(payload, context);
650    }
651
652    pub(crate) fn finish_pending_picker_accept(&mut self) {
653        if self.picker.as_ref().is_some_and(|glue| {
654            glue.accept_when_ranked
655                && glue.rank_pending.is_none()
656                && (!(glue.picker.kind == Kind::Search
657                    && glue.picker.replacement_visible
658                    && glue.picker.field == strop_picker::Field::Replace)
659                    || !glue.picker.streaming)
660        }) {
661            self.accept_current_picker();
662        }
663    }
664}
665
666pub struct PreviewEntry {
667    pub rope: ropey::Rope,
668}
669
670pub enum PreviewSource {
671    Buffer(strop_core::id::DocumentId),
672    Cached(strop_workspace::ResourceLocation),
673    Loading,
674    Failed(String),
675    Cancelled(CancelReason),
676}
677
678pub type Previews = HashMap<strop_workspace::ResourceLocation, PreviewEntry>;
679
680/// One jumplist row; dead documents drop out (0047 §2). The payload
681/// stays a plain destination — accepting a menu entry is a NEW jump
682/// landing (0051 §7), not a ctrl-o view restore.
683fn jump_row(editor: &Editor, record: &super::jumps::JumpRecord, marker: &str) -> Option<Item> {
684    let doc = editor.docs.get(record.document)?;
685    let name = doc
686        .buf
687        .path
688        .as_ref()
689        .map(|path| path.to_string_lossy().into_owned())
690        .unwrap_or_else(|| "[scratch]".into());
691    let line = doc.buf.line_of(record.offset.min(doc.buf.len_bytes()));
692    let text: String = doc.buf.line_text(line).trim().chars().take(48).collect();
693    Some(Item {
694        badge: None,
695        text: format!("{marker}{name}:{}  {text}", line + 1),
696        payload: Payload::Jump {
697            document: record.document,
698            offset: record.offset,
699        },
700    })
701}