//! Editor view — full-screen buffer editor.
//!
//! Layout:
//!   ┌──────────────────────────────────────────────────────────────────┐
//!   │  1 ⌄   fn main() {                                               │
//!   │  2   │     let x = 1;                                            │
//!   │  3   │ }                                                         │
//!   │ src/main.rs [+]                                       1:1  Rust  │
//!   └──────────────────────────────────────────────────────────────────┘
//!

use std::collections::{HashMap, HashSet};

use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, Paragraph},
};

use crate::prelude::*;

use editor::{
    buffer::Buffer, position::Position, fold::FoldState, highlight::{StyledSpan, SyntaxHighlighter}, selection::Selection
};
use input::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind};
use interraction::hit_test;
use operation::{ClipOp, Event, GoToLineOp, LspCompletionItem, LspOp, LspRenameOp, MatchSpan, Operation, SearchOp, SelectionOp};
use registers::Registers;
use settings::Settings;
use views::View;
use widgets::{completion::CompletionWidget, editor::{EditorWidget, GutterMarker, gutter_width}, hover::HoverWidget};
use widgets::focusable::FocusOp;
use widgets::input_field::InputField;

// ---------------------------------------------------------------------------
// LSP overlay state
// ---------------------------------------------------------------------------

#[derive(Debug)]
pub struct CompletionState {
    pub items: Vec<LspCompletionItem>,
    pub cursor: usize,
    /// Buffer cursor position at which completion was triggered.
    /// Used to derive the typed filter by slicing the current buffer.
    pub trigger_cursor: Position,
    /// Whether completion items are still loading (async LSP request in flight)
    pub loading: bool,
}

#[derive(Debug)]
pub struct HoverState {
    pub text: String,
}

// ---------------------------------------------------------------------------
// Search state
// ---------------------------------------------------------------------------

/// State for the go-to-line input bar.
#[derive(Debug)]
pub struct GoToLineState {
    pub input: InputField,
}

impl Default for GoToLineState {
    fn default() -> Self {
        Self::new()
    }
}

impl GoToLineState {
    pub fn new() -> Self {
        Self {
            input: InputField::new("Line"),
        }
    }

    /// Parse the current input as a 1-based line number, returning a 0-based index.
    pub fn line_number(&self) -> Option<usize> {
        self.input.text().trim().parse::<usize>().ok().and_then(|n| n.checked_sub(1))
    }
}

/// State for the inline LSP rename prompt.
#[derive(Debug, Clone)]
pub struct RenameState {
    pub input: InputField,
    /// Cursor position (line, col) captured when the prompt was opened.
    /// Used by app.rs to send the rename request to the right symbol.
    pub cursor_row: u32,
    pub cursor_col: u32,
}

impl RenameState {
    pub fn new(current_name: &str, row: u32, col: u32) -> Self {
        let mut input = InputField::new("New name");
        // Pre-fill with the current symbol name so the user can edit it.
        for c in current_name.chars() {
            input.apply(&crate::widgets::input_field::InputFieldOp::InsertChar(c));
        }
        Self { input, cursor_row: row, cursor_col: col }
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SearchKind {
    Find,
    Replace,
}

#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum SearchMode {
    #[default]
    Inline,
    Expanded,
}

const SEARCH_FOCUS_QUERY: &str = "search_query";
const SEARCH_FOCUS_REPLACEMENT: &str = "search_replacement";
const SEARCH_FOCUS_INCLUDE: &str = "search_include";
const SEARCH_FOCUS_EXCLUDE: &str = "search_exclude";
/// Search results tree has keyboard focus (Expanded mode).
const SEARCH_FOCUS_TREE: &str = "search_tree";
/// File-list panel has keyboard focus (Expanded mode) — legacy, kept for compat.
const SEARCH_FOCUS_FILES: &str = "search_files";
/// Match-results panel has keyboard focus (Expanded mode) — legacy, kept for compat.
const SEARCH_FOCUS_MATCHES: &str = "search_matches";

#[derive(Debug, Clone)]
pub struct SearchOptions {
    pub ignore_case: bool,
    pub regex: bool,
    pub smart_case: bool,
    /// Glob pattern for files to include (default `"*"` = all files).
    pub include_glob: String,
    /// Glob pattern for files to exclude (default `""` = exclude nothing).
    pub exclude_glob: String,
}

impl Default for SearchOptions {
    fn default() -> Self {
        Self {
            ignore_case: false,
            regex: false,
            smart_case: false,
            include_glob: String::from("*"),
            exclude_glob: String::new(),
        }
    }
}

#[derive(Debug)]
pub struct FileMatch {
    pub path: std::path::PathBuf,
    pub matches: Vec<MatchSpan>,
}

/// An entry in the flat search-results tree used for rendering and navigation.
#[derive(Debug)]
enum SearchTreeRow {
    /// A file header row (expandable/collapsible).
    File {
        file_idx: usize,
        expanded: bool,
    },
    /// A match line beneath an expanded file.
    Match {
        file_idx: usize,
        match_idx: usize,
    },
}

/// Build the flat list of rows for the search tree from the current search
/// state.  Files are sorted alphabetically for stable display order.
fn build_search_tree(state: &SearchState) -> Vec<SearchTreeRow> {
    let mut sorted_indices: Vec<usize> = (0..state.files.len()).collect();
    sorted_indices.sort_by(|a, b| state.files[*a].path.cmp(&state.files[*b].path));

    let mut rows = Vec::new();
    for &fi in &sorted_indices {
        let fm = &state.files[fi];
        let expanded = state.expanded_files.contains(&fm.path);
        rows.push(SearchTreeRow::File {
            file_idx: fi,
            expanded,
        });
        if expanded {
            for mi in 0..fm.matches.len() {
                rows.push(SearchTreeRow::Match {
                    file_idx: fi,
                    match_idx: mi,
                });
            }
        }
    }
    rows
}

/// Find the flat index of the cursor in the tree based on identity
/// (path + optional match index).  Returns 0 if not found.
fn resolve_tree_cursor(rows: &[SearchTreeRow], files: &[FileMatch], cursor_path: &Option<std::path::PathBuf>, cursor_match: &Option<usize>) -> usize {
    let Some(path) = cursor_path else { return 0 };
    for (i, row) in rows.iter().enumerate() {
        match row {
            SearchTreeRow::File { file_idx, .. } => {
                if cursor_match.is_none() && files[*file_idx].path == *path {
                    return i;
                }
            }
            SearchTreeRow::Match { file_idx, match_idx } => {
                if cursor_match == &Some(*match_idx) && files[*file_idx].path == *path {
                    return i;
                }
            }
        }
    }
    0
}

/// Set the tree cursor identity from a flat index.
fn set_tree_cursor(state: &mut SearchState, rows: &[SearchTreeRow], flat_idx: usize) {
    if let Some(row) = rows.get(flat_idx) {
        match row {
            SearchTreeRow::File { file_idx, .. } => {
                state.tree_cursor_path = Some(state.files[*file_idx].path.clone());
                state.tree_cursor_match = None;
            }
            SearchTreeRow::Match { file_idx, match_idx } => {
                state.tree_cursor_path = Some(state.files[*file_idx].path.clone());
                state.tree_cursor_match = Some(*match_idx);
            }
        }
    }
}

#[derive(Debug)]
pub struct SearchState {
    pub query: InputField,
    pub replacement: InputField,
    pub kind: SearchKind,
    pub mode: SearchMode,
    pub focus: crate::widgets::focus::FocusRing,
    pub opts: SearchOptions,
    /// (row, byte_start, byte_end) for every match in the buffer.
    pub matches: Vec<(usize, usize, usize)>,
    /// Index of the "current" (highlighted) match.
    pub current: usize,
    // Project-wide file-grouped results (unused by inline search)
    pub files: Vec<FileMatch>,
    /// Fast path → index lookup for `files`.
    pub file_path_index: HashMap<std::path::PathBuf, usize>,
    pub selected_file: usize,
    /// Scroll offset of the file-list panel in Expanded mode.
    pub file_panel_scroll: usize,
    /// Scroll offset of the match-results panel in Expanded mode.
    pub match_panel_scroll: usize,
    /// Include-glob filter input (Expanded mode only).
    pub include_filter: InputField,
    /// Exclude-glob filter input (Expanded mode only).
    pub exclude_filter: InputField,
    /// Generation of the most recently started project search.
    /// `AddProjectResult` ops whose generation does not match are discarded so
    /// results from a superseded (cancelled) search never appear in the UI.
    pub project_search_generation: u64,
    /// Which files are expanded in the search results tree.
    pub expanded_files: HashSet<std::path::PathBuf>,
    /// Cursor identity in the search tree: file path.
    pub tree_cursor_path: Option<std::path::PathBuf>,
    /// Cursor identity in the search tree: match index within the file
    /// (`None` means the cursor is on the file header row).
    pub tree_cursor_match: Option<usize>,
    /// Scroll offset for the search results tree.
    pub tree_scroll: usize,
    /// Flat cursor across all project-wide matches for F3/Shift+F3 navigation.
    /// `None` when no project search has completed or when in Inline mode.
    pub project_match_cursor: Option<usize>,
}

impl SearchState {
    /// Total number of matches across all files in the project search.
    pub fn total_project_matches(&self) -> usize {
        self.files.iter().map(|f| f.matches.len()).sum()
    }

