lumen 2.28.0

lumen is a command-line tool that uses AI to generate commit messages, summarise git diffs or past commits, and more.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Telescope-style global search across all files in the diff.
//!
//! Indexes every side-by-side line in every file once when the modal opens,
//! then re-filters with a nucleo-matcher fuzzy score on each keystroke.

use std::cell::RefCell;
use std::collections::HashMap;

use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};

use super::diff_algo::compute_side_by_side;
use super::highlight::FileHighlighter;
use super::search::MatchPanel;
use super::text_edit::erase_word_backward;
use super::types::{ChangeType, DiffLine, DiffViewSettings, FileDiff};

/// Maximum number of results shown in the list. Above this, the user should narrow the query.
const MAX_RESULTS: usize = 500;

/// One searchable line in the global index.
#[derive(Clone)]
pub struct GlobalSearchEntry {
    pub file_index: usize,
    /// Index into the file's side-by-side rows.
    pub sbs_line_index: usize,
    pub panel: MatchPanel,
    /// 1-based line number from the original file (for display).
    pub line_no: usize,
    /// The line text itself, used both for matching and for the preview snippet.
    #[allow(dead_code)]
    pub text: String,
    /// What kind of change this line represents (drives the gutter color).
    pub change: LineChange,
    /// Short filename (display only).
    pub filename: String,
    /// Combined haystack: "filename:lineno  text". Built once at index time.
    /// Public for the renderer so it can highlight matched chars by index.
    pub haystack: String,
}

#[derive(Clone, Copy, PartialEq)]
pub enum LineChange {
    Equal,
    Added,
    Removed,
    Modified,
}

/// A filtered result with its match score and which characters of the haystack matched
/// (for highlighting in the result list).
#[derive(Clone)]
pub struct ScoredResult {
    pub entry_index: usize,
    pub score: u32,
    /// Char indices into `haystack` that matched, sorted ascending. Used to bold the
    /// matched chars in the result row.
    pub match_indices: Vec<u32>,
}

/// Per-file content kept around so we can lazily build a `FileHighlighter`
/// (and re-derive the side-by-side rows) for the preview pane. Cheap clones
/// happen once at modal-open time.
pub struct FileSnapshot {
    pub filename: String,
    pub old: String,
    pub new: String,
    /// Tab width to use when re-computing side-by-side for the preview.
    pub tab_width: usize,
}

pub struct GlobalSearchState {
    pub query: String,
    /// Full corpus, built once on modal open.
    pub entries: Vec<GlobalSearchEntry>,
    /// Filtered + scored results for the current query. Capped at MAX_RESULTS.
    pub results: Vec<ScoredResult>,
    /// Currently highlighted row in `results`. Set by arrow-key navigation
    /// only — mouse wheel scrolls the list view without touching this.
    pub selected: usize,
    /// Vertical scroll offset into the results list. Adjusted by mouse wheel
    /// directly, and auto-adjusted by selection moves to keep the cursor
    /// visible. The selected row may scroll off-screen if the user wheels past it.
    pub list_scroll: usize,
    /// Horizontal scroll into result rows. The selector (`❯ `) stays pinned;
    /// only the path/text body slides left as this grows.
    pub list_scroll_x: usize,
    /// Extra vertical scroll applied to the preview pane on top of cursor
    /// centering. Resets to 0 when the selected result changes.
    pub preview_scroll_y: i32,
    /// Horizontal scroll into the preview body (chars from the left edge of
    /// the body content, after the line-number gutter and change symbol).
    pub preview_scroll_x: usize,
    /// File contents, indexed by file_index, used to build highlighters on demand.
    pub files: Vec<FileSnapshot>,
    /// Tree-sitter highlighter cache, keyed by (file_index, is_new_panel).
    /// `RefCell` lets us populate on demand inside the immutable preview renderer.
    highlighter_cache: RefCell<HashMap<(usize, bool), FileHighlighter>>,
    /// Per-file side-by-side rows. Pre-populated during `build()` (since we
    /// already compute SBS there to index entries) so the first preview render
    /// is cache-hot. `RefCell` covers the rare miss path for binary files.
    sbs_cache: RefCell<HashMap<usize, Vec<DiffLine>>>,
    /// Reusable nucleo `Matcher`. Held across keystrokes so we don't pay
    /// `Matcher::new` (allocates internal scratch slabs) on every refilter.
    /// `take()`-and-restore pattern dodges the borrow-checker issue of holding
    /// `&mut matcher` and `&mut self.results` simultaneously.
    matcher: Option<Matcher>,
}

