Skip to main content

oo_ide/views/
editor.rs

1//! Editor view — full-screen buffer editor.
2//!
3//! Layout:
4//!   ┌──────────────────────────────────────────────────────────────────┐
5//!   │  1 ⌄   fn main() {                                               │
6//!   │  2   │     let x = 1;                                            │
7//!   │  3   │ }                                                         │
8//!   │ src/main.rs [+]                                       1:1  Rust  │
9//!   └──────────────────────────────────────────────────────────────────┘
10//!
11
12use std::collections::{HashMap, HashSet};
13
14use ratatui::{
15    Frame,
16    layout::{Constraint, Direction, Layout, Rect},
17    style::{Color, Style},
18    text::{Line, Span},
19    widgets::{Block, Borders, Clear, Paragraph},
20};
21
22use crate::prelude::*;
23
24use editor::{
25    buffer::Buffer, position::Position, fold::FoldState, highlight::{StyledSpan, SyntaxHighlighter}, selection::Selection
26};
27use input::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
28use interraction::hit_test;
29use operation::{ClipOp, Event, GoToLineOp, LspCompletionItem, LspOp, LspRenameOp, MatchSpan, Operation, SearchOp, SelectionOp};
30use registers::Registers;
31use settings::Settings;
32use views::View;
33use widgets::{completion::CompletionWidget, editor::{EditorWidget, GutterMarker, gutter_width}, hover::HoverWidget};
34use widgets::focusable::FocusOp;
35use widgets::input_field::InputField;
36
37// ---------------------------------------------------------------------------
38// LSP overlay state
39// ---------------------------------------------------------------------------
40
41#[derive(Debug)]
42pub struct CompletionState {
43    pub items: Vec<LspCompletionItem>,
44    pub cursor: usize,
45    /// Buffer cursor position at which completion was triggered.
46    /// Used to derive the typed filter by slicing the current buffer.
47    pub trigger_cursor: Position,
48    /// Whether completion items are still loading (async LSP request in flight)
49    pub loading: bool,
50}
51
52#[derive(Debug)]
53pub struct HoverState {
54    pub text: String,
55}
56
57// ---------------------------------------------------------------------------
58// Search state
59// ---------------------------------------------------------------------------
60
61/// State for the go-to-line input bar.
62#[derive(Debug)]
63pub struct GoToLineState {
64    pub input: InputField,
65}
66
67impl Default for GoToLineState {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl GoToLineState {
74    pub fn new() -> Self {
75        Self {
76            input: InputField::new("Line"),
77        }
78    }
79
80    /// Parse the current input as a 1-based line number, returning a 0-based index.
81    pub fn line_number(&self) -> Option<usize> {
82        self.input.text().trim().parse::<usize>().ok().and_then(|n| n.checked_sub(1))
83    }
84}
85
86/// State for the inline LSP rename prompt.
87#[derive(Debug, Clone)]
88pub struct RenameState {
89    pub input: InputField,
90    /// Cursor position (line, col) captured when the prompt was opened.
91    /// Used by app.rs to send the rename request to the right symbol.
92    pub cursor_row: u32,
93    pub cursor_col: u32,
94}
95
96impl RenameState {
97    pub fn new(current_name: &str, row: u32, col: u32) -> Self {
98        let mut input = InputField::new("New name");
99        // Pre-fill with the current symbol name so the user can edit it.
100        for c in current_name.chars() {
101            input.apply(&crate::widgets::input_field::InputFieldOp::InsertChar(c));
102        }
103        Self { input, cursor_row: row, cursor_col: col }
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
108pub enum SearchKind {
109    Find,
110    Replace,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Default)]
114pub enum SearchMode {
115    #[default]
116    Inline,
117    Expanded,
118}
119
120const SEARCH_FOCUS_QUERY: &str = "search_query";
121const SEARCH_FOCUS_REPLACEMENT: &str = "search_replacement";
122const SEARCH_FOCUS_INCLUDE: &str = "search_include";
123const SEARCH_FOCUS_EXCLUDE: &str = "search_exclude";
124/// Search results tree has keyboard focus (Expanded mode).
125const SEARCH_FOCUS_TREE: &str = "search_tree";
126/// File-list panel has keyboard focus (Expanded mode) — legacy, kept for compat.
127const SEARCH_FOCUS_FILES: &str = "search_files";
128/// Match-results panel has keyboard focus (Expanded mode) — legacy, kept for compat.
129const SEARCH_FOCUS_MATCHES: &str = "search_matches";
130/// Embedded editor preview focus target
131const SEARCH_FOCUS_EDITOR: &str = "search_editor";
132
133#[derive(Debug, Clone)]
134pub struct SearchOptions {
135    pub ignore_case: bool,
136    pub regex: bool,
137    pub smart_case: bool,
138    /// Glob pattern for files to include (default `"*"` = all files).
139    pub include_glob: String,
140    /// Glob pattern for files to exclude (default `""` = exclude nothing).
141    pub exclude_glob: String,
142}
143
144impl Default for SearchOptions {
145    fn default() -> Self {
146        Self {
147            ignore_case: false,
148            regex: false,
149            smart_case: false,
150            include_glob: String::from("*"),
151            exclude_glob: String::new(),
152        }
153    }
154}
155
156#[derive(Debug)]
157pub struct FileMatch {
158    pub path: std::path::PathBuf,
159    pub matches: Vec<MatchSpan>,
160}
161
162/// An entry in the flat search-results tree used for rendering and navigation.
163#[derive(Debug)]
164enum SearchTreeRow {
165    /// A file header row (expandable/collapsible).
166    File {
167        file_idx: usize,
168        expanded: bool,
169    },
170    /// A match line beneath an expanded file.
171    Match {
172        file_idx: usize,
173        match_idx: usize,
174    },
175}
176
177/// Build the flat list of rows for the search tree from the current search
178/// state.  Files are sorted alphabetically for stable display order.
179fn build_search_tree(state: &SearchState) -> Vec<SearchTreeRow> {
180    let mut sorted_indices: Vec<usize> = (0..state.files.len()).collect();
181    sorted_indices.sort_by(|a, b| state.files[*a].path.cmp(&state.files[*b].path));
182
183    let mut rows = Vec::new();
184    for &fi in &sorted_indices {
185        let fm = &state.files[fi];
186        let expanded = state.expanded_files.contains(&fm.path);
187        rows.push(SearchTreeRow::File {
188            file_idx: fi,
189            expanded,
190        });
191        if expanded {
192            for mi in 0..fm.matches.len() {
193                rows.push(SearchTreeRow::Match {
194                    file_idx: fi,
195                    match_idx: mi,
196                });
197            }
198        }
199    }
200    rows
201}
202
203/// Find the flat index of the cursor in the tree based on identity
204/// (path + optional match index).  Returns 0 if not found.
205fn resolve_tree_cursor(rows: &[SearchTreeRow], files: &[FileMatch], cursor_path: &Option<std::path::PathBuf>, cursor_match: &Option<usize>) -> usize {
206    let Some(path) = cursor_path else { return 0 };
207    for (i, row) in rows.iter().enumerate() {
208        match row {
209            SearchTreeRow::File { file_idx, .. } => {
210                if cursor_match.is_none() && files[*file_idx].path == *path {
211                    return i;
212                }
213            }
214            SearchTreeRow::Match { file_idx, match_idx } => {
215                if cursor_match == &Some(*match_idx) && files[*file_idx].path == *path {
216                    return i;
217                }
218            }
219        }
220    }
221    0
222}
223
224/// Set the tree cursor identity from a flat index.
225fn set_tree_cursor(state: &mut SearchState, rows: &[SearchTreeRow], flat_idx: usize) {
226    if let Some(row) = rows.get(flat_idx) {
227        match row {
228            SearchTreeRow::File { file_idx, .. } => {
229                state.tree_cursor_path = Some(state.files[*file_idx].path.clone());
230                state.tree_cursor_match = None;
231            }
232            SearchTreeRow::Match { file_idx, match_idx } => {
233                state.tree_cursor_path = Some(state.files[*file_idx].path.clone());
234                state.tree_cursor_match = Some(*match_idx);
235            }
236        }
237    }
238}
239
240#[derive(Debug)]
241pub struct SearchState {
242    pub query: InputField,
243    pub replacement: InputField,
244    pub kind: SearchKind,
245    pub mode: SearchMode,
246    pub focus: crate::widgets::focus::FocusRing,
247    pub opts: SearchOptions,
248    /// (row, byte_start, byte_end) for every match in the buffer.
249    pub matches: Vec<(usize, usize, usize)>,
250    /// Index of the "current" (highlighted) match.
251    pub current: usize,
252    // Project-wide file-grouped results (unused by inline search)
253    pub files: Vec<FileMatch>,
254    /// Fast path → index lookup for `files`.
255    pub file_path_index: HashMap<std::path::PathBuf, usize>,
256    pub selected_file: usize,
257    /// Scroll offset of the file-list panel in Expanded mode.
258    pub file_panel_scroll: usize,
259    /// Scroll offset of the match-results panel in Expanded mode.
260    pub match_panel_scroll: usize,
261    /// Include-glob filter input (Expanded mode only).
262    pub include_filter: InputField,
263    /// Exclude-glob filter input (Expanded mode only).
264    pub exclude_filter: InputField,
265    /// Generation of the most recently started project search.
266    /// `AddProjectResult` ops whose generation does not match are discarded so
267    /// results from a superseded (cancelled) search never appear in the UI.
268    pub project_search_generation: u64,
269    /// Which files are expanded in the search results tree.
270    pub expanded_files: HashSet<std::path::PathBuf>,
271    /// Cursor identity in the search tree: file path.
272    pub tree_cursor_path: Option<std::path::PathBuf>,
273    /// Cursor identity in the search tree: match index within the file
274    /// (`None` means the cursor is on the file header row).
275    pub tree_cursor_match: Option<usize>,
276    /// Scroll offset for the search results tree.
277    pub tree_scroll: usize,
278    /// Flat cursor across all project-wide matches for F3/Shift+F3 navigation.
279    /// `None` when no project search has completed or when in Inline mode.
280    pub project_match_cursor: Option<usize>,
281}
282
283impl SearchState {
284    /// Total number of matches across all files in the project search.
285    pub fn total_project_matches(&self) -> usize {
286        self.files.iter().map(|f| f.matches.len()).sum()
287    }
288
289    /// Map a flat match index to `(file_idx, match_idx)`.
290    /// Files are iterated in insertion order (the order used by `files` vec).
291    pub fn project_match_at(&self, flat: usize) -> Option<(usize, usize)> {
292        let mut remaining = flat;
293        for (fi, fm) in self.files.iter().enumerate() {
294            if remaining < fm.matches.len() {
295                return Some((fi, remaining));
296            }
297            remaining -= fm.matches.len();
298        }
299        None
300    }
301}
302
303fn find_matches(lines: &[String], query: &str, opts: &SearchOptions) -> Vec<(usize, usize, usize)> {
304    if query.is_empty() {
305        return vec![];
306    }
307    let ignore = opts.ignore_case || (opts.smart_case && !query.chars().any(|c| c.is_uppercase()));
308
309    let mut out = Vec::new();
310    if opts.regex {
311        if let Ok(re) = regex::RegexBuilder::new(query)
312            .case_insensitive(ignore)
313            .build()
314        {
315            for (row, line) in lines.iter().enumerate() {
316                for m in re.find_iter(line) {
317                    out.push((row, m.start(), m.end()));
318                }
319            }
320        }
321    } else {
322        let q = if ignore {
323            query.to_lowercase()
324        } else {
325            query.to_owned()
326        };
327        for (row, line) in lines.iter().enumerate() {
328            let l = if ignore {
329                line.to_lowercase()
330            } else {
331                line.clone()
332            };
333            let mut start = 0;
334            while let Some(pos) = l[start..].find(&q) {
335                let abs = start + pos;
336                out.push((row, abs, abs + q.len()));
337                start = abs + 1;
338            }
339        }
340    }
341    out
342}
343
344// ---------------------------------------------------------------------------
345// Clipboard history picker state
346// ---------------------------------------------------------------------------
347
348#[derive(Debug)]
349pub(crate) struct ClipboardPickerState {
350    pub cursor: usize,
351}
352
353// ---------------------------------------------------------------------------
354// Mouse interaction state
355// ---------------------------------------------------------------------------
356
357/// Tracks double-click detection and drag state for the editor.
358#[derive(Debug, Default)]
359struct ClickState {
360    /// Screen column of the last Left-Down event.
361    last_col: u16,
362    /// Screen row of the last Left-Down event.
363    last_row: u16,
364    /// Time of the last Left-Down event.
365    last_time: Option<std::time::Instant>,
366    /// Consecutive click count at the same position (1 = single, 2 = double).
367    count: u8,
368    /// Whether the left button is currently held (drag in progress).
369    dragging: bool,
370    /// True if the current drag started with a double-click (word-drag mode).
371    word_drag: bool,
372}
373
374/// Maximum gap between two clicks to count as a double-click.
375const DOUBLE_CLICK_MS: u128 = 400;
376
377// ---------------------------------------------------------------------------
378// EditorView
379// ---------------------------------------------------------------------------
380
381#[derive(Debug)]
382pub struct EditorView {
383    pub buffer: Buffer,
384    pub folds: FoldState,
385    pub highlighter: SyntaxHighlighter,
386    pub gutter_markers: Vec<GutterMarker>,
387    /// Pre-formatted hover text per gutter-marked line (line → text block).
388    /// Updated whenever gutter_markers are rebuilt from the issue registry.
389    pub gutter_issue_texts: HashMap<usize, String>,
390    pub status_msg: Option<String>,
391    // LSP
392    pub completion: Option<CompletionState>,
393    pub hover: Option<HoverState>,
394    /// Incremented on every buffer change; sent with textDocument/didChange.
395    pub lsp_version: i32,
396    /// Secondary ops produced by completion-confirm that the view needs to
397    /// emit but cannot return from handle_operation.  Drained by app.rs.
398    pub deferred_ops: Vec<Operation>,
399    /// Active search/replace state; `None` when bar is closed.
400    pub search: Option<SearchState>,
401    /// Last committed search; kept alive after the bar closes so F3 still works.
402    pub last_search: Option<SearchState>,
403    /// Active go-to-line bar state; `None` when closed.
404    pub go_to_line: Option<GoToLineState>,
405    /// Active LSP rename prompt state; `None` when closed.
406    pub rename_prompt: Option<RenameState>,
407    /// Clipboard history picker state; `None` when closed.
408    /// Managed by `apply_clipboard_op` in `app.rs` (needs register access).
409    pub(crate) clipboard_picker: Option<ClipboardPickerState>,
410    /// When true, long lines are soft-wrapped instead of clipped horizontally.
411    pub word_wrap: bool,
412    /// When true, line numbers are shown in the gutter.
413    pub show_line_numbers: bool,
414    /// When true, use spaces for indentation; otherwise use tabs.
415    pub use_space: bool,
416    /// Number of spaces (or tab width) for indentation.
417    pub indentation_width: usize,
418    /// Fully computed highlight cache keyed by line number.
419    /// Lines are never erased on edits — only updated when recomputed.
420    pub highlight_cache: HashMap<usize, Vec<StyledSpan>>,
421
422    /// Lines currently being computed in background (avoid redundant tasks).
423    pub pending_highlight_lines: HashSet<usize>,
424
425    /// Lines whose cached spans are stale (content changed) but are kept in
426    /// `highlight_cache` for flicker-free rendering until fresh results arrive.
427    pub stale_highlight_lines: HashSet<usize>,
428
429    /// Handle for the in-flight background highlight task.
430    pub highlight_task: Option<tokio::task::JoinHandle<()>>,
431
432    /// Generation counter for highlight tasks. Incremented on each edit
433    /// to detect and discard stale completions.
434    pub highlight_generation: u64,
435    /// Previous scroll position — used to detect scroll direction for
436    /// highlight prefetching.
437    pub(crate) prev_scroll: usize,
438    /// Body area from the last render pass — used to map mouse coordinates to
439    /// buffer positions in `handle_mouse`.
440    pub(crate) last_body_area: Rect,
441    /// Search bar area from the last render pass — used to map mouse clicks to buttons.
442    pub(crate) last_search_bar_area: Rect,
443    /// File-list panel area from the last render pass (Expanded mode only).
444    pub(crate) last_file_panel_area: Rect,
445    /// Match-results panel area from the last render pass (Expanded mode only).
446    pub(crate) last_match_panel_area: Rect,
447    /// Click/drag state for mouse interaction.  Uses interior mutability because
448    /// `handle_mouse` takes `&self`.
449    click_state: std::cell::RefCell<ClickState>,
450    /// Message shown when external modification is detected.
451    pub external_conflict_msg: Option<String>,
452    /// Detected language of the open file (e.g. `"rust"`, `"python"`).
453    pub lang_id: Option<crate::language::LanguageId>,
454    /// Number of active (non-dismissed, non-resolved) error-severity issues for the open file.
455    /// Updated by `rebuild_editor_gutter_markers` whenever the issue registry changes.
456    pub issue_error_count: usize,
457    /// Number of active non-error issues (warnings, info, hints) for the open file.
458    /// Updated by `rebuild_editor_gutter_markers` whenever the issue registry changes.
459    pub issue_warning_count: usize,
460    /// 0-based line indices of lines that are added or modified according to the
461    /// workdir git diff.  Updated asynchronously when the file is opened or saved.
462    pub git_changed_lines: HashSet<usize>,
463}
464
465impl EditorView {
466    pub fn open(buffer: Buffer, folds: FoldState, settings: &crate::settings::Settings) -> Self {
467        use crate::settings::adapters;
468        let highlighter = buffer
469            .path
470            .as_deref()
471            .map(SyntaxHighlighter::for_path)
472            .unwrap_or_else(SyntaxHighlighter::plain);
473        let lang_id = buffer
474            .path
475            .as_deref()
476            .and_then(crate::language::detect_language);
477        Self {
478            buffer,
479            folds,
480            highlighter,
481            gutter_markers: Vec::new(),
482            gutter_issue_texts: HashMap::new(),
483            status_msg: None,
484            completion: None,
485            hover: None,
486            lsp_version: 1,
487            deferred_ops: Vec::new(),
488            search: None,
489            last_search: None,
490            go_to_line: None,
491            rename_prompt: None,
492            clipboard_picker: None,
493            word_wrap: *adapters::editor::word_wrap(settings),
494            show_line_numbers: *adapters::editor::show_line_numbers(settings),
495            use_space: *adapters::editor::use_space(settings),
496            indentation_width: *adapters::editor::indentation_width(settings) as usize,
497            highlight_cache: HashMap::new(),
498            pending_highlight_lines: HashSet::new(),
499            stale_highlight_lines: HashSet::new(),
500            highlight_task: None,
501            highlight_generation: 0,
502            prev_scroll: 0,
503            last_body_area: Rect::default(),
504            last_search_bar_area: Rect::default(),
505            last_file_panel_area: Rect::default(),
506            last_match_panel_area: Rect::default(),
507            click_state: std::cell::RefCell::new(ClickState::default()),
508            external_conflict_msg: None,
509            lang_id,
510            issue_error_count: 0,
511            issue_warning_count: 0,
512            git_changed_lines: HashSet::new(),
513        }
514    }
515
516    pub fn into_state(mut self) -> (Buffer, FoldState) {
517        // Abort any running highlight tasks and clear highlight caches so background
518        // workers do not retain large buffers after the view is stashed.
519        self.invalidate_all_highlights();
520        (self.buffer, self.folds)
521    }
522
523    /// Drain any secondary ops produced during the last handle_operation call.
524    pub fn take_deferred_ops(&mut self) -> Vec<Operation> {
525        std::mem::take(&mut self.deferred_ops)
526    }
527
528    pub fn start_file_watching(&mut self) {
529        if !self.buffer.is_dirty() {
530            self.buffer.compute_and_store_file_hash();
531        }
532    }
533
534    pub fn stop_file_watching(&mut self) {
535    }
536
537    pub fn check_external_modification_for_paths(&mut self, changed_paths: &[std::path::PathBuf]) -> bool {
538        let path = match &self.buffer.path {
539            Some(p) => p.clone(),
540            None => return false,
541        };
542        for changed_path in changed_paths {
543            if changed_path == &path
544                && self.buffer.check_external_modification() {
545                    let filename = path.file_name()
546                        .map(|n| n.to_string_lossy().to_string())
547                        .unwrap_or_else(|| path.to_string_lossy().to_string());
548                    self.external_conflict_msg = Some(
549                        format!("{} modified externally. Press Ctrl+Shift+R to reload or Ctrl+S to save.", filename),
550                    );
551                    return true;
552                }
553        }
554        false
555    }
556
557    pub fn reload_from_disk(&mut self) -> Result<()> {
558        self.buffer.reload_from_disk()?;
559        self.external_conflict_msg = None;
560        self.buffer.compute_and_store_file_hash();
561        self.invalidate_all_highlights();
562        Ok(())
563    }
564
565    /// Invalidate highlights for all lines at and after `from_line`, preserving
566    /// earlier cached spans so they remain visible without flashing.
567    /// Lines at and after `from_line` are kept in the cache as **stale** so they
568    /// continue rendering with their old colors until the worker delivers fresh results.
569    pub(crate) fn invalidate_highlights_from(&mut self, from_line: usize) {
570        // Abort any running task — it may be computing lines that are now stale.
571        if let Some(task) = self.highlight_task.take() {
572            task.abort();
573        }
574        self.highlight_generation = self.highlight_generation.wrapping_add(1);
575        self.highlighter.invalidate_from(from_line);
576        // Mark cached lines >= from_line as stale (keep them for rendering).
577        for &k in self.highlight_cache.keys() {
578            if k >= from_line {
579                self.stale_highlight_lines.insert(k);
580            }
581        }
582        // Cancel any pending requests for stale lines — they'll be re-queued next frame.
583        self.pending_highlight_lines.retain(|&k| k < from_line);
584    }
585
586    /// Full highlight cache reset (use for undo/redo or whole-file changes).
587    pub(crate) fn invalidate_all_highlights(&mut self) {
588        if let Some(task) = self.highlight_task.take() {
589            task.abort();
590        }
591        self.highlight_generation = self.highlight_generation.wrapping_add(1);
592        self.highlighter.clear_cache();
593        self.highlight_cache.clear();
594        self.pending_highlight_lines.clear();
595        self.stale_highlight_lines.clear();
596    }
597
598    pub fn clear_external_modification(&mut self) {
599        self.external_conflict_msg = None;
600        self.buffer.compute_and_store_file_hash();
601    }
602
603    pub(crate) fn path_matches(&self, path: &Option<std::path::PathBuf>) -> bool {
604        match (path, &self.buffer.path) {
605            (None, _) => true,
606            (Some(p), Some(bp)) => p == bp,
607            _ => false,
608        }
609    }
610
611    // -----------------------------------------------------------------------
612    // Rendering helpers
613    // -----------------------------------------------------------------------
614
615    pub(crate) fn recompute_search_matches(&mut self) {
616        if let Some(s) = &mut self.search {
617            s.matches = find_matches(&self.buffer.lines(), s.query.text(), &s.opts);
618            s.current = s.current.min(s.matches.len().saturating_sub(1));
619        }
620        if let Some(s) = &mut self.last_search {
621            s.matches = find_matches(&self.buffer.lines(), s.query.text(), &s.opts);
622            s.current = s.current.min(s.matches.len().saturating_sub(1));
623        }
624    }
625
626    fn render_body(&mut self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
627        let total_lines = self.buffer.lines().len();
628        let gutter_w = gutter_width(self.show_line_numbers, total_lines);
629        let content_w = (area.width as usize).saturating_sub(gutter_w);
630
631        if self.word_wrap {
632            self.buffer.scroll_x = 0;
633            self.buffer
634                .scroll_to_cursor_visual(area.height as usize, content_w, self.indentation_width);
635        } else {
636            self.buffer.scroll_to_cursor(area.height as usize);
637            self.buffer.scroll_x_to_cursor(content_w, self.indentation_width);
638        }
639
640        let (matches, current) = match self.search.as_ref().or(self.last_search.as_ref()) {
641            Some(s) if !s.matches.is_empty() => (s.matches.as_slice(), Some(s.current)),
642            _ => (&[] as &[(usize, usize, usize)], None),
643        };
644        let selection_spans: Vec<(usize, usize, usize)> = self
645            .buffer
646            .selection()
647            .map(|s| s.covered_spans())
648            .unwrap_or_default();
649        frame.render_widget(
650            EditorWidget {
651                buffer: &self.buffer,
652                folds: &self.folds,
653                highlight_cache: &self.highlight_cache,
654                gutter_markers: &self.gutter_markers,
655                git_changed_lines: &self.git_changed_lines,
656                search_matches: matches,
657                search_current: current,
658                selection_spans: &selection_spans,
659                scroll_x: self.buffer.scroll_x,
660                word_wrap: self.word_wrap,
661                show_line_numbers: self.show_line_numbers,
662                tab_width: self.indentation_width,
663                gutter_bg: theme.gutter_bg(),
664            },
665            area,
666        );
667    }
668
669    fn render_search_bar(&self, frame: &mut Frame, area: Rect) {
670        let s = self.search.as_ref().unwrap();
671        let bar_bg = Color::Rgb(30, 45, 70);
672        let active_bg = Color::Rgb(50, 70, 110);
673        let btn_on = Style::default()
674            .fg(Color::Black)
675            .bg(Color::Rgb(80, 170, 220));
676        let btn_off = Style::default()
677            .fg(Color::DarkGray)
678            .bg(Color::Rgb(40, 55, 80));
679
680        // Buttons: " IgnCase  Regex  Smart "
681        // Fixed width so they always sit flush at the right edge.
682        const BTN_W: u16 = 27;
683        let left_w = area.width.saturating_sub(BTN_W);
684
685        let btn_row = Line::from(vec![
686            Span::styled(" ", Style::default().bg(bar_bg)),
687            Span::styled(
688                " IgnCase ",
689                if s.opts.ignore_case { btn_on } else { btn_off },
690            ),
691            Span::styled(" ", Style::default().bg(bar_bg)),
692            Span::styled(" Regex ", if s.opts.regex { btn_on } else { btn_off }),
693            Span::styled(" ", Style::default().bg(bar_bg)),
694            Span::styled(" Smart ", if s.opts.smart_case { btn_on } else { btn_off }),
695            Span::styled(" ", Style::default().bg(bar_bg)),
696        ]);
697
698        let count_str = if s.matches.is_empty() {
699            " No matches".to_owned()
700        } else {
701            format!(" {}/{}", s.current + 1, s.matches.len())
702        };
703
704        // ── Query row ───────────────────────────────────────────────────────
705        let query_area = Rect { height: 1, ..area };
706        let [left_area, right_area] = Layout::default()
707            .direction(Direction::Horizontal)
708            .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
709            .split(query_area)[..]
710        else {
711            return;
712        };
713
714        let query_line = format!(" Find: {}  {}", s.query.text(), count_str);
715        frame.render_widget(
716            Paragraph::new(query_line).style(Style::default().fg(Color::White).bg(
717                if s.focus.current() == SEARCH_FOCUS_QUERY {
718                    active_bg
719                } else {
720                    bar_bg
721                },
722            )),
723            left_area,
724        );
725        frame.render_widget(
726            Paragraph::new(btn_row.clone()).style(Style::default().bg(bar_bg)),
727            right_area,
728        );
729
730        // ── Replace row ─────────────────────────────────────────────────────
731        if s.kind == SearchKind::Replace && area.height >= 2 {
732            let replace_area = Rect {
733                y: area.y + 1,
734                height: 1,
735                ..area
736            };
737            let [left_r, right_r] = Layout::default()
738                .direction(Direction::Horizontal)
739                .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
740                .split(replace_area)[..]
741            else {
742                return;
743            };
744
745            let replace_line = format!(" Replace: {}", s.replacement.text());
746            frame.render_widget(
747                Paragraph::new(replace_line).style(Style::default().fg(Color::White).bg(
748                    if s.focus.current() == SEARCH_FOCUS_REPLACEMENT {
749                        active_bg
750                    } else {
751                        bar_bg
752                    },
753                )),
754                left_r,
755            );
756            let hint = Line::from(vec![Span::styled(
757                "  Enter:replace  Alt+A:all  ",
758                Style::default().fg(Color::DarkGray).bg(bar_bg),
759            )]);
760            frame.render_widget(
761                Paragraph::new(hint).style(Style::default().bg(bar_bg)),
762                right_r,
763            );
764        }
765
766        // ── Filter row (Expanded mode only) ─────────────────────────────────
767        if s.mode == SearchMode::Expanded && area.height >= 2 {
768            let filter_area = Rect {
769                y: area.y + 1,
770                height: 1,
771                ..area
772            };
773            let half = filter_area.width / 2;
774            let [incl_area, excl_area] = Layout::default()
775                .direction(Direction::Horizontal)
776                .constraints([Constraint::Length(half), Constraint::Min(1)])
777                .split(filter_area)[..]
778            else {
779                return;
780            };
781
782            let incl_focused = s.focus.current() == SEARCH_FOCUS_INCLUDE;
783            let excl_focused = s.focus.current() == SEARCH_FOCUS_EXCLUDE;
784
785            let incl_text = format!(" incl: {}", s.include_filter.text());
786            frame.render_widget(
787                Paragraph::new(incl_text).style(Style::default().fg(Color::White).bg(
788                    if incl_focused { active_bg } else { bar_bg },
789                )),
790                incl_area,
791            );
792            let excl_text = format!(" excl: {}", s.exclude_filter.text());
793            frame.render_widget(
794                Paragraph::new(excl_text).style(Style::default().fg(Color::White).bg(
795                    if excl_focused { active_bg } else { bar_bg },
796                )),
797                excl_area,
798            );
799        }
800    }
801
802
803
804
805
806    /// Render the search results tree in the left sidebar.
807    fn render_search_tree(&self, frame: &mut Frame, area: Rect) {
808        let s = match self.search.as_ref() {
809            Some(s) => s,
810            None => return,
811        };
812
813        let tree_bg = Color::Rgb(15, 22, 38);
814        let selected_bg = Color::Rgb(40, 60, 100);
815        let header_bg = Color::Rgb(25, 35, 55);
816        let hit_bg = Color::Rgb(100, 80, 0);
817        let hit_fg = Color::White;
818        let file_fg = Color::Rgb(200, 200, 220);
819        let match_fg = Color::Rgb(160, 160, 180);
820        let count_fg = Color::Rgb(120, 140, 170);
821        let divider_fg = Color::Rgb(60, 70, 90);
822
823        let on_tree = s.focus.current() == SEARCH_FOCUS_TREE;
824
825        // Header row — file count
826        if area.height < 1 { return; }
827        let total_matches: usize = s.files.iter().map(|f| f.matches.len()).sum();
828        let header_text = if s.files.is_empty() && s.project_search_generation > 0 {
829            "  searching…".to_string()
830        } else {
831            format!("  {} files  {} matches", s.files.len(), total_matches)
832        };
833        let header_style = if on_tree {
834            Style::default().fg(Color::White).bg(header_bg)
835        } else {
836            Style::default().fg(count_fg).bg(header_bg)
837        };
838        frame.render_widget(
839            Paragraph::new(header_text).style(header_style),
840            Rect { x: area.x, y: area.y, width: area.width.saturating_sub(1), height: 1 },
841        );
842        // Divider column
843        frame.render_widget(
844            Paragraph::new("│").style(Style::default().fg(divider_fg).bg(header_bg)),
845            Rect { x: area.x + area.width.saturating_sub(1), y: area.y, width: 1, height: 1 },
846        );
847
848        let list_height = (area.height as usize).saturating_sub(1);
849        if list_height == 0 { return; }
850        let list_y = area.y + 1;
851
852        let rows = build_search_tree(s);
853        let cursor_flat = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
854
855        // Auto-scroll: ensure cursor is visible
856        let scroll = {
857            let mut sc = s.tree_scroll;
858            if cursor_flat < sc {
859                sc = cursor_flat;
860            } else if cursor_flat >= sc + list_height {
861                sc = cursor_flat.saturating_sub(list_height - 1);
862            }
863            sc
864        };
865
866        let content_w = area.width.saturating_sub(1); // leave 1 col for divider
867
868        for i in 0..list_height {
869            let row_y = list_y + i as u16;
870            let flat_idx = scroll + i;
871            if flat_idx >= rows.len() {
872                // Empty row
873                frame.render_widget(
874                    Paragraph::new("").style(Style::default().bg(tree_bg)),
875                    Rect { x: area.x, y: row_y, width: content_w, height: 1 },
876                );
877            } else {
878                let is_selected = flat_idx == cursor_flat && on_tree;
879                let row_bg = if is_selected { selected_bg } else { tree_bg };
880
881                match &rows[flat_idx] {
882                    SearchTreeRow::File { file_idx, expanded } => {
883                        let fm = &s.files[*file_idx];
884                        let icon = if *expanded { "▾ " } else { "▸ " };
885                        // Show last 2 path components for context
886                        let display_path = {
887                            let components: Vec<_> = fm.path.components().collect();
888                            let n = components.len();
889                            if n > 2 {
890                                let parent = components[n-2].as_os_str().to_string_lossy();
891                                let name = components[n-1].as_os_str().to_string_lossy();
892                                format!("{}/{}", parent, name)
893                            } else {
894                                fm.path.file_name().map(|f| f.to_string_lossy().to_string())
895                                    .unwrap_or_default()
896                            }
897                        };
898                        let count = format!(" ({})", fm.matches.len());
899                        let spans = vec![
900                            Span::styled(icon, Style::default().fg(count_fg).bg(row_bg)),
901                            Span::styled(display_path, Style::default().fg(file_fg).bg(row_bg)),
902                            Span::styled(count, Style::default().fg(count_fg).bg(row_bg)),
903                        ];
904                        let line = Line::from(spans);
905                        frame.render_widget(
906                            Paragraph::new(line),
907                            Rect { x: area.x, y: row_y, width: content_w, height: 1 },
908                        );
909                    }
910                    SearchTreeRow::Match { file_idx, match_idx } => {
911                        let ms = &s.files[*file_idx].matches[*match_idx];
912                        let indent = "    ";
913                        let line_num = format!("{}: ", ms.line + 1);
914                        let text = ms.line_text.trim_start();
915                        let trim_offset = ms.line_text.len() - text.len();
916
917                        // Build spans with highlighted match region
918                        let mut spans = vec![
919                            Span::styled(indent, Style::default().fg(match_fg).bg(row_bg)),
920                            Span::styled(line_num, Style::default().fg(count_fg).bg(row_bg)),
921                        ];
922                        // Adjust byte offsets for trimmed text
923                        let bs = ms.byte_start.saturating_sub(trim_offset);
924                        let be = ms.byte_end.saturating_sub(trim_offset);
925                        if bs < text.len() && be <= text.len() && bs < be {
926                            if bs > 0 {
927                                spans.push(Span::styled(&text[..bs], Style::default().fg(match_fg).bg(row_bg)));
928                            }
929                            spans.push(Span::styled(&text[bs..be], Style::default().fg(hit_fg).bg(hit_bg)));
930                            if be < text.len() {
931                                spans.push(Span::styled(&text[be..], Style::default().fg(match_fg).bg(row_bg)));
932                            }
933                        } else {
934                            spans.push(Span::styled(text, Style::default().fg(match_fg).bg(row_bg)));
935                        }
936                        let line = Line::from(spans);
937                        frame.render_widget(
938                            Paragraph::new(line),
939                            Rect { x: area.x, y: row_y, width: content_w, height: 1 },
940                        );
941                    }
942                }
943            }
944            // Divider column for this row
945            frame.render_widget(
946                Paragraph::new("│").style(Style::default().fg(divider_fg).bg(tree_bg)),
947                Rect { x: area.x + area.width.saturating_sub(1), y: row_y, width: 1, height: 1 },
948            );
949        }
950    }
951
952    fn render_go_to_line_bar(&self, frame: &mut Frame, area: Rect) {
953        let state = match &self.go_to_line {
954            Some(s) => s,
955            None => return,
956        };
957        let bar_bg = Color::Rgb(30, 45, 70);
958        let total = self.buffer.line_count();
959        let hint = format!(" of {} ", total);
960        let hint_w = hint.len() as u16;
961        let input_w = area.width.saturating_sub(hint_w);
962        let [input_area, hint_area] = Layout::default()
963            .direction(Direction::Horizontal)
964            .constraints([Constraint::Length(input_w), Constraint::Length(hint_w)])
965            .split(area)[..]
966        else {
967            return;
968        };
969        let line = format!(" Go to line: {}", state.input.text());
970        frame.render_widget(
971            Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::Rgb(50, 70, 110))),
972            input_area,
973        );
974        frame.render_widget(
975            Paragraph::new(hint).style(Style::default().fg(Color::DarkGray).bg(bar_bg)),
976            hint_area,
977        );
978    }
979
980    fn render_rename_bar(&self, frame: &mut Frame, area: Rect) {
981        let state = match &self.rename_prompt {
982            Some(s) => s,
983            None => return,
984        };
985        let hint = " [Enter] confirm  [Esc] cancel ";
986        let hint_w = (hint.len() as u16).min(area.width / 2);
987        let input_w = area.width.saturating_sub(hint_w);
988        let [input_area, hint_area] = Layout::default()
989            .direction(Direction::Horizontal)
990            .constraints([Constraint::Length(input_w), Constraint::Length(hint_w)])
991            .split(area)[..]
992        else {
993            return;
994        };
995        let text = format!(" Rename: {}_", state.input.text());
996        frame.render_widget(
997            Paragraph::new(text).style(Style::default().fg(Color::White).bg(Color::Rgb(60, 30, 80))),
998            input_area,
999        );
1000        frame.render_widget(
1001            Paragraph::new(hint).style(Style::default().fg(Color::DarkGray).bg(Color::Rgb(40, 20, 60))),
1002            hint_area,
1003        );
1004    }
1005
1006    fn render_clipboard_picker(&self, frame: &mut Frame, registers: &Registers) {
1007        let picker = match &self.clipboard_picker {
1008            Some(p) => p,
1009            None => return,
1010        };
1011
1012        const MAX_ITEMS: usize = 10;
1013        let history_len = registers.len();
1014        if history_len == 0 {
1015            return;
1016        }
1017
1018        let term = frame.area();
1019        let visible_count = history_len.min(MAX_ITEMS);
1020        // inner rows = items + 1 hint line
1021        let inner_h = (visible_count as u16) + 1;
1022        let popup_h = inner_h + 2; // +2 for block borders
1023        let popup_w = 64u16.min(term.width.saturating_sub(4));
1024        let x = (term.width.saturating_sub(popup_w)) / 2;
1025        let y = (term.height.saturating_sub(popup_h)) / 2;
1026        let popup_area = Rect {
1027            x,
1028            y,
1029            width: popup_w,
1030            height: popup_h,
1031        };
1032
1033        frame.render_widget(Clear, popup_area);
1034
1035        let block = Block::default()
1036            .title(" Clipboard History ")
1037            .borders(Borders::ALL)
1038            .border_style(Style::default().fg(Color::Cyan));
1039        let inner = block.inner(popup_area);
1040        frame.render_widget(block, popup_area);
1041
1042        // Hint line (last row of inner area)
1043        let hint_y = inner.y + inner.height.saturating_sub(1);
1044        frame.render_widget(
1045            Paragraph::new(Line::from(vec![
1046                Span::styled(" ↑↓ navigate", Style::default().fg(Color::DarkGray)),
1047                Span::styled("  Enter paste", Style::default().fg(Color::DarkGray)),
1048                Span::styled("  Esc close ", Style::default().fg(Color::DarkGray)),
1049            ])),
1050            Rect {
1051                x: inner.x,
1052                y: hint_y,
1053                width: inner.width,
1054                height: 1,
1055            },
1056        );
1057
1058        // Item rows (all rows above the hint line)
1059        let items_h = inner.height.saturating_sub(1) as usize;
1060        let scroll = if picker.cursor >= items_h {
1061            picker.cursor + 1 - items_h
1062        } else {
1063            0
1064        };
1065
1066        for (display_idx, (ring_idx, text)) in registers
1067            .iter()
1068            .enumerate()
1069            .skip(scroll)
1070            .take(items_h)
1071            .enumerate()
1072        {
1073            let row_y = inner.y + display_idx as u16;
1074            if row_y >= hint_y {
1075                break;
1076            }
1077            let is_selected = ring_idx == picker.cursor;
1078            let bg = if is_selected {
1079                Color::DarkGray
1080            } else {
1081                Color::Reset
1082            };
1083            let fg = if is_selected {
1084                Color::White
1085            } else {
1086                Color::Gray
1087            };
1088
1089            // Show only the first line of multi-line entries as preview.
1090            let preview = text.lines().next().unwrap_or("");
1091            let label = format!("{:>2}  {}", ring_idx + 1, preview);
1092            let width = inner.width as usize;
1093            let padded = format!("{:<width$}", label.chars().take(width).collect::<String>());
1094
1095            frame.render_widget(
1096                Paragraph::new(padded).style(Style::default().fg(fg).bg(bg)),
1097                Rect {
1098                    x: inner.x,
1099                    y: row_y,
1100                    width: inner.width,
1101                    height: 1,
1102                },
1103            );
1104        }
1105    }
1106
1107
1108
1109    #[allow(dead_code)]
1110    fn render_completion_overlay(
1111        &self,
1112        frame: &mut Frame,
1113        body_area: Rect,
1114        comp: &CompletionState,
1115    ) {
1116        // Compute screen coordinates of the cursor.
1117        let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
1118        let scroll = self.buffer.scroll;
1119        let cursor = self.buffer.cursor();
1120
1121        if cursor.line < scroll || cursor.line - scroll >= body_area.height as usize {
1122            return;
1123        }
1124
1125        let anchor_x = body_area.x + gutter_w as u16 + cursor.column as u16;
1126        let anchor_y = body_area.y + (cursor.line - scroll) as u16;
1127
1128        // Derive filter: text typed since trigger point.
1129        let filter = self.completion_filter(comp);
1130
1131        let widget = CompletionWidget {
1132            items: &comp.items,
1133            cursor: comp.cursor,
1134            filter: &filter,
1135            anchor_x,
1136            anchor_y,
1137            terminal_area: frame.area(),
1138            loading: comp.loading,
1139        };
1140        frame.render_widget(widget, frame.area());
1141    }
1142
1143    #[allow(dead_code)]
1144    fn render_hover_overlay(&self, frame: &mut Frame, body_area: Rect, hover: &HoverState) {
1145        let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
1146        let scroll = self.buffer.scroll;
1147        let cursor = self.buffer.cursor();
1148
1149        if cursor.line < scroll || cursor.line - scroll >= body_area.height as usize {
1150            return;
1151        }
1152
1153        let anchor_x = body_area.x + gutter_w as u16 + cursor.column as u16;
1154        let anchor_y = body_area.y + (cursor.line - scroll) as u16;
1155
1156        let widget = HoverWidget {
1157            text: &hover.text,
1158            anchor_x,
1159            anchor_y,
1160            terminal_area: frame.area(),
1161        };
1162        frame.render_widget(widget, frame.area());
1163    }
1164
1165    // -----------------------------------------------------------------------
1166    // Mouse helpers
1167    // -----------------------------------------------------------------------
1168
1169    /// Map screen coordinates `(col, row)` to a buffer `Cursor`.
1170    ///
1171    /// Returns `None` when the click is outside the body area.  Clicks inside
1172    /// the gutter are mapped to column 0 of the logical line.
1173    pub(crate) fn screen_to_cursor(&self, col: u16, row: u16) -> Option<Position> {
1174        let area = self.last_body_area;
1175        if area.width == 0 || area.height == 0 {
1176            return None;
1177        }
1178        if row < area.y || row >= area.y + area.height {
1179            return None;
1180        }
1181        if col < area.x {
1182            return None;
1183        }
1184
1185        let visual_row = (row - area.y) as usize;
1186
1187        // Resolve visual_row → logical line index (accounting for folds).
1188        // EditorWidget iterates visible_lines, skips those with logical < scroll,
1189        // then takes up to height — so visual_row maps to visible[visual_row] after
1190        // filtering out pre-scroll lines.
1191        let visible: Vec<usize> = self
1192            .folds
1193            .visible_lines(&self.buffer.lines())
1194            .into_iter()
1195            .filter(|(logical, _)| *logical >= self.buffer.scroll)
1196            .map(|(logical, _)| logical)
1197            .collect();
1198        let logical_row = *visible.get(visual_row)?;
1199
1200        let lines = self.buffer.lines();
1201        let line = lines.get(logical_row)?;
1202
1203        let gw = gutter_width(self.show_line_numbers, self.buffer.line_count());
1204        let content_x = area.x + gw as u16;
1205
1206        if col < content_x {
1207            // Click in the gutter → jump to start of line.
1208            return Some(Position::new(logical_row, 0));
1209        }
1210
1211        // visual_col is a character-column offset within the line (no wide-char
1212        // handling; source code is almost always ASCII).
1213        let visual_col = (col - content_x) as usize + self.buffer.scroll_x;
1214
1215        // Position.column uses character indices, not byte offsets.
1216        // Clamp to the actual line length in characters.
1217        let char_col = visual_col.min(line.chars().count());
1218
1219        Some(Position::new(logical_row, char_col))
1220    }
1221
1222    /// Derive the completion filter by extracting buffer text from trigger_cursor
1223    /// to the current cursor on the same line.
1224    fn completion_filter(&self, comp: &CompletionState) -> String {
1225        let cur = self.buffer.cursor();
1226        let trig = comp.trigger_cursor;
1227        if cur.line != trig.line || cur.column <= trig.column {
1228            return String::new();
1229        }
1230        // Get the current line text and slice from trigger col to current col.
1231        if let Some(line) = self.buffer.lines().get(cur.line) {
1232            let bytes = line.as_bytes();
1233            let start = trig.column.min(bytes.len());
1234            let end = cur.column.min(bytes.len());
1235            if start <= end {
1236                return String::from_utf8_lossy(&bytes[start..end]).into_owned();
1237            }
1238        }
1239        String::new()
1240    }
1241}
1242
1243// ---------------------------------------------------------------------------
1244// View trait impl
1245// ---------------------------------------------------------------------------
1246
1247impl View for EditorView {
1248    const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;
1249
1250    fn save_state(&mut self, app: &mut crate::app_state::AppState) {
1251        crate::views::save_state::editor_pre_save(self, app);
1252    }
1253    /// Translate key input into operations — no mutation of self.
1254    fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
1255        let path = self.buffer.path.clone();
1256
1257        // If clipboard history picker is open, capture navigation keys.
1258        if self.clipboard_picker.is_some() {
1259            return match key.key {
1260                Key::ArrowUp => vec![Operation::ClipboardLocal(ClipOp::HistoryUp)],
1261                Key::ArrowDown => vec![Operation::ClipboardLocal(ClipOp::HistoryDown)],
1262                Key::Enter => vec![Operation::ClipboardLocal(ClipOp::HistoryConfirm)],
1263                _ => vec![],
1264            };
1265        }
1266
1267        // If go-to-line bar is open, capture all keypresses for it.
1268        if self.go_to_line.is_some() {
1269            return match key.key {
1270                Key::Enter => vec![Operation::GoToLineLocal(GoToLineOp::Confirm)],
1271                _ => {
1272                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
1273                        vec![Operation::GoToLineLocal(GoToLineOp::Input(field_op))]
1274                    } else {
1275                        vec![]
1276                    }
1277                }
1278            };
1279        }
1280
1281        // If the LSP rename prompt is open, capture all keypresses for it.
1282        if self.rename_prompt.is_some() {
1283            return match key.key {
1284                Key::Enter => {
1285                    let new_name = self.rename_prompt.as_ref()
1286                        .map(|r| r.input.text().trim().to_string())
1287                        .unwrap_or_default();
1288                    let (row, col) = self.rename_prompt.as_ref()
1289                        .map(|r| (r.cursor_row, r.cursor_col))
1290                        .unwrap_or((0, 0));
1291                    // Close the prompt and fire the rename request.
1292                    vec![
1293                        Operation::LspRenameLocal(LspRenameOp::Dismiss),
1294                        Operation::LspTriggerRenameWith { new_name, row, col },
1295                    ]
1296                }
1297                _ => {
1298                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
1299                        vec![Operation::LspRenameLocal(LspRenameOp::Input(field_op))]
1300                    } else {
1301                        vec![]
1302                    }
1303                }
1304            };
1305        }
1306
1307        // If search bar is open, capture all keypresses for it.
1308        if let Some(search) = &self.search {
1309            let on_replacement =
1310                search.kind == SearchKind::Replace && search.focus.current() == SEARCH_FOCUS_REPLACEMENT;
1311            let on_include =
1312                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_INCLUDE;
1313            let on_exclude =
1314                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_EXCLUDE;
1315            let on_tree =
1316                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_TREE;
1317            // Legacy panel focus flags — kept for graceful transition.
1318            let on_files =
1319                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_FILES;
1320            let on_matches =
1321                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_MATCHES;
1322            let on_editor = search.focus.current() == SEARCH_FOCUS_EDITOR;
1323            let on_panel = on_tree || on_files || on_matches;
1324
1325            // When editor has focus, intercept only Ctrl+F (leave editor / focus query).
1326            // All other keys should fall through to the normal editor handler so the user can type into the embedded editor.
1327            if on_editor {
1328                if key.modifiers.contains(Modifiers::CTRL) && key.key == Key::Char('f') {
1329                    // Use FocusSwitch to wrap-around to the query input (editor is last in ring).
1330                    return vec![Operation::SearchLocal(SearchOp::FocusSwitch)];
1331                }
1332                // Do NOT return here — allow other keys to be handled by the regular
1333                // editor key handler below.
1334            } else {
1335                // Not editor-focused: handle search keys as before.
1336                return match (key.modifiers, key.key) {
1337                    (_, Key::F(3)) if !key.modifiers.contains(Modifiers::SHIFT) => {
1338                        vec![Operation::SearchLocal(SearchOp::NextMatch)]
1339                    }
1340                    (m, Key::F(3)) if m.contains(Modifiers::SHIFT) => {
1341                        vec![Operation::SearchLocal(SearchOp::PrevMatch)]
1342                    }
1343                    // Tab / Shift+Tab cycle focus in Replace and Expanded modes
1344                    (_, Key::Tab) if search.kind == SearchKind::Replace || search.mode == SearchMode::Expanded => {
1345                        vec![Operation::Focus(FocusOp::Next)]
1346                    }
1347                    (_, Key::BackTab) if search.kind == SearchKind::Replace || search.mode == SearchMode::Expanded => {
1348                        vec![Operation::Focus(FocusOp::Prev)]
1349                    }
1350                    // Up/Down navigate the search tree when it has focus
1351                    (_, Key::ArrowUp) if on_tree => {
1352                        vec![Operation::SearchLocal(SearchOp::TreeMoveUp)]
1353                    }
1354                    (_, Key::ArrowDown) if on_tree => {
1355                        vec![Operation::SearchLocal(SearchOp::TreeMoveDown)]
1356                    }
1357                    // Right / Enter expand or activate in tree
1358                    (_, Key::ArrowRight) if on_tree => {
1359                        vec![Operation::SearchLocal(SearchOp::TreeToggle)]
1360                    }
1361                    // Left collapses or moves to parent in tree
1362                    (_, Key::ArrowLeft) if on_tree => {
1363                        vec![Operation::SearchLocal(SearchOp::TreeCollapse)]
1364                    }
1365                    // Legacy file/match panel navigation (kept for compat)
1366                    (_, Key::ArrowUp) if on_files => {
1367                        vec![Operation::SearchLocal(SearchOp::SelectFile(
1368                            search.selected_file.saturating_sub(1),
1369                        ))]
1370                    }
1371                    (_, Key::ArrowDown) if on_files => {
1372                        vec![Operation::SearchLocal(SearchOp::SelectFile(
1373                            search.selected_file + 1,
1374                        ))]
1375                    }
1376                    (_, Key::ArrowUp) if on_matches => {
1377                        vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(-1))]
1378                    }
1379                    (_, Key::ArrowDown) if on_matches => {
1380                        vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(1))]
1381                    }
1382                    (_, Key::Enter) => {
1383                        if on_replacement {
1384                            vec![Operation::SearchLocal(SearchOp::ReplaceOne)]
1385                        } else if on_include || on_exclude {
1386                            vec![Operation::Focus(FocusOp::Next)]
1387                        } else if on_tree {
1388                            vec![Operation::SearchLocal(SearchOp::TreeToggle)]
1389                        } else if on_panel {
1390                            vec![]
1391                        } else {
1392                            vec![
1393                                Operation::SearchLocal(SearchOp::NextMatch),
1394                                Operation::SearchLocal(SearchOp::Close),
1395                            ]
1396                        }
1397                    }
1398                    (m, Key::Char('c')) if m.contains(Modifiers::ALT) => {
1399                        vec![Operation::SearchLocal(SearchOp::ToggleIgnoreCase)]
1400                    }
1401                    (m, Key::Char('r')) if m.contains(Modifiers::ALT) => {
1402                        vec![Operation::SearchLocal(SearchOp::ToggleRegex)]
1403                    }
1404                    (m, Key::Char('s')) if m.contains(Modifiers::ALT) => {
1405                        vec![Operation::SearchLocal(SearchOp::ToggleSmartCase)]
1406                    }
1407                    (m, Key::Char('a')) if m.contains(Modifiers::ALT) && on_replacement => {
1408                        vec![Operation::SearchLocal(SearchOp::ReplaceAll)]
1409                    }
1410                    _ => {
1411                        // Only route to text fields; ignore when a panel/tree has focus
1412                        if !on_panel
1413                            && let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
1414                                let op = if on_replacement {
1415                                    SearchOp::ReplacementInput(field_op)
1416                                } else if on_include {
1417                                    SearchOp::IncludeGlobInput(field_op)
1418                                } else if on_exclude {
1419                                    SearchOp::ExcludeGlobInput(field_op)
1420                                } else {
1421                                    SearchOp::QueryInput(field_op)
1422                                };
1423                                return vec![Operation::SearchLocal(op)];
1424                            }
1425                        vec![]
1426                    }
1427                };
1428            }
1429        }
1430
1431        // If completion dropdown is open, intercept navigation keys.
1432        if self.completion.is_some() {
1433            log::debug!("editor: completion is open, checking key: {:?}", key.key);
1434            match key.key {
1435                Key::ArrowUp => return vec![Operation::LspLocal(LspOp::CompletionMoveUp)],
1436                Key::ArrowDown => return vec![Operation::LspLocal(LspOp::CompletionMoveDown)],
1437                Key::Enter => return vec![Operation::LspLocal(LspOp::CompletionConfirm)],
1438                _ => {} // Fall through to normal key handling
1439            }
1440        }
1441
1442        // If hover box is open, motion keys dismiss it.
1443        if self.hover.is_some() {
1444            match key.key {
1445                Key::ArrowUp
1446                | Key::ArrowDown
1447                | Key::ArrowLeft
1448                | Key::ArrowRight => {
1449                    return vec![Operation::LspLocal(LspOp::HoverDismiss)];
1450                }
1451                _ => {}
1452            }
1453        }
1454
1455        match (key.modifiers, key.key) {
1456            // Ctrl+Shift+arrow: extend selection by word.
1457            (m, Key::ArrowLeft)
1458                if m.contains(Modifiers::CTRL) && m.contains(Modifiers::SHIFT) =>
1459            {
1460                vec![Operation::SelectionLocal(SelectionOp::Extend {
1461                    head: self.buffer.word_boundary_prev(self.buffer.cursor()),
1462                })]
1463            }
1464            (m, Key::ArrowRight)
1465                if m.contains(Modifiers::CTRL) && m.contains(Modifiers::SHIFT) =>
1466            {
1467                vec![Operation::SelectionLocal(SelectionOp::Extend {
1468                    head: self.buffer.word_boundary_next(self.buffer.cursor()),
1469                })]
1470            }
1471
1472            // Shift+arrow: extend active selection.
1473            (m, Key::ArrowLeft) if m.contains(Modifiers::SHIFT) => {
1474                vec![Operation::SelectionLocal(SelectionOp::Extend {
1475                    head: self.buffer.offset_left(self.buffer.cursor()),
1476                })]
1477            }
1478            (m, Key::ArrowRight) if m.contains(Modifiers::SHIFT) => {
1479                vec![Operation::SelectionLocal(SelectionOp::Extend {
1480                    head: self.buffer.offset_right(self.buffer.cursor()),
1481                })]
1482            }
1483            (m, Key::ArrowUp) if m.contains(Modifiers::SHIFT) => {
1484                vec![Operation::SelectionLocal(SelectionOp::Extend {
1485                    head: self.buffer.offset_up(self.buffer.cursor()),
1486                })]
1487            }
1488            (m, Key::ArrowDown) if m.contains(Modifiers::SHIFT) => {
1489                vec![Operation::SelectionLocal(SelectionOp::Extend {
1490                    head: self.buffer.offset_down(self.buffer.cursor()),
1491                })]
1492            }
1493            (m, Key::Home) if m.contains(Modifiers::SHIFT) => {
1494                vec![Operation::SelectionLocal(SelectionOp::Extend {
1495                    head: self.buffer.offset_line_start(self.buffer.cursor()),
1496                })]
1497            }
1498            (m, Key::End) if m.contains(Modifiers::SHIFT) => {
1499                vec![Operation::SelectionLocal(SelectionOp::Extend {
1500                    head: self.buffer.offset_line_end(self.buffer.cursor()),
1501                })]
1502            }
1503
1504            (_, Key::ArrowUp) => vec![Operation::MoveCursor {
1505                path,
1506                cursor: self.buffer.offset_up(self.buffer.cursor()),
1507            }],
1508            (_, Key::ArrowDown) => vec![Operation::MoveCursor {
1509                path,
1510                cursor: self.buffer.offset_down(self.buffer.cursor()),
1511            }],
1512            (_, Key::ArrowLeft) => vec![Operation::MoveCursor {
1513                path,
1514                cursor: self.buffer.offset_left(self.buffer.cursor()),
1515            }],
1516            (_, Key::ArrowRight) => vec![Operation::MoveCursor {
1517                path,
1518                cursor: self.buffer.offset_right(self.buffer.cursor()),
1519            }],
1520            (_, Key::Home) => vec![Operation::MoveCursor {
1521                path,
1522                cursor: self.buffer.offset_line_start(self.buffer.cursor()),
1523            }],
1524            (_, Key::End) => vec![Operation::MoveCursor {
1525                path,
1526                cursor: self.buffer.offset_line_end(self.buffer.cursor()),
1527            }],
1528            (_, Key::PageUp) => vec![Operation::MoveCursor {
1529                path,
1530                cursor: self.buffer.offset_up_n(self.buffer.cursor(), 20),
1531            }],
1532            (_, Key::PageDown) => vec![Operation::MoveCursor {
1533                path,
1534                cursor: self.buffer.offset_down_n(self.buffer.cursor(), 20),
1535            }],
1536
1537            (_, Key::Enter) => vec![Operation::InsertText {
1538                path,
1539                cursor: self.buffer.cursor(),
1540                text: "\n".into(),
1541            }],
1542            (_, Key::Backspace) => vec![Operation::DeleteText {
1543                path,
1544                cursor: self.buffer.cursor(),
1545                len: 1,
1546            }],
1547            (m, Key::Tab) if !m.contains(Modifiers::CTRL) => {
1548                let col = self.buffer.cursor().column;
1549                let spaces = self.indentation_width - (col % self.indentation_width);
1550                vec![Operation::InsertText {
1551                    path,
1552                    cursor: self.buffer.cursor(),
1553                    text: " ".repeat(spaces),
1554                }]
1555            }
1556            (m, Key::BackTab) if !m.contains(Modifiers::CTRL) => {
1557                vec![Operation::UnindentLines { path }]
1558            }
1559            (_, Key::Delete) => vec![Operation::DeleteForward {
1560                path,
1561                cursor: self.buffer.cursor(),
1562            }],
1563
1564            (_, Key::Char(c))
1565                if !key.modifiers.contains(Modifiers::CTRL)
1566                    || key.modifiers.contains(Modifiers::ALT) =>
1567            {
1568                let mut ops = vec![Operation::InsertText {
1569                    path,
1570                    cursor: self.buffer.cursor(),
1571                    text: c.to_string(),
1572                }];
1573
1574                // Check if this character is a trigger character that should auto-trigger completion
1575                // Note: passing empty slice for server_triggers as AppState isn't accessible here
1576                // Server triggers are used via LSP when Ctrl+Space is pressed
1577                if is_trigger_character(&c, self.lang_id.as_ref().map(|l| l.as_str()), &[]) {
1578                    log::debug!("editor: trigger character typed, triggering completion, lang_id={:?}", self.lang_id);
1579                    ops.push(Operation::LspTriggerCompletion {
1580                        path: self.buffer.path.clone(),
1581                        trigger_char: Some(c.to_string()),
1582                    });
1583                }
1584
1585                ops
1586            }
1587
1588            _ => vec![],
1589        }
1590    }
1591
1592    fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
1593        // ── Expanded mode: search tree + editor mouse handling ──────────────
1594        if let Some(search) = &self.search
1595            && search.mode == SearchMode::Expanded {
1596                // Search tree click / scroll
1597                if self.last_file_panel_area.width > 0
1598                    && hit_test((mouse.column, mouse.row), self.last_file_panel_area)
1599                {
1600                    let panel = self.last_file_panel_area;
1601                    match mouse.kind {
1602                        MouseEventKind::Down(MouseButton::Left) => {
1603                            // header row is y+0
1604                            if mouse.row > panel.y {
1605                                let rows = build_search_tree(search);
1606                                let flat_idx = search.tree_scroll
1607                                    + (mouse.row - panel.y - 1) as usize;
1608                                if flat_idx < rows.len() {
1609                                    // First move the cursor to this row, then toggle.
1610                                    let mut ops = Vec::new();
1611                                    // We emit MoveUp/Down to reach the target, or we
1612                                    // can just emit two ops: set cursor + toggle.
1613                                    // For simplicity, set cursor directly by identity:
1614                                    match &rows[flat_idx] {
1615                                        SearchTreeRow::File { file_idx: _, .. } => {
1616                                            // Move cursor there + toggle
1617                                            ops.push(Operation::SearchLocal(SearchOp::TreeMoveUp)); // placeholder
1618                                            ops.push(Operation::SearchLocal(SearchOp::TreeToggle));
1619                                        }
1620                                        SearchTreeRow::Match { .. } => {
1621                                            ops.push(Operation::SearchLocal(SearchOp::TreeToggle));
1622                                        }
1623                                    }
1624                                    // Actually, just return a selectfile-like op for now.
1625                                    // We need a more precise approach: store the clicked
1626                                    // index and handle in op.
1627                                    // For mouse, use SelectFile to set selection by flat idx.
1628                                    // (SelectFile is repurposed for tree click).
1629                                    return vec![Operation::SearchLocal(SearchOp::SelectFile(flat_idx))];
1630                                }
1631                            }
1632                            return vec![];
1633                        }
1634                        MouseEventKind::ScrollUp => {
1635                            return vec![Operation::SearchLocal(SearchOp::TreeMoveUp)];
1636                        }
1637                        MouseEventKind::ScrollDown => {
1638                            return vec![Operation::SearchLocal(SearchOp::TreeMoveDown)];
1639                        }
1640                        _ => return vec![],
1641                    }
1642                }
1643            }
1644
1645        // Handle search bar toggle buttons if search is active
1646        if self.search.is_some()
1647            && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
1648                let search_area = self.last_search_bar_area;
1649                if search_area.width > 0 && hit_test((mouse.column, mouse.row), search_area) {
1650                    // Search bar buttons are at the right side: " IgnCase  Regex  Smart "
1651                    // Layout: space(1) + " IgnCase "(9) + space(1) + " Regex "(7) + space(1) + " Smart "(7) + space(1) = 27
1652                    const BTN_W: u16 = 27;
1653                    let buttons_start = search_area.x + search_area.width.saturating_sub(BTN_W);
1654                    let col = mouse.column;
1655
1656                    if col >= buttons_start {
1657                        // Button positions (accounting for leading space before each)
1658                        // " IgnCase " starts at +1, " Regex " at +11, " Smart " at +19
1659                        let igncase_start = buttons_start + 1;
1660                        let regex_start = buttons_start + 11;
1661                        let smart_start = buttons_start + 19;
1662
1663                        if col >= igncase_start && col < igncase_start + 9 {
1664                            return vec![Operation::SearchLocal(SearchOp::ToggleIgnoreCase)];
1665                        }
1666                        if col >= regex_start && col < regex_start + 7 {
1667                            return vec![Operation::SearchLocal(SearchOp::ToggleRegex)];
1668                        }
1669                        if col >= smart_start && col < smart_start + 7 {
1670                            return vec![Operation::SearchLocal(SearchOp::ToggleSmartCase)];
1671                        }
1672                    }
1673                }
1674            }
1675
1676        match mouse.kind {
1677            // ── Left button down: single click or double click ───────────────
1678            MouseEventKind::Down(MouseButton::Left) => {
1679                let Some(click_pos) = self.screen_to_cursor(mouse.column, mouse.row) else {
1680                    return vec![];
1681                };
1682
1683                let mut state = self.click_state.borrow_mut();
1684                let now = std::time::Instant::now();
1685
1686                // Double-click: same screen cell within threshold.
1687                let is_double = state.count >= 1
1688                    && state.last_col == mouse.column
1689                    && state.last_row == mouse.row
1690                    && state
1691                        .last_time
1692                        .map(|t| now.duration_since(t).as_millis() < DOUBLE_CLICK_MS)
1693                        .unwrap_or(false);
1694
1695                state.last_col = mouse.column;
1696                state.last_row = mouse.row;
1697                state.last_time = Some(now);
1698                state.count = if is_double { 2 } else { 1 };
1699                state.dragging = true;
1700                state.word_drag = is_double;
1701                drop(state);
1702
1703                if is_double {
1704                    let (word_start_col, word_end_col) = self.buffer.word_range_at(click_pos);
1705                    let word_start = Position::new(click_pos.line, word_start_col);
1706                    let word_end = Position::new(click_pos.line, word_end_col);
1707                    vec![
1708                        Operation::MoveCursor { path: None, cursor: word_start },
1709                        Operation::SelectionLocal(SelectionOp::Extend { head: word_end }),
1710                    ]
1711                } else {
1712                    // Single click: place cursor, clear selection.
1713                    vec![
1714                        Operation::MoveCursor { path: None, cursor: click_pos },
1715                        Operation::SelectionLocal(SelectionOp::Clear),
1716                    ]
1717                }
1718            }
1719
1720            // ── Drag: extend selection from anchor ───────────────────────────
1721            MouseEventKind::Drag(MouseButton::Left) => {
1722                let state = self.click_state.borrow();
1723                if !state.dragging {
1724                    return vec![];
1725                }
1726                drop(state);
1727
1728                let Some(drag_pos) = self.screen_to_cursor(mouse.column, mouse.row) else {
1729                    return vec![];
1730                };
1731                // Extend uses self.buffer.selection.anchor (set on Down) or cursor.
1732                vec![Operation::SelectionLocal(SelectionOp::Extend { head: drag_pos })]
1733            }
1734
1735            // ── Left button up: end drag ─────────────────────────────────────
1736            MouseEventKind::Up(MouseButton::Left) => {
1737                self.click_state.borrow_mut().dragging = false;
1738                vec![]
1739            }
1740
1741            // ── Scroll ───────────────────────────────────────────────────────
1742            MouseEventKind::ScrollUp => vec![Operation::MoveCursor {
1743                path: None,
1744                cursor: self.buffer.offset_up_n(self.buffer.cursor(), 3),
1745            }],
1746            MouseEventKind::ScrollDown => vec![Operation::MoveCursor {
1747                path: None,
1748                cursor: self.buffer.offset_down_n(self.buffer.cursor(), 3),
1749            }],
1750            MouseEventKind::Down(MouseButton::Right) => {
1751                let has_selection = self.buffer.selection().is_some();
1752                vec![Operation::OpenContextMenu {
1753                    items: vec![
1754                        ("Cut".to_string(),   crate::commands::CommandId::new_static("editor", "cut"), Some(has_selection)),
1755                        ("Copy".to_string(),  crate::commands::CommandId::new_static("editor", "copy"), Some(has_selection)),
1756                        ("Paste".to_string(), crate::commands::CommandId::new_static("editor", "paste"), None),
1757                        ("Find".to_string(),  crate::commands::CommandId::new_static("editor", "find"), Some(true)),
1758                        ("Replace".to_string(), crate::commands::CommandId::new_static("editor", "find_replace"), Some(true)),
1759                        ("Go to Line".to_string(), crate::commands::CommandId::new_static("editor", "go_to_line"), Some(true)),
1760                        ("Select All".to_string(), crate::commands::CommandId::new_static("editor", "select_all"), Some(true)),
1761                        ("Comment/Uncomment".to_string(), crate::commands::CommandId::new_static("editor", "toggle_comment"), Some(true)),
1762                        ("Format Document".to_string(), crate::commands::CommandId::new_static("editor", "format_document"), Some(true)),
1763                    ],
1764                    x: mouse.column,
1765                    y: mouse.row,
1766                }]
1767            }
1768            _ => vec![],
1769        }
1770    }
1771
1772    /// Apply operations this view owns.  Ignores ops that belong elsewhere.
1773    fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
1774        match op {
1775            Operation::InsertText {
1776                path,
1777                cursor: _,
1778                text,
1779            } if self.path_matches(path) => {
1780                let edit_line = self.buffer.cursor().line;
1781                if text == "\n" {
1782                    self.buffer.insert_newline_with_indent(self.use_space, self.indentation_width);
1783                } else {
1784                    self.buffer.insert(text);
1785                }
1786                self.completion = None;
1787                self.lsp_version = self.lsp_version.wrapping_add(1);
1788                self.invalidate_highlights_from(edit_line);
1789                self.recompute_search_matches();
1790                Some(Event::applied("editor", op.clone()))
1791            }
1792
1793            Operation::DeleteText { path, .. } if self.path_matches(path) => {
1794                // Backspace at column 0 merges two lines — invalidate from previous line.
1795                let edit_line = self.buffer.cursor().line.saturating_sub(
1796                    if self.buffer.cursor().column == 0 { 1 } else { 0 }
1797                );
1798                self.buffer.delete_backward();
1799                self.completion = None;
1800                self.lsp_version = self.lsp_version.wrapping_add(1);
1801                self.invalidate_highlights_from(edit_line);
1802                self.recompute_search_matches();
1803                Some(Event::applied("editor", op.clone()))
1804            }
1805
1806            Operation::DeleteForward { path, .. } if self.path_matches(path) => {
1807                let edit_line = self.buffer.cursor().line;
1808                self.buffer.delete_forward();
1809                self.completion = None;
1810                self.lsp_version = self.lsp_version.wrapping_add(1);
1811                self.invalidate_highlights_from(edit_line);
1812                self.recompute_search_matches();
1813                Some(Event::applied("editor", op.clone()))
1814            }
1815
1816            Operation::DeleteWordBackward { path } if self.path_matches(path) => {
1817                // Word-backward delete may cross a line boundary.
1818                let edit_line = self.buffer.cursor().line.saturating_sub(1);
1819                self.buffer.delete_word_backward();
1820                self.completion = None;
1821                self.lsp_version = self.lsp_version.wrapping_add(1);
1822                self.invalidate_highlights_from(edit_line);
1823                self.recompute_search_matches();
1824                Some(Event::applied("editor", op.clone()))
1825            }
1826
1827            Operation::DeleteWordForward { path } if self.path_matches(path) => {
1828                let edit_line = self.buffer.cursor().line;
1829                self.buffer.delete_word_forward();
1830                self.completion = None;
1831                self.lsp_version = self.lsp_version.wrapping_add(1);
1832                self.invalidate_highlights_from(edit_line);
1833                self.recompute_search_matches();
1834                Some(Event::applied("editor", op.clone()))
1835            }
1836
1837            Operation::DeleteLine { path } if self.path_matches(path) => {
1838                let edit_line = self.buffer.cursor().line;
1839                self.buffer.delete_line();
1840                self.completion = None;
1841                self.lsp_version = self.lsp_version.wrapping_add(1);
1842                self.invalidate_highlights_from(edit_line);
1843                self.recompute_search_matches();
1844                Some(Event::applied("editor", op.clone()))
1845            }
1846
1847            Operation::IndentLines { path } if self.path_matches(path) => {
1848    log::info!("indent");
1849                let (start_line, end_line) = if let Some(sel) = self.buffer.selection() {
1850                    let min = sel.min();
1851                    let max = sel.max();
1852                    (min.line, max.line)
1853                } else {
1854                    let line = self.buffer.cursor().line;
1855                    (line, line)
1856                };
1857
1858                for line_idx in start_line..=end_line {
1859                    if let Some(line_text) = self.buffer.line(line_idx) {
1860                        let leading = line_text.len() - line_text.trim_start().len();
1861                        let target = if leading % self.indentation_width == 0 {
1862                            leading + self.indentation_width
1863                        } else if (leading + 1) % self.indentation_width == 0 {
1864                            leading.div_ceil(self.indentation_width) * self.indentation_width + self.indentation_width
1865                        } else {
1866                            leading.div_ceil(self.indentation_width) * self.indentation_width
1867                        };
1868                        let spaces_to_add = target - leading;
1869                        let to_insert = if self.use_space {
1870                            " ".repeat(spaces_to_add)
1871                        } else {
1872                            "\t".to_string()
1873                        };
1874                        self.buffer.replace_range(
1875                            Position::new(line_idx, leading),
1876                            Position::new(line_idx, leading),
1877                            &to_insert,
1878                        );
1879                    }
1880                }
1881
1882                self.invalidate_highlights_from(start_line);
1883                self.recompute_search_matches();
1884                Some(Event::applied("editor", op.clone()))
1885            }
1886
1887            Operation::UnindentLines { path } if self.path_matches(path) => {
1888    log::info!("unindent");
1889
1890                    let (start_line, end_line) = if let Some(sel) = self.buffer.selection() {
1891                    let min = sel.min();
1892                    let max = sel.max();
1893                    (min.line, max.line)
1894                } else {
1895                    let line = self.buffer.cursor().line;
1896                    (line, line)
1897                };
1898
1899                for line_idx in start_line..=end_line {
1900                    if let Some(line_text) = self.buffer.line(line_idx) {
1901                        let leading = line_text.len() - line_text.trim_start().len();
1902                        let spaces_to_remove = self.indentation_width.min(leading);
1903                        if spaces_to_remove > 0 {
1904                            self.buffer.replace_range(
1905                                Position::new(line_idx, 0),
1906                                Position::new(line_idx, spaces_to_remove),
1907                                "",
1908                            );
1909                        }
1910                    }
1911                }
1912
1913                self.invalidate_highlights_from(start_line);
1914                self.recompute_search_matches();
1915                Some(Event::applied("editor", op.clone()))
1916            }
1917
1918            Operation::ReplaceRange {
1919                path,
1920                start,
1921                end,
1922                text,
1923            } if self.path_matches(path) => {
1924                // Validate: dismiss completion if cursor moved significantly
1925                // The issue: if user moved cursor after triggering completion,
1926                // the replace range might be invalid (end < start)
1927                let current_cursor = self.buffer.cursor();
1928                if *start > *end || *start > current_cursor {
1929                    // Cursor moved: dismiss completion without applying
1930                    self.completion = None;
1931                    return Some(Event::applied("editor", op.clone()));
1932                }
1933                self.buffer.replace_range(*start, *end, text);
1934                self.completion = None;
1935                self.lsp_version = self.lsp_version.wrapping_add(1);
1936                self.invalidate_highlights_from(start.line);
1937                self.recompute_search_matches();
1938                Some(Event::applied("editor", op.clone()))
1939            }
1940
1941            Operation::MoveCursor { path, cursor } if self.path_matches(path) => {
1942                // Dismiss completion when cursor moves (navigation keys, clicks)
1943                // This prevents the crash from invalid ReplaceRange after cursor moves
1944                self.completion = None;
1945                self.buffer.set_cursor(*cursor);
1946                Some(Event::applied("editor", op.clone()))
1947            }
1948
1949            Operation::Undo { path } if self.path_matches(path) => {
1950                self.buffer.undo();
1951                self.completion = None;
1952                self.invalidate_all_highlights();
1953                Some(Event::applied("editor", op.clone()))
1954            }
1955
1956            Operation::Redo { path } if self.path_matches(path) => {
1957                self.buffer.redo();
1958                self.completion = None;
1959                self.invalidate_all_highlights();
1960                Some(Event::applied("editor", op.clone()))
1961            }
1962
1963            Operation::ToggleFold { path, line } if self.path_matches(path) => {
1964                self.folds.toggle(*line, &self.buffer.lines());
1965                Some(Event::applied("editor", op.clone()))
1966            }
1967
1968            Operation::ToggleMarker { path, line } if self.path_matches(path) => {
1969                let row = *line;
1970                if let Some(pos) = self.buffer.markers.iter().position(|m| m.line == row) {
1971                    self.buffer.markers.remove(pos);
1972                } else {
1973                    self.buffer.markers.push(crate::editor::buffer::Marker {
1974                        line: row,
1975                        label: "●".into(),
1976                    });
1977                }
1978                Some(Event::applied("editor", op.clone()))
1979            }
1980
1981            Operation::ToggleWordWrap => {
1982                self.word_wrap = !self.word_wrap;
1983                self.buffer.scroll_x = 0;
1984                Some(Event::applied("editor", op.clone()))
1985            }
1986
1987            // --- LSP local ops ---
1988            Operation::LspLocal(lsp_op) => {
1989                match lsp_op {
1990                    LspOp::CompletionLoading { trigger } => {
1991                        // Show loading state while waiting for LSP response
1992                        // Validate: only show loading if cursor hasn't moved from trigger point
1993                        if self.buffer.cursor() != *trigger {
1994                            return None;
1995                        }
1996                        self.completion = Some(CompletionState {
1997                            items: vec![],
1998                            cursor: 0,
1999                            trigger_cursor: *trigger,
2000                            loading: true,
2001                        });
2002                        Some(Event::applied("editor", op.clone()))
2003                    }
2004
2005                    LspOp::CompletionResponse { items, trigger, version } => {
2006                        if let Some(v) = version
2007                            && *v != self.lsp_version {
2008                                return None;
2009                            }
2010                        let incoming_trigger = trigger.unwrap_or_else(|| self.buffer.cursor());
2011
2012                        // Validate: only show completion if cursor hasn't moved from trigger point
2013                        // If cursor moved (e.g., user typed more or moved with arrow keys),
2014                        // silently dismiss any existing completion
2015                        if let Some(ref existing) = self.completion
2016                            && self.buffer.cursor() != existing.trigger_cursor {
2017                                // Cursor moved - don't show completion
2018                                return None;
2019                            }
2020
2021                        self.completion = Some(CompletionState {
2022                            items: items.clone(),
2023                            cursor: 0,
2024                            trigger_cursor: incoming_trigger,
2025                            loading: false,
2026                        });
2027                        Some(Event::applied("editor", op.clone()))
2028                    }
2029
2030                    LspOp::HoverResponse(Some(text)) => {
2031                        self.hover = Some(HoverState { text: text.clone() });
2032                        Some(Event::applied("editor", op.clone()))
2033                    }
2034
2035                    LspOp::HoverResponse(None) => {
2036                        self.hover = None;
2037                        Some(Event::applied("editor", op.clone()))
2038                    }
2039
2040                    LspOp::CompletionMoveUp => {
2041                        // Compute visible count first (immutable borrow), then mutate.
2042                        let visible_count = if let Some(c) = &self.completion {
2043                            let f = self.completion_filter(c);
2044                            c.items
2045                                .iter()
2046                                .filter(|item| {
2047                                    f.is_empty()
2048                                        || item.label.to_lowercase().contains(&f.to_lowercase())
2049                                })
2050                                .count()
2051                        } else {
2052                            0
2053                        };
2054                        if let Some(c) = &mut self.completion {
2055                            // Clamp first in case filter shrunk the list.
2056                            if visible_count > 0 {
2057                                c.cursor = c.cursor.min(visible_count - 1);
2058                            }
2059                            // Wrap-around: going up from 0 wraps to bottom.
2060                            c.cursor = if c.cursor == 0 {
2061                                visible_count.saturating_sub(1)
2062                            } else {
2063                                c.cursor - 1
2064                            };
2065                        }
2066                        Some(Event::applied("editor", op.clone()))
2067                    }
2068
2069                    LspOp::CompletionMoveDown => {
2070                        // Compute filter and visible count before mutably borrowing.
2071                        let visible_count = if let Some(c) = &self.completion {
2072                            let f = self.completion_filter(c);
2073                            c.items
2074                                .iter()
2075                                .filter(|item| {
2076                                    f.is_empty()
2077                                        || item.label.to_lowercase().contains(&f.to_lowercase())
2078                                })
2079                                .count()
2080                        } else {
2081                            0
2082                        };
2083                        if let Some(c) = &mut self.completion {
2084                            // Clamp first in case filter shrunk the list.
2085                            if visible_count > 0 {
2086                                c.cursor = c.cursor.min(visible_count - 1);
2087                            }
2088                            // Wrap-around: going down from last wraps to top.
2089                            c.cursor = if visible_count == 0 || c.cursor + 1 >= visible_count {
2090                                0
2091                            } else {
2092                                c.cursor + 1
2093                            };
2094                        }
2095                        Some(Event::applied("editor", op.clone()))
2096                    }
2097
2098                    LspOp::CompletionConfirm => {
2099                        let confirm = self.completion.as_ref().and_then(|c| {
2100                            let filter = self.completion_filter(c);
2101                            let filter_lower = filter.to_lowercase();
2102                            c.items
2103                                .iter()
2104                                .filter(|item| {
2105                                    filter.is_empty()
2106                                        || item.label.to_lowercase().contains(&filter_lower)
2107                                })
2108                                .nth(c.cursor)
2109                                .map(|item| {
2110                                    let text = item
2111                                        .insert_text
2112                                        .clone()
2113                                        .unwrap_or_else(|| item.label.clone());
2114                                    (c.trigger_cursor, text)
2115                                })
2116                        });
2117                        self.completion = None;
2118                        if let Some((trigger, text)) = confirm {
2119                            let end_cursor = self.buffer.cursor();
2120                            // ReplaceRange deletes the filter prefix and inserts the
2121                            // completion text in one operation, so `pri` + confirm `println`
2122                            // yields `println` not `priprintln`.
2123                            self.deferred_ops.push(Operation::ReplaceRange {
2124                                path: self.buffer.path.clone(),
2125                                start: trigger,
2126                                end: end_cursor,
2127                                text,
2128                            });
2129                        }
2130                        Some(Event::applied("editor", op.clone()))
2131                    }
2132
2133                    LspOp::CompletionDismiss => {
2134                        self.completion = None;
2135                        Some(Event::applied("editor", op.clone()))
2136                    }
2137
2138                    LspOp::HoverDismiss => {
2139                        self.hover = None;
2140                        Some(Event::applied("editor", op.clone()))
2141                    }
2142                }
2143            }
2144
2145            // --- Search local ops ---
2146            Operation::SearchLocal(sop) => {
2147                match sop {
2148                    SearchOp::Open { replace } => {
2149                        let kind = if *replace {
2150                            SearchKind::Replace
2151                        } else {
2152                            SearchKind::Find
2153                        };
2154                        if self.search.is_none() {
2155                            // Restore last query/opts so the bar opens pre-filled.
2156                            let (query_text, opts) = self
2157                                .last_search
2158                                .as_ref()
2159                                .map(|s| (s.query.text().to_owned(), s.opts.clone()))
2160                                .unwrap_or_default();
2161                            let mut query = InputField::new("Find");
2162                            if !query_text.is_empty() {
2163                                query.set_text(query_text);
2164                            }
2165                            let focus = if kind == SearchKind::Replace {
2166                                crate::widgets::focus::FocusRing::new(vec![
2167                                    SEARCH_FOCUS_QUERY,
2168                                    SEARCH_FOCUS_REPLACEMENT,
2169                                    SEARCH_FOCUS_EDITOR,
2170                                ])
2171                            } else {
2172                                crate::widgets::focus::FocusRing::new(vec![
2173                                    SEARCH_FOCUS_QUERY,
2174                                    SEARCH_FOCUS_EDITOR,
2175                                ])
2176                            };
2177                            self.search = Some(SearchState {
2178                                query,
2179                                replacement: InputField::new("Replace"),
2180                                kind,
2181                                mode: SearchMode::default(),
2182                                focus,
2183                                opts,
2184                                matches: vec![],
2185                                current: 0,
2186                                files: Vec::new(),
2187                                file_path_index: HashMap::new(),
2188                                selected_file: 0,
2189                                file_panel_scroll: 0,
2190                                match_panel_scroll: 0,
2191                                include_filter: InputField::new("incl").with_text("*"),
2192                                exclude_filter: InputField::new("excl"),
2193                                project_search_generation: 0,
2194                                expanded_files: HashSet::new(),
2195                                tree_cursor_path: None,
2196                                tree_cursor_match: None,
2197                                tree_scroll: 0,
2198                                project_match_cursor: None,
2199                            });
2200                            self.recompute_search_matches();
2201                        } else if let Some(s) = &mut self.search {
2202                            // If the search bar already exists and the request is a Find
2203                            // open, toggle to Expanded mode on repeated Ctrl+F presses when
2204                            // currently Inline. Otherwise preserve existing behavior.
2205                            if kind == SearchKind::Find && s.kind == SearchKind::Find {
2206                                if s.mode == SearchMode::Inline {
2207                                    s.mode = SearchMode::Expanded;
2208                                    // Expand focus ring to include filter fields and tree.
2209                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
2210                                        SEARCH_FOCUS_QUERY,
2211                                        SEARCH_FOCUS_INCLUDE,
2212                                        SEARCH_FOCUS_EXCLUDE,
2213                                        SEARCH_FOCUS_TREE,
2214                                        SEARCH_FOCUS_EDITOR,
2215                                    ]);
2216                                } else {
2217                                    // Already expanded: refocus the query input.
2218                                    s.focus.set_focus(SEARCH_FOCUS_QUERY);
2219                                }
2220                            }
2221
2222                            s.kind = kind;
2223                            s.focus.set_focus(SEARCH_FOCUS_QUERY);
2224                            // Rebuild focus ring for new kind (only when not already Expanded).
2225                            if s.mode != SearchMode::Expanded {
2226                                if kind == SearchKind::Replace {
2227                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
2228                                        SEARCH_FOCUS_QUERY, SEARCH_FOCUS_REPLACEMENT,
2229                                    ]);
2230                                } else {
2231                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
2232                                        SEARCH_FOCUS_QUERY,
2233                                    ]);
2234                                }
2235                            }
2236                        }
2237                    }
2238                    SearchOp::Close => {
2239                        // Move to last_search so F3 still works after closing.
2240                        self.last_search = self.search.take();
2241                    }
2242                    SearchOp::QueryInput(field_op) => {
2243                        if let Some(s) = &mut self.search {
2244                            s.query.apply(field_op);
2245                        }
2246                        self.recompute_search_matches();
2247                    }
2248                    SearchOp::ReplacementInput(field_op) => {
2249                        if let Some(s) = &mut self.search {
2250                            s.replacement.apply(field_op);
2251                        }
2252                    }
2253                    SearchOp::IncludeGlobInput(field_op) => {
2254                        if let Some(s) = &mut self.search {
2255                            s.include_filter.apply(field_op);
2256                            s.opts.include_glob = s.include_filter.text().to_owned();
2257                        }
2258                    }
2259                    SearchOp::ExcludeGlobInput(field_op) => {
2260                        if let Some(s) = &mut self.search {
2261                            s.exclude_filter.apply(field_op);
2262                            s.opts.exclude_glob = s.exclude_filter.text().to_owned();
2263                        }
2264                    }
2265                    SearchOp::ToggleIgnoreCase => {
2266                        if let Some(s) = &mut self.search {
2267                            s.opts.ignore_case ^= true;
2268                        }
2269                        self.recompute_search_matches();
2270                    }
2271                    SearchOp::ToggleRegex => {
2272                        if let Some(s) = &mut self.search {
2273                            s.opts.regex ^= true;
2274                        }
2275                        self.recompute_search_matches();
2276                    }
2277                    SearchOp::ToggleSmartCase => {
2278                        if let Some(s) = &mut self.search {
2279                            s.opts.smart_case ^= true;
2280                        }
2281                        self.recompute_search_matches();
2282                    }
2283                    SearchOp::FocusSwitch => {
2284                        if let Some(s) = &mut self.search {
2285                            s.focus.focus_next();
2286                        }
2287                    }
2288                    SearchOp::NextMatch => {
2289                        // In Expanded mode, step through project-wide matches.
2290                        if self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded) {
2291                            if let Some(s) = &mut self.search {
2292                                let total = s.total_project_matches();
2293                                if total > 0 {
2294                                    let next = s.project_match_cursor
2295                                        .map(|c| (c + 1) % total)
2296                                        .unwrap_or(0);
2297                                    s.project_match_cursor = Some(next);
2298                                    if let Some((fi, mi)) = s.project_match_at(next) {
2299                                        let fm = &s.files[fi];
2300                                        let ms = &fm.matches[mi];
2301                                        let col = ms.line_text[..ms.byte_start].chars().count();
2302                                        // Sync tree cursor to this match.
2303                                        s.tree_cursor_path = Some(fm.path.clone());
2304                                        s.tree_cursor_match = Some(mi);
2305                                        s.expanded_files.insert(fm.path.clone());
2306                                        self.deferred_ops.push(Operation::GoToProjectMatch {
2307                                            file: fm.path.clone(),
2308                                            line: ms.line,
2309                                            col,
2310                                        });
2311                                    }
2312                                }
2313                            }
2314                        } else {
2315                            // Inline mode — existing logic.
2316                            let bar_open = self.search.is_some();
2317                            let cur = self.buffer.cursor();
2318                            let new_pos = {
2319                                let state = if bar_open {
2320                                    self.search.as_mut()
2321                                } else {
2322                                    self.last_search.as_mut()
2323                                };
2324                                state.filter(|s| !s.matches.is_empty()).map(|s| {
2325                                    s.current = if bar_open {
2326                                        (s.current + 1) % s.matches.len()
2327                                    } else {
2328                                        s.matches
2329                                            .iter()
2330                                            .position(|&(r, c, _)| (r, c) > (cur.line, cur.column))
2331                                            .unwrap_or(0)
2332                                    };
2333                                    let (row, col, _) = s.matches[s.current];
2334                                    Position::new(row, col)
2335                                })
2336                            };
2337                            if let Some(c) = new_pos {
2338                                self.buffer.set_cursor(c);
2339                            }
2340                        }
2341                    }
2342                    SearchOp::PrevMatch => {
2343                        // In Expanded mode, step backward through project-wide matches.
2344                        if self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded) {
2345                            if let Some(s) = &mut self.search {
2346                                let total = s.total_project_matches();
2347                                if total > 0 {
2348                                    let prev = s.project_match_cursor
2349                                        .map(|c| if c == 0 { total - 1 } else { c - 1 })
2350                                        .unwrap_or(total - 1);
2351                                    s.project_match_cursor = Some(prev);
2352                                    if let Some((fi, mi)) = s.project_match_at(prev) {
2353                                        let fm = &s.files[fi];
2354                                        let ms = &fm.matches[mi];
2355                                        let col = ms.line_text[..ms.byte_start].chars().count();
2356                                        s.tree_cursor_path = Some(fm.path.clone());
2357                                        s.tree_cursor_match = Some(mi);
2358                                        s.expanded_files.insert(fm.path.clone());
2359                                        self.deferred_ops.push(Operation::GoToProjectMatch {
2360                                            file: fm.path.clone(),
2361                                            line: ms.line,
2362                                            col,
2363                                        });
2364                                    }
2365                                }
2366                            }
2367                        } else {
2368                            // Inline mode — existing logic.
2369                            let bar_open = self.search.is_some();
2370                            let cur = self.buffer.cursor();
2371                            let new_pos = {
2372                                let state = if bar_open {
2373                                    self.search.as_mut()
2374                                } else {
2375                                    self.last_search.as_mut()
2376                                };
2377                                state.filter(|s| !s.matches.is_empty()).map(|s| {
2378                                    s.current = if bar_open {
2379                                        s.current.checked_sub(1).unwrap_or(s.matches.len() - 1)
2380                                    } else {
2381                                        s.matches
2382                                            .iter()
2383                                            .rposition(|&(r, c, _)| (r, c) < (cur.line, cur.column))
2384                                            .unwrap_or(s.matches.len() - 1)
2385                                    };
2386                                    let (row, col, _) = s.matches[s.current];
2387                                    Position::new(row, col)
2388                                })
2389                            };
2390                            if let Some(c) = new_pos {
2391                                self.buffer.set_cursor(c);
2392                            }
2393                        }
2394                    }
2395                    SearchOp::ReplaceOne => {
2396                        if let Some(s) = &self.search
2397                            && !s.matches.is_empty()
2398                        {
2399                            let (row, start, end) = s.matches[s.current];
2400                            let replacement = s.replacement.text().to_owned();
2401                            let path = self.buffer.path.clone();
2402                            self.deferred_ops.push(Operation::ReplaceRange {
2403                                path,
2404                                start: Position::new(row, start),
2405                                end: Position::new(row, end),
2406                                text: replacement,
2407                            });
2408                        }
2409                    }
2410                    SearchOp::ReplaceAll => {
2411                        if let Some(s) = &self.search {
2412                            let replacement = s.replacement.text().to_owned();
2413                            let matches = s.matches.clone();
2414                            for &(row, start, end) in matches.iter().rev() {
2415                                self.buffer
2416                                    .replace_range(Position::new(row, start), Position::new(row, end), &replacement);
2417                            }
2418                            self.lsp_version = self.lsp_version.wrapping_add(1);
2419                        }
2420                        self.recompute_search_matches();
2421                    }
2422                    SearchOp::AddProjectResult { file, result, generation } => {
2423                        if let Some(s) = &mut self.search {
2424                            if *generation != s.project_search_generation {
2425                                // Stale result from a superseded search — discard.
2426                            } else if let Some(&idx) = s.file_path_index.get(file) {
2427                                s.files[idx].matches.push(result.clone());
2428                            } else {
2429                                let idx = s.files.len();
2430                                s.file_path_index.insert(file.clone(), idx);
2431                                s.files.push(FileMatch {
2432                                    path: file.clone(),
2433                                    matches: vec![result.clone()],
2434                                });
2435                            }
2436                        }
2437                    }
2438                    SearchOp::ClearProjectResults { generation } => {
2439                        if let Some(s) = &mut self.search {
2440                            s.files.clear();
2441                            s.file_path_index.clear();
2442                            s.selected_file = 0;
2443                            s.file_panel_scroll = 0;
2444                            s.match_panel_scroll = 0;
2445                            s.project_search_generation = *generation;
2446                            s.expanded_files.clear();
2447                            s.tree_cursor_path = None;
2448                            s.tree_cursor_match = None;
2449                            s.tree_scroll = 0;
2450                            s.project_match_cursor = None;
2451                        }
2452                    }
2453                    SearchOp::SelectFile(idx) => {
2454                        if let Some(s) = &mut self.search {
2455                            // In the tree UI, SelectFile is reused for mouse clicks:
2456                            // idx is the flat tree index.
2457                            let rows = build_search_tree(s);
2458                            let clamped = (*idx).min(rows.len().saturating_sub(1));
2459                            set_tree_cursor(s, &rows, clamped);
2460                            // Also update legacy fields for compat.
2461                            let legacy = (*idx).min(s.files.len().saturating_sub(1));
2462                            s.selected_file = legacy;
2463                            s.file_panel_scroll = legacy.saturating_sub(5);
2464                            s.match_panel_scroll = 0;
2465                        }
2466                    }
2467                    SearchOp::ScrollMatchPanel(delta) => {
2468                        if let Some(s) = &mut self.search {
2469                            let total = s.files.get(s.selected_file).map_or(0, |f| f.matches.len());
2470                            if *delta > 0 {
2471                                s.match_panel_scroll = (s.match_panel_scroll + *delta as usize).min(total.saturating_sub(1));
2472                            } else {
2473                                s.match_panel_scroll = s.match_panel_scroll.saturating_sub((-delta) as usize);
2474                            }
2475                        }
2476                    }
2477                    SearchOp::TreeMoveUp => {
2478                        if let Some(s) = &mut self.search {
2479                            let rows = build_search_tree(s);
2480                            if rows.is_empty() { return Some(Event::applied("editor", op.clone())); }
2481                            let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
2482                            let new = cur.saturating_sub(1);
2483                            set_tree_cursor(s, &rows, new);
2484                        }
2485                    }
2486                    SearchOp::TreeMoveDown => {
2487                        if let Some(s) = &mut self.search {
2488                            let rows = build_search_tree(s);
2489                            if rows.is_empty() { return Some(Event::applied("editor", op.clone())); }
2490                            let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
2491                            let new = (cur + 1).min(rows.len() - 1);
2492                            set_tree_cursor(s, &rows, new);
2493                        }
2494                    }
2495                    SearchOp::TreeToggle => {
2496                        if let Some(s) = &mut self.search {
2497                            let rows = build_search_tree(s);
2498                            let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
2499                            if let Some(row) = rows.get(cur) {
2500                                match row {
2501                                    SearchTreeRow::File { file_idx, expanded } => {
2502                                        let path = s.files[*file_idx].path.clone();
2503                                        if *expanded {
2504                                            s.expanded_files.remove(&path);
2505                                        } else {
2506                                            s.expanded_files.insert(path);
2507                                        }
2508                                    }
2509                                    SearchTreeRow::Match { file_idx, match_idx } => {
2510                                        let fm = &s.files[*file_idx];
2511                                        let ms = &fm.matches[*match_idx];
2512                                        // Convert byte offset to char column.
2513                                        let col = ms.line_text[..ms.byte_start].chars().count();
2514                                        self.deferred_ops.push(Operation::GoToProjectMatch {
2515                                            file: fm.path.clone(),
2516                                            line: ms.line,
2517                                            col,
2518                                        });
2519                                    }
2520                                }
2521                            }
2522                        }
2523                    }
2524                    SearchOp::TreeCollapse => {
2525                        if let Some(s) = &mut self.search {
2526                            let rows = build_search_tree(s);
2527                            let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
2528                            if let Some(row) = rows.get(cur) {
2529                                match row {
2530                                    SearchTreeRow::File { file_idx, expanded } => {
2531                                        if *expanded {
2532                                            let path = s.files[*file_idx].path.clone();
2533                                            s.expanded_files.remove(&path);
2534                                        }
2535                                    }
2536                                    SearchTreeRow::Match { file_idx, .. } => {
2537                                        // Move cursor to the parent file header.
2538                                        let path = s.files[*file_idx].path.clone();
2539                                        s.tree_cursor_path = Some(path);
2540                                        s.tree_cursor_match = None;
2541                                    }
2542                                }
2543                            }
2544                        }
2545                    }
2546                }
2547                Some(Event::applied("editor", op.clone()))
2548            }
2549
2550            // --- Go-to-line bar ops ---
2551            Operation::GoToLineLocal(gop) => {
2552                match gop {
2553                    GoToLineOp::Open => {
2554                        if self.go_to_line.is_none() {
2555                            self.go_to_line = Some(GoToLineState::new());
2556                        }
2557                    }
2558                    GoToLineOp::Close => {
2559                        self.go_to_line = None;
2560                    }
2561                    GoToLineOp::Confirm => {
2562                        if let Some(state) = &self.go_to_line
2563                            && let Some(line) = state.line_number() {
2564                                let clamped = line.min(self.buffer.line_count().saturating_sub(1));
2565                                self.buffer.set_cursor(Position::new(clamped, 0));
2566                            }
2567                        self.go_to_line = None;
2568                    }
2569                    GoToLineOp::Input(field_op) => {
2570                        if let Some(state) = &mut self.go_to_line {
2571                            // Only allow digit characters; discard non-numeric input.
2572                            if let crate::widgets::input_field::InputFieldOp::InsertChar(c) = field_op {
2573                                if c.is_ascii_digit() {
2574                                    state.input.apply(field_op);
2575                                }
2576                            } else {
2577                                state.input.apply(field_op);
2578                            }
2579                            // Preview: jump the cursor live as the user types.
2580                            if let Some(line) = state.line_number() {
2581                                let clamped = line.min(self.buffer.line_count().saturating_sub(1));
2582                                self.buffer.set_cursor(Position::new(clamped, 0));
2583                            }
2584                        }
2585                    }
2586                    GoToLineOp::JumpTo { line, column } => {
2587                        let clamped_line = (*line).min(self.buffer.line_count().saturating_sub(1));
2588                        self.buffer.set_cursor(Position::new(clamped_line, *column));
2589                    }
2590                }
2591                Some(Event::applied("editor", op.clone()))
2592            }
2593
2594            // --- LSP rename local ops ---
2595            Operation::LspRenameLocal(rename_op) => {
2596                match rename_op {
2597                    LspRenameOp::OpenPrompt { current_name } => {
2598                        let row = self.buffer.cursor().line as u32;
2599                        let col = self.buffer.cursor().column as u32;
2600                        self.rename_prompt = Some(RenameState::new(current_name, row, col));
2601                    }
2602                    LspRenameOp::Input(field_op) => {
2603                        if let Some(state) = &mut self.rename_prompt {
2604                            state.input.apply(field_op);
2605                        }
2606                    }
2607                    LspRenameOp::Dismiss => {
2608                        self.rename_prompt = None;
2609                    }
2610                }
2611                Some(Event::applied("editor", op.clone()))
2612            }
2613
2614            // --- Selection local ops ---
2615            Operation::SelectionLocal(sel_op) => {
2616                match sel_op {
2617                    SelectionOp::Extend { head } => {
2618                        let anchor = self.buffer.selection()
2619                            .map(|s| s.anchor)
2620                            .unwrap_or(self.buffer.cursor());
2621                        self.buffer.set_selection(Some(Selection { anchor, active: *head }));
2622                    }
2623                    SelectionOp::SelectAll => {
2624                        self.buffer.select_all();
2625                    }
2626                    SelectionOp::Clear => {
2627                        self.buffer.set_selection(None);
2628                    }
2629                }
2630                Some(Event::applied("editor", op.clone()))
2631            }
2632
2633            // --- Git diff gutter markers ---
2634            Operation::SetEditorGitChanges { path, changed_lines } => {
2635                if self.path_matches(&Some(path.clone())) {
2636                    self.git_changed_lines = changed_lines.clone();
2637                }
2638                None
2639            }
2640
2641            // --- Async highlight result (legacy, for backward compat) ---
2642            Operation::SetEditorHighlights { path, version, start_line, generation, spans } => {
2643                if self.path_matches(path)
2644                    && *version == self.buffer.version()
2645                    && *generation == self.highlight_generation
2646                {
2647                    for (i, line_spans) in spans.iter().enumerate() {
2648                        let line = *start_line + i;
2649                        self.highlight_cache.insert(line, line_spans.clone());
2650                        self.pending_highlight_lines.remove(&line);
2651                        self.stale_highlight_lines.remove(&line);
2652                    }
2653                }
2654                None
2655            }
2656
2657            // --- Incremental highlight chunk update ---
2658            Operation::SetEditorHighlightsChunk { path, version, generation, spans } => {
2659                if self.path_matches(path)
2660                    && *version == self.buffer.version()
2661                    && *generation == self.highlight_generation
2662                {
2663                    for (line, line_spans) in spans {
2664                        self.highlight_cache.insert(*line, line_spans.clone());
2665                        self.pending_highlight_lines.remove(line);
2666                        self.stale_highlight_lines.remove(line);
2667                    }
2668                }
2669                None
2670            }
2671
2672            // ClipboardLocal ops are handled in app::apply_clipboard_op which
2673            // has access to AppState.registers and AppState.clipboard.
2674
2675            // Focus ops for search bar.
2676            Operation::Focus(focus_op) => {
2677                if let Some(s) = &mut self.search {
2678                    match focus_op {
2679                        FocusOp::Next => s.focus.focus_next(),
2680                        FocusOp::Prev => s.focus.focus_prev(),
2681                        _ => {}
2682                    }
2683                }
2684                None
2685            }
2686
2687            // Not this view's operation.
2688            _ => None,
2689        }
2690    }
2691
2692    fn render(&self, frame: &mut Frame, area: Rect, _theme: &crate::theme::Theme) {
2693        // EditorView is always rendered via render_with_registers in app::render.
2694        // This stub satisfies the View trait; it is never called from app.rs.
2695        let _ = (frame, area);
2696    }
2697
2698    fn status_bar(
2699        &self,
2700        _state: &crate::app_state::AppState,
2701        bar: &mut crate::widgets::status_bar::StatusBarBuilder,
2702    ) {
2703        // Filename + dirty indicator: use path-elision rendering so the
2704        // filename is shown first and the directory context fills available space.
2705        match &self.buffer.path {
2706            Some(p) => {
2707                bar.file_path(p.clone(), self.buffer.is_dirty());
2708            }
2709            None => {
2710                bar.label("(no file)");
2711                return; // omit language and cursor when there is no file
2712            }
2713        }
2714
2715        // Language (short, stable identifier).
2716        let lang = self
2717            .lang_id
2718            .as_ref()
2719            .map(|l| l.as_str())
2720            .unwrap_or(self.highlighter.syntax_name.as_str())
2721            .to_owned();
2722        bar.label(lang);
2723
2724        // Cursor position (1-indexed line:column).
2725        let c = self.buffer.cursor();
2726        bar.label(format!("{}:{}", c.line + 1, c.column + 1));
2727
2728        // Issue counts — only shown when there are active diagnostics.
2729        if self.issue_error_count > 0 || self.issue_warning_count > 0 {
2730            let mut parts = Vec::new();
2731            if self.issue_error_count > 0 {
2732                parts.push(format!("● E:{}", self.issue_error_count));
2733            }
2734            if self.issue_warning_count > 0 {
2735                parts.push(format!("▲ W:{}", self.issue_warning_count));
2736            }
2737            bar.label(parts.join("  "));
2738        }
2739
2740        // Transient editor messages (conflict warning takes priority).
2741        if let Some(msg) = self.external_conflict_msg.as_deref() {
2742            bar.label(format!("⚠ {}", msg));
2743        } else if let Some(msg) = self.status_msg.as_deref() {
2744            bar.label(msg.to_owned());
2745        }
2746    }
2747}
2748
2749impl EditorView {
2750    /// Full render path used by `app::render` — passes the yank ring so the
2751    /// clipboard history picker reads from the authoritative source.
2752    pub fn render_with_registers(&mut self, frame: &mut Frame, area: Rect, registers: &Registers, theme: &crate::theme::Theme) {
2753        let go_to_line_open = self.go_to_line.is_some();
2754        let rename_open = self.rename_prompt.is_some();
2755        let is_expanded = !go_to_line_open && !rename_open
2756            && self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded);
2757
2758        // ── Expanded mode: search bar on top, tree on left, editor body on right ──
2759        if is_expanded {
2760            const TREE_PANEL_W: u16 = 36;
2761            let v_chunks = Layout::default()
2762                .direction(Direction::Vertical)
2763                .constraints([Constraint::Length(2), Constraint::Min(1)])
2764                .split(area);
2765
2766            self.render_search_bar(frame, v_chunks[0]);
2767            self.last_search_bar_area = v_chunks[0];
2768
2769            let [panel_area, body_area] = Layout::default()
2770                .direction(Direction::Horizontal)
2771                .constraints([Constraint::Length(TREE_PANEL_W), Constraint::Min(1)])
2772                .split(v_chunks[1])[..]
2773            else {
2774                self.last_file_panel_area = Rect::default();
2775                self.last_match_panel_area = Rect::default();
2776                self.last_body_area = v_chunks[1];
2777                self.render_body(frame, v_chunks[1], theme);
2778                return;
2779            };
2780
2781            self.last_file_panel_area = panel_area;
2782            self.last_match_panel_area = Rect::default();
2783            self.render_search_tree(frame, panel_area);
2784            self.last_body_area = body_area;
2785            self.render_body(frame, body_area, theme);
2786        } else {
2787            // ── Inline / closed mode: existing behaviour ─────────────────────
2788            self.last_file_panel_area = Rect::default();
2789            self.last_match_panel_area = Rect::default();
2790
2791            let search_h: u16 = if go_to_line_open || rename_open {
2792                1
2793            } else {
2794                match &self.search {
2795                    None => 0,
2796                    Some(s) if s.kind == SearchKind::Replace => 2,
2797                    Some(_) => 1,
2798                }
2799            };
2800            let body_area = if search_h == 0 {
2801                self.last_search_bar_area = Rect::default();
2802                area
2803            } else {
2804                let chunks = Layout::default()
2805                    .direction(Direction::Vertical)
2806                    .constraints([Constraint::Length(search_h), Constraint::Min(1)])
2807                    .split(area);
2808                if rename_open {
2809                    self.render_rename_bar(frame, chunks[0]);
2810                    self.last_search_bar_area = Rect::default();
2811                } else if go_to_line_open {
2812                    self.render_go_to_line_bar(frame, chunks[0]);
2813                    self.last_search_bar_area = Rect::default();
2814                } else {
2815                    self.render_search_bar(frame, chunks[0]);
2816                    self.last_search_bar_area = chunks[0];
2817                }
2818                chunks[1]
2819            };
2820            self.last_body_area = body_area;
2821            self.render_body(frame, body_area, theme);
2822        }
2823
2824        if let Some(comp) = &self.completion {
2825            let items = comp.items.clone();
2826            let cursor = comp.cursor;
2827            let trigger_cursor = comp.trigger_cursor;
2828            let filter = {
2829                let comp_ref = self.completion.as_ref().unwrap();
2830                self.completion_filter(comp_ref)
2831            };
2832            let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
2833            let scroll = self.buffer.scroll;
2834            let buf_cursor = self.buffer.cursor();
2835            let body_area = self.last_body_area;
2836            if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
2837                let anchor_x = body_area.x + gutter_w as u16 + buf_cursor.column as u16;
2838                let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
2839                let _ = trigger_cursor;
2840                frame.render_widget(
2841                    CompletionWidget {
2842                        items: &items,
2843                        cursor,
2844                        filter: &filter,
2845                        anchor_x,
2846                        anchor_y,
2847                        terminal_area: area,
2848                        loading: false,
2849                    },
2850                    area,
2851                );
2852            }
2853        }
2854
2855        if let Some(hover) = &self.hover {
2856            let text = hover.text.clone();
2857            let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
2858            let scroll = self.buffer.scroll;
2859            let buf_cursor = self.buffer.cursor();
2860            let body_area = self.last_body_area;
2861            if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
2862                let anchor_x = body_area.x + gutter_w as u16 + buf_cursor.column as u16;
2863                let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
2864                frame.render_widget(
2865                    HoverWidget {
2866                        text: &text,
2867                        anchor_x,
2868                        anchor_y,
2869                        terminal_area: area,
2870                    },
2871                    area,
2872                );
2873            }
2874        } else if let Some(text) = self.gutter_issue_texts.get(&self.buffer.cursor().line).cloned() {
2875            // Show gutter-marker hover when LSP hover is not active.
2876            let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
2877            let scroll = self.buffer.scroll;
2878            let buf_cursor = self.buffer.cursor();
2879            let body_area = self.last_body_area;
2880            if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
2881                let anchor_x = body_area.x + gutter_w as u16;
2882                let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
2883                frame.render_widget(
2884                    HoverWidget {
2885                        text: &text,
2886                        anchor_x,
2887                        anchor_y,
2888                        terminal_area: area,
2889                    },
2890                    area,
2891                );
2892            }
2893        }
2894
2895        self.render_clipboard_picker(frame, registers);
2896    }
2897}
2898
2899// ---------------------------------------------------------------------------
2900// Helpers
2901// ---------------------------------------------------------------------------
2902
2903
2904
2905
2906
2907/// Check if a character is a trigger character that should auto-trigger LSP completion.
2908/// Uses server-provided trigger characters if available (via AppState), falls back to hardcoded list.
2909pub(crate) fn is_trigger_character(c: &char, lang_id: Option<&str>, _server_triggers: &[String]) -> bool {
2910    // Note: server_triggers parameter available for future use when we have a way to
2911    // pass AppState data to EditorView. For now, fall back to hardcoded triggers.
2912    // The server trigger characters are still used when Ctrl+Space is pressed.
2913
2914    // Hardcoded common trigger characters that work across many languages
2915    let common_triggers = ['.', ';', '{', '}', '[', ']', '(', ')', '<', '>', ',', ':', '"', '\''];
2916
2917    if common_triggers.contains(c) {
2918        match lang_id {
2919            Some("rust") | Some("python") | Some("javascript") | Some("typescript")
2920            | Some("cpp") | Some("c") | Some("go") | Some("java") | Some("json")
2921            | Some("yaml") | Some("toml") | Some("html") | Some("css") => true,
2922            Some(_) => true,
2923            None => *c == '.',
2924        }
2925    } else {
2926        false
2927    }
2928}
2929
2930// ---------------------------------------------------------------------------
2931// Tests
2932// ---------------------------------------------------------------------------
2933
2934#[cfg(test)]
2935mod tests {
2936    use super::*;
2937    use crate::editor::fold::FoldState;
2938
2939    fn make_editor() -> (EditorView, crate::settings::Settings) {
2940        let dir = tempfile::tempdir().unwrap();
2941        let path = dir.path().join("test.rs");
2942        std::fs::write(&path, "fn main() {}\n").unwrap();
2943        let buf = Buffer::open(&path).unwrap();
2944        let settings = crate::settings::Settings::new(dir.path().join("config.yaml").as_path())
2945            .expect("Settings::new failed in test");
2946        // Keep dir alive by leaking — acceptable in tests
2947        std::mem::forget(dir);
2948        (EditorView::open(buf, FoldState::default(), &settings), settings)
2949    }
2950
2951    #[test]
2952    fn completion_move_down_up() {
2953        let (mut ed, settings) = make_editor();
2954        ed.completion = Some(CompletionState {
2955            items: vec![
2956                crate::operation::LspCompletionItem {
2957                    label: "foo".into(),
2958                    kind: None,
2959                    detail: None,
2960                    insert_text: None,
2961                },
2962                crate::operation::LspCompletionItem {
2963                    label: "bar".into(),
2964                    kind: None,
2965                    detail: None,
2966                    insert_text: None,
2967                },
2968            ],
2969            cursor: 0,
2970            trigger_cursor: Position::default(),
2971            loading: false,
2972        });
2973
2974        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2975        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2976
2977        // Past end wraps to top.
2978        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2979        assert_eq!(ed.completion.as_ref().unwrap().cursor, 0);
2980
2981        // Move down again to get back to 1.
2982        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
2983        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2984
2985        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveUp), &settings);
2986        assert_eq!(ed.completion.as_ref().unwrap().cursor, 0);
2987
2988        // Past top wraps to bottom.
2989        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveUp), &settings);
2990        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
2991    }
2992
2993    #[test]
2994    fn completion_dismiss() {
2995        let (mut ed, settings) = make_editor();
2996        ed.completion = Some(CompletionState {
2997            items: vec![],
2998            cursor: 0,
2999            trigger_cursor: Position::default(),
3000            loading: false,
3001        });
3002        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionDismiss), &settings);
3003        assert!(ed.completion.is_none());
3004    }
3005
3006    #[test]
3007    fn hover_dismiss() {
3008        let (mut ed, settings) = make_editor();
3009        ed.hover = Some(HoverState {
3010            text: "docs".into(),
3011        });
3012        ed.handle_operation(&Operation::LspLocal(LspOp::HoverDismiss), &settings);
3013        assert!(ed.hover.is_none());
3014    }
3015
3016    #[test]
3017    fn completion_confirm_produces_deferred_op() {
3018        let (mut ed, settings) = make_editor();
3019        ed.completion = Some(CompletionState {
3020            items: vec![crate::operation::LspCompletionItem {
3021                label: "println".into(),
3022                kind: None,
3023                detail: None,
3024                insert_text: Some("println!($1)".into()),
3025            }],
3026            cursor: 0,
3027            trigger_cursor: Position::default(),
3028            loading: false,
3029        });
3030        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionConfirm), &settings);
3031        assert!(ed.completion.is_none());
3032        let deferred = ed.take_deferred_ops();
3033        assert_eq!(deferred.len(), 1);
3034        assert!(
3035            matches!(&deferred[0], Operation::ReplaceRange { text, .. } if text == "println!($1)")
3036        );
3037    }
3038
3039    // -----------------------------------------------------------------------
3040    // screen_to_cursor tests
3041    // -----------------------------------------------------------------------
3042
3043    /// Build a minimal EditorView whose body area and buffer are fully controlled.
3044    fn make_editor_with_lines(lines: &[&str]) -> EditorView {
3045        let dir = tempfile::tempdir().unwrap();
3046        let path = dir.path().join("test.txt");
3047        std::fs::write(&path, lines.join("\n")).unwrap();
3048        let buf = Buffer::open(&path).unwrap();
3049        let settings = crate::settings::Settings::new(dir.path().join("config.yaml").as_path())
3050            .expect("Settings::new failed");
3051        std::mem::forget(dir);
3052        let mut ed = EditorView::open(buf, FoldState::default(), &settings);
3053        // Disable line numbers so gutter_w = 4 (constant, easier to reason about).
3054        ed.show_line_numbers = false;
3055        // Set a fixed body area: x=0, y=0, width=40, height=20.
3056        ed.last_body_area = Rect { x: 0, y: 0, width: 40, height: 20 };
3057        ed
3058    }
3059
3060    #[test]
3061    fn click_outside_body_returns_none() {
3062        let ed = make_editor_with_lines(&["hello"]);
3063        // Row below body area (height=20, so row 20 is out).
3064        assert!(ed.screen_to_cursor(5, 20).is_none());
3065        // Col before area.x=0 is impossible (u16), but area with offset:
3066        let mut ed2 = make_editor_with_lines(&["hello"]);
3067        ed2.last_body_area = Rect { x: 5, y: 2, width: 40, height: 20 };
3068        assert!(ed2.screen_to_cursor(3, 2).is_none()); // col < area.x
3069        assert!(ed2.screen_to_cursor(5, 1).is_none()); // row < area.y
3070    }
3071
3072    #[test]
3073    fn click_in_gutter_returns_col_zero() {
3074        // gutter_w = 4 (no line numbers), content starts at col 4.
3075        let ed = make_editor_with_lines(&["hello world"]);
3076        // Click at col=2 (inside gutter) → col 0 of line 0.
3077        assert_eq!(ed.screen_to_cursor(2, 0), Some(Position::new(0, 0)));
3078        assert_eq!(ed.screen_to_cursor(0, 0), Some(Position::new(0, 0)));
3079    }
3080
3081    #[test]
3082    fn click_maps_to_correct_char() {
3083        // gutter_w=4 → content starts at col 4.
3084        // Col 4 → char 0, col 5 → char 1, ...
3085        let ed = make_editor_with_lines(&["hello"]);
3086        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0))); // 'h'
3087        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 1))); // 'e'
3088        assert_eq!(ed.screen_to_cursor(8, 0), Some(Position::new(0, 4))); // 'o'
3089    }
3090
3091    #[test]
3092    fn click_past_end_of_line_clamps_to_line_len() {
3093        let ed = make_editor_with_lines(&["hi"]);
3094        // "hi" is 2 bytes; clicking at col 4+10 = char 10 → clamps to len=2.
3095        assert_eq!(ed.screen_to_cursor(14, 0), Some(Position::new(0, 2)));
3096    }
3097
3098    #[test]
3099    fn click_selects_correct_row() {
3100        let ed = make_editor_with_lines(&["line0", "line1", "line2"]);
3101        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0)));
3102        assert_eq!(ed.screen_to_cursor(4, 1), Some(Position::new(1, 0)));
3103        assert_eq!(ed.screen_to_cursor(4, 2), Some(Position::new(2, 0)));
3104    }
3105
3106    #[test]
3107    fn scroll_offsets_row_mapping() {
3108        let mut ed = make_editor_with_lines(&["a", "b", "c", "d", "e"]);
3109        // Scroll down by 2: visual row 0 → logical row 2.
3110        ed.buffer.scroll = 2;
3111        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(2, 0)));
3112        assert_eq!(ed.screen_to_cursor(4, 1), Some(Position::new(3, 0)));
3113    }
3114
3115    #[test]
3116    fn scroll_x_offsets_col_mapping() {
3117        let mut ed = make_editor_with_lines(&["abcdef"]);
3118        // Horizontal scroll by 2: col 4 on screen → char index 0+2=2 in line → 'c'.
3119        ed.buffer.scroll_x = 2;
3120        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 2)));
3121        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 3)));
3122    }
3123
3124    #[test]
3125    fn multibyte_utf8_maps_by_char() {
3126        // "éàü" — each is 2 bytes in UTF-8 but 1 character each.
3127        let ed = make_editor_with_lines(&["éàü"]);
3128        // col=4 → char 0
3129        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0)));
3130        // col=5 → char 1 (é is at screen col 5)
3131        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 1)));
3132        // col=6 → char 2
3133        assert_eq!(ed.screen_to_cursor(6, 0), Some(Position::new(0, 2)));
3134    }
3135
3136    // -----------------------------------------------------------------------
3137    // Tests — SetEditorGitChanges operation
3138    // -----------------------------------------------------------------------
3139
3140    #[test]
3141    fn set_editor_git_changes_populates_changed_lines() {
3142        let (mut ed, settings) = make_editor();
3143        assert!(ed.git_changed_lines.is_empty(), "should start empty");
3144
3145        let path = ed.buffer.path.clone().unwrap();
3146        let mut changed = std::collections::HashSet::new();
3147        changed.insert(0usize);
3148        changed.insert(3usize);
3149
3150        ed.handle_operation(
3151            &Operation::SetEditorGitChanges { path, changed_lines: changed.clone() },
3152            &settings,
3153        );
3154
3155        assert_eq!(ed.git_changed_lines, changed);
3156    }
3157
3158    #[test]
3159    fn set_editor_git_changes_ignores_wrong_path() {
3160        let (mut ed, settings) = make_editor();
3161        let wrong_path = std::path::PathBuf::from("/totally/wrong/path.rs");
3162        let mut changed = std::collections::HashSet::new();
3163        changed.insert(5usize);
3164
3165        ed.handle_operation(
3166            &Operation::SetEditorGitChanges { path: wrong_path, changed_lines: changed },
3167            &settings,
3168        );
3169
3170        // Wrong path → git_changed_lines must remain empty.
3171        assert!(ed.git_changed_lines.is_empty(), "wrong path should not update changed lines");
3172    }
3173
3174    #[test]
3175    fn set_editor_git_changes_replaces_previous_set() {
3176        let (mut ed, settings) = make_editor();
3177        let path = ed.buffer.path.clone().unwrap();
3178
3179        // First update.
3180        let mut first = std::collections::HashSet::new();
3181        first.insert(1usize);
3182        first.insert(2usize);
3183        ed.handle_operation(
3184            &Operation::SetEditorGitChanges { path: path.clone(), changed_lines: first },
3185            &settings,
3186        );
3187        assert!(ed.git_changed_lines.contains(&1));
3188        assert!(ed.git_changed_lines.contains(&2));
3189
3190        // Second update replaces the first entirely.
3191        let mut second = std::collections::HashSet::new();
3192        second.insert(7usize);
3193        ed.handle_operation(
3194            &Operation::SetEditorGitChanges { path: path.clone(), changed_lines: second },
3195            &settings,
3196        );
3197        assert!(!ed.git_changed_lines.contains(&1), "old line 1 should be gone");
3198        assert!(!ed.git_changed_lines.contains(&2), "old line 2 should be gone");
3199        assert!(ed.git_changed_lines.contains(&7));
3200    }
3201}