    /// Map a flat match index to `(file_idx, match_idx)`.
    /// Files are iterated in insertion order (the order used by `files` vec).
    pub fn project_match_at(&self, flat: usize) -> Option<(usize, usize)> {
        let mut remaining = flat;
        for (fi, fm) in self.files.iter().enumerate() {
            if remaining < fm.matches.len() {
                return Some((fi, remaining));
            }
            remaining -= fm.matches.len();
        }
        None
    }
}

fn find_matches(lines: &[String], query: &str, opts: &SearchOptions) -> Vec<(usize, usize, usize)> {
    if query.is_empty() {
        return vec![];
    }
    let ignore = opts.ignore_case || (opts.smart_case && !query.chars().any(|c| c.is_uppercase()));

    let mut out = Vec::new();
    if opts.regex {
        if let Ok(re) = regex::RegexBuilder::new(query)
            .case_insensitive(ignore)
            .build()
        {
            for (row, line) in lines.iter().enumerate() {
                for m in re.find_iter(line) {
                    out.push((row, m.start(), m.end()));
                }
            }
        }
    } else {
        let q = if ignore {
            query.to_lowercase()
        } else {
            query.to_owned()
        };
        for (row, line) in lines.iter().enumerate() {
            let l = if ignore {
                line.to_lowercase()
            } else {
                line.clone()
            };
            let mut start = 0;
            while let Some(pos) = l[start..].find(&q) {
                let abs = start + pos;
                out.push((row, abs, abs + q.len()));
                start = abs + 1;
            }
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Clipboard history picker state
// ---------------------------------------------------------------------------

#[derive(Debug)]
pub(crate) struct ClipboardPickerState {
    pub cursor: usize,
}

// ---------------------------------------------------------------------------
// Mouse interaction state
// ---------------------------------------------------------------------------

/// Tracks double-click detection and drag state for the editor.
#[derive(Debug, Default)]
struct ClickState {
    /// Screen column of the last Left-Down event.
    last_col: u16,
    /// Screen row of the last Left-Down event.
    last_row: u16,
    /// Time of the last Left-Down event.
    last_time: Option<std::time::Instant>,
    /// Consecutive click count at the same position (1 = single, 2 = double).
    count: u8,
    /// Whether the left button is currently held (drag in progress).
    dragging: bool,
    /// True if the current drag started with a double-click (word-drag mode).
    word_drag: bool,
}

/// Maximum gap between two clicks to count as a double-click.
const DOUBLE_CLICK_MS: u128 = 400;

// ---------------------------------------------------------------------------
// EditorView
// ---------------------------------------------------------------------------

#[derive(Debug)]
pub struct EditorView {
    pub buffer: Buffer,
    pub folds: FoldState,
    pub highlighter: SyntaxHighlighter,
    pub gutter_markers: Vec<GutterMarker>,
    /// Pre-formatted hover text per gutter-marked line (line → text block).
    /// Updated whenever gutter_markers are rebuilt from the issue registry.
    pub gutter_issue_texts: HashMap<usize, String>,
    pub status_msg: Option<String>,
    // LSP
    pub completion: Option<CompletionState>,
    pub hover: Option<HoverState>,
    /// Incremented on every buffer change; sent with textDocument/didChange.
    pub lsp_version: i32,
    /// Secondary ops produced by completion-confirm that the view needs to
    /// emit but cannot return from handle_operation.  Drained by app.rs.
    pub deferred_ops: Vec<Operation>,
    /// Active search/replace state; `None` when bar is closed.
    pub search: Option<SearchState>,
    /// Last committed search; kept alive after the bar closes so F3 still works.
    pub last_search: Option<SearchState>,
    /// Active go-to-line bar state; `None` when closed.
    pub go_to_line: Option<GoToLineState>,
    /// Active LSP rename prompt state; `None` when closed.
    pub rename_prompt: Option<RenameState>,
    /// Clipboard history picker state; `None` when closed.
    /// Managed by `apply_clipboard_op` in `app.rs` (needs register access).
    pub(crate) clipboard_picker: Option<ClipboardPickerState>,
    /// When true, long lines are soft-wrapped instead of clipped horizontally.
    pub word_wrap: bool,
    /// When true, line numbers are shown in the gutter.
    pub show_line_numbers: bool,
    /// When true, use spaces for indentation; otherwise use tabs.
    pub use_space: bool,
    /// Number of spaces (or tab width) for indentation.
    pub indentation_width: usize,
    /// Fully computed highlight cache keyed by line number.
    /// Lines are never erased on edits — only updated when recomputed.
    pub highlight_cache: HashMap<usize, Vec<StyledSpan>>,

    /// Lines currently being computed in background (avoid redundant tasks).
    pub pending_highlight_lines: HashSet<usize>,

    /// Lines whose cached spans are stale (content changed) but are kept in
    /// `highlight_cache` for flicker-free rendering until fresh results arrive.
    pub stale_highlight_lines: HashSet<usize>,

    /// Handle for the in-flight background highlight task.
    pub highlight_task: Option<tokio::task::JoinHandle<()>>,

    /// Generation counter for highlight tasks. Incremented on each edit
    /// to detect and discard stale completions.
    pub highlight_generation: u64,
    /// Previous scroll position — used to detect scroll direction for
    /// highlight prefetching.
    pub(crate) prev_scroll: usize,
    /// Body area from the last render pass — used to map mouse coordinates to
    /// buffer positions in `handle_mouse`.
    pub(crate) last_body_area: Rect,
    /// Search bar area from the last render pass — used to map mouse clicks to buttons.
    pub(crate) last_search_bar_area: Rect,
    /// File-list panel area from the last render pass (Expanded mode only).
    pub(crate) last_file_panel_area: Rect,
    /// Match-results panel area from the last render pass (Expanded mode only).
    pub(crate) last_match_panel_area: Rect,
    /// Click/drag state for mouse interaction.  Uses interior mutability because
    /// `handle_mouse` takes `&self`.
    click_state: std::cell::RefCell<ClickState>,
    /// Message shown when external modification is detected.
    pub external_conflict_msg: Option<String>,
    /// Detected language of the open file (e.g. `"rust"`, `"python"`).
    pub lang_id: Option<crate::language::LanguageId>,
    /// Number of active (non-dismissed, non-resolved) error-severity issues for the open file.
    /// Updated by `rebuild_editor_gutter_markers` whenever the issue registry changes.
    pub issue_error_count: usize,
    /// Number of active non-error issues (warnings, info, hints) for the open file.
    /// Updated by `rebuild_editor_gutter_markers` whenever the issue registry changes.
    pub issue_warning_count: usize,
    /// 0-based line indices of lines that are added or modified according to the
    /// workdir git diff.  Updated asynchronously when the file is opened or saved.
    pub git_changed_lines: HashSet<usize>,
}

impl EditorView {
    pub fn open(buffer: Buffer, folds: FoldState, settings: &crate::settings::Settings) -> Self {
        use crate::settings::adapters;
        let highlighter = buffer
            .path
            .as_deref()
            .map(SyntaxHighlighter::for_path)
            .unwrap_or_else(SyntaxHighlighter::plain);
        let lang_id = buffer
            .path
            .as_deref()
            .and_then(crate::language::detect_language);
        Self {
            buffer,
            folds,
            highlighter,
            gutter_markers: Vec::new(),
            gutter_issue_texts: HashMap::new(),
            status_msg: None,
            completion: None,
            hover: None,
            lsp_version: 1,
            deferred_ops: Vec::new(),
            search: None,
            last_search: None,
            go_to_line: None,
            rename_prompt: None,
            clipboard_picker: None,
            word_wrap: *adapters::editor::word_wrap(settings),
            show_line_numbers: *adapters::editor::show_line_numbers(settings),
            use_space: *adapters::editor::use_space(settings),
            indentation_width: *adapters::editor::indentation_width(settings) as usize,
            highlight_cache: HashMap::new(),
            pending_highlight_lines: HashSet::new(),
            stale_highlight_lines: HashSet::new(),
            highlight_task: None,
            highlight_generation: 0,
            prev_scroll: 0,
            last_body_area: Rect::default(),
            last_search_bar_area: Rect::default(),
            last_file_panel_area: Rect::default(),
            last_match_panel_area: Rect::default(),
            click_state: std::cell::RefCell::new(ClickState::default()),
            external_conflict_msg: None,
            lang_id,
            issue_error_count: 0,
            issue_warning_count: 0,
            git_changed_lines: HashSet::new(),
        }
    }

    pub fn into_state(mut self) -> (Buffer, FoldState) {
        // Abort any running highlight tasks and clear highlight caches so background
        // workers do not retain large buffers after the view is stashed.
        self.invalidate_all_highlights();
        (self.buffer, self.folds)
    }

    /// Drain any secondary ops produced during the last handle_operation call.
    pub fn take_deferred_ops(&mut self) -> Vec<Operation> {
        std::mem::take(&mut self.deferred_ops)
    }

    pub fn start_file_watching(&mut self) {
        if !self.buffer.is_dirty() {
            self.buffer.compute_and_store_file_hash();
        }
    }

    pub fn stop_file_watching(&mut self) {
    }

    pub fn check_external_modification_for_paths(&mut self, changed_paths: &[std::path::PathBuf]) -> bool {
        let path = match &self.buffer.path {
            Some(p) => p.clone(),
            None => return false,
        };
        for changed_path in changed_paths {
            if changed_path == &path
                && self.buffer.check_external_modification() {
                    let filename = path.file_name()
                        .map(|n| n.to_string_lossy().to_string())
                        .unwrap_or_else(|| path.to_string_lossy().to_string());
                    self.external_conflict_msg = Some(
                        format!("{} modified externally. Press Ctrl+Shift+R to reload or Ctrl+S to save.", filename),
                    );
                    return true;
                }
        }
        false
    }

    pub fn reload_from_disk(&mut self) -> Result<()> {
        self.buffer.reload_from_disk()?;
        self.external_conflict_msg = None;
        self.buffer.compute_and_store_file_hash();
        self.invalidate_all_highlights();
        Ok(())
    }

    /// Invalidate highlights for all lines at and after `from_line`, preserving
    /// earlier cached spans so they remain visible without flashing.
    /// Lines at and after `from_line` are kept in the cache as **stale** so they
    /// continue rendering with their old colors until the worker delivers fresh results.
    pub(crate) fn invalidate_highlights_from(&mut self, from_line: usize) {
        // Abort any running task — it may be computing lines that are now stale.
        if let Some(task) = self.highlight_task.take() {
            task.abort();
        }
        self.highlight_generation = self.highlight_generation.wrapping_add(1);
        self.highlighter.invalidate_from(from_line);
        // Mark cached lines >= from_line as stale (keep them for rendering).
        for &k in self.highlight_cache.keys() {
            if k >= from_line {
                self.stale_highlight_lines.insert(k);
            }
        }
        // Cancel any pending requests for stale lines — they'll be re-queued next frame.
        self.pending_highlight_lines.retain(|&k| k < from_line);
    }

    /// Full highlight cache reset (use for undo/redo or whole-file changes).
    pub(crate) fn invalidate_all_highlights(&mut self) {
        if let Some(task) = self.highlight_task.take() {
            task.abort();
        }
        self.highlight_generation = self.highlight_generation.wrapping_add(1);
        self.highlighter.clear_cache();
        self.highlight_cache.clear();
        self.pending_highlight_lines.clear();
        self.stale_highlight_lines.clear();
    }

    pub fn clear_external_modification(&mut self) {
        self.external_conflict_msg = None;
        self.buffer.compute_and_store_file_hash();
    }

    pub(crate) fn path_matches(&self, path: &Option<std::path::PathBuf>) -> bool {
        match (path, &self.buffer.path) {
            (None, _) => true,
            (Some(p), Some(bp)) => p == bp,
            _ => false,
        }
    }

    // -----------------------------------------------------------------------
    // Rendering helpers
    // -----------------------------------------------------------------------

    pub(crate) fn recompute_search_matches(&mut self) {
        if let Some(s) = &mut self.search {
            s.matches = find_matches(&self.buffer.lines(), s.query.text(), &s.opts);
            s.current = s.current.min(s.matches.len().saturating_sub(1));
        }
        if let Some(s) = &mut self.last_search {
            s.matches = find_matches(&self.buffer.lines(), s.query.text(), &s.opts);
            s.current = s.current.min(s.matches.len().saturating_sub(1));
        }
    }

    fn render_body(&mut self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
        let total_lines = self.buffer.lines().len();
        let gutter_w = gutter_width(self.show_line_numbers, total_lines);
        let content_w = (area.width as usize).saturating_sub(gutter_w);

        if self.word_wrap {
            self.buffer.scroll_x = 0;
            self.buffer
                .scroll_to_cursor_visual(area.height as usize, content_w, self.indentation_width);
        } else {
            self.buffer.scroll_to_cursor(area.height as usize);
            self.buffer.scroll_x_to_cursor(content_w, self.indentation_width);
        }

        let (matches, current) = match self.search.as_ref().or(self.last_search.as_ref()) {
            Some(s) if !s.matches.is_empty() => (s.matches.as_slice(), Some(s.current)),
            _ => (&[] as &[(usize, usize, usize)], None),
        };
        let selection_spans: Vec<(usize, usize, usize)> = self
            .buffer
            .selection()
            .map(|s| s.covered_spans())
            .unwrap_or_default();
        frame.render_widget(
            EditorWidget {
                buffer: &self.buffer,
                folds: &self.folds,
                highlight_cache: &self.highlight_cache,
                gutter_markers: &self.gutter_markers,
                git_changed_lines: &self.git_changed_lines,
                search_matches: matches,
                search_current: current,
                selection_spans: &selection_spans,
                scroll_x: self.buffer.scroll_x,
                word_wrap: self.word_wrap,
                show_line_numbers: self.show_line_numbers,
                tab_width: self.indentation_width,
                gutter_bg: theme.gutter_bg(),
            },
            area,
        );
    }

    fn render_search_bar(&self, frame: &mut Frame, area: Rect) {
        let s = self.search.as_ref().unwrap();
        let bar_bg = Color::Rgb(30, 45, 70);
        let active_bg = Color::Rgb(50, 70, 110);
        let btn_on = Style::default()
            .fg(Color::Black)
            .bg(Color::Rgb(80, 170, 220));
        let btn_off = Style::default()
            .fg(Color::DarkGray)
            .bg(Color::Rgb(40, 55, 80));

        // Buttons: " IgnCase  Regex  Smart "
        // Fixed width so they always sit flush at the right edge.
        const BTN_W: u16 = 27;
        let left_w = area.width.saturating_sub(BTN_W);

        let btn_row = Line::from(vec![
            Span::styled(" ", Style::default().bg(bar_bg)),
            Span::styled(
                " IgnCase ",
                if s.opts.ignore_case { btn_on } else { btn_off },
            ),
            Span::styled(" ", Style::default().bg(bar_bg)),
            Span::styled(" Regex ", if s.opts.regex { btn_on } else { btn_off }),
            Span::styled(" ", Style::default().bg(bar_bg)),
            Span::styled(" Smart ", if s.opts.smart_case { btn_on } else { btn_off }),
            Span::styled(" ", Style::default().bg(bar_bg)),
        ]);

        let count_str = if s.matches.is_empty() {
            " No matches".to_owned()
        } else {
            format!(" {}/{}", s.current + 1, s.matches.len())
        };

        // ── Query row ───────────────────────────────────────────────────────
        let query_area = Rect { height: 1, ..area };
        let [left_area, right_area] = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
            .split(query_area)[..]
        else {
            return;
        };

        let query_line = format!(" Find: {}  {}", s.query.text(), count_str);
        frame.render_widget(
            Paragraph::new(query_line).style(Style::default().fg(Color::White).bg(
                if s.focus.current() == SEARCH_FOCUS_QUERY {
                    active_bg
                } else {
                    bar_bg
                },
            )),
            left_area,
        );
        frame.render_widget(
            Paragraph::new(btn_row.clone()).style(Style::default().bg(bar_bg)),
            right_area,
        );

        // ── Replace row ─────────────────────────────────────────────────────
        if s.kind == SearchKind::Replace && area.height >= 2 {
            let replace_area = Rect {
                y: area.y + 1,
                height: 1,
                ..area
            };
            let [left_r, right_r] = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Length(left_w), Constraint::Length(BTN_W)])
                .split(replace_area)[..]
            else {
                return;
            };

            let replace_line = format!(" Replace: {}", s.replacement.text());
            frame.render_widget(
                Paragraph::new(replace_line).style(Style::default().fg(Color::White).bg(
                    if s.focus.current() == SEARCH_FOCUS_REPLACEMENT {
                        active_bg
                    } else {
                        bar_bg
                    },
                )),
                left_r,
            );
            let hint = Line::from(vec![Span::styled(
                "  Enter:replace  Alt+A:all  ",
                Style::default().fg(Color::DarkGray).bg(bar_bg),
            )]);
            frame.render_widget(
                Paragraph::new(hint).style(Style::default().bg(bar_bg)),
                right_r,
            );
        }

        // ── Filter row (Expanded mode only) ─────────────────────────────────
        if s.mode == SearchMode::Expanded && area.height >= 2 {
            let filter_area = Rect {
                y: area.y + 1,
                height: 1,
                ..area
            };
            let half = filter_area.width / 2;
            let [incl_area, excl_area] = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Length(half), Constraint::Min(1)])
                .split(filter_area)[..]
            else {
                return;
            };

            let incl_focused = s.focus.current() == SEARCH_FOCUS_INCLUDE;
            let excl_focused = s.focus.current() == SEARCH_FOCUS_EXCLUDE;

            let incl_text = format!(" incl: {}", s.include_filter.text());
            frame.render_widget(
                Paragraph::new(incl_text).style(Style::default().fg(Color::White).bg(
                    if incl_focused { active_bg } else { bar_bg },
                )),
                incl_area,
            );
            let excl_text = format!(" excl: {}", s.exclude_filter.text());
            frame.render_widget(
                Paragraph::new(excl_text).style(Style::default().fg(Color::White).bg(
                    if excl_focused { active_bg } else { bar_bg },
                )),
                excl_area,
            );
        }
    }