impl GlobalSearchState {
    /// Build the full index from all file diffs. This is the expensive step;
    /// call once when the modal opens.
    pub fn build(file_diffs: &[FileDiff], settings: &DiffViewSettings) -> Self {
        let mut entries = Vec::new();
        let mut files = Vec::with_capacity(file_diffs.len());
        // Pre-seed the sbs cache with what we compute below so the first
        // preview render is cache-hot instead of triggering a recompute.
        let mut sbs_cache: HashMap<usize, Vec<DiffLine>> = HashMap::with_capacity(file_diffs.len());

        for (file_idx, file) in file_diffs.iter().enumerate() {
            // Always push a snapshot so `files[file_idx]` lines up with file_diffs
            // indexing. Binary files get an empty snapshot — they're skipped below.
            files.push(FileSnapshot {
                filename: file.filename.clone(),
                old: file.old_content.clone(),
                new: file.new_content.clone(),
                tab_width: settings.tab_width,
            });

            if file.is_binary {
                continue;
            }
            let sbs =
                compute_side_by_side(&file.old_content, &file.new_content, settings.tab_width);

            // Full relative path from project root — git already emits diff
            // filenames as project-relative, so no further work needed.
            let filename_short = file.filename.clone();

            for (sbs_idx, line) in sbs.iter().enumerate() {
                let change = match line.change_type {
                    ChangeType::Equal => LineChange::Equal,
                    ChangeType::Delete => LineChange::Removed,
                    ChangeType::Insert => LineChange::Added,
                    ChangeType::Modified => LineChange::Modified,
                };

                // Decide which panels are worth indexing for this row:
                //   Equal     → new only (old and new are identical text — no point indexing twice)
                //   Delete    → old only (only side that has text)
                //   Insert    → new only (only side that has text)
                //   Modified  → both    (old and new are genuinely different text)
                let (emit_old, emit_new) = match line.change_type {
                    ChangeType::Equal => (false, true),
                    ChangeType::Delete => (true, false),
                    ChangeType::Insert => (false, true),
                    ChangeType::Modified => (true, true),
                };

                let mut push = |panel: MatchPanel, ln: usize, text: &str| {
                    if text.trim().is_empty() {
                        return;
                    }
                    let haystack = format!("{}:{}  {}", filename_short, ln, text);
                    entries.push(GlobalSearchEntry {
                        file_index: file_idx,
                        sbs_line_index: sbs_idx,
                        panel,
                        line_no: ln,
                        text: text.to_string(),
                        change,
                        filename: filename_short.clone(),
                        haystack,
                    });
                };

                if emit_old {
                    if let Some((ln, text)) = &line.old_line {
                        push(MatchPanel::Old, *ln, text);
                    }
                }
                if emit_new {
                    if let Some((ln, text)) = &line.new_line {
                        push(MatchPanel::New, *ln, text);
                    }
                }
            }

            // Stash the freshly-computed SBS so the first preview render for
            // this file doesn't have to recompute it.
            sbs_cache.insert(file_idx, sbs);
        }

        Self {
            query: String::new(),
            entries,
            results: Vec::new(),
            selected: 0,
            list_scroll: 0,
            list_scroll_x: 0,
            preview_scroll_y: 0,
            preview_scroll_x: 0,
            files,
            highlighter_cache: RefCell::new(HashMap::new()),
            sbs_cache: RefCell::new(sbs_cache),
            matcher: None,
        }
    }

    fn reset_preview_scroll(&mut self) {
        self.preview_scroll_y = 0;
        self.preview_scroll_x = 0;
    }

    /// Scroll the preview pane vertically by `delta` rows (negative = up).
    /// Triggered by mouse wheel over the preview area.
    pub fn scroll_preview_y(&mut self, delta: i32) {
        self.preview_scroll_y = self.preview_scroll_y.saturating_add(delta);
    }

    /// Scroll the preview body horizontally by `delta` columns. Clamped to 0.
    pub fn scroll_preview_x(&mut self, delta: i32) {
        let new = (self.preview_scroll_x as i32).saturating_add(delta);
        self.preview_scroll_x = new.max(0) as usize;
    }

