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