    /// Render the search results tree in the left sidebar.
    fn render_search_tree(&self, frame: &mut Frame, area: Rect) {
        let s = match self.search.as_ref() {
            Some(s) => s,
            None => return,
        };

        let tree_bg = Color::Rgb(15, 22, 38);
        let selected_bg = Color::Rgb(40, 60, 100);
        let header_bg = Color::Rgb(25, 35, 55);
        let hit_bg = Color::Rgb(100, 80, 0);
        let hit_fg = Color::White;
        let file_fg = Color::Rgb(200, 200, 220);
        let match_fg = Color::Rgb(160, 160, 180);
        let count_fg = Color::Rgb(120, 140, 170);
        let divider_fg = Color::Rgb(60, 70, 90);

        let on_tree = s.focus.current() == SEARCH_FOCUS_TREE;

        // Header row — file count
        if area.height < 1 { return; }
        let total_matches: usize = s.files.iter().map(|f| f.matches.len()).sum();
        let header_text = if s.files.is_empty() && s.project_search_generation > 0 {
            "  searching…".to_string()
        } else {
            format!("  {} files  {} matches", s.files.len(), total_matches)
        };
        let header_style = if on_tree {
            Style::default().fg(Color::White).bg(header_bg)
        } else {
            Style::default().fg(count_fg).bg(header_bg)
        };
        frame.render_widget(
            Paragraph::new(header_text).style(header_style),
            Rect { x: area.x, y: area.y, width: area.width.saturating_sub(1), height: 1 },
        );
        // Divider column
        frame.render_widget(
            Paragraph::new("│").style(Style::default().fg(divider_fg).bg(header_bg)),
            Rect { x: area.x + area.width.saturating_sub(1), y: area.y, width: 1, height: 1 },
        );

        let list_height = (area.height as usize).saturating_sub(1);
        if list_height == 0 { return; }
        let list_y = area.y + 1;

        let rows = build_search_tree(s);
        let cursor_flat = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);

        // Auto-scroll: ensure cursor is visible
        let scroll = {
            let mut sc = s.tree_scroll;
            if cursor_flat < sc {
                sc = cursor_flat;
            } else if cursor_flat >= sc + list_height {
                sc = cursor_flat.saturating_sub(list_height - 1);
            }
            sc
        };

        let content_w = area.width.saturating_sub(1); // leave 1 col for divider

        for i in 0..list_height {
            let row_y = list_y + i as u16;
            let flat_idx = scroll + i;
            if flat_idx >= rows.len() {
                // Empty row
                frame.render_widget(
                    Paragraph::new("").style(Style::default().bg(tree_bg)),
                    Rect { x: area.x, y: row_y, width: content_w, height: 1 },
                );
            } else {
                let is_selected = flat_idx == cursor_flat && on_tree;
                let row_bg = if is_selected { selected_bg } else { tree_bg };

                match &rows[flat_idx] {
                    SearchTreeRow::File { file_idx, expanded } => {
                        let fm = &s.files[*file_idx];
                        let icon = if *expanded { "▾ " } else { "▸ " };
                        // Show last 2 path components for context
                        let display_path = {
                            let components: Vec<_> = fm.path.components().collect();
                            let n = components.len();
                            if n > 2 {
                                let parent = components[n-2].as_os_str().to_string_lossy();
                                let name = components[n-1].as_os_str().to_string_lossy();
                                format!("{}/{}", parent, name)
                            } else {
                                fm.path.file_name().map(|f| f.to_string_lossy().to_string())
                                    .unwrap_or_default()
                            }
                        };
                        let count = format!(" ({})", fm.matches.len());
                        let spans = vec![
                            Span::styled(icon, Style::default().fg(count_fg).bg(row_bg)),
                            Span::styled(display_path, Style::default().fg(file_fg).bg(row_bg)),
                            Span::styled(count, Style::default().fg(count_fg).bg(row_bg)),
                        ];
                        let line = Line::from(spans);
                        frame.render_widget(
                            Paragraph::new(line),
                            Rect { x: area.x, y: row_y, width: content_w, height: 1 },
                        );
                    }
                    SearchTreeRow::Match { file_idx, match_idx } => {
                        let ms = &s.files[*file_idx].matches[*match_idx];
                        let indent = "    ";
                        let line_num = format!("{}: ", ms.line + 1);
                        let text = ms.line_text.trim_start();
                        let trim_offset = ms.line_text.len() - text.len();

                        // Build spans with highlighted match region
                        let mut spans = vec![
                            Span::styled(indent, Style::default().fg(match_fg).bg(row_bg)),
                            Span::styled(line_num, Style::default().fg(count_fg).bg(row_bg)),
                        ];
                        // Adjust byte offsets for trimmed text
                        let bs = ms.byte_start.saturating_sub(trim_offset);
                        let be = ms.byte_end.saturating_sub(trim_offset);
                        if bs < text.len() && be <= text.len() && bs < be {
                            if bs > 0 {
                                spans.push(Span::styled(&text[..bs], Style::default().fg(match_fg).bg(row_bg)));
                            }
                            spans.push(Span::styled(&text[bs..be], Style::default().fg(hit_fg).bg(hit_bg)));
                            if be < text.len() {
                                spans.push(Span::styled(&text[be..], Style::default().fg(match_fg).bg(row_bg)));
                            }
                        } else {
                            spans.push(Span::styled(text, Style::default().fg(match_fg).bg(row_bg)));
                        }
                        let line = Line::from(spans);
                        frame.render_widget(
                            Paragraph::new(line),
                            Rect { x: area.x, y: row_y, width: content_w, height: 1 },
                        );
                    }
                }
            }
            // Divider column for this row
            frame.render_widget(
                Paragraph::new("│").style(Style::default().fg(divider_fg).bg(tree_bg)),
                Rect { x: area.x + area.width.saturating_sub(1), y: row_y, width: 1, height: 1 },
            );
        }
    }

    fn render_go_to_line_bar(&self, frame: &mut Frame, area: Rect) {
        let state = match &self.go_to_line {
            Some(s) => s,
            None => return,
        };
        let bar_bg = Color::Rgb(30, 45, 70);
        let total = self.buffer.line_count();
        let hint = format!(" of {} ", total);
        let hint_w = hint.len() as u16;
        let input_w = area.width.saturating_sub(hint_w);
        let [input_area, hint_area] = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(input_w), Constraint::Length(hint_w)])
            .split(area)[..]
        else {
            return;
        };
        let line = format!(" Go to line: {}", state.input.text());
        frame.render_widget(
            Paragraph::new(line).style(Style::default().fg(Color::White).bg(Color::Rgb(50, 70, 110))),
            input_area,
        );
        frame.render_widget(
            Paragraph::new(hint).style(Style::default().fg(Color::DarkGray).bg(bar_bg)),
            hint_area,
        );
    }

    fn render_rename_bar(&self, frame: &mut Frame, area: Rect) {
        let state = match &self.rename_prompt {
            Some(s) => s,
            None => return,
        };
        let hint = " [Enter] confirm  [Esc] cancel ";
        let hint_w = (hint.len() as u16).min(area.width / 2);
        let input_w = area.width.saturating_sub(hint_w);
        let [input_area, hint_area] = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Length(input_w), Constraint::Length(hint_w)])
            .split(area)[..]
        else {
            return;
        };
        let text = format!(" Rename: {}_", state.input.text());
        frame.render_widget(
            Paragraph::new(text).style(Style::default().fg(Color::White).bg(Color::Rgb(60, 30, 80))),
            input_area,
        );
        frame.render_widget(
            Paragraph::new(hint).style(Style::default().fg(Color::DarkGray).bg(Color::Rgb(40, 20, 60))),
            hint_area,
        );
    }

    fn render_clipboard_picker(&self, frame: &mut Frame, registers: &Registers) {
        let picker = match &self.clipboard_picker {
            Some(p) => p,
            None => return,
        };

        const MAX_ITEMS: usize = 10;
        let history_len = registers.len();
        if history_len == 0 {
            return;
        }

        let term = frame.area();
        let visible_count = history_len.min(MAX_ITEMS);
        // inner rows = items + 1 hint line
        let inner_h = (visible_count as u16) + 1;
        let popup_h = inner_h + 2; // +2 for block borders
        let popup_w = 64u16.min(term.width.saturating_sub(4));
        let x = (term.width.saturating_sub(popup_w)) / 2;
        let y = (term.height.saturating_sub(popup_h)) / 2;
        let popup_area = Rect {
            x,
            y,
            width: popup_w,
            height: popup_h,
        };

        frame.render_widget(Clear, popup_area);

        let block = Block::default()
            .title(" Clipboard History ")
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::Cyan));
        let inner = block.inner(popup_area);
        frame.render_widget(block, popup_area);

        // Hint line (last row of inner area)
        let hint_y = inner.y + inner.height.saturating_sub(1);
        frame.render_widget(
            Paragraph::new(Line::from(vec![
                Span::styled(" ↑↓ navigate", Style::default().fg(Color::DarkGray)),
                Span::styled("  Enter paste", Style::default().fg(Color::DarkGray)),
                Span::styled("  Esc close ", Style::default().fg(Color::DarkGray)),
            ])),
            Rect {
                x: inner.x,
                y: hint_y,
                width: inner.width,
                height: 1,
            },
        );

        // Item rows (all rows above the hint line)
        let items_h = inner.height.saturating_sub(1) as usize;
        let scroll = if picker.cursor >= items_h {
            picker.cursor + 1 - items_h
        } else {
            0
        };

        for (display_idx, (ring_idx, text)) in registers
            .iter()
            .enumerate()
            .skip(scroll)
            .take(items_h)
            .enumerate()
        {
            let row_y = inner.y + display_idx as u16;
            if row_y >= hint_y {
                break;
            }
            let is_selected = ring_idx == picker.cursor;
            let bg = if is_selected {
                Color::DarkGray
            } else {
                Color::Reset
            };
            let fg = if is_selected {
                Color::White
            } else {
                Color::Gray
            };

            // Show only the first line of multi-line entries as preview.
            let preview = text.lines().next().unwrap_or("");
            let label = format!("{:>2}  {}", ring_idx + 1, preview);
            let width = inner.width as usize;
            let padded = format!("{:<width$}", label.chars().take(width).collect::<String>());

            frame.render_widget(
                Paragraph::new(padded).style(Style::default().fg(fg).bg(bg)),
                Rect {
                    x: inner.x,
                    y: row_y,
                    width: inner.width,
                    height: 1,
                },
            );
        }
    }



    #[allow(dead_code)]
    fn render_completion_overlay(
        &self,
        frame: &mut Frame,
        body_area: Rect,
        comp: &CompletionState,
    ) {
        // Compute screen coordinates of the cursor.
        let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
        let scroll = self.buffer.scroll;
        let cursor = self.buffer.cursor();

        if cursor.line < scroll || cursor.line - scroll >= body_area.height as usize {
            return;
        }

        let anchor_x = body_area.x + gutter_w as u16 + cursor.column as u16;
        let anchor_y = body_area.y + (cursor.line - scroll) as u16;

        // Derive filter: text typed since trigger point.
        let filter = self.completion_filter(comp);

        let widget = CompletionWidget {
            items: &comp.items,
            cursor: comp.cursor,
            filter: &filter,
            anchor_x,
            anchor_y,
            terminal_area: frame.area(),
            loading: comp.loading,
        };
        frame.render_widget(widget, frame.area());
    }

    #[allow(dead_code)]
    fn render_hover_overlay(&self, frame: &mut Frame, body_area: Rect, hover: &HoverState) {
        let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
        let scroll = self.buffer.scroll;
        let cursor = self.buffer.cursor();

        if cursor.line < scroll || cursor.line - scroll >= body_area.height as usize {
            return;
        }

        let anchor_x = body_area.x + gutter_w as u16 + cursor.column as u16;
        let anchor_y = body_area.y + (cursor.line - scroll) as u16;

        let widget = HoverWidget {
            text: &hover.text,
            anchor_x,
            anchor_y,
            terminal_area: frame.area(),
        };
        frame.render_widget(widget, frame.area());
    }

    // -----------------------------------------------------------------------
    // Mouse helpers
    // -----------------------------------------------------------------------

    /// Map screen coordinates `(col, row)` to a buffer `Cursor`.
    ///
    /// Returns `None` when the click is outside the body area.  Clicks inside
    /// the gutter are mapped to column 0 of the logical line.
    pub(crate) fn screen_to_cursor(&self, col: u16, row: u16) -> Option<Position> {
        let area = self.last_body_area;
        if area.width == 0 || area.height == 0 {
            return None;
        }
        if row < area.y || row >= area.y + area.height {
            return None;
        }
        if col < area.x {
            return None;
        }

        let visual_row = (row - area.y) as usize;

        // Resolve visual_row → logical line index (accounting for folds).
        // EditorWidget iterates visible_lines, skips those with logical < scroll,
        // then takes up to height — so visual_row maps to visible[visual_row] after
        // filtering out pre-scroll lines.
        let visible: Vec<usize> = self
            .folds
            .visible_lines(&self.buffer.lines())
            .into_iter()
            .filter(|(logical, _)| *logical >= self.buffer.scroll)
            .map(|(logical, _)| logical)
            .collect();
        let logical_row = *visible.get(visual_row)?;

        let lines = self.buffer.lines();
        let line = lines.get(logical_row)?;

        let gw = gutter_width(self.show_line_numbers, self.buffer.line_count());
        let content_x = area.x + gw as u16;

        if col < content_x {
            // Click in the gutter → jump to start of line.
            return Some(Position::new(logical_row, 0));
        }

        // visual_col is a character-column offset within the line (no wide-char
        // handling; source code is almost always ASCII).
        let visual_col = (col - content_x) as usize + self.buffer.scroll_x;

        // Position.column uses character indices, not byte offsets.
        // Clamp to the actual line length in characters.
        let char_col = visual_col.min(line.chars().count());

        Some(Position::new(logical_row, char_col))
    }

    /// Derive the completion filter by extracting buffer text from trigger_cursor
    /// to the current cursor on the same line.
    fn completion_filter(&self, comp: &CompletionState) -> String {
        let cur = self.buffer.cursor();
        let trig = comp.trigger_cursor;
        if cur.line != trig.line || cur.column <= trig.column {
            return String::new();
        }
        // Get the current line text and slice from trigger col to current col.
        if let Some(line) = self.buffer.lines().get(cur.line) {
            let bytes = line.as_bytes();
            let start = trig.column.min(bytes.len());
            let end = cur.column.min(bytes.len());
            if start <= end {
                return String::from_utf8_lossy(&bytes[start..end]).into_owned();
            }
        }
        String::new()
    }
}