    /// Mouse wheel on the list pane scrolls the view by `delta` rows. Unlike
    /// arrow-key navigation, this does NOT change the selected row — the user
    /// can scroll past the selection. `visible` is the pane's row count,
    /// computed by the caller from the terminal size.
    pub fn scroll_list_y(&mut self, delta: i32, visible: usize) {
        let max = self.results.len().saturating_sub(visible.max(1));
        let new = (self.list_scroll as i32 + delta).clamp(0, max as i32);
        self.list_scroll = new as usize;
    }

    /// Horizontal mouse scroll on the list pane shifts result rows left/right
    /// so long paths / matched text can be read in full.
    pub fn scroll_list_x(&mut self, delta: i32) {
        let new = (self.list_scroll_x as i32 + delta).max(0);
        self.list_scroll_x = new as usize;
    }

    /// Adjust `list_scroll` so the selected row is on-screen. Called from the
    /// selection-move methods only — mouse-wheel scrolling intentionally
    /// bypasses this so the user can scroll past the cursor.
    fn ensure_selection_visible(&mut self, visible: usize) {
        let visible = visible.max(1);
        if self.selected < self.list_scroll {
            self.list_scroll = self.selected;
        } else if self.selected >= self.list_scroll + visible {
            self.list_scroll = self.selected + 1 - visible;
        }
    }

    /// Borrow the cached side-by-side rows for `file_index`, computing them
    /// on first access. The closure receives a slice — handing the renderer
    /// raw `Ref` ergonomics would force lifetime gymnastics for callers.
    pub fn with_sbs<R>(&self, file_index: usize, f: impl FnOnce(&[DiffLine]) -> R) -> R {
        // Populate the cache on miss. Split into its own scope so the mutable
        // borrow drops before we take the immutable borrow below.
        if !self.sbs_cache.borrow().contains_key(&file_index) {
            if let Some(file) = self.files.get(file_index) {
                let sbs = compute_side_by_side(&file.old, &file.new, file.tab_width);
                self.sbs_cache.borrow_mut().insert(file_index, sbs);
            }
        }
        let cache = self.sbs_cache.borrow();
        f(cache.get(&file_index).map(Vec::as_slice).unwrap_or(&[]))
    }

    /// Render-side helper: get pre-highlighted spans for a specific line.
    ///
    /// Lazily builds (and caches) a `FileHighlighter` for the requested
    /// `(file_index, panel)` pair. Panels are tracked separately because old and
    /// new content are independent files from tree-sitter's perspective —
    /// constructs straddling line boundaries differ between the two.
    pub fn highlighted_line_spans<'a>(
        &self,
        file_index: usize,
        panel: MatchPanel,
        line_no: usize,
        bg: Option<ratatui::style::Color>,
    ) -> Vec<ratatui::text::Span<'a>> {
        let is_new = matches!(panel, MatchPanel::New);
        let key = (file_index, is_new);

        // Fast path: cache hit.
        if let Some(h) = self.highlighter_cache.borrow().get(&key) {
            return h.get_line_spans(line_no, bg);
        }

        // Miss: build the highlighter from the corresponding file content.
        let Some(file) = self.files.get(file_index) else {
            return Vec::new();
        };
        let content = if is_new { &file.new } else { &file.old };
        let highlighter = FileHighlighter::new(content, &file.filename);
        let spans = highlighter.get_line_spans(line_no, bg);
        self.highlighter_cache.borrow_mut().insert(key, highlighter);
        spans
    }

    pub fn push_char(&mut self, c: char) {
        self.query.push(c);
        self.refilter();
    }

    pub fn pop_char(&mut self) {
        self.query.pop();
        self.refilter();
    }

    pub fn clear_query(&mut self) {
        self.query.clear();
        self.refilter();
    }

    /// Drop the trailing "word" from the query, macOS `opt+backspace`
    /// semantics. Word boundaries respect punctuation, so e.g.
    /// "something/another" → "something/". Re-filters once after the edit
    /// instead of paying for one refilter per removed char.
    pub fn erase_query_word(&mut self) {
        erase_word_backward(&mut self.query);
        self.refilter();
    }

    /// Move the selected row to `target` (clamped) and update list scroll +
    /// preview scroll. Single chokepoint for every selection-changing key.
    /// `step_moves` skips the work when the position didn't change (so a
    /// no-op `move_down` at end-of-list doesn't reset preview scroll);
    /// jump-style moves bypass that guard.
    fn set_selection(&mut self, target: usize, visible: usize, step_move: bool) {
        let max = self.results.len().saturating_sub(1);
        let new = target.min(max);
        if step_move && new == self.selected {
            return;
        }
        self.selected = new;
        self.ensure_selection_visible(visible);
        self.reset_preview_scroll();
    }

    pub fn move_down(&mut self, visible: usize) {
        self.set_selection(self.selected.saturating_add(1), visible, true);
    }

    pub fn move_up(&mut self, visible: usize) {
        self.set_selection(self.selected.saturating_sub(1), visible, true);
    }

    pub fn page_down(&mut self, visible: usize) {
        let step = visible.max(1);
        self.set_selection(self.selected.saturating_add(step), visible, true);
    }

    pub fn page_up(&mut self, visible: usize) {
        let step = visible.max(1);
        self.set_selection(self.selected.saturating_sub(step), visible, true);
    }

    pub fn jump_top(&mut self, visible: usize) {
        self.set_selection(0, visible, false);
    }

    pub fn jump_bottom(&mut self, visible: usize) {
        self.set_selection(usize::MAX, visible, false);
    }

    /// Mouse-click selection: jump straight to `target` (clamped) and reset
    /// preview scroll. Treated as a jump (not a step) so clicking the already-
    /// selected row still resets preview scroll.
    pub fn select(&mut self, target: usize, visible: usize) {
        self.set_selection(target, visible, false);
    }

    /// Get the currently selected entry, if any.
    pub fn current_entry(&self) -> Option<&GlobalSearchEntry> {
        self.results
            .get(self.selected)
            .and_then(|r| self.entries.get(r.entry_index))
    }

    pub fn result_count(&self) -> usize {
        self.results.len()
    }

    pub fn total_indexed(&self) -> usize {
        self.entries.len()
    }

    fn refilter(&mut self) {
        self.selected = 0;
        self.list_scroll = 0;
        self.reset_preview_scroll();
        self.results.clear();

        if self.query.is_empty() {
            // Empty query → empty results. The list and preview stay blank until
            // the user types something. This avoids the "what is all this stuff?"
            // feeling of seeing the first N file lines on modal open.
            return;
        }

        let pattern = Pattern::parse(&self.query, CaseMatching::Smart, Normalization::Smart);
        // Reuse the matcher across keystrokes — `Matcher::new` allocates
        // internal scratch slabs we'd rather not re-pay every refilter. Take
        // it out so we can hold `&mut matcher` while also writing self.results
        // below, then put it back at the end.
        let mut matcher = self
            .matcher
            .take()
            .unwrap_or_else(|| Matcher::new(Config::DEFAULT));

        // Score every entry. nucleo-matcher returns Option<u32>; None = no match.
        let mut scored: Vec<ScoredResult> = Vec::with_capacity(self.entries.len() / 4);
        let mut indices_buf: Vec<u32> = Vec::new();
        let mut haystack_buf = Vec::new();

        for (i, entry) in self.entries.iter().enumerate() {
            haystack_buf.clear();
            let haystack = nucleo_matcher::Utf32Str::new(&entry.haystack, &mut haystack_buf);

            indices_buf.clear();
            if let Some(score) = pattern.indices(haystack, &mut matcher, &mut indices_buf) {
                scored.push(ScoredResult {
                    entry_index: i,
                    score,
                    match_indices: indices_buf.clone(),
                });
            }
        }

        // Highest score first, then by entry-index for tie stability.
        let cmp = |a: &ScoredResult, b: &ScoredResult| {
            b.score
                .cmp(&a.score)
                .then_with(|| a.entry_index.cmp(&b.entry_index))
        };
        // Partition first (O(N)) to find the top MAX_RESULTS unsorted, then
        // sort just those K (O(K log K)). For typical big diffs this is ~10x
        // faster than sorting the full match list before truncating.
        if scored.len() > MAX_RESULTS {
            scored.select_nth_unstable_by(MAX_RESULTS, cmp);
            scored.truncate(MAX_RESULTS);
        }
        scored.sort_by(cmp);
        self.results = scored;
        self.matcher = Some(matcher);
    }
}