// ---------------------------------------------------------------------------
// View trait impl
// ---------------------------------------------------------------------------

impl View for EditorView {
    const KIND: crate::views::ViewKind = crate::views::ViewKind::Primary;

    fn save_state(&mut self, app: &mut crate::app_state::AppState) {
        crate::views::save_state::editor_pre_save(self, app);
    }
    /// Translate key input into operations — no mutation of self.
    fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
        let path = self.buffer.path.clone();

        // If clipboard history picker is open, capture navigation keys.
        if self.clipboard_picker.is_some() {
            return match key.key {
                Key::ArrowUp => vec![Operation::ClipboardLocal(ClipOp::HistoryUp)],
                Key::ArrowDown => vec![Operation::ClipboardLocal(ClipOp::HistoryDown)],
                Key::Enter => vec![Operation::ClipboardLocal(ClipOp::HistoryConfirm)],
                _ => vec![],
            };
        }

        // If go-to-line bar is open, capture all keypresses for it.
        if self.go_to_line.is_some() {
            return match key.key {
                Key::Enter => vec![Operation::GoToLineLocal(GoToLineOp::Confirm)],
                _ => {
                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
                        vec![Operation::GoToLineLocal(GoToLineOp::Input(field_op))]
                    } else {
                        vec![]
                    }
                }
            };
        }

        // If the LSP rename prompt is open, capture all keypresses for it.
        if self.rename_prompt.is_some() {
            return match key.key {
                Key::Enter => {
                    let new_name = self.rename_prompt.as_ref()
                        .map(|r| r.input.text().trim().to_string())
                        .unwrap_or_default();
                    let (row, col) = self.rename_prompt.as_ref()
                        .map(|r| (r.cursor_row, r.cursor_col))
                        .unwrap_or((0, 0));
                    // Close the prompt and fire the rename request.
                    vec![
                        Operation::LspRenameLocal(LspRenameOp::Dismiss),
                        Operation::LspTriggerRenameWith { new_name, row, col },
                    ]
                }
                _ => {
                    if let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
                        vec![Operation::LspRenameLocal(LspRenameOp::Input(field_op))]
                    } else {
                        vec![]
                    }
                }
            };
        }

        // If search bar is open, capture all keypresses for it.
        if let Some(search) = &self.search {
            let on_replacement =
                search.kind == SearchKind::Replace && search.focus.current() == SEARCH_FOCUS_REPLACEMENT;
            let on_include =
                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_INCLUDE;
            let on_exclude =
                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_EXCLUDE;
            let on_tree =
                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_TREE;
            // Legacy panel focus flags — kept for graceful transition.
            let on_files =
                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_FILES;
            let on_matches =
                search.mode == SearchMode::Expanded && search.focus.current() == SEARCH_FOCUS_MATCHES;
            let on_panel = on_tree || on_files || on_matches;
            return match (key.modifiers, key.key) {
                (_, Key::F(3)) if !key.modifiers.contains(Modifiers::SHIFT) => {
                    vec![Operation::SearchLocal(SearchOp::NextMatch)]
                }
                (m, Key::F(3)) if m.contains(Modifiers::SHIFT) => {
                    vec![Operation::SearchLocal(SearchOp::PrevMatch)]
                }
                // Tab / Shift+Tab cycle focus in Replace and Expanded modes
                (_, Key::Tab) if search.kind == SearchKind::Replace || search.mode == SearchMode::Expanded => {
                    vec![Operation::Focus(FocusOp::Next)]
                }
                (_, Key::BackTab) if search.kind == SearchKind::Replace || search.mode == SearchMode::Expanded => {
                    vec![Operation::Focus(FocusOp::Prev)]
                }
                // Up/Down navigate the search tree when it has focus
                (_, Key::ArrowUp) if on_tree => {
                    vec![Operation::SearchLocal(SearchOp::TreeMoveUp)]
                }
                (_, Key::ArrowDown) if on_tree => {
                    vec![Operation::SearchLocal(SearchOp::TreeMoveDown)]
                }
                // Right / Enter expand or activate in tree
                (_, Key::ArrowRight) if on_tree => {
                    vec![Operation::SearchLocal(SearchOp::TreeToggle)]
                }
                // Left collapses or moves to parent in tree
                (_, Key::ArrowLeft) if on_tree => {
                    vec![Operation::SearchLocal(SearchOp::TreeCollapse)]
                }
                // Legacy file/match panel navigation (kept for compat)
                (_, Key::ArrowUp) if on_files => {
                    vec![Operation::SearchLocal(SearchOp::SelectFile(
                        search.selected_file.saturating_sub(1),
                    ))]
                }
                (_, Key::ArrowDown) if on_files => {
                    vec![Operation::SearchLocal(SearchOp::SelectFile(
                        search.selected_file + 1,
                    ))]
                }
                (_, Key::ArrowUp) if on_matches => {
                    vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(-1))]
                }
                (_, Key::ArrowDown) if on_matches => {
                    vec![Operation::SearchLocal(SearchOp::ScrollMatchPanel(1))]
                }
                (_, Key::Enter) => {
                    if on_replacement {
                        vec![Operation::SearchLocal(SearchOp::ReplaceOne)]
                    } else if on_include || on_exclude {
                        vec![Operation::Focus(FocusOp::Next)]
                    } else if on_tree {
                        vec![Operation::SearchLocal(SearchOp::TreeToggle)]
                    } else if on_panel {
                        vec![]
                    } else {
                        vec![
                            Operation::SearchLocal(SearchOp::NextMatch),
                            Operation::SearchLocal(SearchOp::Close),
                        ]
                    }
                }
                (m, Key::Char('c')) if m.contains(Modifiers::ALT) => {
                    vec![Operation::SearchLocal(SearchOp::ToggleIgnoreCase)]
                }
                (m, Key::Char('r')) if m.contains(Modifiers::ALT) => {
                    vec![Operation::SearchLocal(SearchOp::ToggleRegex)]
                }
                (m, Key::Char('s')) if m.contains(Modifiers::ALT) => {
                    vec![Operation::SearchLocal(SearchOp::ToggleSmartCase)]
                }
                (m, Key::Char('a')) if m.contains(Modifiers::ALT) && on_replacement => {
                    vec![Operation::SearchLocal(SearchOp::ReplaceAll)]
                }
                _ => {
                    // Only route to text fields; ignore when a panel/tree has focus
                    if !on_panel
                        && let Some(field_op) = crate::widgets::input_field::key_to_op(key) {
                            let op = if on_replacement {
                                SearchOp::ReplacementInput(field_op)
                            } else if on_include {
                                SearchOp::IncludeGlobInput(field_op)
                            } else if on_exclude {
                                SearchOp::ExcludeGlobInput(field_op)
                            } else {
                                SearchOp::QueryInput(field_op)
                            };
                            return vec![Operation::SearchLocal(op)];
                        }
                    vec![]
                }
            };
        }

        // If completion dropdown is open, intercept navigation keys.
        if self.completion.is_some() {
            log::debug!("editor: completion is open, checking key: {:?}", key.key);
            match key.key {
                Key::ArrowUp => return vec![Operation::LspLocal(LspOp::CompletionMoveUp)],
                Key::ArrowDown => return vec![Operation::LspLocal(LspOp::CompletionMoveDown)],
                Key::Enter => return vec![Operation::LspLocal(LspOp::CompletionConfirm)],
                _ => {} // Fall through to normal key handling
            }
        }

        // If hover box is open, motion keys dismiss it.
        if self.hover.is_some() {
            match key.key {
                Key::ArrowUp
                | Key::ArrowDown
                | Key::ArrowLeft
                | Key::ArrowRight => {
                    return vec![Operation::LspLocal(LspOp::HoverDismiss)];
                }
                _ => {}
            }
        }

        match (key.modifiers, key.key) {
            // Ctrl+Shift+arrow: extend selection by word.
            (m, Key::ArrowLeft)
                if m.contains(Modifiers::CTRL) && m.contains(Modifiers::SHIFT) =>
            {
                vec![Operation::SelectionLocal(SelectionOp::Extend {
                    head: self.buffer.word_boundary_prev(self.buffer.cursor()),
                })]
            }
            (m, Key::ArrowRight)
                if m.contains(Modifiers::CTRL) && m.contains(Modifiers::SHIFT) =>
            {
                vec![Operation::SelectionLocal(SelectionOp::Extend {
                    head: self.buffer.word_boundary_next(self.buffer.cursor()),
                })]
            }

            // Shift+arrow: extend active selection.
            (m, Key::ArrowLeft) if m.contains(Modifiers::SHIFT) => {
                vec![Operation::SelectionLocal(SelectionOp::Extend {
                    head: self.buffer.offset_left(self.buffer.cursor()),
                })]
            }
            (m, Key::ArrowRight) if m.contains(Modifiers::SHIFT) => {
                vec![Operation::SelectionLocal(SelectionOp::Extend {
                    head: self.buffer.offset_right(self.buffer.cursor()),
                })]
            }
            (m, Key::ArrowUp) if m.contains(Modifiers::SHIFT) => {
                vec![Operation::SelectionLocal(SelectionOp::Extend {
                    head: self.buffer.offset_up(self.buffer.cursor()),
                })]
            }
            (m, Key::ArrowDown) if m.contains(Modifiers::SHIFT) => {
                vec![Operation::SelectionLocal(SelectionOp::Extend {
                    head: self.buffer.offset_down(self.buffer.cursor()),
                })]
            }
            (m, Key::Home) if m.contains(Modifiers::SHIFT) => {
                vec![Operation::SelectionLocal(SelectionOp::Extend {
                    head: self.buffer.offset_line_start(self.buffer.cursor()),
                })]
            }
            (m, Key::End) if m.contains(Modifiers::SHIFT) => {
                vec![Operation::SelectionLocal(SelectionOp::Extend {
                    head: self.buffer.offset_line_end(self.buffer.cursor()),
                })]
            }

            (_, Key::ArrowUp) => vec![Operation::MoveCursor {
                path,
                cursor: self.buffer.offset_up(self.buffer.cursor()),
            }],
            (_, Key::ArrowDown) => vec![Operation::MoveCursor {
                path,
                cursor: self.buffer.offset_down(self.buffer.cursor()),
            }],
            (_, Key::ArrowLeft) => vec![Operation::MoveCursor {
                path,
                cursor: self.buffer.offset_left(self.buffer.cursor()),
            }],
            (_, Key::ArrowRight) => vec![Operation::MoveCursor {
                path,
                cursor: self.buffer.offset_right(self.buffer.cursor()),
            }],
            (_, Key::Home) => vec![Operation::MoveCursor {
                path,
                cursor: self.buffer.offset_line_start(self.buffer.cursor()),
            }],
            (_, Key::End) => vec![Operation::MoveCursor {
                path,
                cursor: self.buffer.offset_line_end(self.buffer.cursor()),
            }],
            (_, Key::PageUp) => vec![Operation::MoveCursor {
                path,
                cursor: self.buffer.offset_up_n(self.buffer.cursor(), 20),
            }],
            (_, Key::PageDown) => vec![Operation::MoveCursor {
                path,
                cursor: self.buffer.offset_down_n(self.buffer.cursor(), 20),
            }],

            (_, Key::Enter) => vec![Operation::InsertText {
                path,
                cursor: self.buffer.cursor(),
                text: "\n".into(),
            }],
            (_, Key::Backspace) => vec![Operation::DeleteText {
                path,
                cursor: self.buffer.cursor(),
                len: 1,
            }],
            (m, Key::Tab) if !m.contains(Modifiers::CTRL) => {
                let col = self.buffer.cursor().column;
                let spaces = self.indentation_width - (col % self.indentation_width);
                vec![Operation::InsertText {
                    path,
                    cursor: self.buffer.cursor(),
                    text: " ".repeat(spaces),
                }]
            }
            (_, Key::Delete) => vec![Operation::DeleteForward {
                path,
                cursor: self.buffer.cursor(),
            }],

            (_, Key::Char(c))
                if !key.modifiers.contains(Modifiers::CTRL)
                    || key.modifiers.contains(Modifiers::ALT) =>
            {
                let mut ops = vec![Operation::InsertText {
                    path,
                    cursor: self.buffer.cursor(),
                    text: c.to_string(),
                }];

                // Check if this character is a trigger character that should auto-trigger completion
                // Note: passing empty slice for server_triggers as AppState isn't accessible here
                // Server triggers are used via LSP when Ctrl+Space is pressed
                if is_trigger_character(&c, self.lang_id.as_ref().map(|l| l.as_str()), &[]) {
                    log::debug!("editor: trigger character typed, triggering completion, lang_id={:?}", self.lang_id);
                    ops.push(Operation::LspTriggerCompletion {
                        path: self.buffer.path.clone(),
                        trigger_char: Some(c.to_string()),
                    });
                }

                ops
            }

            _ => vec![],
        }
    }

    fn handle_mouse(&self, mouse: MouseEvent) -> Vec<Operation> {
        // ── Expanded mode: search tree + editor mouse handling ──────────────
        if let Some(search) = &self.search
            && search.mode == SearchMode::Expanded {
                // Search tree click / scroll
                if self.last_file_panel_area.width > 0
                    && hit_test((mouse.column, mouse.row), self.last_file_panel_area)
                {
                    let panel = self.last_file_panel_area;
                    match mouse.kind {
                        MouseEventKind::Down(MouseButton::Left) => {
                            // header row is y+0
                            if mouse.row > panel.y {
                                let rows = build_search_tree(search);
                                let flat_idx = search.tree_scroll
                                    + (mouse.row - panel.y - 1) as usize;
                                if flat_idx < rows.len() {
                                    // First move the cursor to this row, then toggle.
                                    let mut ops = Vec::new();
                                    // We emit MoveUp/Down to reach the target, or we
                                    // can just emit two ops: set cursor + toggle.
                                    // For simplicity, set cursor directly by identity:
                                    match &rows[flat_idx] {
                                        SearchTreeRow::File { file_idx: _, .. } => {
                                            // Move cursor there + toggle
                                            ops.push(Operation::SearchLocal(SearchOp::TreeMoveUp)); // placeholder
                                            ops.push(Operation::SearchLocal(SearchOp::TreeToggle));
                                        }
                                        SearchTreeRow::Match { .. } => {
                                            ops.push(Operation::SearchLocal(SearchOp::TreeToggle));
                                        }
                                    }
                                    // Actually, just return a selectfile-like op for now.
                                    // We need a more precise approach: store the clicked
                                    // index and handle in op.
                                    // For mouse, use SelectFile to set selection by flat idx.
                                    // (SelectFile is repurposed for tree click).
                                    return vec![Operation::SearchLocal(SearchOp::SelectFile(flat_idx))];
                                }
                            }
                            return vec![];
                        }
                        MouseEventKind::ScrollUp => {
                            return vec![Operation::SearchLocal(SearchOp::TreeMoveUp)];
                        }
                        MouseEventKind::ScrollDown => {
                            return vec![Operation::SearchLocal(SearchOp::TreeMoveDown)];
                        }
                        _ => return vec![],
                    }
                }
            }

        // Handle search bar toggle buttons if search is active
        if self.search.is_some()
            && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
                let search_area = self.last_search_bar_area;
                if search_area.width > 0 && hit_test((mouse.column, mouse.row), search_area) {
                    // Search bar buttons are at the right side: " IgnCase  Regex  Smart "
                    // Layout: space(1) + " IgnCase "(9) + space(1) + " Regex "(7) + space(1) + " Smart "(7) + space(1) = 27
                    const BTN_W: u16 = 27;
                    let buttons_start = search_area.x + search_area.width.saturating_sub(BTN_W);
                    let col = mouse.column;

                    if col >= buttons_start {
                        // Button positions (accounting for leading space before each)
                        // " IgnCase " starts at +1, " Regex " at +11, " Smart " at +19
                        let igncase_start = buttons_start + 1;
                        let regex_start = buttons_start + 11;
                        let smart_start = buttons_start + 19;

                        if col >= igncase_start && col < igncase_start + 9 {
                            return vec![Operation::SearchLocal(SearchOp::ToggleIgnoreCase)];
                        }
                        if col >= regex_start && col < regex_start + 7 {
                            return vec![Operation::SearchLocal(SearchOp::ToggleRegex)];
                        }
                        if col >= smart_start && col < smart_start + 7 {
                            return vec![Operation::SearchLocal(SearchOp::ToggleSmartCase)];
                        }
                    }
                }
            }

        match mouse.kind {
            // ── Left button down: single click or double click ───────────────
            MouseEventKind::Down(MouseButton::Left) => {
                let Some(click_pos) = self.screen_to_cursor(mouse.column, mouse.row) else {
                    return vec![];
                };

                let mut state = self.click_state.borrow_mut();
                let now = std::time::Instant::now();

                // Double-click: same screen cell within threshold.
                let is_double = state.count >= 1
                    && state.last_col == mouse.column
                    && state.last_row == mouse.row
                    && state
                        .last_time
                        .map(|t| now.duration_since(t).as_millis() < DOUBLE_CLICK_MS)
                        .unwrap_or(false);

                state.last_col = mouse.column;
                state.last_row = mouse.row;
                state.last_time = Some(now);
                state.count = if is_double { 2 } else { 1 };
                state.dragging = true;
                state.word_drag = is_double;
                drop(state);

                if is_double {
                    let (word_start_col, word_end_col) = self.buffer.word_range_at(click_pos);
                    let word_start = Position::new(click_pos.line, word_start_col);
                    let word_end = Position::new(click_pos.line, word_end_col);
                    vec![
                        Operation::MoveCursor { path: None, cursor: word_start },
                        Operation::SelectionLocal(SelectionOp::Extend { head: word_end }),
                    ]
                } else {
                    // Single click: place cursor, clear selection.
                    vec![
                        Operation::MoveCursor { path: None, cursor: click_pos },
                        Operation::SelectionLocal(SelectionOp::Clear),
                    ]
                }
            }

            // ── Drag: extend selection from anchor ───────────────────────────
            MouseEventKind::Drag(MouseButton::Left) => {
                let state = self.click_state.borrow();
                if !state.dragging {
                    return vec![];
                }
                drop(state);

                let Some(drag_pos) = self.screen_to_cursor(mouse.column, mouse.row) else {
                    return vec![];
                };
                // Extend uses self.buffer.selection.anchor (set on Down) or cursor.
                vec![Operation::SelectionLocal(SelectionOp::Extend { head: drag_pos })]
            }

            // ── Left button up: end drag ─────────────────────────────────────
            MouseEventKind::Up(MouseButton::Left) => {
                self.click_state.borrow_mut().dragging = false;
                vec![]
            }

            // ── Scroll ───────────────────────────────────────────────────────
            MouseEventKind::ScrollUp => vec![Operation::MoveCursor {
                path: None,
                cursor: self.buffer.offset_up_n(self.buffer.cursor(), 3),
            }],
            MouseEventKind::ScrollDown => vec![Operation::MoveCursor {
                path: None,
                cursor: self.buffer.offset_down_n(self.buffer.cursor(), 3),
            }],
            MouseEventKind::Down(MouseButton::Right) => {
                let has_selection = self.buffer.selection().is_some();
                vec![Operation::OpenContextMenu {
                    items: vec![
                        ("Cut".to_string(),   crate::commands::CommandId::new_static("editor", "cut"), Some(has_selection)),
                        ("Copy".to_string(),  crate::commands::CommandId::new_static("editor", "copy"), Some(has_selection)),
                        ("Paste".to_string(), crate::commands::CommandId::new_static("editor", "paste"), None),
                        ("Find".to_string(),  crate::commands::CommandId::new_static("editor", "find"), Some(true)),
                        ("Replace".to_string(), crate::commands::CommandId::new_static("editor", "find_replace"), Some(true)),
                        ("Go to Line".to_string(), crate::commands::CommandId::new_static("editor", "go_to_line"), Some(true)),
                        ("Select All".to_string(), crate::commands::CommandId::new_static("editor", "select_all"), Some(true)),
                        ("Comment/Uncomment".to_string(), crate::commands::CommandId::new_static("editor", "toggle_comment"), Some(true)),
                        ("Format Document".to_string(), crate::commands::CommandId::new_static("editor", "format_document"), Some(true)),
                    ],
                    x: mouse.column,
                    y: mouse.row,
                }]
            }
            _ => vec![],
        }
    }

    /// Apply operations this view owns.  Ignores ops that belong elsewhere.
    fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
        match op {
            Operation::InsertText {
                path,
                cursor: _,
                text,
            } if self.path_matches(path) => {
                let edit_line = self.buffer.cursor().line;
                if text == "\n" {
                    self.buffer.insert_newline_with_indent(self.use_space, self.indentation_width);
                } else {
                    self.buffer.insert(text);
                }
                self.completion = None;
                self.lsp_version = self.lsp_version.wrapping_add(1);
                self.invalidate_highlights_from(edit_line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::DeleteText { path, .. } if self.path_matches(path) => {
                // Backspace at column 0 merges two lines — invalidate from previous line.
                let edit_line = self.buffer.cursor().line.saturating_sub(
                    if self.buffer.cursor().column == 0 { 1 } else { 0 }
                );
                self.buffer.delete_backward();
                self.completion = None;
                self.lsp_version = self.lsp_version.wrapping_add(1);
                self.invalidate_highlights_from(edit_line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::DeleteForward { path, .. } if self.path_matches(path) => {
                let edit_line = self.buffer.cursor().line;
                self.buffer.delete_forward();
                self.completion = None;
                self.lsp_version = self.lsp_version.wrapping_add(1);
                self.invalidate_highlights_from(edit_line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::DeleteWordBackward { path } if self.path_matches(path) => {
                // Word-backward delete may cross a line boundary.
                let edit_line = self.buffer.cursor().line.saturating_sub(1);
                self.buffer.delete_word_backward();
                self.completion = None;
                self.lsp_version = self.lsp_version.wrapping_add(1);
                self.invalidate_highlights_from(edit_line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::DeleteWordForward { path } if self.path_matches(path) => {
                let edit_line = self.buffer.cursor().line;
                self.buffer.delete_word_forward();
                self.completion = None;
                self.lsp_version = self.lsp_version.wrapping_add(1);
                self.invalidate_highlights_from(edit_line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::DeleteLine { path } if self.path_matches(path) => {
                let edit_line = self.buffer.cursor().line;
                self.buffer.delete_line();
                self.completion = None;
                self.lsp_version = self.lsp_version.wrapping_add(1);
                self.invalidate_highlights_from(edit_line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::IndentLines { path } if self.path_matches(path) => {
    log::info!("indent");
                let (start_line, end_line) = if let Some(sel) = self.buffer.selection() {
                    let min = sel.min();
                    let max = sel.max();
                    (min.line, max.line)
                } else {
                    let line = self.buffer.cursor().line;
                    (line, line)
                };

                for line_idx in start_line..=end_line {
                    if let Some(line_text) = self.buffer.line(line_idx) {
                        let leading = line_text.len() - line_text.trim_start().len();
                        let target = if leading % self.indentation_width == 0 {
                            leading + self.indentation_width
                        } else if (leading + 1) % self.indentation_width == 0 {
                            leading.div_ceil(self.indentation_width) * self.indentation_width + self.indentation_width
                        } else {
                            leading.div_ceil(self.indentation_width) * self.indentation_width
                        };
                        let spaces_to_add = target - leading;
                        let to_insert = if self.use_space {
                            " ".repeat(spaces_to_add)
                        } else {
                            "\t".to_string()
                        };
                        self.buffer.replace_range(
                            Position::new(line_idx, leading),
                            Position::new(line_idx, leading),
                            &to_insert,
                        );
                    }
                }

                self.invalidate_highlights_from(start_line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::UnindentLines { path } if self.path_matches(path) => {
    log::info!("unindent");

                    let (start_line, end_line) = if let Some(sel) = self.buffer.selection() {
                    let min = sel.min();
                    let max = sel.max();
                    (min.line, max.line)
                } else {
                    let line = self.buffer.cursor().line;
                    (line, line)
                };

                for line_idx in start_line..=end_line {
                    if let Some(line_text) = self.buffer.line(line_idx) {
                        let leading = line_text.len() - line_text.trim_start().len();
                        let spaces_to_remove = self.indentation_width.min(leading);
                        if spaces_to_remove > 0 {
                            self.buffer.replace_range(
                                Position::new(line_idx, 0),
                                Position::new(line_idx, spaces_to_remove),
                                "",
                            );
                        }
                    }
                }

                self.invalidate_highlights_from(start_line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::ReplaceRange {
                path,
                start,
                end,
                text,
            } if self.path_matches(path) => {
                // Validate: dismiss completion if cursor moved significantly
                // The issue: if user moved cursor after triggering completion,
                // the replace range might be invalid (end < start)
                let current_cursor = self.buffer.cursor();
                if *start > *end || *start > current_cursor {
                    // Cursor moved: dismiss completion without applying
                    self.completion = None;
                    return Some(Event::applied("editor", op.clone()));
                }
                self.buffer.replace_range(*start, *end, text);
                self.completion = None;
                self.lsp_version = self.lsp_version.wrapping_add(1);
                self.invalidate_highlights_from(start.line);
                self.recompute_search_matches();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::MoveCursor { path, cursor } if self.path_matches(path) => {
                // Dismiss completion when cursor moves (navigation keys, clicks)
                // This prevents the crash from invalid ReplaceRange after cursor moves
                self.completion = None;
                self.buffer.set_cursor(*cursor);
                Some(Event::applied("editor", op.clone()))
            }

            Operation::Undo { path } if self.path_matches(path) => {
                self.buffer.undo();
                self.completion = None;
                self.invalidate_all_highlights();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::Redo { path } if self.path_matches(path) => {
                self.buffer.redo();
                self.completion = None;
                self.invalidate_all_highlights();
                Some(Event::applied("editor", op.clone()))
            }

            Operation::ToggleFold { path, line } if self.path_matches(path) => {
                self.folds.toggle(*line, &self.buffer.lines());
                Some(Event::applied("editor", op.clone()))
            }

            Operation::ToggleMarker { path, line } if self.path_matches(path) => {
                let row = *line;
                if let Some(pos) = self.buffer.markers.iter().position(|m| m.line == row) {
                    self.buffer.markers.remove(pos);
                } else {
                    self.buffer.markers.push(crate::editor::buffer::Marker {
                        line: row,
                        label: "●".into(),
                    });
                }
                Some(Event::applied("editor", op.clone()))
            }

            Operation::ToggleWordWrap => {
                self.word_wrap = !self.word_wrap;
                self.buffer.scroll_x = 0;
                Some(Event::applied("editor", op.clone()))
            }

            // --- LSP local ops ---
            Operation::LspLocal(lsp_op) => {
                match lsp_op {
                    LspOp::CompletionLoading { trigger } => {
                        // Show loading state while waiting for LSP response
                        // Validate: only show loading if cursor hasn't moved from trigger point
                        if self.buffer.cursor() != *trigger {
                            return None;
                        }
                        self.completion = Some(CompletionState {
                            items: vec![],
                            cursor: 0,
                            trigger_cursor: *trigger,
                            loading: true,
                        });
                        Some(Event::applied("editor", op.clone()))
                    }

                    LspOp::CompletionResponse { items, trigger, version } => {
                        if let Some(v) = version
                            && *v != self.lsp_version {
                                return None;
                            }
                        let incoming_trigger = trigger.unwrap_or_else(|| self.buffer.cursor());

                        // Validate: only show completion if cursor hasn't moved from trigger point
                        // If cursor moved (e.g., user typed more or moved with arrow keys),
                        // silently dismiss any existing completion
                        if let Some(ref existing) = self.completion
                            && self.buffer.cursor() != existing.trigger_cursor {
                                // Cursor moved - don't show completion
                                return None;
                            }

                        self.completion = Some(CompletionState {
                            items: items.clone(),
                            cursor: 0,
                            trigger_cursor: incoming_trigger,
                            loading: false,
                        });
                        Some(Event::applied("editor", op.clone()))
                    }

                    LspOp::HoverResponse(Some(text)) => {
                        self.hover = Some(HoverState { text: text.clone() });
                        Some(Event::applied("editor", op.clone()))
                    }

                    LspOp::HoverResponse(None) => {
                        self.hover = None;
                        Some(Event::applied("editor", op.clone()))
                    }

                    LspOp::CompletionMoveUp => {
                        // Compute visible count first (immutable borrow), then mutate.
                        let visible_count = if let Some(c) = &self.completion {
                            let f = self.completion_filter(c);
                            c.items
                                .iter()
                                .filter(|item| {
                                    f.is_empty()
                                        || item.label.to_lowercase().contains(&f.to_lowercase())
                                })
                                .count()
                        } else {
                            0
                        };
                        if let Some(c) = &mut self.completion {
                            // Clamp first in case filter shrunk the list.
                            if visible_count > 0 {
                                c.cursor = c.cursor.min(visible_count - 1);
                            }
                            // Wrap-around: going up from 0 wraps to bottom.
                            c.cursor = if c.cursor == 0 {
                                visible_count.saturating_sub(1)
                            } else {
                                c.cursor - 1
                            };
                        }
                        Some(Event::applied("editor", op.clone()))
                    }

                    LspOp::CompletionMoveDown => {
                        // Compute filter and visible count before mutably borrowing.
                        let visible_count = if let Some(c) = &self.completion {
                            let f = self.completion_filter(c);
                            c.items
                                .iter()
                                .filter(|item| {
                                    f.is_empty()
                                        || item.label.to_lowercase().contains(&f.to_lowercase())
                                })
                                .count()
                        } else {
                            0
                        };
                        if let Some(c) = &mut self.completion {
                            // Clamp first in case filter shrunk the list.
                            if visible_count > 0 {
                                c.cursor = c.cursor.min(visible_count - 1);
                            }
                            // Wrap-around: going down from last wraps to top.
                            c.cursor = if visible_count == 0 || c.cursor + 1 >= visible_count {
                                0
                            } else {
                                c.cursor + 1
                            };
                        }
                        Some(Event::applied("editor", op.clone()))
                    }

                    LspOp::CompletionConfirm => {
                        let confirm = self.completion.as_ref().and_then(|c| {
                            let filter = self.completion_filter(c);
                            let filter_lower = filter.to_lowercase();
                            c.items
                                .iter()
                                .filter(|item| {
                                    filter.is_empty()
                                        || item.label.to_lowercase().contains(&filter_lower)
                                })
                                .nth(c.cursor)
                                .map(|item| {
                                    let text = item
                                        .insert_text
                                        .clone()
                                        .unwrap_or_else(|| item.label.clone());
                                    (c.trigger_cursor, text)
                                })
                        });
                        self.completion = None;
                        if let Some((trigger, text)) = confirm {
                            let end_cursor = self.buffer.cursor();
                            // ReplaceRange deletes the filter prefix and inserts the
                            // completion text in one operation, so `pri` + confirm `println`
                            // yields `println` not `priprintln`.
                            self.deferred_ops.push(Operation::ReplaceRange {
                                path: self.buffer.path.clone(),
                                start: trigger,
                                end: end_cursor,
                                text,
                            });
                        }
                        Some(Event::applied("editor", op.clone()))
                    }

                    LspOp::CompletionDismiss => {
                        self.completion = None;
                        Some(Event::applied("editor", op.clone()))
                    }

                    LspOp::HoverDismiss => {
                        self.hover = None;
                        Some(Event::applied("editor", op.clone()))
                    }
                }
            }

            // --- Search local ops ---
            Operation::SearchLocal(sop) => {
                match sop {
                    SearchOp::Open { replace } => {
                        let kind = if *replace {
                            SearchKind::Replace
                        } else {
                            SearchKind::Find
                        };
                        if self.search.is_none() {
                            // Restore last query/opts so the bar opens pre-filled.
                            let (query_text, opts) = self
                                .last_search
                                .as_ref()
                                .map(|s| (s.query.text().to_owned(), s.opts.clone()))
                                .unwrap_or_default();
                            let mut query = InputField::new("Find");
                            if !query_text.is_empty() {
                                query.set_text(query_text);
                            }
                            let focus = if kind == SearchKind::Replace {
                                crate::widgets::focus::FocusRing::new(vec![
                                    SEARCH_FOCUS_QUERY, SEARCH_FOCUS_REPLACEMENT,
                                ])
                            } else {
                                crate::widgets::focus::FocusRing::new(vec![
                                    SEARCH_FOCUS_QUERY,
                                ])
                            };
                            self.search = Some(SearchState {
                                query,
                                replacement: InputField::new("Replace"),
                                kind,
                                mode: SearchMode::default(),
                                focus,
                                opts,
                                matches: vec![],
                                current: 0,
                                files: Vec::new(),
                                file_path_index: HashMap::new(),
                                selected_file: 0,
                                file_panel_scroll: 0,
                                match_panel_scroll: 0,
                                include_filter: InputField::new("incl").with_text("*"),
                                exclude_filter: InputField::new("excl"),
                                project_search_generation: 0,
                                expanded_files: HashSet::new(),
                                tree_cursor_path: None,
                                tree_cursor_match: None,
                                tree_scroll: 0,
                                project_match_cursor: None,
                            });
                            self.recompute_search_matches();
                        } else if let Some(s) = &mut self.search {
                            // If the search bar already exists and the request is a Find
                            // open, toggle to Expanded mode on repeated Ctrl+F presses when
                            // currently Inline. Otherwise preserve existing behavior.
                            if kind == SearchKind::Find && s.kind == SearchKind::Find {
                                if s.mode == SearchMode::Inline {
                                    s.mode = SearchMode::Expanded;
                                    // Expand focus ring to include filter fields and tree.
                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
                                        SEARCH_FOCUS_QUERY,
                                        SEARCH_FOCUS_INCLUDE,
                                        SEARCH_FOCUS_EXCLUDE,
                                        SEARCH_FOCUS_TREE,
                                    ]);
                                } else {
                                    // Already expanded: refocus the query input.
                                    s.focus.set_focus(SEARCH_FOCUS_QUERY);
                                }
                            }

                            s.kind = kind;
                            s.focus.set_focus(SEARCH_FOCUS_QUERY);
                            // Rebuild focus ring for new kind (only when not already Expanded).
                            if s.mode != SearchMode::Expanded {
                                if kind == SearchKind::Replace {
                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
                                        SEARCH_FOCUS_QUERY, SEARCH_FOCUS_REPLACEMENT,
                                    ]);
                                } else {
                                    s.focus = crate::widgets::focus::FocusRing::new(vec![
                                        SEARCH_FOCUS_QUERY,
                                    ]);
                                }
                            }
                        }
                    }
                    SearchOp::Close => {
                        // Move to last_search so F3 still works after closing.
                        self.last_search = self.search.take();
                    }
                    SearchOp::QueryInput(field_op) => {
                        if let Some(s) = &mut self.search {
                            s.query.apply(field_op);
                        }
                        self.recompute_search_matches();
                    }
                    SearchOp::ReplacementInput(field_op) => {
                        if let Some(s) = &mut self.search {
                            s.replacement.apply(field_op);
                        }
                    }
                    SearchOp::IncludeGlobInput(field_op) => {
                        if let Some(s) = &mut self.search {
                            s.include_filter.apply(field_op);
                            s.opts.include_glob = s.include_filter.text().to_owned();
                        }
                    }
                    SearchOp::ExcludeGlobInput(field_op) => {
                        if let Some(s) = &mut self.search {
                            s.exclude_filter.apply(field_op);
                            s.opts.exclude_glob = s.exclude_filter.text().to_owned();
                        }
                    }
                    SearchOp::ToggleIgnoreCase => {
                        if let Some(s) = &mut self.search {
                            s.opts.ignore_case ^= true;
                        }
                        self.recompute_search_matches();
                    }
                    SearchOp::ToggleRegex => {
                        if let Some(s) = &mut self.search {
                            s.opts.regex ^= true;
                        }
                        self.recompute_search_matches();
                    }
                    SearchOp::ToggleSmartCase => {
                        if let Some(s) = &mut self.search {
                            s.opts.smart_case ^= true;
                        }
                        self.recompute_search_matches();
                    }
                    SearchOp::FocusSwitch => {
                        if let Some(s) = &mut self.search {
                            s.focus.focus_next();
                        }
                    }
                    SearchOp::NextMatch => {
                        // In Expanded mode, step through project-wide matches.
                        if self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded) {
                            if let Some(s) = &mut self.search {
                                let total = s.total_project_matches();
                                if total > 0 {
                                    let next = s.project_match_cursor
                                        .map(|c| (c + 1) % total)
                                        .unwrap_or(0);
                                    s.project_match_cursor = Some(next);
                                    if let Some((fi, mi)) = s.project_match_at(next) {
                                        let fm = &s.files[fi];
                                        let ms = &fm.matches[mi];
                                        let col = ms.line_text[..ms.byte_start].chars().count();
                                        // Sync tree cursor to this match.
                                        s.tree_cursor_path = Some(fm.path.clone());
                                        s.tree_cursor_match = Some(mi);
                                        s.expanded_files.insert(fm.path.clone());
                                        self.deferred_ops.push(Operation::GoToProjectMatch {
                                            file: fm.path.clone(),
                                            line: ms.line,
                                            col,
                                        });
                                    }
                                }
                            }
                        } else {
                            // Inline mode — existing logic.
                            let bar_open = self.search.is_some();
                            let cur = self.buffer.cursor();
                            let new_pos = {
                                let state = if bar_open {
                                    self.search.as_mut()
                                } else {
                                    self.last_search.as_mut()
                                };
                                state.filter(|s| !s.matches.is_empty()).map(|s| {
                                    s.current = if bar_open {
                                        (s.current + 1) % s.matches.len()
                                    } else {
                                        s.matches
                                            .iter()
                                            .position(|&(r, c, _)| (r, c) > (cur.line, cur.column))
                                            .unwrap_or(0)
                                    };
                                    let (row, col, _) = s.matches[s.current];
                                    Position::new(row, col)
                                })
                            };
                            if let Some(c) = new_pos {
                                self.buffer.set_cursor(c);
                            }
                        }
                    }
                    SearchOp::PrevMatch => {
                        // In Expanded mode, step backward through project-wide matches.
                        if self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded) {
                            if let Some(s) = &mut self.search {
                                let total = s.total_project_matches();
                                if total > 0 {
                                    let prev = s.project_match_cursor
                                        .map(|c| if c == 0 { total - 1 } else { c - 1 })
                                        .unwrap_or(total - 1);
                                    s.project_match_cursor = Some(prev);
                                    if let Some((fi, mi)) = s.project_match_at(prev) {
                                        let fm = &s.files[fi];
                                        let ms = &fm.matches[mi];
                                        let col = ms.line_text[..ms.byte_start].chars().count();
                                        s.tree_cursor_path = Some(fm.path.clone());
                                        s.tree_cursor_match = Some(mi);
                                        s.expanded_files.insert(fm.path.clone());
                                        self.deferred_ops.push(Operation::GoToProjectMatch {
                                            file: fm.path.clone(),
                                            line: ms.line,
                                            col,
                                        });
                                    }
                                }
                            }
                        } else {
                            // Inline mode — existing logic.
                            let bar_open = self.search.is_some();
                            let cur = self.buffer.cursor();
                            let new_pos = {
                                let state = if bar_open {
                                    self.search.as_mut()
                                } else {
                                    self.last_search.as_mut()
                                };
                                state.filter(|s| !s.matches.is_empty()).map(|s| {
                                    s.current = if bar_open {
                                        s.current.checked_sub(1).unwrap_or(s.matches.len() - 1)
                                    } else {
                                        s.matches
                                            .iter()
                                            .rposition(|&(r, c, _)| (r, c) < (cur.line, cur.column))
                                            .unwrap_or(s.matches.len() - 1)
                                    };
                                    let (row, col, _) = s.matches[s.current];
                                    Position::new(row, col)
                                })
                            };
                            if let Some(c) = new_pos {
                                self.buffer.set_cursor(c);
                            }
                        }
                    }
                    SearchOp::ReplaceOne => {
                        if let Some(s) = &self.search
                            && !s.matches.is_empty()
                        {
                            let (row, start, end) = s.matches[s.current];
                            let replacement = s.replacement.text().to_owned();
                            let path = self.buffer.path.clone();
                            self.deferred_ops.push(Operation::ReplaceRange {
                                path,
                                start: Position::new(row, start),
                                end: Position::new(row, end),
                                text: replacement,
                            });
                        }
                    }
                    SearchOp::ReplaceAll => {
                        if let Some(s) = &self.search {
                            let replacement = s.replacement.text().to_owned();
                            let matches = s.matches.clone();
                            for &(row, start, end) in matches.iter().rev() {
                                self.buffer
                                    .replace_range(Position::new(row, start), Position::new(row, end), &replacement);
                            }
                            self.lsp_version = self.lsp_version.wrapping_add(1);
                        }
                        self.recompute_search_matches();
                    }
                    SearchOp::AddProjectResult { file, result, generation } => {
                        if let Some(s) = &mut self.search {
                            if *generation != s.project_search_generation {
                                // Stale result from a superseded search — discard.
                            } else if let Some(&idx) = s.file_path_index.get(file) {
                                s.files[idx].matches.push(result.clone());
                            } else {
                                let idx = s.files.len();
                                s.file_path_index.insert(file.clone(), idx);
                                s.files.push(FileMatch {
                                    path: file.clone(),
                                    matches: vec![result.clone()],
                                });
                            }
                        }
                    }
                    SearchOp::ClearProjectResults { generation } => {
                        if let Some(s) = &mut self.search {
                            s.files.clear();
                            s.file_path_index.clear();
                            s.selected_file = 0;
                            s.file_panel_scroll = 0;
                            s.match_panel_scroll = 0;
                            s.project_search_generation = *generation;
                            s.expanded_files.clear();
                            s.tree_cursor_path = None;
                            s.tree_cursor_match = None;
                            s.tree_scroll = 0;
                            s.project_match_cursor = None;
                        }
                    }
                    SearchOp::SelectFile(idx) => {
                        if let Some(s) = &mut self.search {
                            // In the tree UI, SelectFile is reused for mouse clicks:
                            // idx is the flat tree index.
                            let rows = build_search_tree(s);
                            let clamped = (*idx).min(rows.len().saturating_sub(1));
                            set_tree_cursor(s, &rows, clamped);
                            // Also update legacy fields for compat.
                            let legacy = (*idx).min(s.files.len().saturating_sub(1));
                            s.selected_file = legacy;
                            s.file_panel_scroll = legacy.saturating_sub(5);
                            s.match_panel_scroll = 0;
                        }
                    }
                    SearchOp::ScrollMatchPanel(delta) => {
                        if let Some(s) = &mut self.search {
                            let total = s.files.get(s.selected_file).map_or(0, |f| f.matches.len());
                            if *delta > 0 {
                                s.match_panel_scroll = (s.match_panel_scroll + *delta as usize).min(total.saturating_sub(1));
                            } else {
                                s.match_panel_scroll = s.match_panel_scroll.saturating_sub((-delta) as usize);
                            }
                        }
                    }
                    SearchOp::TreeMoveUp => {
                        if let Some(s) = &mut self.search {
                            let rows = build_search_tree(s);
                            if rows.is_empty() { return Some(Event::applied("editor", op.clone())); }
                            let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
                            let new = cur.saturating_sub(1);
                            set_tree_cursor(s, &rows, new);
                        }
                    }
                    SearchOp::TreeMoveDown => {
                        if let Some(s) = &mut self.search {
                            let rows = build_search_tree(s);
                            if rows.is_empty() { return Some(Event::applied("editor", op.clone())); }
                            let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
                            let new = (cur + 1).min(rows.len() - 1);
                            set_tree_cursor(s, &rows, new);
                        }
                    }
                    SearchOp::TreeToggle => {
                        if let Some(s) = &mut self.search {
                            let rows = build_search_tree(s);
                            let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
                            if let Some(row) = rows.get(cur) {
                                match row {
                                    SearchTreeRow::File { file_idx, expanded } => {
                                        let path = s.files[*file_idx].path.clone();
                                        if *expanded {
                                            s.expanded_files.remove(&path);
                                        } else {
                                            s.expanded_files.insert(path);
                                        }
                                    }
                                    SearchTreeRow::Match { file_idx, match_idx } => {
                                        let fm = &s.files[*file_idx];
                                        let ms = &fm.matches[*match_idx];
                                        // Convert byte offset to char column.
                                        let col = ms.line_text[..ms.byte_start].chars().count();
                                        self.deferred_ops.push(Operation::GoToProjectMatch {
                                            file: fm.path.clone(),
                                            line: ms.line,
                                            col,
                                        });
                                    }
                                }
                            }
                        }
                    }
                    SearchOp::TreeCollapse => {
                        if let Some(s) = &mut self.search {
                            let rows = build_search_tree(s);
                            let cur = resolve_tree_cursor(&rows, &s.files, &s.tree_cursor_path, &s.tree_cursor_match);
                            if let Some(row) = rows.get(cur) {
                                match row {
                                    SearchTreeRow::File { file_idx, expanded } => {
                                        if *expanded {
                                            let path = s.files[*file_idx].path.clone();
                                            s.expanded_files.remove(&path);
                                        }
                                    }
                                    SearchTreeRow::Match { file_idx, .. } => {
                                        // Move cursor to the parent file header.
                                        let path = s.files[*file_idx].path.clone();
                                        s.tree_cursor_path = Some(path);
                                        s.tree_cursor_match = None;
                                    }
                                }
                            }
                        }
                    }
                }
                Some(Event::applied("editor", op.clone()))
            }

            // --- Go-to-line bar ops ---
            Operation::GoToLineLocal(gop) => {
                match gop {
                    GoToLineOp::Open => {
                        if self.go_to_line.is_none() {
                            self.go_to_line = Some(GoToLineState::new());
                        }
                    }
                    GoToLineOp::Close => {
                        self.go_to_line = None;
                    }
                    GoToLineOp::Confirm => {
                        if let Some(state) = &self.go_to_line
                            && let Some(line) = state.line_number() {
                                let clamped = line.min(self.buffer.line_count().saturating_sub(1));
                                self.buffer.set_cursor(Position::new(clamped, 0));
                            }
                        self.go_to_line = None;
                    }
                    GoToLineOp::Input(field_op) => {
                        if let Some(state) = &mut self.go_to_line {
                            // Only allow digit characters; discard non-numeric input.
                            if let crate::widgets::input_field::InputFieldOp::InsertChar(c) = field_op {
                                if c.is_ascii_digit() {
                                    state.input.apply(field_op);
                                }
                            } else {
                                state.input.apply(field_op);
                            }
                            // Preview: jump the cursor live as the user types.
                            if let Some(line) = state.line_number() {
                                let clamped = line.min(self.buffer.line_count().saturating_sub(1));
                                self.buffer.set_cursor(Position::new(clamped, 0));
                            }
                        }
                    }
                    GoToLineOp::JumpTo { line, column } => {
                        let clamped_line = (*line).min(self.buffer.line_count().saturating_sub(1));
                        self.buffer.set_cursor(Position::new(clamped_line, *column));
                    }
                }
                Some(Event::applied("editor", op.clone()))
            }

            // --- LSP rename local ops ---
            Operation::LspRenameLocal(rename_op) => {
                match rename_op {
                    LspRenameOp::OpenPrompt { current_name } => {
                        let row = self.buffer.cursor().line as u32;
                        let col = self.buffer.cursor().column as u32;
                        self.rename_prompt = Some(RenameState::new(current_name, row, col));
                    }
                    LspRenameOp::Input(field_op) => {
                        if let Some(state) = &mut self.rename_prompt {
                            state.input.apply(field_op);
                        }
                    }
                    LspRenameOp::Dismiss => {
                        self.rename_prompt = None;
                    }
                }
                Some(Event::applied("editor", op.clone()))
            }

            // --- Selection local ops ---
            Operation::SelectionLocal(sel_op) => {
                match sel_op {
                    SelectionOp::Extend { head } => {
                        let anchor = self.buffer.selection()
                            .map(|s| s.anchor)
                            .unwrap_or(self.buffer.cursor());
                        self.buffer.set_selection(Some(Selection { anchor, active: *head }));
                    }
                    SelectionOp::SelectAll => {
                        self.buffer.select_all();
                    }
                    SelectionOp::Clear => {
                        self.buffer.set_selection(None);
                    }
                }
                Some(Event::applied("editor", op.clone()))
            }

            // --- Git diff gutter markers ---
            Operation::SetEditorGitChanges { path, changed_lines } => {
                if self.path_matches(&Some(path.clone())) {
                    self.git_changed_lines = changed_lines.clone();
                }
                None
            }

            // --- Async highlight result (legacy, for backward compat) ---
            Operation::SetEditorHighlights { path, version, start_line, generation, spans } => {
                if self.path_matches(path)
                    && *version == self.buffer.version()
                    && *generation == self.highlight_generation
                {
                    for (i, line_spans) in spans.iter().enumerate() {
                        let line = *start_line + i;
                        self.highlight_cache.insert(line, line_spans.clone());
                        self.pending_highlight_lines.remove(&line);
                        self.stale_highlight_lines.remove(&line);
                    }
                }
                None
            }

            // --- Incremental highlight chunk update ---
            Operation::SetEditorHighlightsChunk { path, version, generation, spans } => {
                if self.path_matches(path)
                    && *version == self.buffer.version()
                    && *generation == self.highlight_generation
                {
                    for (line, line_spans) in spans {
                        self.highlight_cache.insert(*line, line_spans.clone());
                        self.pending_highlight_lines.remove(line);
                        self.stale_highlight_lines.remove(line);
                    }
                }
                None
            }

            // ClipboardLocal ops are handled in app::apply_clipboard_op which
            // has access to AppState.registers and AppState.clipboard.

            // Focus ops for search bar.
            Operation::Focus(focus_op) => {
                if let Some(s) = &mut self.search {
                    match focus_op {
                        FocusOp::Next => s.focus.focus_next(),
                        FocusOp::Prev => s.focus.focus_prev(),
                        _ => {}
                    }
                }
                None
            }

            // Not this view's operation.
            _ => None,
        }
    }

    fn render(&self, frame: &mut Frame, area: Rect, _theme: &crate::theme::Theme) {
        // EditorView is always rendered via render_with_registers in app::render.
        // This stub satisfies the View trait; it is never called from app.rs.
        let _ = (frame, area);
    }

    fn status_bar(
        &self,
        _state: &crate::app_state::AppState,
        bar: &mut crate::widgets::status_bar::StatusBarBuilder,
    ) {
        // Filename + dirty indicator: use path-elision rendering so the
        // filename is shown first and the directory context fills available space.
        match &self.buffer.path {
            Some(p) => {
                bar.file_path(p.clone(), self.buffer.is_dirty());
            }
            None => {
                bar.label("(no file)");
                return; // omit language and cursor when there is no file
            }
        }

        // Language (short, stable identifier).
        let lang = self
            .lang_id
            .as_ref()
            .map(|l| l.as_str())
            .unwrap_or(self.highlighter.syntax_name.as_str())
            .to_owned();
        bar.label(lang);

        // Cursor position (1-indexed line:column).
        let c = self.buffer.cursor();
        bar.label(format!("{}:{}", c.line + 1, c.column + 1));

        // Issue counts — only shown when there are active diagnostics.
        if self.issue_error_count > 0 || self.issue_warning_count > 0 {
            let mut parts = Vec::new();
            if self.issue_error_count > 0 {
                parts.push(format!("● E:{}", self.issue_error_count));
            }
            if self.issue_warning_count > 0 {
                parts.push(format!("▲ W:{}", self.issue_warning_count));
            }
            bar.label(parts.join("  "));
        }

        // Transient editor messages (conflict warning takes priority).
        if let Some(msg) = self.external_conflict_msg.as_deref() {
            bar.label(format!("⚠ {}", msg));
        } else if let Some(msg) = self.status_msg.as_deref() {
            bar.label(msg.to_owned());
        }
    }
}

impl EditorView {
    /// Full render path used by `app::render` — passes the yank ring so the
    /// clipboard history picker reads from the authoritative source.
    pub fn render_with_registers(&mut self, frame: &mut Frame, area: Rect, registers: &Registers, theme: &crate::theme::Theme) {
        let go_to_line_open = self.go_to_line.is_some();
        let rename_open = self.rename_prompt.is_some();
        let is_expanded = !go_to_line_open && !rename_open
            && self.search.as_ref().is_some_and(|s| s.mode == SearchMode::Expanded);

        // ── Expanded mode: search bar on top, tree on left, editor body on right ──
        if is_expanded {
            const TREE_PANEL_W: u16 = 36;
            let v_chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Length(2), Constraint::Min(1)])
                .split(area);

            self.render_search_bar(frame, v_chunks[0]);
            self.last_search_bar_area = v_chunks[0];

            let [panel_area, body_area] = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Length(TREE_PANEL_W), Constraint::Min(1)])
                .split(v_chunks[1])[..]
            else {
                self.last_file_panel_area = Rect::default();
                self.last_match_panel_area = Rect::default();
                self.last_body_area = v_chunks[1];
                self.render_body(frame, v_chunks[1], theme);
                return;
            };

            self.last_file_panel_area = panel_area;
            self.last_match_panel_area = Rect::default();
            self.render_search_tree(frame, panel_area);
            self.last_body_area = body_area;
            self.render_body(frame, body_area, theme);
        } else {
            // ── Inline / closed mode: existing behaviour ─────────────────────
            self.last_file_panel_area = Rect::default();
            self.last_match_panel_area = Rect::default();

            let search_h: u16 = if go_to_line_open || rename_open {
                1
            } else {
                match &self.search {
                    None => 0,
                    Some(s) if s.kind == SearchKind::Replace => 2,
                    Some(_) => 1,
                }
            };
            let body_area = if search_h == 0 {
                self.last_search_bar_area = Rect::default();
                area
            } else {
                let chunks = Layout::default()
                    .direction(Direction::Vertical)
                    .constraints([Constraint::Length(search_h), Constraint::Min(1)])
                    .split(area);
                if rename_open {
                    self.render_rename_bar(frame, chunks[0]);
                    self.last_search_bar_area = Rect::default();
                } else if go_to_line_open {
                    self.render_go_to_line_bar(frame, chunks[0]);
                    self.last_search_bar_area = Rect::default();
                } else {
                    self.render_search_bar(frame, chunks[0]);
                    self.last_search_bar_area = chunks[0];
                }
                chunks[1]
            };
            self.last_body_area = body_area;
            self.render_body(frame, body_area, theme);
        }

        if let Some(comp) = &self.completion {
            let items = comp.items.clone();
            let cursor = comp.cursor;
            let trigger_cursor = comp.trigger_cursor;
            let filter = {
                let comp_ref = self.completion.as_ref().unwrap();
                self.completion_filter(comp_ref)
            };
            let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
            let scroll = self.buffer.scroll;
            let buf_cursor = self.buffer.cursor();
            let body_area = self.last_body_area;
            if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
                let anchor_x = body_area.x + gutter_w as u16 + buf_cursor.column as u16;
                let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
                let _ = trigger_cursor;
                frame.render_widget(
                    CompletionWidget {
                        items: &items,
                        cursor,
                        filter: &filter,
                        anchor_x,
                        anchor_y,
                        terminal_area: area,
                        loading: false,
                    },
                    area,
                );
            }
        }

        if let Some(hover) = &self.hover {
            let text = hover.text.clone();
            let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
            let scroll = self.buffer.scroll;
            let buf_cursor = self.buffer.cursor();
            let body_area = self.last_body_area;
            if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
                let anchor_x = body_area.x + gutter_w as u16 + buf_cursor.column as u16;
                let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
                frame.render_widget(
                    HoverWidget {
                        text: &text,
                        anchor_x,
                        anchor_y,
                        terminal_area: area,
                    },
                    area,
                );
            }
        } else if let Some(text) = self.gutter_issue_texts.get(&self.buffer.cursor().line).cloned() {
            // Show gutter-marker hover when LSP hover is not active.
            let gutter_w = gutter_width(self.show_line_numbers, self.buffer.line_count());
            let scroll = self.buffer.scroll;
            let buf_cursor = self.buffer.cursor();
            let body_area = self.last_body_area;
            if buf_cursor.line >= scroll && buf_cursor.line - scroll < body_area.height as usize {
                let anchor_x = body_area.x + gutter_w as u16;
                let anchor_y = body_area.y + (buf_cursor.line - scroll) as u16;
                frame.render_widget(
                    HoverWidget {
                        text: &text,
                        anchor_x,
                        anchor_y,
                        terminal_area: area,
                    },
                    area,
                );
            }
        }

        self.render_clipboard_picker(frame, registers);
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------





/// Check if a character is a trigger character that should auto-trigger LSP completion.
/// Uses server-provided trigger characters if available (via AppState), falls back to hardcoded list.
pub(crate) fn is_trigger_character(c: &char, lang_id: Option<&str>, _server_triggers: &[String]) -> bool {
    // Note: server_triggers parameter available for future use when we have a way to
    // pass AppState data to EditorView. For now, fall back to hardcoded triggers.
    // The server trigger characters are still used when Ctrl+Space is pressed.

    // Hardcoded common trigger characters that work across many languages
    let common_triggers = ['.', ';', '{', '}', '[', ']', '(', ')', '<', '>', ',', ':', '"', '\''];

    if common_triggers.contains(c) {
        match lang_id {
            Some("rust") | Some("python") | Some("javascript") | Some("typescript")
            | Some("cpp") | Some("c") | Some("go") | Some("java") | Some("json")
            | Some("yaml") | Some("toml") | Some("html") | Some("css") => true,
            Some(_) => true,
            None => *c == '.',
        }
    } else {
        false
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::editor::fold::FoldState;

    fn make_editor() -> (EditorView, crate::settings::Settings) {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let buf = Buffer::open(&path).unwrap();
        let settings = crate::settings::Settings::new(dir.path().join("config.yaml").as_path())
            .expect("Settings::new failed in test");
        // Keep dir alive by leaking — acceptable in tests
        std::mem::forget(dir);
        (EditorView::open(buf, FoldState::default(), &settings), settings)
    }

    #[test]
    fn completion_move_down_up() {
        let (mut ed, settings) = make_editor();
        ed.completion = Some(CompletionState {
            items: vec![
                crate::operation::LspCompletionItem {
                    label: "foo".into(),
                    kind: None,
                    detail: None,
                    insert_text: None,
                },
                crate::operation::LspCompletionItem {
                    label: "bar".into(),
                    kind: None,
                    detail: None,
                    insert_text: None,
                },
            ],
            cursor: 0,
            trigger_cursor: Position::default(),
            loading: false,
        });

        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);

        // Past end wraps to top.
        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
        assert_eq!(ed.completion.as_ref().unwrap().cursor, 0);

        // Move down again to get back to 1.
        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveDown), &settings);
        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);

        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveUp), &settings);
        assert_eq!(ed.completion.as_ref().unwrap().cursor, 0);

        // Past top wraps to bottom.
        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionMoveUp), &settings);
        assert_eq!(ed.completion.as_ref().unwrap().cursor, 1);
    }

    #[test]
    fn completion_dismiss() {
        let (mut ed, settings) = make_editor();
        ed.completion = Some(CompletionState {
            items: vec![],
            cursor: 0,
            trigger_cursor: Position::default(),
            loading: false,
        });
        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionDismiss), &settings);
        assert!(ed.completion.is_none());
    }

    #[test]
    fn hover_dismiss() {
        let (mut ed, settings) = make_editor();
        ed.hover = Some(HoverState {
            text: "docs".into(),
        });
        ed.handle_operation(&Operation::LspLocal(LspOp::HoverDismiss), &settings);
        assert!(ed.hover.is_none());
    }

    #[test]
    fn completion_confirm_produces_deferred_op() {
        let (mut ed, settings) = make_editor();
        ed.completion = Some(CompletionState {
            items: vec![crate::operation::LspCompletionItem {
                label: "println".into(),
                kind: None,
                detail: None,
                insert_text: Some("println!($1)".into()),
            }],
            cursor: 0,
            trigger_cursor: Position::default(),
            loading: false,
        });
        ed.handle_operation(&Operation::LspLocal(LspOp::CompletionConfirm), &settings);
        assert!(ed.completion.is_none());
        let deferred = ed.take_deferred_ops();
        assert_eq!(deferred.len(), 1);
        assert!(
            matches!(&deferred[0], Operation::ReplaceRange { text, .. } if text == "println!($1)")
        );
    }

    // -----------------------------------------------------------------------
    // screen_to_cursor tests
    // -----------------------------------------------------------------------

    /// Build a minimal EditorView whose body area and buffer are fully controlled.
    fn make_editor_with_lines(lines: &[&str]) -> EditorView {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("test.txt");
        std::fs::write(&path, lines.join("\n")).unwrap();
        let buf = Buffer::open(&path).unwrap();
        let settings = crate::settings::Settings::new(dir.path().join("config.yaml").as_path())
            .expect("Settings::new failed");
        std::mem::forget(dir);
        let mut ed = EditorView::open(buf, FoldState::default(), &settings);
        // Disable line numbers so gutter_w = 4 (constant, easier to reason about).
        ed.show_line_numbers = false;
        // Set a fixed body area: x=0, y=0, width=40, height=20.
        ed.last_body_area = Rect { x: 0, y: 0, width: 40, height: 20 };
        ed
    }

    #[test]
    fn click_outside_body_returns_none() {
        let ed = make_editor_with_lines(&["hello"]);
        // Row below body area (height=20, so row 20 is out).
        assert!(ed.screen_to_cursor(5, 20).is_none());
        // Col before area.x=0 is impossible (u16), but area with offset:
        let mut ed2 = make_editor_with_lines(&["hello"]);
        ed2.last_body_area = Rect { x: 5, y: 2, width: 40, height: 20 };
        assert!(ed2.screen_to_cursor(3, 2).is_none()); // col < area.x
        assert!(ed2.screen_to_cursor(5, 1).is_none()); // row < area.y
    }

    #[test]
    fn click_in_gutter_returns_col_zero() {
        // gutter_w = 4 (no line numbers), content starts at col 4.
        let ed = make_editor_with_lines(&["hello world"]);
        // Click at col=2 (inside gutter) → col 0 of line 0.
        assert_eq!(ed.screen_to_cursor(2, 0), Some(Position::new(0, 0)));
        assert_eq!(ed.screen_to_cursor(0, 0), Some(Position::new(0, 0)));
    }

    #[test]
    fn click_maps_to_correct_char() {
        // gutter_w=4 → content starts at col 4.
        // Col 4 → char 0, col 5 → char 1, ...
        let ed = make_editor_with_lines(&["hello"]);
        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0))); // 'h'
        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 1))); // 'e'
        assert_eq!(ed.screen_to_cursor(8, 0), Some(Position::new(0, 4))); // 'o'
    }

    #[test]
    fn click_past_end_of_line_clamps_to_line_len() {
        let ed = make_editor_with_lines(&["hi"]);
        // "hi" is 2 bytes; clicking at col 4+10 = char 10 → clamps to len=2.
        assert_eq!(ed.screen_to_cursor(14, 0), Some(Position::new(0, 2)));
    }

    #[test]
    fn click_selects_correct_row() {
        let ed = make_editor_with_lines(&["line0", "line1", "line2"]);
        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0)));
        assert_eq!(ed.screen_to_cursor(4, 1), Some(Position::new(1, 0)));
        assert_eq!(ed.screen_to_cursor(4, 2), Some(Position::new(2, 0)));
    }

    #[test]
    fn scroll_offsets_row_mapping() {
        let mut ed = make_editor_with_lines(&["a", "b", "c", "d", "e"]);
        // Scroll down by 2: visual row 0 → logical row 2.
        ed.buffer.scroll = 2;
        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(2, 0)));
        assert_eq!(ed.screen_to_cursor(4, 1), Some(Position::new(3, 0)));
    }

    #[test]
    fn scroll_x_offsets_col_mapping() {
        let mut ed = make_editor_with_lines(&["abcdef"]);
        // Horizontal scroll by 2: col 4 on screen → char index 0+2=2 in line → 'c'.
        ed.buffer.scroll_x = 2;
        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 2)));
        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 3)));
    }

    #[test]
    fn multibyte_utf8_maps_by_char() {
        // "éàü" — each is 2 bytes in UTF-8 but 1 character each.
        let ed = make_editor_with_lines(&["éàü"]);
        // col=4 → char 0
        assert_eq!(ed.screen_to_cursor(4, 0), Some(Position::new(0, 0)));
        // col=5 → char 1 (é is at screen col 5)
        assert_eq!(ed.screen_to_cursor(5, 0), Some(Position::new(0, 1)));
        // col=6 → char 2
        assert_eq!(ed.screen_to_cursor(6, 0), Some(Position::new(0, 2)));
    }

    // -----------------------------------------------------------------------
    // Tests — SetEditorGitChanges operation
    // -----------------------------------------------------------------------

    #[test]
    fn set_editor_git_changes_populates_changed_lines() {
        let (mut ed, settings) = make_editor();
        assert!(ed.git_changed_lines.is_empty(), "should start empty");

        let path = ed.buffer.path.clone().unwrap();
        let mut changed = std::collections::HashSet::new();
        changed.insert(0usize);
        changed.insert(3usize);

        ed.handle_operation(
            &Operation::SetEditorGitChanges { path, changed_lines: changed.clone() },
            &settings,
        );

        assert_eq!(ed.git_changed_lines, changed);
    }

    #[test]
    fn set_editor_git_changes_ignores_wrong_path() {
        let (mut ed, settings) = make_editor();
        let wrong_path = std::path::PathBuf::from("/totally/wrong/path.rs");
        let mut changed = std::collections::HashSet::new();
        changed.insert(5usize);

        ed.handle_operation(
            &Operation::SetEditorGitChanges { path: wrong_path, changed_lines: changed },
            &settings,
        );

        // Wrong path → git_changed_lines must remain empty.
        assert!(ed.git_changed_lines.is_empty(), "wrong path should not update changed lines");
    }

    #[test]
    fn set_editor_git_changes_replaces_previous_set() {
        let (mut ed, settings) = make_editor();
        let path = ed.buffer.path.clone().unwrap();

        // First update.
        let mut first = std::collections::HashSet::new();
        first.insert(1usize);
        first.insert(2usize);
        ed.handle_operation(
            &Operation::SetEditorGitChanges { path: path.clone(), changed_lines: first },
            &settings,
        );
        assert!(ed.git_changed_lines.contains(&1));
        assert!(ed.git_changed_lines.contains(&2));

        // Second update replaces the first entirely.
        let mut second = std::collections::HashSet::new();
        second.insert(7usize);
        ed.handle_operation(
            &Operation::SetEditorGitChanges { path: path.clone(), changed_lines: second },
            &settings,
        );
        assert!(!ed.git_changed_lines.contains(&1), "old line 1 should be gone");
        assert!(!ed.git_changed_lines.contains(&2), "old line 2 should be gone");
        assert!(ed.git_changed_lines.contains(&7));
    }
}
