Skip to main content

xei_core/
app.rs

1use std::env;
2use std::fs;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6use crate::buffer::{Buffer, Position};
7use crate::completion::Completions;
8use crate::config;
9use crate::explorer::Explorer;
10use crate::fold::FoldState;
11use crate::git::{GitBlame, GitGutter};
12use crate::multi_cursor::MultiCursor;
13use crate::lsp::LspClient;
14use crate::git_workbench::GitWorkbench;
15use crate::preview::PreviewState;
16use crate::scm::ScmPanel;
17use crate::session::{self, Session, SessionFile};
18use crate::settings::SettingsPanel;
19use crate::nav::{FindKind, Jump, JumpList, LastFind, Marks};
20use crate::ops::{
21    self, delete_range, extract_text, range_for_motion, range_for_textobject, LastChange, Motion,
22    Operator, TextObject,
23};
24use crate::macros::MacroBank;
25use crate::palette::{Palette, PaletteAction};
26use crate::registers::Registers;
27use crate::substitute::{self, SubstituteCmd};
28use crate::syntax::SyntaxEngine;
29use crate::term::Terminal;
30use crate::theme::{self, Theme, OCEAN};
31use crate::undo::UndoStack;
32use crate::xlc::{Xlc, XlcCmd};
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum Mode {
36    Normal,
37    Insert,
38    Visual,
39    VisualLine,
40    VisualBlock,
41    XlcInput,
42    Search,
43    Explorer,
44    Terminal,
45    Palette,
46    /// Light Source Control panel (Ctrl+G) — stage / commit / graph
47    SourceControl,
48    /// Full Git workbench (Ctrl+Shift+G) — branch / sync / diff / stash
49    GitWorkbench,
50    /// Unified settings (Ctrl+,) — About / Setting / Help
51    Settings,
52    /// Pretty document preview (Markdown / JSON) — Ctrl+Shift+V
53    Preview,
54    /// Workspace find / replace (Ctrl+Shift+F)
55    WorkspaceSearch,
56    /// `:screensaver` / xeifetch splash
57    Screensaver,
58    /// DAP debugger panel (F5 / Ctrl+Shift+D)
59    Debug,
60    /// LSP call hierarchy panel
61    CallHierarchy,
62    /// Interactive git rebase planner
63    Rebase,
64    /// PR multi-file review surface
65    PrReview,
66    /// Live self-benchmark results screen (`:bench`)
67    Bench,
68}
69
70/// Sampled resource usage of the xei process, filled by the frontend for the
71/// `:status` line. GPU is `None` where no per-process figure is obtainable
72/// (e.g. macOS without elevated tooling) and renders as `—`.
73#[derive(Debug, Clone, Copy, Default)]
74pub struct ProcMetrics {
75    /// CPU busy normalized to one core (100% = one full core, like `top`).
76    pub cpu_pct: f32,
77    /// Total logical cores, so the UI can also show cores-in-use (cpu%/100).
78    pub cores: u32,
79    pub mem_pct: f32,
80    pub mem_mb: f32,
81    pub gpu_pct: Option<f32>,
82    /// Set once the first sample lands, so the UI can show `…` until then.
83    pub sampled: bool,
84}
85
86pub struct App {
87    pub running: bool,
88    pub mode: Mode,
89    pub buffer: Buffer,
90    pub message: String,
91    pub filename: Option<PathBuf>,
92    pub scroll: usize,
93    pub xlc: Xlc,
94    pub undo_stack: UndoStack,
95    /// Deprecated alias surface: prefer `registers`. Kept in sync with unnamed.
96    pub yank_buffer: Option<String>,
97    pub registers: Registers,
98    pub marks: Marks,
99    pub jumps: JumpList,
100    pub last_find: Option<LastFind>,
101    /// After `"` waiting for register name
102    pub pending_register: bool,
103    /// After `m` waiting for mark name
104    pub pending_mark_set: bool,
105    /// After `'` or `` ` `` waiting for mark name (`true` = linewise `'`)
106    pub pending_mark_jump: Option<bool>,
107    pub pending_key: Option<char>,
108    pub pending_ft: Option<char>,
109    pub count: Option<usize>,
110    pub pending_hints: Vec<(&'static str, &'static str)>,
111    /// Which-key delay + Space-leader path.
112    pub which_key: crate::which_key::WhichKeyState,
113    pub visual_anchor: Option<Position>,
114    /// Last committed search pattern (used by n/N after leaving search mode).
115    pub search_pattern: Option<String>,
116    /// Live query while in Search mode (does not touch `search_pattern` until commit).
117    pub search_input: String,
118    pub search_matches: Vec<Position>,
119    pub search_current: usize,
120    /// Cursor when `/` was pressed — restored on Esc cancel.
121    pub search_origin: Option<Position>,
122    pub search_scroll_origin: usize,
123    /// Pattern that existed before this search session (restored on cancel).
124    search_pattern_backup: Option<String>,
125    /// `true` = forward `/`, `false` = reverse `?`
126    pub search_forward: bool,
127    pub completions: Completions,
128    pub modified: bool,
129    pub mouse: MouseState,
130    pub viewport: EditorViewport,
131    pub explorer: Explorer,
132    pub terminal: Terminal,
133    pub explorer_width: u16,
134    pub terminal_width: u16,
135    pub resize_target: Option<ResizeTarget>,
136    pub explorer_separator_x: u16,
137    pub terminal_separator_x: u16,
138    pub screen_width: u16,
139    pub screen_height: u16,
140    pub theme: &'static Theme,
141    pub xlc_height: u16,
142    pub xlc_separator_y: u16,
143    pub file_mtime: Option<std::time::SystemTime>,
144    pub buffers: Vec<BufferTab>,
145    pub current_buffer: usize,
146    pub syntax: SyntaxEngine,
147    pub lsp: LspClient,
148    pub debug: bool,
149    /// `:status` — show live CPU/MEM/GPU of this process in the status line.
150    /// The frontend samples (platform-specific) and writes into `metrics`; core
151    /// only owns the toggle + the last-sampled snapshot for rendering.
152    pub show_metrics: bool,
153    pub metrics: ProcMetrics,
154    /// Latest `:bench` results (shown in `Mode::Bench`).
155    pub bench_report: Option<crate::bench::BenchReport>,
156    /// Last change for `.` repeat
157    pub last_change: Option<LastChange>,
158    /// Pending operator (`d`/`c`/`y`) while waiting for motion/object
159    pub pending_operator: Option<Operator>,
160    /// Pending text-object modifier `i`/`a` after operator
161    pub pending_to_mod: Option<char>,
162    /// Tab bar hit regions for mouse (filled by UI each frame)
163    pub tab_hit_regions: Vec<(u16, u16, usize)>, // x_start, x_end, tab_index
164    pub tab_bar_y: u16,
165    /// Screen-row → buffer-row map for the current frame (handles soft-wrap).
166    /// Index 0 = `viewport.text_y`. Built in the TUI draw path.
167    pub screen_row_to_buffer: Vec<usize>,
168    /// For each screen row, visual-column base within that buffer line
169    /// (0, text_width, 2*text_width, …). Parallel to `screen_row_to_buffer`.
170    pub screen_row_visual_base: Vec<usize>,
171    pub palette: Palette,
172    /// Hover popup text (LSP)
173    pub hover_text: Option<String>,
174    /// Double-click tracking (ms-ish ticks via counter)
175    pub last_click: Option<(u16, u16, std::time::Instant)>,
176    pub macros: MacroBank,
177    pub tab_width: usize,
178    pub clipboard_sync: bool,
179    pub relative_number: bool,
180    /// Soft-wrap long lines; false = horizontal scroll via `hscroll`.
181    pub wrap_lines: bool,
182    /// Persist undo history to ~/.xei/undo on close (config `undo_caching`).
183    pub undo_caching: bool,
184    /// Per-feature GPU toggles under `gpu_acc`.
185    pub gpu_graphics: bool,
186    pub gpu_hyperlinks: bool,
187    /// Horizontal pan (visual columns) when wrap_lines is off.
188    pub hscroll: usize,
189    /// Last buffer version handed to the syntax highlighter (render cache).
190    pub syntax_seen_version: u64,
191    /// Last buffer version pushed to the LSP (didChange gate).
192    lsp_synced_version: u64,
193    /// Git gutter signs for the current file
194    pub git: GitGutter,
195    /// Optional git blame overlay (`gb` toggle)
196    pub blame: GitBlame,
197    /// Indent-based folds (`za` / `zc` / `zo` / `zM` / `zR`)
198    pub folds: FoldState,
199    /// Extra carets (primary = `buffer.cursor`)
200    pub multi: MultiCursor,
201    /// Light Source Control side panel (Ctrl+G)
202    pub scm: ScmPanel,
203    /// Full Git workbench (Ctrl+Shift+G)
204    pub git_wb: GitWorkbench,
205    /// Settings — About / Setting / Help (Ctrl+,)
206    pub settings: SettingsPanel,
207    /// Pretty preview pane (Markdown / JSON / media)
208    pub preview: PreviewState,
209    /// Kitty image asset for PreviewKind::Image
210    pub preview_image: Option<crate::media::ImageAsset>,
211    /// Audio player for PreviewKind::Audio
212    pub preview_audio: Option<crate::media::AudioPlayer>,
213    /// Editor splits (Ctrl+W v/s)
214    pub split: crate::split::SplitState,
215    /// Peek definition overlay
216    pub peek: crate::peek::PeekState,
217    /// Workspace find/replace panel
218    pub workspace_search: crate::workspace_search::WorkspaceSearch,
219    /// `:screensaver` xeifetch overlay
220    pub screensaver: crate::screensaver::Screensaver,
221    /// Desktop pet GIF overlay
222    pub pet: crate::pet::PetState,
223    /// Pane hit regions filled each frame: (x, y, w, h, pane_idx)
224    pub pane_hit_regions: Vec<(u16, u16, u16, u16, usize)>,
225    /// Split separator for mouse drag-resize (filled by UI each frame).
226    pub split_sep_hit: Option<SplitSepHit>,
227    /// Git workbench Log rows: (x, y, w, h, commit_index) for right-click menus
228    pub git_log_hits: Vec<(u16, u16, u16, u16, usize)>,
229    /// Git toolbar chips: (x, y, w, h, key 1..=8)
230    pub git_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
231    /// DAP panel tab hits: (x, y, w, h, pane_id 0..3)
232    pub dap_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
233    /// DAP list row hits: (x, y, w, h, row_index)
234    pub dap_row_hits: Vec<(u16, u16, u16, u16, usize)>,
235    /// DAP panel body rect for mouse (x, y, w, h)
236    pub dap_panel_rect: Option<(u16, u16, u16, u16)>,
237    /// Terminal rect (side panel / full window / pane-bound) for wheel routing
238    pub terminal_rect: Option<(u16, u16, u16, u16)>,
239    /// Inline preview images wanted this frame: (path, x, y, w_cells, rows).
240    pub preview_gfx: Vec<(String, u16, u16, u16, u16)>,
241    /// PR review tab chips: (x, y, w, h, tab 0=Files 1=Comments 2=Body)
242    pub pr_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
243    /// PR review list rows: (x, y, w, h, row index)
244    pub pr_row_hits: Vec<(u16, u16, u16, u16, usize)>,
245    /// Git docked columns: (x, y, w, h, pane_id 0=Changes 1=Log 2=Files)
246    pub git_pane_hits: Vec<(u16, u16, u16, u16, u8)>,
247    /// Editor right-click context menu (Insert / Normal / Visual)
248    pub editor_ctx: Option<EditorContextMenu>,
249    /// Show LSP inlay hints when available
250    pub inlay_hints_enabled: bool,
251    /// Code actions awaiting palette selection (Ctrl+.)
252    pub code_action_bank: Vec<crate::lsp::CodeActionItem>,
253    /// GPU-terminal enhancements (Ghostty/Kitty sync, undercurl, graphics…)
254    pub gpu_acc: bool,
255    /// Which-key style chord hints after prefix keys.
256    pub key_hints: bool,
257    /// DAP debugger client + panel state.
258    pub dap: crate::dap::DapClient,
259    /// Call hierarchy panel (gC / SPC l c).
260    pub call_hierarchy: crate::call_hierarchy::CallHierarchyState,
261    /// Interactive rebase planner.
262    pub rebase: crate::rebase::RebaseState,
263    /// PR review (files + comments + diff).
264    pub pr_review: crate::pr_review::PrReviewState,
265    /// Plugin hooks (`~/.xei/hooks.toml`).
266    pub hooks: crate::hooks::HooksConfig,
267    /// Release check + self-update (welcome notice · :update).
268    pub update: crate::update::UpdateState,
269    /// Hook results from background threads (drained by poll_hook_messages).
270    hook_msg_tx: std::sync::mpsc::Sender<String>,
271    hook_msg_rx: std::sync::mpsc::Receiver<String>,
272    /// Async git gutter/blame refresh (latest generation wins).
273    #[allow(clippy::type_complexity)]
274    git_refresh_rx: Option<
275        std::sync::mpsc::Receiver<(
276            u64,
277            String,
278            (bool, std::collections::HashMap<usize, crate::git::GitSign>),
279            Option<(bool, std::collections::HashMap<usize, crate::git::BlameLine>)>,
280        )>,
281    >,
282    git_refresh_gen: u64,
283    /// Show LSP code lenses in the editor.
284    pub code_lens_enabled: bool,
285    /// Detected terminal capabilities (filled by TUI shell at startup).
286    /// Core only stores a simple summary string so headless tests stay free of
287    /// crossterm queries; detailed flags live in the TUI `term_caps` module.
288    pub term_caps_summary: String,
289    pub term_sync: bool,
290    pub term_undercurl: bool,
291    pub term_underline_color: bool,
292    pub term_hyperlinks: bool,
293    /// Physical pixels per cell (from the frontend probe; 0 = unknown → 14).
294    pub cell_px: u32,
295    pub cell_px_h: u32,
296    pub term_modern: bool,
297    /// Terminal speaks Kitty graphics protocol (Ghostty/Kitty/WezTerm).
298    pub term_kitty_graphics: bool,
299    /// While replaying a macro — suppress nested recording
300    pub replaying_macro: bool,
301    /// Pending rename: new name input via XLC or message
302    pub rename_pending: bool,
303    /// Last document state pushed to the LSP via didChange (path + text hash).
304    /// `sync_lsp_document` uses these to send post-edit full-text syncs exactly
305    /// once per change instead of the old pre-edit push_undo notification.
306    lsp_synced_path: Option<PathBuf>,
307    lsp_synced_hash: u64,
308}
309
310#[derive(Clone)]
311pub struct BufferTab {
312    pub buffer: Buffer,
313    pub filename: Option<PathBuf>,
314    pub scroll: usize,
315    pub modified: bool,
316    pub undo_stack: UndoStack,
317    pub file_mtime: Option<std::time::SystemTime>,
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq)]
321pub enum ResizeTarget {
322    Explorer,
323    Terminal,
324    Xlc,
325    /// Drag the split divider between editor panes
326    Split,
327}
328
329/// Hit target for the split divider (mouse drag resize).
330#[derive(Clone, Copy, Debug)]
331pub struct SplitSepHit {
332    /// True = vertical split (left|right), divider is a column.
333    pub vertical: bool,
334    /// Screen x (vertical) or y (horizontal) of the divider line.
335    pub pos: u16,
336    /// Parent split area origin + size — used to compute ratio on drag.
337    pub area_x: u16,
338    pub area_y: u16,
339    pub area_w: u16,
340    pub area_h: u16,
341}
342
343#[derive(Clone, Copy, Debug, Default)]
344pub struct MouseState {
345    pub dragging: bool,
346    pub drag_anchor: Option<Position>,
347}
348
349/// Right-click menu over the editor buffer.
350#[derive(Debug, Clone)]
351pub struct EditorContextMenu {
352    pub x: u16,
353    pub y: u16,
354    pub sel: usize,
355    pub items: Vec<EditorCtxItem>,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub enum EditorCtxItem {
360    Cut,
361    Copy,
362    Paste,
363    SelectAll,
364    Undo,
365    Redo,
366    GoToDefinition,
367    FormatDocument,
368    CommandPalette,
369}
370
371impl EditorCtxItem {
372    pub fn label(self) -> &'static str {
373        match self {
374            EditorCtxItem::Cut => "Cut",
375            EditorCtxItem::Copy => "Copy",
376            EditorCtxItem::Paste => "Paste",
377            EditorCtxItem::SelectAll => "Select All",
378            EditorCtxItem::Undo => "Undo",
379            EditorCtxItem::Redo => "Redo",
380            EditorCtxItem::GoToDefinition => "Go to Definition",
381            EditorCtxItem::FormatDocument => "Format Document",
382            EditorCtxItem::CommandPalette => "Command Palette…",
383        }
384    }
385    pub fn key_hint(self) -> &'static str {
386        match self {
387            EditorCtxItem::Cut => "⌘X",
388            EditorCtxItem::Copy => "⌘C",
389            EditorCtxItem::Paste => "⌘V",
390            EditorCtxItem::SelectAll => "⌘A",
391            EditorCtxItem::Undo => "u",
392            EditorCtxItem::Redo => "^R",
393            EditorCtxItem::GoToDefinition => "gd",
394            EditorCtxItem::FormatDocument => "^⇧I",
395            EditorCtxItem::CommandPalette => "⇧⌘P",
396        }
397    }
398}
399
400#[derive(Clone, Copy, Debug, Default)]
401pub struct EditorViewport {
402    pub x: u16,
403    pub y: u16,
404    pub width: u16,
405    pub height: u16,
406    /// X of first text column (after line-number gutter).
407    pub text_x: u16,
408    /// Y of first editor content row (same as `y` when borderless).
409    pub text_y: u16,
410}
411
412impl Default for App {
413    fn default() -> Self {
414        let (hook_msg_tx, hook_msg_rx) = std::sync::mpsc::channel();
415        Self {
416            running: true,
417            mode: Mode::Normal,
418            buffer: Buffer::new(),
419            message: String::from("Welcome to xei! i=insert :=XLC h/j/k/l=move"),
420            filename: None,
421            scroll: 0,
422            xlc: Xlc::new(),
423            undo_stack: UndoStack::new(),
424            yank_buffer: None,
425            registers: Registers::new(),
426            marks: Marks::new(),
427            jumps: JumpList::new(),
428            last_find: None,
429            pending_register: false,
430            pending_mark_set: false,
431            pending_mark_jump: None,
432            pending_key: None,
433            pending_ft: None,
434            count: None,
435            pending_hints: Vec::new(),
436            which_key: crate::which_key::WhichKeyState::default(),
437            visual_anchor: None,
438            search_pattern: None,
439            search_input: String::new(),
440            search_matches: Vec::new(),
441            search_current: 0,
442            search_origin: None,
443            search_scroll_origin: 0,
444            search_pattern_backup: None,
445            search_forward: true,
446            completions: Completions::new(),
447            modified: false,
448            mouse: MouseState::default(),
449            viewport: EditorViewport::default(),
450            explorer: Explorer::new(),
451            terminal: Terminal::new(),
452            explorer_width: 22,
453            terminal_width: 30,
454            resize_target: None,
455            explorer_separator_x: 0,
456            terminal_separator_x: 0,
457            screen_width: 80,
458            screen_height: 24,
459            theme: &OCEAN,
460            xlc_height: 11,
461            xlc_separator_y: 0,
462            file_mtime: None,
463            buffers: vec![BufferTab {
464                buffer: Buffer::new(),
465                filename: None,
466                scroll: 0,
467                modified: false,
468                undo_stack: UndoStack::new(),
469                file_mtime: None,
470            }],
471            current_buffer: 0,
472            syntax: SyntaxEngine::new(),
473            lsp: LspClient::new(),
474            debug: false,
475            show_metrics: false,
476            metrics: ProcMetrics::default(),
477            bench_report: None,
478            last_change: None,
479            pending_operator: None,
480            pending_to_mod: None,
481            tab_hit_regions: Vec::new(),
482            tab_bar_y: 0,
483            screen_row_to_buffer: Vec::new(),
484            screen_row_visual_base: Vec::new(),
485            palette: Palette::new(),
486            hover_text: None,
487            last_click: None,
488            macros: MacroBank::new(),
489            tab_width: 4,
490            clipboard_sync: true,
491            relative_number: false,
492            wrap_lines: true,
493            undo_caching: false,
494            gpu_graphics: true,
495            gpu_hyperlinks: true,
496            hscroll: 0,
497            syntax_seen_version: 0,
498            lsp_synced_version: 0,
499            git: GitGutter::new(),
500            blame: GitBlame::default(),
501            folds: FoldState::new(),
502            multi: MultiCursor::new(),
503            scm: ScmPanel::new(),
504            git_wb: GitWorkbench::new(),
505            settings: SettingsPanel::new(),
506            preview: PreviewState::new(),
507            preview_image: None,
508            preview_audio: None,
509            split: crate::split::SplitState::new(),
510            peek: crate::peek::PeekState::new(),
511            workspace_search: crate::workspace_search::WorkspaceSearch::new(),
512            screensaver: crate::screensaver::Screensaver::new(),
513            pet: crate::pet::PetState::new(),
514            pane_hit_regions: Vec::new(),
515            split_sep_hit: None,
516            git_log_hits: Vec::new(),
517            git_tab_hits: Vec::new(),
518            dap_tab_hits: Vec::new(),
519            dap_row_hits: Vec::new(),
520            dap_panel_rect: None,
521            terminal_rect: None,
522            preview_gfx: Vec::new(),
523            pr_tab_hits: Vec::new(),
524            pr_row_hits: Vec::new(),
525            git_pane_hits: Vec::new(),
526            editor_ctx: None,
527            inlay_hints_enabled: true,
528            code_action_bank: Vec::new(),
529            gpu_acc: true,
530            key_hints: true,
531            dap: crate::dap::DapClient::new(),
532            call_hierarchy: crate::call_hierarchy::CallHierarchyState::new(),
533            rebase: crate::rebase::RebaseState::new(),
534            pr_review: crate::pr_review::PrReviewState::new(),
535            hooks: crate::hooks::HooksConfig::load(),
536            update: crate::update::UpdateState::new(),
537            hook_msg_tx,
538            hook_msg_rx,
539            git_refresh_rx: None,
540            git_refresh_gen: 0,
541            code_lens_enabled: true,
542            term_caps_summary: String::new(),
543            term_sync: false,
544            term_undercurl: false,
545            term_underline_color: false,
546            cell_px: 0,
547            cell_px_h: 0,
548            term_hyperlinks: false,
549            term_modern: false,
550            term_kitty_graphics: false,
551            replaying_macro: false,
552            rename_pending: false,
553            lsp_synced_path: None,
554            lsp_synced_hash: 0,
555        }
556    }
557}
558
559/// FNV-1a over the whole document — cheap enough per sync tick, and unlike a
560/// sampled fingerprint it cannot miss an edit.
561fn text_hash(s: &str) -> u64 {
562    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
563    for &b in s.as_bytes() {
564        h ^= b as u64;
565        h = h.wrapping_mul(0x0100_0000_01b3);
566    }
567    h
568}
569
570impl App {
571    pub fn apply_config(&mut self) {
572        let cfg = config::load();
573        self.tab_width = cfg.tab_width;
574        self.clipboard_sync = cfg.clipboard_sync;
575        self.relative_number = cfg.relative_number;
576        self.wrap_lines = cfg.wrap_lines;
577        if self.wrap_lines {
578            self.hscroll = 0;
579        }
580        self.undo_caching = cfg.undo_caching;
581        self.gpu_graphics = cfg.gpu_graphics;
582        self.gpu_hyperlinks = cfg.gpu_hyperlinks;
583        self.gpu_acc = cfg.gpu_acc;
584        self.key_hints = cfg.key_hints;
585        self.lsp
586            .apply_config(cfg.lsp_enabled, cfg.lsp_servers.clone());
587        if let Some(t) = theme::find(&cfg.theme) {
588            self.theme = t;
589        }
590        self.apply_pet_from_config(&cfg);
591    }
592
593    pub fn apply_pet_from_config(&mut self, cfg: &config::Config) {
594        self.pet.x = cfg.pet_x;
595        self.pet.y = cfg.pet_y;
596        let new_w = cfg.pet_width_cells.max(4);
597        if new_w != self.pet.width_cells {
598            self.pet.width_cells = new_w;
599            self.pet.invalidate_display_cache();
600        } else {
601            self.pet.width_cells = new_w;
602        }
603        self.pet.speed = crate::pet::PetState::clamp_speed(cfg.pet_speed);
604        let path = crate::pet::expand_path(&cfg.pet_path);
605        let path_s = path.display().to_string();
606        if !cfg.pet_path.is_empty()
607            && (self.pet.path != path_s || !self.pet.has_frames())
608        {
609            self.pet.load_path(&path_s);
610        }
611        if cfg.pet_path.is_empty() {
612            self.pet.path.clear();
613        }
614        // Pet only runs with GPU + Kitty graphics — never enable otherwise.
615        // Do **not** clamp x/y here: before the first draw `screen_*` is still
616        // the default 80×24, which would permanently trash a bottom-right save.
617        self.pet.enabled = cfg.pet_enabled && self.pet_graphics_ok() && self.pet.has_frames();
618    }
619
620    /// Pet overlay is allowed only with gpu_acc + Kitty graphics terminal.
621    pub fn pet_graphics_ok(&self) -> bool {
622        self.gpu_acc && self.term_kitty_graphics
623    }
624
625    /// Max cell coords for nudging in Settings (uses live terminal size).
626    pub fn pet_pos_max(&self) -> (u16, u16) {
627        let w = self.screen_width.max(1);
628        let h = self.screen_height.max(1);
629        // Until the first real draw, report a generous max so we don't clamp
630        // config values when the user opens Settings very early.
631        if w <= 80 && h <= 24 && self.screen_width == 80 && self.screen_height == 24 {
632            // Still might be a real 80×24 — use actual size either way.
633        }
634        let max_x = w.saturating_sub(self.pet.width_cells.max(1));
635        let max_y = h.saturating_sub(2); // tab/status
636        (max_x, max_y)
637    }
638
639    /// Paint-time position only (does not mutate saved coords).
640    pub fn pet_screen_xy(&self) -> (u16, u16) {
641        self.pet.screen_xy(self.screen_width, self.screen_height)
642    }
643
644    /// `:status` — toggle the live CPU/MEM/GPU readout in the status line.
645    pub fn toggle_status_metrics(&mut self) {
646        self.show_metrics = !self.show_metrics;
647        if self.show_metrics {
648            // Force an immediate sample next frame instead of showing stale data.
649            self.metrics.sampled = false;
650            self.message = "status: live CPU/MEM/GPU on — :status to hide".into();
651        } else {
652            self.message = "status: metrics off".into();
653        }
654    }
655
656    /// Frontend hook: store the latest sampled process metrics.
657    pub fn set_metrics(&mut self, m: ProcMetrics) {
658        self.metrics = m;
659    }
660
661    /// `:bench` — run the self-benchmark and switch to the results screen.
662    pub fn run_bench(&mut self) {
663        let report = crate::bench::run(self);
664        self.message = format!("bench: {:.1} ms total · r rerun · Esc exit", report.total_ms);
665        self.bench_report = Some(report);
666        self.mode = Mode::Bench;
667    }
668
669    pub fn exit_bench(&mut self) {
670        if self.mode == Mode::Bench {
671            self.mode = Mode::Normal;
672            self.message.clear();
673        }
674    }
675
676    pub fn toggle_screensaver(&mut self) {
677        if self.mode == Mode::Screensaver {
678            self.screensaver.close();
679            self.mode = Mode::Normal;
680            self.message.clear();
681        } else {
682            // Don't stack over terminal/palette awkwardly — close light overlays
683            if self.palette.open {
684                self.palette.close();
685            }
686            self.screensaver.open();
687            self.mode = Mode::Screensaver;
688            self.message = "xeifetch · Esc exit · weather loading…".into();
689        }
690    }
691
692    pub fn new() -> Self {
693        let mut app = Self::default();
694        app.apply_config();
695        app.dap.load_persisted_breakpoints();
696        app
697    }
698
699    pub fn open_file(path: &str) -> Self {
700        let pathbuf = PathBuf::from(path);
701        let abs_path = if pathbuf.is_absolute() {
702            pathbuf
703        } else {
704            env::current_dir()
705                .unwrap_or_default()
706                .join(&pathbuf)
707        };
708        let content = fs::read_to_string(&abs_path).unwrap_or_default();
709        let message = format!("Opened: {}", abs_path.display());
710        let buffer = Buffer::from_string(&content);
711        let mut undo = UndoStack::new();
712        undo.push(buffer.snapshot());
713        let mtime = std::fs::metadata(&abs_path).ok().and_then(|m| m.modified().ok());
714        let mut app = Self {
715            buffer: buffer.clone(),
716            filename: Some(abs_path.clone()),
717            message,
718            modified: false,
719            undo_stack: undo.clone(),
720            file_mtime: mtime,
721            buffers: vec![BufferTab {
722                buffer,
723                filename: Some(abs_path.clone()),
724                scroll: 0,
725                modified: false,
726                undo_stack: undo,
727                file_mtime: mtime,
728            }],
729            current_buffer: 0,
730            ..Self::default()
731        };
732        app.apply_config();
733        {
734            let text = app.buffer.text();
735            app.undo_stack
736                .attach_file(&abs_path, app.undo_caching, &text);
737            app.lsp
738                .auto_start_with_text(&abs_path.display().to_string(), Some(&text));
739            app.lsp_synced_path = Some(abs_path.clone());
740            app.lsp_synced_hash = text_hash(&text);
741        }
742        app.refresh_git();
743        app
744    }
745
746    /// Restore tabs/cursors from `~/.xei/session` (used when started with no file args).
747    pub fn restore_session(&mut self) {
748        let session = session::load();
749        if session.files.is_empty() {
750            return;
751        }
752        for (i, f) in session.files.iter().enumerate() {
753            if i == 0 {
754                // Replace the empty first tab
755                let content = fs::read_to_string(&f.path).unwrap_or_default();
756                self.buffer = Buffer::from_string(&content);
757                self.filename = Some(PathBuf::from(&f.path));
758                self.buffer.cursor.row = f.row.min(self.buffer.line_count().saturating_sub(1));
759                let line_len = self.buffer.line(self.buffer.cursor.row).chars().count();
760                self.buffer.cursor.col = f.col.min(line_len);
761                self.modified = false;
762                if !self.buffers.is_empty() {
763                    self.buffers[0].buffer = self.buffer.clone();
764                    self.buffers[0].filename = self.filename.clone();
765                    self.buffers[0].modified = false;
766                }
767            } else {
768                self.open_new_tab(&f.path);
769                self.buffer.cursor.row = f.row.min(self.buffer.line_count().saturating_sub(1));
770                let line_len = self.buffer.line(self.buffer.cursor.row).chars().count();
771                self.buffer.cursor.col = f.col.min(line_len);
772            }
773        }
774        let active = session.active.min(self.buffers.len().saturating_sub(1));
775        if active != self.current_buffer {
776            self.save_state_to_tab();
777            self.current_buffer = active;
778            self.restore_state_from_tab();
779        }
780        if let Some(ref p) = self.filename {
781            let text = self.buffer.text();
782            self.lsp
783                .auto_start_with_text(&p.display().to_string(), Some(&text));
784            self.lsp_synced_path = Some(p.clone());
785            self.lsp_synced_hash = text_hash(&text);
786        }
787        self.refresh_git();
788        self.dap.load_persisted_breakpoints();
789        self.message = format!("Restored session ({} file(s))", session.files.len());
790    }
791
792    pub fn save_session(&self) {
793        let mut files = Vec::new();
794        for (i, tab) in self.buffers.iter().enumerate() {
795            let Some(ref path) = tab.filename else {
796                continue;
797            };
798            let (row, col) = if i == self.current_buffer {
799                (self.buffer.cursor.row, self.buffer.cursor.col)
800            } else {
801                (tab.buffer.cursor.row, tab.buffer.cursor.col)
802            };
803            files.push(SessionFile {
804                path: path.display().to_string(),
805                row,
806                col,
807            });
808        }
809        if files.is_empty() {
810            return;
811        }
812        let active = self
813            .buffers
814            .iter()
815            .enumerate()
816            .filter(|(_, t)| t.filename.is_some())
817            .position(|(i, _)| i == self.current_buffer)
818            .unwrap_or(0);
819        session::save(&Session { files, active });
820        let _ = self.dap.persist_breakpoints();
821    }
822
823    /// Non-blocking: `git diff` (+ `git blame` when the panel is up) run on a
824    /// background thread; results land via [`App::poll_git_refresh`].
825    pub fn refresh_git(&mut self) {
826        if let Some(ref p) = self.filename {
827            let path = p.display().to_string();
828            let want_blame = self.blame.enabled || self.blame.open;
829            self.git_refresh_gen = self.git_refresh_gen.wrapping_add(1);
830            let generation = self.git_refresh_gen;
831            let (tx, rx) = std::sync::mpsc::channel();
832            self.git_refresh_rx = Some(rx);
833            std::thread::spawn(move || {
834                let gutter = crate::git::compute_gutter(&path);
835                let blame = if want_blame {
836                    Some(crate::git::compute_blame(&path))
837                } else {
838                    None
839                };
840                let _ = tx.send((generation, path, gutter, blame));
841            });
842        } else {
843            self.git.clear();
844            self.blame.clear();
845            self.blame.enabled = false;
846        }
847        self.rebuild_folds();
848    }
849
850    /// Apply a finished background git refresh (call once per frame).
851    pub fn poll_git_refresh(&mut self) -> bool {
852        use std::sync::mpsc::TryRecvError;
853        let Some(rx) = self.git_refresh_rx.take() else {
854            return false;
855        };
856        match rx.try_recv() {
857            Ok((generation, path, (g_avail, signs), blame)) => {
858                if generation != self.git_refresh_gen {
859                    return false;
860                }
861                self.git.path = path.clone();
862                self.git.available = g_avail;
863                self.git.signs = signs;
864                if let Some((b_avail, lines)) = blame {
865                    self.blame.path = path;
866                    self.blame.available = b_avail;
867                    self.blame.lines = lines;
868                    if !b_avail && self.blame.open {
869                        self.blame.close_panel();
870                        self.blame.enabled = false;
871                        self.message = "Blame unavailable (not a git file?)".into();
872                    }
873                }
874                true
875            }
876            Err(TryRecvError::Empty) => {
877                self.git_refresh_rx = Some(rx);
878                false
879            }
880            Err(TryRecvError::Disconnected) => false,
881        }
882    }
883
884    pub fn rebuild_folds(&mut self) {
885        let lines = self.buffer.lines();
886        self.folds.rebuild(&lines, self.tab_width.max(1));
887    }
888
889    /// Toggle blame side panel (`Ctrl+B` / `gb`) with slide animation.
890    pub fn toggle_blame(&mut self) {
891        let path = self
892            .filename
893            .as_ref()
894            .map(|p| p.display().to_string())
895            .unwrap_or_default();
896        if path.is_empty() {
897            self.message = "No file for blame".into();
898            return;
899        }
900        self.message = self.blame.toggle_panel(&path);
901    }
902
903    /// Toggle DAP debug panel *focus* (Ctrl+Shift+D). Visibility and focus are
904    /// separate: Esc drops focus back to the editor keeping the panel docked;
905    /// `q` in the panel closes it.
906    pub fn toggle_debug_panel(&mut self) {
907        if self.mode == Mode::Debug {
908            self.mode = Mode::Normal;
909            self.message = "Debug unfocused · Ctrl+Shift+D refocus · q in panel closes".into();
910        } else if self.dap.panel_open {
911            self.mode = Mode::Debug;
912            self.message = "Debug · F5 start · F9 bp · F10/F11 step · Esc unfocus".into();
913        } else {
914            self.dap.panel_open = true;
915            self.dap.arm_panel_animation();
916            self.mode = Mode::Debug;
917            self.message = "Debug · F5 start · F9 bp · F10/F11 step · Esc unfocus".into();
918        }
919    }
920
921    /// Close the debug panel entirely (`q` from the panel).
922    pub fn close_debug_panel(&mut self) {
923        self.dap.panel_open = false;
924        if self.mode == Mode::Debug {
925            self.mode = Mode::Normal;
926        }
927        self.message = "Debug panel closed".into();
928    }
929
930    /// `:mbb` — fresh blank tab landing on the welcome screen.
931    pub fn open_blank_tab(&mut self) {
932        self.save_state_to_tab();
933        let buffer = Buffer::new();
934        let mut undo = UndoStack::new();
935        undo.push(buffer.snapshot());
936        self.buffers.push(crate::BufferTab {
937            buffer,
938            filename: None,
939            scroll: 0,
940            modified: false,
941            undo_stack: undo,
942            file_mtime: None,
943        });
944        self.current_buffer = self.buffers.len() - 1;
945        self.restore_state_from_tab();
946        self.split.clamp_tabs(self.buffers.len());
947        self.refresh_git();
948        self.mode = Mode::Normal;
949        self.message = "New tab · i insert · Ctrl+P files · :e <file>".into();
950    }
951
952    /// F9 — toggle breakpoint on cursor line.
953    pub fn dap_toggle_breakpoint(&mut self) {
954        let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
955            self.message = "No file for breakpoint".into();
956            return;
957        };
958        let line = self.buffer.cursor.row;
959        let on = self.dap.toggle_breakpoint(&path, line);
960        self.message = if on {
961            format!("● Breakpoint L{}", line + 1)
962        } else {
963            format!("○ Cleared BP L{}", line + 1)
964        };
965    }
966
967    /// F5 — start or continue.
968    pub fn dap_start_or_continue(&mut self) {
969        use crate::dap::DapState;
970        match self.dap.state {
971            DapState::Stopped => {
972                self.dap.continue_exec();
973                self.message = "→ continue".into();
974            }
975            DapState::Running | DapState::Starting => {
976                self.message = format!("DAP {}", self.dap.state.label());
977            }
978            DapState::Idle | DapState::Ending => {
979                let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
980                    self.message = "Open a file to debug".into();
981                    return;
982                };
983                let cwd = self
984                    .filename
985                    .as_ref()
986                    .and_then(|p| p.parent().map(|d| d.to_path_buf()));
987                let ext = self.file_extension();
988                let lang = ext.as_deref().map(|e| match e {
989                    "py" | "pyw" => "python",
990                    "rs" => "rust",
991                    "go" => "go",
992                    "c" | "h" | "cc" | "cpp" | "cxx" | "hpp" => "cpp",
993                    "js" | "mjs" | "cjs" | "ts" | "tsx" => "node",
994                    _ => "unknown",
995                });
996                let was_closed = !self.dap.panel_open;
997                match self.dap.start(&path, cwd.as_deref(), lang, &[]) {
998                    Ok(()) => {
999                        if was_closed {
1000                            self.dap.arm_panel_animation();
1001                        }
1002                        self.mode = Mode::Debug;
1003                        self.message = format!(
1004                            "▶ DAP {} · {}",
1005                            self.dap.adapter_name,
1006                            self.dap.last_program.as_deref().unwrap_or(&path)
1007                        );
1008                    }
1009                    Err(e) => {
1010                        self.message = e;
1011                    }
1012                }
1013            }
1014        }
1015    }
1016
1017    /// Launch a program (XLC `:DapLaunch <path> [args…]`).
1018    pub fn dap_launch_program(&mut self, program_line: &str) {
1019        let mut parts = program_line.split_whitespace();
1020        let Some(program) = parts.next() else {
1021            self.message = "DapLaunch: missing program".into();
1022            return;
1023        };
1024        let args: Vec<String> = parts.map(|s| s.to_string()).collect();
1025        let cwd = Path::new(program)
1026            .parent()
1027            .map(|p| p.to_path_buf())
1028            .or_else(|| {
1029                self.filename
1030                    .as_ref()
1031                    .and_then(|p| p.parent().map(|d| d.to_path_buf()))
1032            });
1033        let was_closed = !self.dap.panel_open;
1034        match self.dap.start(program, cwd.as_deref(), None, &args) {
1035            Ok(()) => {
1036                if was_closed {
1037                    self.dap.arm_panel_animation();
1038                }
1039                self.mode = Mode::Debug;
1040                self.message = format!("▶ DAP launch {program_line}");
1041            }
1042            Err(e) => self.message = e,
1043        }
1044    }
1045
1046    /// F6 — suspend a running program.
1047    pub fn dap_pause(&mut self) {
1048        self.dap.pause();
1049        self.message = "⏸ pause requested".into();
1050    }
1051
1052    /// Evaluate expression in the stopped frame (Console REPL).
1053    pub fn dap_evaluate(&mut self, expr: &str) {
1054        self.dap.evaluate(expr);
1055        self.message = format!("eval: {expr}");
1056    }
1057
1058    /// `:bp if <expr>` — conditional breakpoint on cursor line.
1059    pub fn dap_set_condition(&mut self, condition: &str) {
1060        let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
1061            self.message = "No file for breakpoint".into();
1062            return;
1063        };
1064        let line = self.buffer.cursor.row;
1065        let cond = condition.trim();
1066        if cond.is_empty() {
1067            self.dap.set_breakpoint_condition(&path, line, None);
1068            self.message = format!("○ condition cleared L{}", line + 1);
1069        } else {
1070            self.dap
1071                .set_breakpoint_condition(&path, line, Some(cond.to_string()));
1072            self.message = format!("● L{} if {cond}", line + 1);
1073        }
1074    }
1075
1076    /// `:bp log <msg>` — logpoint on cursor line.
1077    pub fn dap_set_logpoint(&mut self, msg: &str) {
1078        let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
1079            self.message = "No file for logpoint".into();
1080            return;
1081        };
1082        let line = self.buffer.cursor.row;
1083        let m = msg.trim();
1084        if m.is_empty() {
1085            self.dap.set_breakpoint_log(&path, line, None);
1086            self.message = format!("○ logpoint cleared L{}", line + 1);
1087        } else {
1088            self.dap
1089                .set_breakpoint_log(&path, line, Some(m.to_string()));
1090            self.message = format!("● L{} log {m}", line + 1);
1091        }
1092    }
1093
1094    /// Launch using a named config from `.vscode/launch.json`.
1095    pub fn dap_launch_config(&mut self, name: Option<&str>) {
1096        let hint = self.filename.as_deref();
1097        let configs = crate::dap::load_launch_configs(hint);
1098        if configs.is_empty() {
1099            self.message = "No .vscode/launch.json configurations found".into();
1100            return;
1101        }
1102        let cfg = if let Some(n) = name {
1103            configs.iter().find(|c| c.name == n)
1104        } else {
1105            configs.first()
1106        };
1107        let Some(cfg) = cfg else {
1108            let names: Vec<_> = configs.iter().map(|c| c.name.as_str()).collect();
1109            self.message = format!("Unknown config. Available: {}", names.join(", "));
1110            return;
1111        };
1112        let was_closed = !self.dap.panel_open;
1113        let result = if cfg.request == "attach" {
1114            // Prefer port from env-less configs: look for numeric in program or name
1115            // launch.json attach often has "port" field — re-parse via args empty + name
1116            self.dap_attach_from_config(cfg)
1117        } else {
1118            if cfg.program.is_empty() {
1119                self.message = format!("Config '{}' has no program", cfg.name);
1120                return;
1121            }
1122            let cwd = cfg
1123                .cwd
1124                .as_ref()
1125                .map(PathBuf::from)
1126                .or_else(|| {
1127                    self.filename
1128                        .as_ref()
1129                        .and_then(|p| p.parent().map(|d| d.to_path_buf()))
1130                });
1131            let lang = match cfg.adapter_type.as_str() {
1132                "python" | "debugpy" => Some("python"),
1133                "go" | "delve" => Some("go"),
1134                "lldb" | "cppdbg" | "codelldb" => Some("rust"),
1135                "node" | "pwa-node" => Some("node"),
1136                _ => None,
1137            };
1138            self.dap
1139                .start(&cfg.program, cwd.as_deref(), lang, &cfg.args)
1140        };
1141        match result {
1142            Ok(()) => {
1143                if was_closed {
1144                    self.dap.arm_panel_animation();
1145                }
1146                self.mode = Mode::Debug;
1147                self.message = format!("▶ launch.json · {}", cfg.name);
1148            }
1149            Err(e) => self.message = e,
1150        }
1151    }
1152
1153    fn dap_attach_from_config(&mut self, cfg: &crate::dap::LaunchConfig) -> Result<(), String> {
1154        let lang = match cfg.adapter_type.as_str() {
1155            "python" | "debugpy" => Some("python"),
1156            "node" | "pwa-node" => Some("node"),
1157            "lldb" | "cppdbg" | "codelldb" => Some("native"),
1158            other if !other.is_empty() => Some(other),
1159            _ => None,
1160        };
1161        if let Some(pid) = cfg.pid {
1162            return self.dap.attach_pid(pid);
1163        }
1164        if let Some(port) = cfg.port {
1165            return self
1166                .dap
1167                .attach_port(port, lang, cfg.host.as_deref());
1168        }
1169        // Heuristic fallback: program field as port or pid
1170        if let Some(port) = cfg.program.parse::<u16>().ok().or_else(|| {
1171            cfg.program
1172                .rsplit(':')
1173                .next()
1174                .and_then(|s| s.parse().ok())
1175        }) {
1176            let host = if cfg.program.contains(':') {
1177                cfg.program.split(':').next()
1178            } else {
1179                None
1180            };
1181            return self.dap.attach_port(port, lang, host);
1182        }
1183        if let Ok(pid) = cfg.program.parse::<u32>() {
1184            return self.dap.attach_pid(pid);
1185        }
1186        Err(format!(
1187            "Attach config '{}' needs port, processId/pid, or program=port|pid",
1188            cfg.name
1189        ))
1190    }
1191
1192    /// `:DapAttach pid <n>` or `:DapAttach port <n> [lang]`
1193    pub fn dap_attach(&mut self, spec: &str) {
1194        let parts: Vec<&str> = spec.split_whitespace().collect();
1195        if parts.is_empty() {
1196            self.message = "Usage: DapAttach pid <n> | DapAttach port <n> [python|node]".into();
1197            return;
1198        }
1199        let was_closed = !self.dap.panel_open;
1200        let result = match parts[0] {
1201            "pid" => {
1202                let Some(pid) = parts.get(1).and_then(|s| s.parse::<u32>().ok()) else {
1203                    self.message = "Usage: DapAttach pid <n>".into();
1204                    return;
1205                };
1206                self.dap.attach_pid(pid)
1207            }
1208            "port" => {
1209                let Some(port) = parts.get(1).and_then(|s| s.parse::<u16>().ok()) else {
1210                    self.message = "Usage: DapAttach port <n> [python|node]".into();
1211                    return;
1212                };
1213                let lang = parts.get(2).copied();
1214                self.dap.attach_port(port, lang, None)
1215            }
1216            // Bare number: prefer port if ≤65535, else pid
1217            n if n.parse::<u32>().is_ok() => {
1218                let num: u32 = n.parse().unwrap();
1219                if num <= 65535 {
1220                    self.dap.attach_port(num as u16, Some("python"), None)
1221                } else {
1222                    self.dap.attach_pid(num)
1223                }
1224            }
1225            _ => {
1226                self.message = "Usage: DapAttach pid <n> | DapAttach port <n> [lang]".into();
1227                return;
1228            }
1229        };
1230        match result {
1231            Ok(()) => {
1232                if was_closed {
1233                    self.dap.arm_panel_animation();
1234                }
1235                self.mode = Mode::Debug;
1236                self.message = format!("▶ attach · {spec}");
1237            }
1238            Err(e) => self.message = e,
1239        }
1240    }
1241
1242    /// List launch.json configs into message / XLC.
1243    pub fn dap_list_configs(&mut self) {
1244        let hint = self.filename.as_deref();
1245        let configs = crate::dap::load_launch_configs(hint);
1246        if configs.is_empty() {
1247            self.message = "No launch.json configs".into();
1248            self.xlc.add_output("No .vscode/launch.json found");
1249            return;
1250        }
1251        self.xlc.add_output("=== launch.json ===");
1252        for c in &configs {
1253            self.xlc.add_output(&format!(
1254                "  {}  [{}]  {}",
1255                c.name, c.request, c.program
1256            ));
1257        }
1258        self.message = format!("{} launch config(s) — :DapConfig <name>", configs.len());
1259    }
1260
1261    pub fn dap_stop(&mut self) {
1262        self.dap.stop();
1263        self.message = "■ Debug stopped".into();
1264    }
1265
1266    pub fn dap_step_over(&mut self) {
1267        self.dap.step_over();
1268        self.message = "→ step over".into();
1269    }
1270
1271    pub fn dap_step_into(&mut self) {
1272        self.dap.step_into();
1273        self.message = "→ step into".into();
1274    }
1275
1276    pub fn dap_step_out(&mut self) {
1277        self.dap.step_out();
1278        self.message = "→ step out".into();
1279    }
1280
1281    /// Open call hierarchy (incoming by default).
1282    pub fn open_call_hierarchy(&mut self, outgoing: bool) {
1283        let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
1284            self.message = "No file for call hierarchy".into();
1285            return;
1286        };
1287        if !self.lsp.server_running {
1288            self.message = "LSP not running".into();
1289            return;
1290        }
1291        let dir = if outgoing {
1292            crate::call_hierarchy::CallDirection::Outgoing
1293        } else {
1294            crate::call_hierarchy::CallDirection::Incoming
1295        };
1296        let c = self.buffer.cursor();
1297        // Word under cursor as provisional root name
1298        let word = {
1299            let w = self.word_under_cursor();
1300            if w.is_empty() {
1301                "?".into()
1302            } else {
1303                w
1304            }
1305        };
1306        self.sync_lsp_document();
1307        self.call_hierarchy.begin(&word, dir);
1308        self.mode = Mode::CallHierarchy;
1309        self.lsp
1310            .request_call_hierarchy(&path, c.row, c.col, dir);
1311        self.message = format!("Call hierarchy ({})…", dir.label());
1312    }
1313
1314    pub fn toggle_call_direction(&mut self) {
1315        if !self.call_hierarchy.open {
1316            return;
1317        }
1318        let dir = self.call_hierarchy.direction.toggle();
1319        let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
1320            return;
1321        };
1322        let c = self.buffer.cursor();
1323        let name = self.call_hierarchy.root_name.clone();
1324        self.call_hierarchy.begin(&name, dir);
1325        self.lsp
1326            .request_call_hierarchy(&path, c.row, c.col, dir);
1327    }
1328
1329    /// Apply finished call hierarchy from LSP poll.
1330    pub fn poll_call_hierarchy(&mut self) {
1331        if !self.lsp.call_hierarchy_ready {
1332            return;
1333        }
1334        self.lsp.call_hierarchy_ready = false;
1335        let items = std::mem::take(&mut self.lsp.pending_call_hierarchy);
1336        if let Some(dir) = self.lsp.pending_call_direction {
1337            self.call_hierarchy.direction = dir;
1338        }
1339        if let Some(first) = items.first() {
1340            if self.call_hierarchy.root_name == "?" || self.call_hierarchy.root_name.is_empty() {
1341                self.call_hierarchy.root_name = first.name.clone();
1342            }
1343        }
1344        self.call_hierarchy.set_items(items);
1345        self.message = self.call_hierarchy.message.clone();
1346    }
1347
1348    pub fn open_rebase(&mut self, count: usize) {
1349        let hint = self.filename.as_deref();
1350        let Some(root) = crate::git_ops::find_git_root(hint) else {
1351            self.message = "Not a git repository".into();
1352            return;
1353        };
1354        match self.rebase.open_for(&root, count) {
1355            Ok(()) => {
1356                self.mode = Mode::Rebase;
1357                self.message = self.rebase.message.clone();
1358            }
1359            Err(e) => self.message = e,
1360        }
1361    }
1362
1363    pub fn run_rebase_plan(&mut self) {
1364        match self.rebase.run() {
1365            Ok(msg) => {
1366                self.mode = Mode::Normal;
1367                self.message = msg;
1368            }
1369            Err(e) => self.message = e,
1370        }
1371    }
1372
1373    pub fn open_pr_review(&mut self, number: u64) {
1374        let hint = self.filename.as_deref();
1375        let Some(root) = crate::git_ops::find_git_root(hint).or_else(|| {
1376            self.git_wb.root.clone()
1377        }) else {
1378            self.message = "Not a git repository".into();
1379            return;
1380        };
1381        match self.pr_review.open_pr(&root, number) {
1382            Ok(()) => {
1383                self.mode = Mode::PrReview;
1384                self.message = self.pr_review.message.clone();
1385            }
1386            Err(e) => self.message = e,
1387        }
1388    }
1389
1390    pub fn open_pr_review_selected(&mut self) {
1391        // From git workbench PR list — pr_sel is visual index into filtered list
1392        let idxs: Vec<usize> = if !self.git_wb.pr_filter.is_empty() {
1393            self.git_wb.pr_filtered.clone()
1394        } else {
1395            (0..self.git_wb.prs.len()).collect()
1396        };
1397        let num = idxs
1398            .get(self.git_wb.pr_sel)
1399            .and_then(|&i| self.git_wb.prs.get(i))
1400            .map(|p| p.number)
1401            .or_else(|| self.git_wb.prs.get(self.git_wb.pr_sel).map(|p| p.number));
1402        if let Some(n) = num {
1403            self.open_pr_review(n);
1404        } else {
1405            self.message = "No PR selected".into();
1406        }
1407    }
1408
1409    pub fn toggle_code_lens(&mut self) {
1410        self.code_lens_enabled = !self.code_lens_enabled;
1411        self.message = if self.code_lens_enabled {
1412            self.lsp.mark_code_lens_dirty();
1413            "code lens on".into()
1414        } else {
1415            "code lens off".into()
1416        };
1417    }
1418
1419    pub fn reload_hooks(&mut self) {
1420        self.hooks = crate::hooks::HooksConfig::load();
1421        self.message = format!(
1422            "hooks reloaded · enabled={}",
1423            self.hooks.enabled
1424        );
1425    }
1426
1427    /// Run the hook for `event` on a background thread; results arrive via
1428    /// poll_hook_messages() so a slow hook never blocks the editor.
1429    fn fire_hook(&mut self, event: crate::hooks::HookEvent) {
1430        if !self.hooks.has_hook(event) {
1431            return;
1432        }
1433        let cfg = self.hooks.clone();
1434        let file = self.filename.clone();
1435        let tx = self.hook_msg_tx.clone();
1436        std::thread::spawn(move || {
1437            if let Some(msg) = crate::hooks::run_hooks(&cfg, event, file.as_deref()) {
1438                let _ = tx.send(msg);
1439            }
1440        });
1441    }
1442
1443    /// Drain finished hook results into the status message (call per frame).
1444    pub fn poll_hook_messages(&mut self) {
1445        while let Ok(msg) = self.hook_msg_rx.try_recv() {
1446            self.message = msg;
1447        }
1448    }
1449
1450    /// After DAP poll: jump editor to stopped frame if path matches an openable file.
1451    pub fn dap_apply_stopped_location(&mut self) {
1452        if !self.dap.location_dirty {
1453            return;
1454        }
1455        self.dap.location_dirty = false;
1456        let Some(path) = self.dap.current_path.clone() else {
1457            return;
1458        };
1459        let Some(line) = self.dap.current_line else {
1460            return;
1461        };
1462        // Open / switch to file if needed
1463        let same = self
1464            .filename
1465            .as_ref()
1466            .map(|p| {
1467                let a = std::fs::canonicalize(p).unwrap_or_else(|_| p.clone());
1468                let b = std::fs::canonicalize(&path).unwrap_or_else(|_| PathBuf::from(&path));
1469                a == b
1470            })
1471            .unwrap_or(false);
1472        if !same && Path::new(&path).is_file() {
1473            self.open_new_tab(&path);
1474        }
1475        if self.buffer.line_count() == 0 {
1476            return;
1477        }
1478        self.buffer.cursor.row = line.min(self.buffer.line_count().saturating_sub(1));
1479        self.buffer.move_to_line_start();
1480        self.update_scroll();
1481    }
1482
1483    pub fn fold_toggle(&mut self) {
1484        let row = self.buffer.cursor.row;
1485        self.rebuild_folds();
1486        if let Some(msg) = self.folds.toggle(row) {
1487            self.message = msg.into();
1488            if self.folds.is_hidden(self.buffer.cursor.row) {
1489                for r in &self.folds.ranges {
1490                    if self.folds.is_closed(r.start)
1491                        && self.buffer.cursor.row > r.start
1492                        && self.buffer.cursor.row <= r.end
1493                    {
1494                        self.buffer.cursor.row = r.start;
1495                        self.buffer.clamp_col();
1496                        break;
1497                    }
1498                }
1499            }
1500            self.update_scroll();
1501        } else {
1502            self.message = "No fold here".into();
1503        }
1504    }
1505
1506    pub fn fold_close(&mut self) {
1507        self.rebuild_folds();
1508        if self.folds.close_at(self.buffer.cursor.row) {
1509            self.message = "fold closed".into();
1510        } else {
1511            self.message = "No fold here".into();
1512        }
1513    }
1514
1515    pub fn fold_open(&mut self) {
1516        if self.folds.open_at(self.buffer.cursor.row) {
1517            self.message = "fold opened".into();
1518        } else {
1519            self.message = "No closed fold here".into();
1520        }
1521    }
1522
1523    pub fn fold_close_all(&mut self) {
1524        self.rebuild_folds();
1525        self.folds.close_all();
1526        self.message = "all folds closed".into();
1527        self.update_scroll();
1528    }
1529
1530    pub fn fold_open_all(&mut self) {
1531        self.folds.open_all();
1532        self.message = "all folds opened".into();
1533    }
1534
1535    /// Toggle light Source Control panel (Ctrl+G).
1536    /// From Git workbench → step back to light SCM.
1537    pub fn toggle_scm(&mut self) {
1538        if self.mode == Mode::GitWorkbench {
1539            self.leave_git_workbench_to_scm();
1540            return;
1541        }
1542        if self.scm.open && self.mode == Mode::SourceControl {
1543            if self.scm.closing {
1544                let hint = self.filename.as_deref();
1545                self.scm.open_and_refresh(hint);
1546                return;
1547            }
1548            self.close_scm();
1549            return;
1550        }
1551        if self.palette.open {
1552            self.palette.close();
1553        }
1554        if self.preview.open {
1555            self.preview.close_immediate();
1556        }
1557        if self.git_wb.open {
1558            self.git_wb.close();
1559        }
1560        let hint = self.filename.as_deref();
1561        self.scm.open_and_refresh(hint);
1562        self.mode = Mode::SourceControl;
1563        if let Some(ref err) = self.scm.error {
1564            self.message = err.clone();
1565        } else {
1566            let n = self.scm.total_files();
1567            let branch = if self.scm.branch.is_empty() {
1568                "git".into()
1569            } else {
1570                self.scm.branch.clone()
1571            };
1572            self.message = format!(
1573                "SCM · {} · {} change(s)  ·  Ctrl+Shift+G full Git",
1574                branch, n
1575            );
1576        }
1577    }
1578
1579    /// Begin slide-out close (mode flips to Normal when anim settles).
1580    pub fn close_scm(&mut self) {
1581        if !self.scm.open {
1582            if self.mode == Mode::SourceControl {
1583                self.mode = Mode::Normal;
1584            }
1585            return;
1586        }
1587        self.scm.close();
1588    }
1589
1590    pub fn close_scm_immediate(&mut self) {
1591        self.scm.close_immediate();
1592        if matches!(self.mode, Mode::SourceControl) {
1593            self.mode = Mode::Normal;
1594        }
1595    }
1596
1597    /// Open full Git workbench (Ctrl+Shift+G).
1598    pub fn open_git_workbench(&mut self) {
1599        let from_scm = self.mode == Mode::SourceControl || self.scm.open;
1600        if self.palette.open {
1601            self.palette.close();
1602        }
1603        if self.preview.open {
1604            self.preview.close_immediate();
1605        }
1606        // Keep SCM state but hide panel while in workbench
1607        if self.scm.open && !self.scm.closing {
1608            // leave scm open flag? close visual only
1609            self.scm.close_immediate();
1610        }
1611        // Hint = open file, else cwd (same resolution light SCM uses via find_git_root)
1612        let cwd = env::current_dir().ok();
1613        let hint = self
1614            .filename
1615            .as_deref()
1616            .or(cwd.as_deref());
1617        self.git_wb.open_at(hint, from_scm);
1618        self.mode = Mode::GitWorkbench;
1619        let b = if self.git_wb.branch.is_empty() {
1620            "git".into()
1621        } else {
1622            self.git_wb.branch.clone()
1623        };
1624        let root_note = self
1625            .git_wb
1626            .root
1627            .as_ref()
1628            .and_then(|r| r.file_name())
1629            .and_then(|n| n.to_str())
1630            .unwrap_or(".");
1631        self.message = format!(
1632            "Git · {} @ {}  ·  Status ready  ·  Esc back",
1633            b, root_note
1634        );
1635    }
1636
1637    pub fn toggle_git_workbench(&mut self) {
1638        if self.mode == Mode::GitWorkbench {
1639            self.close_git_workbench();
1640        } else {
1641            self.open_git_workbench();
1642        }
1643    }
1644
1645    /// Esc / close from workbench: back to light SCM if we came from there.
1646    pub fn close_git_workbench(&mut self) {
1647        let back_to_scm = self.git_wb.from_scm;
1648        self.git_wb.close();
1649        if back_to_scm {
1650            let hint = self.filename.as_deref();
1651            self.scm.open_and_refresh(hint);
1652            self.mode = Mode::SourceControl;
1653            self.message = String::from("Source Control");
1654        } else {
1655            self.mode = Mode::Normal;
1656            self.message.clear();
1657        }
1658    }
1659
1660    fn leave_git_workbench_to_scm(&mut self) {
1661        self.git_wb.from_scm = true;
1662        self.close_git_workbench();
1663    }
1664
1665    /// Open unified Settings (Ctrl+,). Starts on About page.
1666    pub fn open_settings(&mut self) {
1667        if self.mode == Mode::Settings {
1668            self.close_settings();
1669            return;
1670        }
1671        if self.palette.open {
1672            self.palette.close();
1673        }
1674        if self.preview.open {
1675            self.preview.close_immediate();
1676        }
1677        if self.git_wb.open {
1678            self.git_wb.close();
1679        }
1680        if self.scm.open {
1681            self.scm.close_immediate();
1682        }
1683        self.settings.open_panel();
1684        self.mode = Mode::Settings;
1685        self.message = format!(
1686            "Settings · {}  ·  Tab pages · Enter apply · s save · Esc",
1687            crate::settings::SettingsPanel::version_string()
1688        );
1689    }
1690
1691    pub fn close_settings(&mut self) {
1692        // Apply draft theme/editor opts if dirty? Prefer explicit save.
1693        self.settings.close();
1694        self.mode = Mode::Normal;
1695        self.message.clear();
1696    }
1697
1698    pub fn apply_settings_draft(&mut self) {
1699        let cfg = self.settings.draft.clone();
1700        self.tab_width = cfg.tab_width;
1701        self.clipboard_sync = cfg.clipboard_sync;
1702        self.relative_number = cfg.relative_number;
1703        self.wrap_lines = cfg.wrap_lines;
1704        if self.wrap_lines {
1705            self.hscroll = 0;
1706        }
1707        self.undo_caching = cfg.undo_caching;
1708        self.gpu_graphics = cfg.gpu_graphics;
1709        self.gpu_hyperlinks = cfg.gpu_hyperlinks;
1710        self.gpu_acc = cfg.gpu_acc;
1711        self.key_hints = cfg.key_hints;
1712        self.lsp
1713            .apply_config(cfg.lsp_enabled, cfg.lsp_servers.clone());
1714        if let Some(t) = theme::find(&cfg.theme) {
1715            self.theme = t;
1716            set_cursor_esc(t.cursor);
1717        }
1718        self.apply_pet_from_config(&cfg);
1719        // Restart LSP for current file with new server map
1720        self.lsp_restart_for_current();
1721    }
1722
1723    /// Set which-key hints if the feature is enabled.
1724    pub fn set_hints(&mut self, hints: Vec<(&'static str, &'static str)>) {
1725        if self.key_hints {
1726            self.pending_hints = hints;
1727        } else {
1728            self.pending_hints.clear();
1729        }
1730    }
1731
1732    /// Begin a prefix chord with title + delayed popup.
1733    pub fn begin_chord(
1734        &mut self,
1735        title: &str,
1736        hints: Vec<(&'static str, &'static str)>,
1737    ) {
1738        self.which_key.begin_prefix(title);
1739        self.set_hints(hints);
1740    }
1741
1742    /// Open Space leader root map.
1743    pub fn begin_leader(&mut self) {
1744        self.which_key.begin_leader();
1745        self.set_hints(crate::which_key::leader_hints(""));
1746        self.message = String::from("-- SPC --");
1747    }
1748
1749    /// Enter Space submenu (`f`, `g`, …).
1750    pub fn leader_enter_sub(&mut self, key: char, label: &str) {
1751        self.which_key.enter_leader_sub(key, label);
1752        self.set_hints(crate::which_key::leader_hints(&key.to_string()));
1753        self.message = format!("-- SPC {label} --");
1754    }
1755
1756    /// Clear leader / which-key session.
1757    pub fn clear_which_key(&mut self) {
1758        self.which_key.clear();
1759        self.pending_hints.clear();
1760    }
1761
1762    /// Whether the which-key popup should draw this frame.
1763    pub fn which_key_visible(&self) -> bool {
1764        if !self.key_hints || self.pending_hints.is_empty() {
1765            return false;
1766        }
1767        if !self.which_key.ready() {
1768            return false;
1769        }
1770        self.which_key.is_leader()
1771            || self.pending_key.is_some()
1772            || self.pending_operator.is_some()
1773            || self.pending_register
1774            || self.pending_mark_set
1775            || self.pending_mark_jump.is_some()
1776            || self.split.pending_chord
1777            || self.pending_to_mod.is_some()
1778    }
1779
1780    pub fn toggle_terminal_side(&mut self) {
1781        if self.terminal.open && !self.terminal.full_panel {
1782            self.terminal.open = false;
1783            self.terminal.shutdown();
1784            self.mode = Mode::Normal;
1785        } else {
1786            // Switch from full to side, or open side
1787            self.terminal.full_panel = false;
1788            self.terminal.pane_bound = None;
1789            self.terminal.close_confirm = false;
1790            self.terminal.open = true;
1791            self.terminal.start(self.filename.as_ref());
1792            self.mode = Mode::Terminal;
1793        }
1794    }
1795
1796    /// Ctrl+Shift+T — terminal as a real split window (not Mode::Terminal).
1797    /// Stays in Normal so Ctrl+W / Git / layout chords keep working.
1798    pub fn toggle_terminal_full(&mut self) {
1799        if self.terminal.open && self.terminal.full_panel {
1800            // Closing always goes through confirm (same as Esc)
1801            self.request_close_pane_terminal();
1802            return;
1803        }
1804        // Open as a window: ensure a split exists so the editor stays visible.
1805        if !self.split.is_split() {
1806            let tab = self.current_buffer;
1807            let scroll = self.scroll;
1808            let cur = (self.buffer.cursor.row, self.buffer.cursor.col);
1809            self.split
1810                .open_split(crate::split::SplitKind::Vertical, tab, scroll, cur);
1811            self.split.set_focus(1);
1812            self.sync_split_from_active();
1813        }
1814        self.terminal.full_panel = true;
1815        self.terminal.pane_bound =
1816            Some(self.split.focus.min(self.split.panes.len().saturating_sub(1)));
1817        self.terminal.close_confirm = false;
1818        self.terminal.open = true;
1819        // Defer `start()` until first draw so COLUMNS/LINES match the pane
1820        // size (agent TUIs like opencode/claude read size at spawn).
1821        // Critical: stay in Normal — terminal is a pane, not a mode that
1822        // swallows layout shortcuts.
1823        if matches!(self.mode, Mode::Terminal | Mode::Insert) {
1824            self.mode = Mode::Normal;
1825        }
1826        self.message =
1827            "Terminal focused · keys → shell (Ctrl+C works) · ^⇧W close · ^W w other pane"
1828                .into();
1829    }
1830
1831    /// Whether the focused split pane is showing the Ctrl+Shift+T terminal.
1832    pub fn terminal_window_focused(&self) -> bool {
1833        if !self.terminal.open || !self.terminal.full_panel {
1834            return false;
1835        }
1836        match self.terminal.pane_bound {
1837            Some(i) if self.split.is_split() => {
1838                self.split.focus.min(self.split.panes.len().saturating_sub(1)) == i
1839            }
1840            // Unsplit but still full_panel (split closed under us)
1841            _ => true,
1842        }
1843    }
1844
1845    pub fn request_close_pane_terminal(&mut self) {
1846        if !self.terminal.open || !self.terminal.full_panel {
1847            return;
1848        }
1849        if self.terminal.close_confirm {
1850            // Second Ctrl+Shift+W while confirming — cancel
1851            self.terminal.close_confirm = false;
1852            self.message = "Close cancelled".into();
1853            return;
1854        }
1855        self.terminal.close_confirm = true;
1856        self.message = "Close terminal?  [y]es  /  [n]o  ·  Ctrl+Shift+W cancel".into();
1857    }
1858
1859    pub fn confirm_close_pane_terminal(&mut self, yes: bool) {
1860        self.terminal.close_confirm = false;
1861        if !yes {
1862            self.message = "Close cancelled".into();
1863            return;
1864        }
1865        if self.terminal.open && self.terminal.full_panel {
1866            self.terminal.open = false;
1867            self.terminal.full_panel = false;
1868            self.terminal.pane_bound = None;
1869            self.terminal.shutdown();
1870            if matches!(self.mode, Mode::Terminal) {
1871                self.mode = Mode::Normal;
1872            }
1873            self.message = "Terminal window closed".into();
1874        }
1875    }
1876
1877    /// Whether progressive GPU-terminal features should run this session.
1878    pub fn gpu_active(&self) -> bool {
1879        self.gpu_acc
1880            && (self.term_modern
1881                || self.term_sync
1882                || self.term_underline_color
1883                || self.term_undercurl)
1884    }
1885
1886    /// Install terminal caps discovered by the TUI shell.
1887    pub fn set_term_caps(
1888        &mut self,
1889        summary: String,
1890        sync: bool,
1891        undercurl: bool,
1892        underline_color: bool,
1893        hyperlinks: bool,
1894        modern: bool,
1895        kitty_graphics: bool,
1896    ) {
1897        self.term_caps_summary = summary;
1898        self.term_sync = sync;
1899        self.term_kitty_graphics = kitty_graphics;
1900        self.term_undercurl = undercurl;
1901        self.term_underline_color = underline_color;
1902        self.term_hyperlinks = hyperlinks;
1903        self.term_modern = modern;
1904    }
1905
1906    pub fn save_settings(&mut self) {
1907        self.settings.save();
1908        self.apply_settings_draft();
1909        self.message = self
1910            .settings
1911            .status
1912            .clone()
1913            .unwrap_or_else(|| "Settings saved".into());
1914    }
1915
1916    pub fn scm_refresh(&mut self) {
1917        let hint = self.filename.as_deref();
1918        self.scm.refresh(hint);
1919        self.refresh_git();
1920    }
1921
1922    pub fn scm_commit(&mut self) {
1923        match self.scm.commit(false) {
1924            Ok(()) => {
1925                let summary = self
1926                    .scm
1927                    .last_result
1928                    .clone()
1929                    .unwrap_or_else(|| "Committed".into());
1930                self.message = format!("✓ {}", summary);
1931                self.refresh_git();
1932            }
1933            Err(e) => {
1934                self.message = e;
1935            }
1936        }
1937    }
1938
1939    pub fn scm_stage_selected(&mut self) {
1940        match self.scm.stage_selected() {
1941            Ok(()) => {
1942                self.message = "Staged/unstaged".into();
1943                self.refresh_git();
1944            }
1945            Err(e) => self.message = e,
1946        }
1947    }
1948
1949    pub fn scm_stage_all(&mut self) {
1950        match self.scm.stage_all() {
1951            Ok(()) => {
1952                self.message = self
1953                    .scm
1954                    .last_result
1955                    .clone()
1956                    .unwrap_or_else(|| "Staged all".into());
1957                self.refresh_git();
1958            }
1959            Err(e) => self.message = e,
1960        }
1961    }
1962
1963    pub fn scm_open_selected_file(&mut self) {
1964        let Some(entry) = self.scm.entry_at(self.scm.selected).cloned() else {
1965            return;
1966        };
1967        let path = if let Some(ref root) = self.scm.root {
1968            root.join(&entry.path)
1969        } else {
1970            PathBuf::from(&entry.path)
1971        };
1972        let path_str = path.display().to_string();
1973        self.close_scm_immediate();
1974        self.open_new_tab(&path_str);
1975    }
1976
1977    /// Toggle pretty preview for the current buffer (Markdown / JSON / media).
1978    pub fn toggle_preview(&mut self) {
1979        if self.preview.open && self.mode == Mode::Preview {
1980            if self.preview.closing {
1981                let text = self.buffer.text();
1982                let ext = self.file_extension();
1983                self.preview.base_dir = self
1984                    .filename
1985                    .as_ref()
1986                    .and_then(|p| p.parent().map(|d| d.to_path_buf()));
1987                self.preview.cell_dims =
1988                    (self.cell_px_or_default(), self.cell_px_h_or_default());
1989                self.preview.open_for(&text, ext.as_deref());
1990                return;
1991            }
1992            self.close_preview();
1993            return;
1994        }
1995        if self.scm.open {
1996            self.close_scm_immediate();
1997        }
1998        if self.palette.open {
1999            self.palette.close();
2000        }
2001        // Prefer path-based media if current file is an image/csv/npy/audio
2002        if let Some(ref path) = self.filename.clone() {
2003            if crate::media::is_media_path(path) {
2004                match self.open_media_preview(path) {
2005                    Ok(()) => return,
2006                    Err(e) => {
2007                        self.message = e;
2008                        return;
2009                    }
2010                }
2011            }
2012        }
2013        let text = self.buffer.text();
2014        let ext = self.file_extension();
2015        self.clear_media_handles();
2016        self.preview.open_for(&text, ext.as_deref());
2017        self.mode = Mode::Preview;
2018        let kind = self
2019            .preview
2020            .kind
2021            .map(|k| k.label())
2022            .unwrap_or("Preview");
2023        self.message = format!("Preview · {kind} — Esc close · j/k scroll · r refresh");
2024    }
2025
2026    /// Open media / data preview from a filesystem path (explorer Enter).
2027    /// Effective pixels-per-cell for image caches.
2028    pub fn cell_px_or_default(&self) -> u32 {
2029        if self.cell_px >= 4 { self.cell_px } else { 14 }
2030    }
2031
2032    pub fn cell_px_h_or_default(&self) -> u32 {
2033        if self.cell_px_h >= 6 {
2034            self.cell_px_h
2035        } else {
2036            self.cell_px_or_default() * 2
2037        }
2038    }
2039
2040    pub fn open_media_preview(&mut self, path: &std::path::Path) -> Result<(), String> {
2041        self.clear_media_handles();
2042        self.preview.open_path(path)?;
2043        let kind = self.preview.kind;
2044        match kind {
2045            Some(crate::preview::PreviewKind::Image) => {
2046                match crate::media::ImageAsset::load(path, self.cell_px_or_default()) {
2047                    Ok(img) => {
2048                        self.message = format!(
2049                            "Image · {}×{} · ←/→ resize · Esc close",
2050                            img.src_w, img.src_h
2051                        );
2052                        self.preview_image = Some(img);
2053                    }
2054                    Err(e) => {
2055                        self.preview.lines.push(crate::preview::PreviewLine {
2056                            spans: vec![(format!("  load error: {e}"), crate::preview::PreviewStyle::AlertWarning)],
2057                            image: None,
2058                        });
2059                        self.message = e;
2060                    }
2061                }
2062            }
2063            Some(crate::preview::PreviewKind::Audio) => {
2064                self.preview_audio = Some(crate::media::AudioPlayer::new(path.to_path_buf()));
2065                self.message = "Audio · Space play/stop · Esc close".into();
2066            }
2067            Some(k) => {
2068                self.message = format!("Preview · {} — Esc close · j/k scroll", k.label());
2069            }
2070            None => {}
2071        }
2072        self.mode = Mode::Preview;
2073        Ok(())
2074    }
2075
2076    pub fn clear_media_handles(&mut self) {
2077        if let Some(mut a) = self.preview_audio.take() {
2078            a.stop();
2079        }
2080        self.preview_image = None;
2081    }
2082
2083    /// Begin reverse transform close (mode flips when anim settles).
2084    pub fn close_preview(&mut self) {
2085        if !self.preview.open {
2086            self.mode = Mode::Normal;
2087            return;
2088        }
2089        self.clear_media_handles();
2090        self.preview.close();
2091    }
2092
2093    pub fn close_preview_immediate(&mut self) {
2094        self.clear_media_handles();
2095        self.preview.close_immediate();
2096        self.mode = Mode::Normal;
2097    }
2098
2099    pub fn refresh_preview_if_open(&mut self) {
2100        if self.preview.open && !self.preview.closing {
2101            let text = self.buffer.text();
2102            let ext = self.file_extension();
2103            self.preview.rebuild(&text, ext.as_deref());
2104        }
2105    }
2106
2107    /// Settle modes after panel/preview close animations complete.
2108    pub fn settle_anims(&mut self) {
2109        if self.scm.take_just_closed() {
2110            self.mode = Mode::Normal;
2111        }
2112        if self.preview.take_just_closed() {
2113            self.clear_media_handles();
2114            self.mode = Mode::Normal;
2115        }
2116    }
2117
2118    /// Breadcrumb path segments for the current file (VS Code-style).
2119    pub fn breadcrumbs(&self) -> Vec<String> {
2120        let Some(ref path) = self.filename else {
2121            return vec!["untitled".into()];
2122        };
2123        let mut parts: Vec<String> = Vec::new();
2124        for c in path.components() {
2125            match c {
2126                std::path::Component::Normal(s) => {
2127                    parts.push(s.to_string_lossy().into_owned());
2128                }
2129                std::path::Component::RootDir => parts.push("/".into()),
2130                std::path::Component::Prefix(p) => {
2131                    parts.push(p.as_os_str().to_string_lossy().into_owned());
2132                }
2133                _ => {}
2134            }
2135        }
2136        // Keep last 4 segments for readability
2137        if parts.len() > 4 {
2138            let tail: Vec<_> = parts.into_iter().rev().take(4).collect::<Vec<_>>();
2139            let mut v: Vec<_> = tail.into_iter().rev().collect();
2140            v.insert(0, "…".into());
2141            v
2142        } else if parts.is_empty() {
2143            vec!["untitled".into()]
2144        } else {
2145            parts
2146        }
2147    }
2148
2149    pub fn file_extension(&self) -> Option<String> {
2150        self.filename
2151            .as_ref()
2152            .and_then(|p| p.extension())
2153            .and_then(|e| e.to_str())
2154            .map(|s| s.to_lowercase())
2155    }
2156
2157    pub fn file_name(&self) -> &str {
2158        self.filename
2159            .as_ref()
2160            .and_then(|p| p.file_stem())
2161            .and_then(|s| s.to_str())
2162            .unwrap_or("untitled")
2163    }
2164
2165    pub fn push_undo(&mut self) {
2166        self.undo_stack.push(self.buffer.snapshot());
2167        self.modified = true;
2168        if self.current_buffer < self.buffers.len() {
2169            self.buffers[self.current_buffer].modified = true;
2170        }
2171        // didChange is sent by sync_lsp_document (post-edit); notifying here
2172        // would push the *pre-edit* snapshot since push_undo runs first.
2173    }
2174
2175    /// Push the current buffer to the LSP if it differs from what the server
2176    /// last saw. Frontends call this from their loop (throttled) and before
2177    /// position-based requests, so the server always answers against the
2178    /// post-edit document — including plain insert-mode typing, which never
2179    /// produced a didChange before.
2180    pub fn sync_lsp_document(&mut self) {
2181        if !self.lsp.server_running {
2182            return;
2183        }
2184        let Some(path) = self.filename.clone() else {
2185            return;
2186        };
2187        let path_str = path.display().to_string();
2188        if !crate::lsp::has_server_for(&path_str) {
2189            return;
2190        }
2191        // Version gate: skip the O(file) join + hash entirely when the text
2192        // hasn't mutated since the last sync (this runs every ~5 frames).
2193        let path_changed = self.lsp_synced_path.as_ref() != Some(&path);
2194        if !path_changed && self.lsp_synced_version == self.buffer.version() {
2195            return;
2196        }
2197        let text = self.buffer.text();
2198        let hash = text_hash(&text);
2199        if path_changed || self.lsp_synced_hash != hash {
2200            self.lsp.notify_change(&path_str, &text);
2201            self.lsp_synced_path = Some(path);
2202            self.lsp_synced_hash = hash;
2203        }
2204        self.lsp_synced_version = self.buffer.version();
2205    }
2206
2207    pub fn undo(&mut self) {
2208        let current = self.buffer.snapshot();
2209        if let Some(snap) = self.undo_stack.undo(current) {
2210            self.buffer.restore(&snap);
2211            self.message = String::from("UNDO");
2212        } else {
2213            self.message = String::from("Already at oldest change");
2214        }
2215    }
2216
2217    pub fn redo(&mut self) {
2218        let current = self.buffer.snapshot();
2219        if let Some(snap) = self.undo_stack.redo(current) {
2220            self.buffer.restore(&snap);
2221            self.modified = true;
2222            self.message = String::from("REDO");
2223        } else {
2224            self.message = String::from("Already at newest change");
2225        }
2226    }
2227
2228    /// Apply operator to a motion; records last_change for `.`
2229    pub fn apply_operator_motion(&mut self, op: Operator, motion: Motion, count: usize) {
2230        let count = count.max(1);
2231        let range = range_for_motion(&self.buffer, motion, count);
2232        self.apply_operator_range(op, range, true);
2233        self.last_change = Some(LastChange::Operator { op, motion, count });
2234        self.clear_operator_pending();
2235    }
2236
2237    pub fn apply_operator_textobject(&mut self, op: Operator, obj: TextObject, count: usize) {
2238        let count = count.max(1);
2239        // count>1: apply repeatedly from cursor (vim-ish for words)
2240        for i in 0..count {
2241            let Some(range) = range_for_textobject(&self.buffer, obj) else {
2242                if i == 0 {
2243                    self.message = String::from("Text object not found");
2244                }
2245                break;
2246            };
2247            let record = i == 0;
2248            self.apply_operator_range(op, range, record);
2249            if op == Operator::Yank {
2250                break;
2251            }
2252            if op == Operator::Change {
2253                break;
2254            }
2255        }
2256        self.last_change = Some(LastChange::TextObject { op, obj, count });
2257        self.clear_operator_pending();
2258    }
2259
2260    fn apply_operator_range(
2261        &mut self,
2262        op: Operator,
2263        range: ops::EditRange,
2264        push_undo_first: bool,
2265    ) {
2266        match op {
2267            Operator::Yank => {
2268                let text = extract_text(&self.buffer, range);
2269                let linewise = range.linewise;
2270                let stored = if linewise && !text.ends_with('\n') {
2271                    format!("{}\n", text)
2272                } else {
2273                    text
2274                };
2275                let label = self.registers.active_label();
2276                self.store_yank(stored, linewise);
2277                self.message = format!("Yanked → {}", label);
2278            }
2279            Operator::Delete => {
2280                if push_undo_first {
2281                    self.push_undo();
2282                }
2283                let text = delete_range(&mut self.buffer, range);
2284                let linewise = range.linewise;
2285                let stored = if linewise && !text.ends_with('\n') {
2286                    format!("{}\n", text)
2287                } else {
2288                    text
2289                };
2290                self.store_yank(stored, linewise);
2291                self.update_scroll();
2292                self.message = String::from("Deleted");
2293            }
2294            Operator::Change => {
2295                if push_undo_first {
2296                    self.push_undo();
2297                }
2298                let text = delete_range(&mut self.buffer, range);
2299                self.store_yank(text, range.linewise);
2300                self.mode = Mode::Insert;
2301                self.message = String::from("-- INSERT --");
2302                self.update_scroll();
2303            }
2304        }
2305    }
2306
2307    pub fn store_yank(&mut self, text: String, linewise: bool) {
2308        self.registers.store(text.clone(), linewise);
2309        self.yank_buffer = Some(text);
2310    }
2311
2312    pub fn apply_substitute_cmd(&mut self, cmd: SubstituteCmd) {
2313        self.push_undo();
2314        let lines: Vec<String> = self.buffer.lines().to_vec();
2315        let row = self.buffer.cursor.row;
2316        let (new_lines, n) = substitute::apply_substitute(&lines, &cmd, row);
2317        // rebuild buffer preserving cursor row
2318        let text = new_lines.join("\n");
2319        let col = self.buffer.cursor.col;
2320        self.buffer = Buffer::from_string(&text);
2321        self.buffer.cursor.row = row.min(self.buffer.line_count().saturating_sub(1));
2322        self.buffer.cursor.col = col;
2323        self.buffer.clamp_col();
2324        self.message = format!("{} substitution(s)", n);
2325        self.xlc.add_output(&format!("{} substitution(s) on {}", n, if cmd.global_file { "file" } else { "line" }));
2326        self.sync_lsp_document();
2327    }
2328
2329    /// Visual-block range: (min_row, max_row, min_col, max_col) inclusive cols.
2330    pub fn block_range(&self) -> Option<(usize, usize, usize, usize)> {
2331        let anchor = self.visual_anchor?;
2332        let cur = self.buffer.cursor();
2333        let (r0, r1) = if anchor.row <= cur.row {
2334            (anchor.row, cur.row)
2335        } else {
2336            (cur.row, anchor.row)
2337        };
2338        let (c0, c1) = if anchor.col <= cur.col {
2339            (anchor.col, cur.col)
2340        } else {
2341            (cur.col, anchor.col)
2342        };
2343        Some((r0, r1, c0, c1))
2344    }
2345
2346    pub fn yank_block(&mut self) {
2347        let Some((r0, r1, c0, c1)) = self.block_range() else {
2348            return;
2349        };
2350        let mut lines = Vec::new();
2351        for row in r0..=r1 {
2352            let chars: Vec<char> = self.buffer.line(row).chars().collect();
2353            let s = c0.min(chars.len());
2354            let e = (c1 + 1).min(chars.len());
2355            if s < e {
2356                lines.push(chars[s..e].iter().collect::<String>());
2357            } else {
2358                lines.push(String::new());
2359            }
2360        }
2361        self.store_yank(lines.join("\n"), false);
2362        self.enter_normal();
2363        self.message = String::from("Yanked block");
2364    }
2365
2366    pub fn delete_block(&mut self) {
2367        let Some((r0, r1, c0, c1)) = self.block_range() else {
2368            return;
2369        };
2370        self.push_undo();
2371        let mut yanked = Vec::new();
2372        for row in r0..=r1 {
2373            let chars: Vec<char> = self.buffer.line(row).chars().collect();
2374            let s = c0.min(chars.len());
2375            let e = (c1 + 1).min(chars.len());
2376            if s < e {
2377                yanked.push(chars[s..e].iter().collect::<String>());
2378                let new_line: String = chars[..s].iter().chain(chars[e..].iter()).collect();
2379                self.buffer.set_line(row, new_line);
2380            } else {
2381                yanked.push(String::new());
2382            }
2383        }
2384        self.store_yank(yanked.join("\n"), false);
2385        self.buffer.cursor = Position::new(r0, c0);
2386        self.buffer.clamp_col();
2387        self.enter_normal();
2388        self.message = String::from("Deleted block");
2389    }
2390
2391    pub fn request_references(&mut self) {
2392        self.sync_lsp_document();
2393        if let Some(ref path) = self.filename.clone() {
2394            let c = self.buffer.cursor();
2395            self.lsp
2396                .request_references(&path.display().to_string(), c.row, c.col);
2397            self.message = String::from("Finding references…");
2398        }
2399    }
2400
2401    pub fn request_rename(&mut self, new_name: &str) {
2402        if new_name.is_empty() {
2403            self.message = String::from("Empty name");
2404            return;
2405        }
2406        self.sync_lsp_document();
2407        if let Some(ref path) = self.filename.clone() {
2408            let c = self.buffer.cursor();
2409            self.lsp
2410                .request_rename(&path.display().to_string(), c.row, c.col, new_name);
2411            self.message = format!("Renaming to {}…", new_name);
2412        }
2413    }
2414
2415    /// Copy current visual selection, or the current line in Normal mode, to
2416    /// the system clipboard (Cmd+C / Ctrl+C style).
2417    pub fn clipboard_copy(&mut self) {
2418        if matches!(self.mode, Mode::Visual | Mode::VisualLine) {
2419            self.yank_selection();
2420            // yank_selection already store_yank → system
2421            self.message = String::from("Copied to clipboard");
2422            return;
2423        }
2424        // Normal / Insert: copy current line
2425        let line = self.buffer.line(self.buffer.cursor.row).to_string();
2426        let text = if line.ends_with('\n') {
2427            line
2428        } else {
2429            format!("{}\n", line)
2430        };
2431        self.store_yank(text, true);
2432        self.message = String::from("Copied line to clipboard");
2433    }
2434
2435    /// Paste from system clipboard (Cmd+V / Ctrl+V style) into the buffer.
2436    pub fn clipboard_paste(&mut self) {
2437        // Force system clipboard path
2438        self.registers.select('+');
2439        if self.mode == Mode::Insert {
2440            if let Some(val) = self.registers.load_for_put() {
2441                self.push_undo();
2442                for c in val.text.chars() {
2443                    if c == '\n' {
2444                        self.buffer.insert_newline_with_indent(false);
2445                    } else if c != '\r' {
2446                        self.buffer.insert_char(c);
2447                    }
2448                }
2449                self.update_scroll();
2450                self.message = String::from("Pasted from clipboard");
2451            } else {
2452                self.message = String::from("Clipboard empty");
2453            }
2454        } else {
2455            // Normal / Visual: put after (and leave insert if visual was replaced)
2456            if matches!(self.mode, Mode::Visual | Mode::VisualLine) {
2457                self.delete_selection();
2458            }
2459            self.registers.select('+');
2460            self.paste();
2461            self.message = String::from("Pasted from clipboard");
2462        }
2463    }
2464
2465    /// Insert pasted text at the cursor verbatim (no auto-indent — a bracketed
2466    /// paste from the outer terminal should land exactly as-is). Used by the
2467    /// TUI's `Event::Paste` handler in editor Insert mode.
2468    pub fn paste_text_at_cursor(&mut self, text: &str) {
2469        if text.is_empty() {
2470            return;
2471        }
2472        self.push_undo();
2473        let clean = text.replace('\r', "");
2474        self.buffer.insert_str(&clean);
2475        self.update_scroll();
2476        self.sync_lsp_document();
2477        self.message = String::from("Pasted");
2478    }
2479
2480    /// Select entire buffer (Ctrl+A / context menu).
2481    pub fn select_all(&mut self) {
2482        let last = self.buffer.line_count().saturating_sub(1);
2483        let end_col = self.buffer.line(last).chars().count();
2484        self.visual_anchor = Some(Position::new(0, 0));
2485        self.buffer.cursor = Position::new(last, end_col);
2486        self.mode = Mode::Visual;
2487        self.completions.deactivate();
2488        self.message = String::from("-- VISUAL -- select all");
2489    }
2490
2491    /// Open editor right-click context menu at screen coords.
2492    pub fn open_editor_ctx(&mut self, x: u16, y: u16) {
2493        let mut items = vec![
2494            EditorCtxItem::Cut,
2495            EditorCtxItem::Copy,
2496            EditorCtxItem::Paste,
2497            EditorCtxItem::SelectAll,
2498            EditorCtxItem::Undo,
2499            EditorCtxItem::Redo,
2500        ];
2501        if self.filename.is_some() {
2502            items.push(EditorCtxItem::GoToDefinition);
2503            items.push(EditorCtxItem::FormatDocument);
2504        }
2505        items.push(EditorCtxItem::CommandPalette);
2506        self.editor_ctx = Some(EditorContextMenu {
2507            x,
2508            y,
2509            sel: 0,
2510            items,
2511        });
2512        self.message = "Menu · j/k · Enter · Esc".into();
2513    }
2514
2515    pub fn close_editor_ctx(&mut self) {
2516        self.editor_ctx = None;
2517    }
2518
2519    /// Run selected editor context-menu action.
2520    pub fn run_editor_ctx_action(&mut self) -> Result<String, String> {
2521        let menu = self
2522            .editor_ctx
2523            .clone()
2524            .ok_or_else(|| "No menu".to_string())?;
2525        let item = *menu
2526            .items
2527            .get(menu.sel)
2528            .ok_or_else(|| "No item".to_string())?;
2529        self.editor_ctx = None;
2530        match item {
2531            EditorCtxItem::Cut => {
2532                if matches!(self.mode, Mode::Visual | Mode::VisualLine | Mode::VisualBlock) {
2533                    if self.mode == Mode::VisualBlock {
2534                        self.delete_block();
2535                    } else {
2536                        self.delete_selection();
2537                    }
2538                    Ok("Cut".into())
2539                } else {
2540                    self.delete_line();
2541                    Ok(self.message.clone())
2542                }
2543            }
2544            EditorCtxItem::Copy => {
2545                self.clipboard_copy();
2546                Ok(self.message.clone())
2547            }
2548            EditorCtxItem::Paste => {
2549                self.clipboard_paste();
2550                Ok(self.message.clone())
2551            }
2552            EditorCtxItem::SelectAll => {
2553                self.select_all();
2554                Ok("Select all".into())
2555            }
2556            EditorCtxItem::Undo => {
2557                self.undo();
2558                Ok(self.message.clone())
2559            }
2560            EditorCtxItem::Redo => {
2561                self.redo();
2562                Ok(self.message.clone())
2563            }
2564            EditorCtxItem::GoToDefinition => {
2565                let path = self
2566                    .filename
2567                    .as_ref()
2568                    .map(|p| p.display().to_string())
2569                    .ok_or_else(|| "No file".to_string())?;
2570                let c = self.buffer.cursor();
2571                self.push_jump();
2572                self.sync_lsp_document();
2573                self.lsp.request_definition(&path, c.row, c.col);
2574                Ok("Requested definition…".into())
2575            }
2576            EditorCtxItem::FormatDocument => {
2577                self.format_document();
2578                Ok(self.message.clone())
2579            }
2580            EditorCtxItem::CommandPalette => {
2581                self.open_command_palette();
2582                Ok("Command palette".into())
2583            }
2584        }
2585    }
2586
2587    /// Snapshot current location for jumplist (call before big moves).
2588    pub fn push_jump(&mut self) {
2589        self.jumps.push(Jump {
2590            pos: self.buffer.cursor(),
2591            scroll: self.scroll,
2592            path: self.filename.clone(),
2593        });
2594    }
2595
2596    pub fn jump_back(&mut self) {
2597        let current = Jump {
2598            pos: self.buffer.cursor(),
2599            scroll: self.scroll,
2600            path: self.filename.clone(),
2601        };
2602        if let Some(j) = self.jumps.back(current) {
2603            self.apply_jump(j);
2604            self.message = String::from("Jump ←");
2605        } else {
2606            self.message = String::from("Already at oldest jump");
2607        }
2608    }
2609
2610    pub fn jump_forward(&mut self) {
2611        if let Some(j) = self.jumps.forward() {
2612            self.apply_jump(j);
2613            self.message = String::from("Jump →");
2614        } else {
2615            self.message = String::from("Already at newest jump");
2616        }
2617    }
2618
2619    fn apply_jump(&mut self, j: Jump) {
2620        // Switch buffer if path differs and is open
2621        if let Some(ref path) = j.path {
2622            if self.filename.as_ref() != Some(path) {
2623                let path_str = path.display().to_string();
2624                // Prefer existing tab without re-pushing jump
2625                self.open_new_tab(&path_str);
2626            }
2627        }
2628        self.buffer.cursor = j.pos;
2629        self.buffer.clamp_col();
2630        self.scroll = j.scroll;
2631        self.update_scroll();
2632    }
2633
2634    pub fn set_mark(&mut self, name: char) {
2635        if self.marks.set(name, self.buffer.cursor(), self.filename.clone()) {
2636            self.message = format!("Mark '{}' set", name);
2637        } else {
2638            self.message = String::from("Invalid mark (use a-z)");
2639        }
2640        self.pending_mark_set = false;
2641    }
2642
2643    pub fn jump_to_mark(&mut self, name: char, linewise: bool) {
2644        self.pending_mark_jump = None;
2645        let Some(mark) = self.marks.get(name).cloned() else {
2646            self.message = format!("Mark '{}' not set", name);
2647            return;
2648        };
2649        self.push_jump();
2650        if let Some(ref path) = mark.path {
2651            if self.filename.as_ref() != Some(path) {
2652                self.open_new_tab(&path.display().to_string());
2653            }
2654        }
2655        self.buffer.cursor = mark.pos;
2656        if linewise {
2657            self.buffer.move_to_first_non_blank();
2658        }
2659        self.buffer.clamp_col();
2660        self.update_scroll();
2661        self.message = format!("Jump to '{}'", name);
2662    }
2663
2664    pub fn record_find(&mut self, kind: FindKind, forward: bool, ch: char) {
2665        self.last_find = Some(LastFind { ch, kind, forward });
2666    }
2667
2668    pub fn repeat_find(&mut self, reverse: bool) {
2669        let Some(lf) = self.last_find else {
2670            self.message = String::from("No previous f/t");
2671            return;
2672        };
2673        let (kind, forward, ch) = lf.repeat(reverse);
2674        match (kind, forward) {
2675            (FindKind::Find, true) => self.buffer.find_char_forward(ch),
2676            (FindKind::Find, false) => self.buffer.find_char_backward(ch),
2677            (FindKind::Till, true) => self.buffer.till_char_forward(ch),
2678            (FindKind::Till, false) => self.buffer.till_char_backward(ch),
2679        }
2680        self.update_scroll();
2681    }
2682
2683    pub fn clear_operator_pending(&mut self) {
2684        self.pending_operator = None;
2685        self.pending_to_mod = None;
2686        self.pending_key = None;
2687        self.pending_hints.clear();
2688        self.which_key.clear();
2689    }
2690
2691    pub fn begin_operator(&mut self, op: Operator) {
2692        self.pending_operator = Some(op);
2693        self.pending_to_mod = None;
2694        self.pending_key = None;
2695        let name = match op {
2696            Operator::Delete => "d",
2697            Operator::Change => "c",
2698            Operator::Yank => "y",
2699        };
2700        let hints = match op {
2701            Operator::Delete => crate::which_key::as_hints(crate::which_key::map_operator_delete()),
2702            Operator::Change => crate::which_key::as_hints(crate::which_key::map_operator_change()),
2703            Operator::Yank => crate::which_key::as_hints(crate::which_key::map_operator_yank()),
2704        };
2705        self.begin_chord(name, hints);
2706        self.message = format!("-- {} --", name);
2707    }
2708
2709    /// Replay last change (`.` command).
2710    pub fn repeat_last_change(&mut self) {
2711        let Some(change) = self.last_change.clone() else {
2712            self.message = String::from("No change to repeat");
2713            return;
2714        };
2715        match change {
2716            LastChange::Operator { op, motion, count } => {
2717                self.apply_operator_motion(op, motion, count);
2718            }
2719            LastChange::TextObject { op, obj, count } => {
2720                self.apply_operator_textobject(op, obj, count);
2721            }
2722            LastChange::DeleteChar { count } => {
2723                self.push_undo();
2724                for _ in 0..count.max(1) {
2725                    if self.buffer.cursor.col < self.buffer.current_line_len() {
2726                        self.buffer.delete_char_at_cursor();
2727                    }
2728                }
2729            }
2730            LastChange::ReplaceChar { ch } => {
2731                self.push_undo();
2732                self.buffer.replace_char(ch);
2733            }
2734        }
2735        self.message = String::from("Repeated");
2736    }
2737
2738    pub fn goto_line(&mut self, line_1based: usize) {
2739        self.push_jump();
2740        let target = line_1based.saturating_sub(1).min(self.buffer.line_count().saturating_sub(1));
2741        self.buffer.cursor.row = target;
2742        self.buffer.move_to_line_start();
2743        self.update_scroll();
2744        self.message = format!("Line {}", target + 1);
2745    }
2746
2747    pub fn search_word_under_cursor_backward(&mut self) {
2748        let word = self.word_under_cursor();
2749        if word.is_empty() {
2750            self.message = String::from("No word under cursor");
2751            return;
2752        }
2753        self.push_jump();
2754        self.search_pattern = Some(word.clone());
2755        self.search_forward = false;
2756        self.recompute_search(&word, true);
2757        if self.search_matches.len() > 1 {
2758            self.search_prev();
2759        } else if self.search_matches.is_empty() {
2760            self.message = format!("Pattern not found: {}", word);
2761        } else {
2762            self.message = format!("?{}/  1/1", word);
2763        }
2764    }
2765
2766    pub fn quit(&mut self) {
2767        // Persist or discard undo history for every open file (undo_caching).
2768        self.save_state_to_tab();
2769        let caching = self.undo_caching;
2770        for tab in &mut self.buffers {
2771            if tab.filename.is_some() {
2772                let text = tab.buffer.text();
2773                tab.undo_stack.finish(caching, &text);
2774            }
2775        }
2776        // Detached so quitting is instant; the hook keeps running after exit.
2777        crate::hooks::run_hooks_detached(
2778            &self.hooks,
2779            crate::hooks::HookEvent::Quit,
2780            self.filename.as_deref(),
2781        );
2782        self.save_session();
2783        self.running = false;
2784    }
2785
2786    pub fn enter_insert(&mut self) {
2787        self.push_undo();
2788        self.visual_anchor = None;
2789        self.mode = Mode::Insert;
2790        self.message = String::from("-- INSERT --");
2791    }
2792
2793    pub fn enter_normal(&mut self) {
2794        self.mode = Mode::Normal;
2795        self.visual_anchor = None;
2796        self.pending_key = None;
2797        self.pending_ft = None;
2798        self.pending_hints.clear();
2799        self.count = None;
2800        self.pending_register = false;
2801        self.pending_mark_set = false;
2802        self.pending_mark_jump = None;
2803        self.clear_operator_pending();
2804        self.completions.deactivate();
2805        self.palette.close();
2806        self.hover_text = None;
2807        // Keep multi-cursors when returning to normal (Esc once clears them)
2808        self.message = String::new();
2809    }
2810
2811    pub fn clear_multi_cursors(&mut self) {
2812        if self.multi.is_active() {
2813            self.multi.clear();
2814            self.message = "Multi-cursor cleared".into();
2815        }
2816    }
2817
2818    /// Ctrl+D — add next occurrence of word under primary cursor.
2819    pub fn multi_cursor_add_next(&mut self) {
2820        let primary = self.buffer.cursor();
2821        let Some((_, end, word)) = crate::multi_cursor::word_at(&self.buffer, primary) else {
2822            self.message = "No word under cursor".into();
2823            return;
2824        };
2825        // Search after the last multi-cursor (or after primary word)
2826        let from = self
2827            .multi
2828            .extras
2829            .last()
2830            .copied()
2831            .map(|p| Position {
2832                row: p.row,
2833                col: p.col + word.chars().count(),
2834            })
2835            .unwrap_or(end);
2836        if let Some(pos) = crate::multi_cursor::find_next(&self.buffer, &word, from) {
2837            self.multi.add(primary, pos);
2838            self.message = format!("cursors: {}", self.multi.count(primary));
2839        } else {
2840            self.message = "No more matches".into();
2841        }
2842    }
2843
2844    /// Ctrl+Alt+Down / `]c` — column cursor below primary.
2845    pub fn multi_cursor_add_below(&mut self) {
2846        let p = self.buffer.cursor();
2847        if p.row + 1 >= self.buffer.line_count() {
2848            self.message = "No line below".into();
2849            return;
2850        }
2851        let mut np = Position {
2852            row: p.row + 1,
2853            col: p.col,
2854        };
2855        let max = self.buffer.line(np.row).chars().count();
2856        if np.col > max {
2857            np.col = max;
2858        }
2859        self.multi.add(p, np);
2860        self.message = format!("cursors: {}", self.multi.count(p));
2861    }
2862
2863    pub fn multi_cursor_add_above(&mut self) {
2864        let p = self.buffer.cursor();
2865        if p.row == 0 {
2866            self.message = "No line above".into();
2867            return;
2868        }
2869        let mut np = Position {
2870            row: p.row - 1,
2871            col: p.col,
2872        };
2873        let max = self.buffer.line(np.row).chars().count();
2874        if np.col > max {
2875            np.col = max;
2876        }
2877        self.multi.add(p, np);
2878        self.message = format!("cursors: {}", self.multi.count(p));
2879    }
2880
2881    /// Apply insert-mode edit at every cursor (bottom→top so offsets stay valid).
2882    pub fn multi_insert_char(&mut self, ch: char) {
2883        if !self.multi.is_active() {
2884            self.buffer.insert_char(ch);
2885            return;
2886        }
2887        let primary = self.buffer.cursor();
2888        let mut all = self.multi.all(primary);
2889        all.sort_by(|a, b| b.row.cmp(&a.row).then(b.col.cmp(&a.col)));
2890        let mut updated = Vec::with_capacity(all.len());
2891        for pos in all {
2892            self.buffer.cursor = pos;
2893            self.buffer.insert_char(ch);
2894            updated.push(self.buffer.cursor);
2895        }
2896        updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
2897        updated.dedup();
2898        if let Some(first) = updated.first().copied() {
2899            self.buffer.cursor = first;
2900            self.multi.set_from_all(updated);
2901        }
2902        self.multi.clamp_all(&self.buffer);
2903        self.modified = true;
2904    }
2905
2906    pub fn multi_backspace(&mut self) {
2907        if !self.multi.is_active() {
2908            self.buffer.backspace();
2909            return;
2910        }
2911        let primary = self.buffer.cursor();
2912        let mut all = self.multi.all(primary);
2913        all.sort_by(|a, b| b.row.cmp(&a.row).then(b.col.cmp(&a.col)));
2914        let mut updated = Vec::with_capacity(all.len());
2915        for pos in all {
2916            self.buffer.cursor = pos;
2917            self.buffer.backspace();
2918            updated.push(self.buffer.cursor);
2919        }
2920        updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
2921        updated.dedup();
2922        if let Some(first) = updated.first().copied() {
2923            self.buffer.cursor = first;
2924            self.multi.set_from_all(updated);
2925        }
2926        self.multi.clamp_all(&self.buffer);
2927        self.modified = true;
2928    }
2929
2930    pub fn multi_delete_char(&mut self) {
2931        if !self.multi.is_active() {
2932            self.buffer.delete_char_at_cursor();
2933            return;
2934        }
2935        let primary = self.buffer.cursor();
2936        let mut all = self.multi.all(primary);
2937        all.sort_by(|a, b| b.row.cmp(&a.row).then(b.col.cmp(&a.col)));
2938        let mut updated = Vec::with_capacity(all.len());
2939        for pos in all {
2940            self.buffer.cursor = pos;
2941            self.buffer.delete_char_at_cursor();
2942            updated.push(self.buffer.cursor);
2943        }
2944        updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
2945        updated.dedup();
2946        if let Some(first) = updated.first().copied() {
2947            self.buffer.cursor = first;
2948            self.multi.set_from_all(updated);
2949        }
2950        self.multi.clamp_all(&self.buffer);
2951        self.modified = true;
2952    }
2953
2954    pub fn multi_move_each(&mut self, f: impl Fn(&mut crate::buffer::Buffer)) {
2955        if !self.multi.is_active() {
2956            f(&mut self.buffer);
2957            return;
2958        }
2959        let primary = self.buffer.cursor();
2960        let all = self.multi.all(primary);
2961        let mut updated = Vec::with_capacity(all.len());
2962        for pos in all {
2963            self.buffer.cursor = pos;
2964            f(&mut self.buffer);
2965            updated.push(self.buffer.cursor);
2966        }
2967        updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
2968        updated.dedup();
2969        if let Some(first) = updated.first().copied() {
2970            self.buffer.cursor = first;
2971            self.multi.set_from_all(updated);
2972        }
2973        self.multi.clamp_all(&self.buffer);
2974    }
2975
2976    pub fn multi_newline(&mut self) {
2977        if !self.multi.is_active() {
2978            let row = self.buffer.cursor.row;
2979            let trimmed = self.buffer.line(row).trim_end().to_string();
2980            let ends_block = trimmed.ends_with('{')
2981                || trimmed.ends_with('[')
2982                || trimmed.ends_with('(')
2983                || trimmed.ends_with(':')
2984                || trimmed.ends_with("=>")
2985                || trimmed.ends_with("->");
2986            let ends_close = trimmed.ends_with(')') || trimmed.ends_with(']');
2987            self.buffer
2988                .insert_newline_with_indent(ends_block && !ends_close);
2989            if let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) {
2990                // Newline splits after `row` → shift BPs on later lines +1
2991                self.dap.shift_breakpoints(&path, row, 1);
2992            }
2993            return;
2994        }
2995        let primary = self.buffer.cursor();
2996        let mut all = self.multi.all(primary);
2997        all.sort_by(|a, b| b.row.cmp(&a.row).then(b.col.cmp(&a.col)));
2998        let mut updated = Vec::with_capacity(all.len());
2999        for pos in all {
3000            self.buffer.cursor = pos;
3001            let trimmed = self.buffer.line(self.buffer.cursor.row).trim_end().to_string();
3002            let ends_block = trimmed.ends_with('{')
3003                || trimmed.ends_with('[')
3004                || trimmed.ends_with('(')
3005                || trimmed.ends_with(':')
3006                || trimmed.ends_with("=>")
3007                || trimmed.ends_with("->");
3008            let ends_close = trimmed.ends_with(')') || trimmed.ends_with(']');
3009            self.buffer
3010                .insert_newline_with_indent(ends_block && !ends_close);
3011            updated.push(self.buffer.cursor);
3012        }
3013        updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
3014        updated.dedup();
3015        if let Some(first) = updated.first().copied() {
3016            self.buffer.cursor = first;
3017            self.multi.set_from_all(updated);
3018        }
3019        self.multi.clamp_all(&self.buffer);
3020        self.modified = true;
3021    }
3022
3023    pub fn open_file_palette(&mut self) {
3024        let root = self
3025            .filename
3026            .as_ref()
3027            .and_then(|p| p.parent().map(|d| d.to_path_buf()))
3028            .unwrap_or_else(|| env::current_dir().unwrap_or_default());
3029        self.palette.open_files(&root);
3030        self.mode = Mode::Palette;
3031        self.message = String::from("Open file — type to filter, Enter open, Esc cancel");
3032    }
3033
3034    pub fn open_command_palette(&mut self) {
3035        self.palette.open_commands();
3036        self.mode = Mode::Palette;
3037        self.message = String::from("Commands — type to filter, Enter run, Esc cancel");
3038    }
3039
3040    pub fn open_problems_palette(&mut self) {
3041        self.palette.open_problems(&self.lsp.diagnostics);
3042        self.mode = Mode::Palette;
3043        self.message = format!("Problems — {} items", self.lsp.diagnostics.len());
3044    }
3045
3046    pub fn execute_palette_selection(&mut self) {
3047        let action = self.palette.selected_action().cloned();
3048        self.palette.close();
3049        self.mode = Mode::Normal;
3050        let Some(action) = action else {
3051            return;
3052        };
3053        match action {
3054            PaletteAction::OpenFile(path) => {
3055                self.open_new_tab(&path.display().to_string());
3056            }
3057            PaletteAction::Goto { row, col } => {
3058                self.push_jump();
3059                self.buffer.cursor.row = row.min(self.buffer.line_count().saturating_sub(1));
3060                self.buffer.cursor.col = col;
3061                self.buffer.clamp_col();
3062                self.update_scroll();
3063                self.message = format!("Jumped to {}:{}", row + 1, col + 1);
3064            }
3065            PaletteAction::GotoFile { path, row, col } => {
3066                self.goto_file_location(&path.display().to_string(), row, col);
3067            }
3068            PaletteAction::CodeAction(i) => {
3069                self.apply_code_action(i);
3070            }
3071            PaletteAction::Command(id) => self.run_palette_command(id),
3072        }
3073    }
3074
3075    /// Jump to path:line:col (opens tab if needed).
3076    pub fn goto_file_location(&mut self, path: &str, row: usize, col: usize) {
3077        self.push_jump();
3078        let cur = self
3079            .filename
3080            .as_ref()
3081            .map(|p| p.display().to_string())
3082            .unwrap_or_default();
3083        if cur != path {
3084            self.open_new_tab(path);
3085        }
3086        self.buffer.cursor.row = row.min(self.buffer.line_count().saturating_sub(1));
3087        let line = self.buffer.line(self.buffer.cursor.row);
3088        // col may already be char index from search; clamp only
3089        self.buffer.cursor.col = col.min(line.chars().count());
3090        self.buffer.clamp_col();
3091        self.update_scroll();
3092        self.sync_split_from_active();
3093        self.message = format!("→ {}:{}:{}", path, row + 1, col + 1);
3094    }
3095
3096    pub fn project_root(&self) -> std::path::PathBuf {
3097        if let Some(ref f) = self.filename {
3098            if let Some(parent) = f.parent() {
3099                // walk up for Cargo.toml / package.json / .git
3100                let mut cur = parent.to_path_buf();
3101                loop {
3102                    if cur.join("Cargo.toml").exists()
3103                        || cur.join("package.json").exists()
3104                        || cur.join(".git").exists()
3105                        || cur.join("go.mod").exists()
3106                        || cur.join("pyproject.toml").exists()
3107                    {
3108                        return cur;
3109                    }
3110                    if !cur.pop() {
3111                        break;
3112                    }
3113                }
3114                return parent.to_path_buf();
3115            }
3116        }
3117        std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
3118    }
3119
3120    pub fn open_workspace_search(&mut self) {
3121        let root = self.project_root();
3122        self.workspace_search.open_at(root);
3123        self.mode = Mode::WorkspaceSearch;
3124        self.message = String::from("Find in files — type pattern, Enter open hit");
3125    }
3126
3127    pub fn open_document_symbols(&mut self) {
3128        let path = self.filename.as_ref().map(|p| p.display().to_string());
3129        if let Some(path) = path {
3130            self.sync_lsp_document();
3131            self.lsp.request_document_symbols(&path);
3132            self.message = String::from("Loading document symbols…");
3133        } else {
3134            self.message = String::from("No file for symbols");
3135        }
3136    }
3137
3138    pub fn open_workspace_symbols(&mut self) {
3139        if !self.lsp.server_running {
3140            self.message = String::from("LSP not running");
3141            return;
3142        }
3143        self.sync_lsp_document();
3144        self.lsp.request_workspace_symbols("");
3145        self.message = String::from("Loading workspace symbols…");
3146    }
3147
3148    pub fn apply_pending_symbols(&mut self) {
3149        let symbols = std::mem::take(&mut self.lsp.pending_symbols);
3150        if symbols.is_empty() {
3151            return;
3152        }
3153        let cur_path = self
3154            .filename
3155            .as_ref()
3156            .map(|p| p.display().to_string())
3157            .unwrap_or_default();
3158        let items: Vec<crate::palette::PaletteItem> = symbols
3159            .into_iter()
3160            .map(|s| {
3161                let path = if s.path.is_empty() {
3162                    cur_path.clone()
3163                } else {
3164                    s.path.clone()
3165                };
3166                let detail = if s.detail.is_empty() {
3167                    format!("{}  L{}", s.kind, s.row + 1)
3168                } else {
3169                    format!("{}  {}  L{}", s.kind, s.detail, s.row + 1)
3170                };
3171                crate::palette::PaletteItem {
3172                    label: s.name,
3173                    detail,
3174                    action: PaletteAction::GotoFile {
3175                        path: std::path::PathBuf::from(path),
3176                        row: s.row,
3177                        col: s.col,
3178                    },
3179                }
3180            })
3181            .collect();
3182        self.palette.open_symbols(items);
3183        self.mode = Mode::Palette;
3184        self.message = format!("Symbols — {} items", self.palette.items.len());
3185    }
3186
3187    pub fn request_peek_definition(&mut self) {
3188        let path = self.filename.as_ref().map(|p| p.display().to_string());
3189        if let Some(path) = path {
3190            self.sync_lsp_document();
3191            let c = self.buffer.cursor();
3192            self.lsp.request_peek_definition(&path, c.row, c.col);
3193            self.message = String::from("Peek definition…");
3194        }
3195    }
3196
3197    pub fn open_peek_at(&mut self, path: &str, row: usize, col: usize) {
3198        let fallback = if self.filename.as_ref().map(|p| p.display().to_string()).as_deref()
3199            == Some(path)
3200        {
3201            Some(self.buffer.text())
3202        } else {
3203            None
3204        };
3205        self.peek.open_at(
3206            std::path::PathBuf::from(path),
3207            row,
3208            col,
3209            fallback.as_deref(),
3210            8,
3211        );
3212        self.message = format!(
3213            "Peek {} — Enter open · Esc dismiss",
3214            self.peek.path.display()
3215        );
3216    }
3217
3218    pub fn promote_peek(&mut self) {
3219        if !self.peek.open {
3220            return;
3221        }
3222        let path = self.peek.path.display().to_string();
3223        let row = self.peek.target_row;
3224        let col = self.peek.target_col;
3225        self.peek.close();
3226        self.goto_file_location(&path, row, col);
3227    }
3228
3229    // ── Splits ──────────────────────────────────────────
3230
3231    pub fn split_vertical(&mut self) {
3232        self.open_split_kind(crate::split::SplitKind::Vertical, "Vertical");
3233    }
3234
3235    pub fn split_horizontal(&mut self) {
3236        self.open_split_kind(crate::split::SplitKind::Horizontal, "Horizontal");
3237    }
3238
3239    fn open_split_kind(&mut self, kind: crate::split::SplitKind, label: &str) {
3240        use crate::split::SplitAdd;
3241        self.save_state_to_tab();
3242        self.sync_split_from_active();
3243        let cur = (self.buffer.cursor.row, self.buffer.cursor.col);
3244        let r = self
3245            .split
3246            .open_split(kind, self.current_buffer, self.scroll, cur);
3247        self.message = match r {
3248            SplitAdd::Opened => {
3249                format!("{label} split · Ctrl+W w cycle · Ctrl+W q close")
3250            }
3251            SplitAdd::Added => format!(
3252                "Pane added ({}) · Ctrl+W w cycle · Ctrl+W q close",
3253                self.split.pane_count()
3254            ),
3255            SplitAdd::Full => format!("Max {} panes", crate::split::MAX_PANES),
3256            SplitAdd::MixedKind => {
3257                "Already split the other way — Ctrl+W q panes first".into()
3258            }
3259        };
3260    }
3261
3262    /// Vim `C-w q`: close the *focused* pane; neighbors survive (the split
3263    /// collapses once one pane remains).
3264    pub fn close_split(&mut self) {
3265        if !self.split.is_split() {
3266            return;
3267        }
3268        let closed = self
3269            .split
3270            .focus
3271            .min(self.split.panes.len().saturating_sub(1));
3272        // Pane terminal dies with its pane; higher indices shift down.
3273        if self.terminal.open && self.terminal.full_panel {
3274            match self.terminal.pane_bound {
3275                Some(b) if b == closed => {
3276                    self.terminal.open = false;
3277                    self.terminal.full_panel = false;
3278                    self.terminal.pane_bound = None;
3279                    self.terminal.close_confirm = false;
3280                    self.terminal.shutdown();
3281                }
3282                Some(b) if b > closed => {
3283                    self.terminal.pane_bound = Some(b - 1);
3284                }
3285                _ => {}
3286            }
3287        }
3288        let survivor = self.split.remove_focused();
3289        if self.split.is_split() {
3290            self.apply_focused_pane();
3291            self.message = format!("Pane closed · {} left", self.split.pane_count());
3292            return;
3293        }
3294        // Collapsed to a single view: adopt the survivor snapshot.
3295        if self.terminal.pane_bound.is_some() {
3296            self.terminal.pane_bound = None; // continues as the full-main window
3297        }
3298        if let Some(p) = survivor {
3299            if p.tab_index != self.current_buffer && p.tab_index < self.buffers.len() {
3300                self.save_state_to_tab();
3301                self.current_buffer = p.tab_index;
3302                self.restore_state_from_tab();
3303                self.lsp_restart_for_current();
3304                self.refresh_git();
3305            }
3306            let max_row = self.buffer.line_count().saturating_sub(1);
3307            self.buffer.cursor.row = p.cursor.0.min(max_row);
3308            self.buffer.cursor.col = p.cursor.1;
3309            self.buffer.clamp_col();
3310            self.scroll = p.scroll;
3311            self.update_scroll();
3312        }
3313        self.message = String::from("Pane closed");
3314    }
3315
3316    /// Vim `C-w h/j/k/l`: directional focus along the split axis (steps one
3317    /// pane per press; works for any pane count).
3318    pub fn focus_dir(&mut self, dir: char) {
3319        if !self.split.is_split() {
3320            return;
3321        }
3322        let vertical = self.split.kind == crate::split::SplitKind::Vertical;
3323        let delta: isize = match (vertical, dir) {
3324            (true, 'h') | (false, 'k') => -1,
3325            (true, 'l') | (false, 'j') => 1,
3326            _ => return, // off-axis — splits are single-direction
3327        };
3328        let n = self.split.panes.len() as isize;
3329        let cur = self.split.focus.min(self.split.panes.len().saturating_sub(1)) as isize;
3330        let next = (cur + delta).clamp(0, n - 1) as usize;
3331        if next == cur as usize {
3332            return;
3333        }
3334        self.sync_split_from_active();
3335        self.split.set_focus(next);
3336        self.apply_focused_pane();
3337        self.message = format!("Pane {}", next + 1);
3338    }
3339
3340    /// Persist active buffer scroll into the focused pane, then switch focus.
3341    pub fn focus_other_pane(&mut self) {
3342        if !self.split.is_split() {
3343            return;
3344        }
3345        self.sync_split_from_active();
3346        self.split.focus_other();
3347        self.apply_focused_pane();
3348        self.message = format!("Pane {}", self.split.focus + 1);
3349    }
3350
3351    pub fn focus_pane(&mut self, idx: usize) {
3352        if !self.split.is_split() {
3353            return;
3354        }
3355        self.sync_split_from_active();
3356        self.split.set_focus(idx);
3357        self.apply_focused_pane();
3358    }
3359
3360    /// Write current scroll/tab into focused pane slot.
3361    pub fn sync_split_from_active(&mut self) {
3362        if !self.split.is_split() {
3363            return;
3364        }
3365        let cur = (self.buffer.cursor.row, self.buffer.cursor.col);
3366        let p = self.split.focused_pane_mut();
3367        p.tab_index = self.current_buffer;
3368        p.scroll = self.scroll;
3369        p.cursor = cur;
3370    }
3371
3372    /// Load focused pane's tab into the active editor.
3373    pub fn apply_focused_pane(&mut self) {
3374        if !self.split.is_split() {
3375            return;
3376        }
3377        let pane = self.split.focused_pane().clone();
3378        if pane.tab_index != self.current_buffer && pane.tab_index < self.buffers.len() {
3379            self.save_state_to_tab();
3380            self.current_buffer = pane.tab_index;
3381            self.restore_state_from_tab();
3382            self.lsp_restart_for_current();
3383            self.refresh_git();
3384        }
3385        // Per-pane cursor (clamped — the buffer may have changed underneath).
3386        let max_row = self.buffer.line_count().saturating_sub(1);
3387        self.buffer.cursor.row = pane.cursor.0.min(max_row);
3388        self.buffer.cursor.col = pane.cursor.1;
3389        self.buffer.clamp_col();
3390        self.scroll = pane.scroll;
3391        self.update_scroll();
3392    }
3393
3394    /// Assign a different tab to the focused pane (e.g. after gt in a split).
3395    pub fn sync_focused_pane_tab(&mut self) {
3396        if self.split.is_split() {
3397            let cur = (self.buffer.cursor.row, self.buffer.cursor.col);
3398            let p = self.split.focused_pane_mut();
3399            p.tab_index = self.current_buffer;
3400            p.scroll = self.scroll;
3401            p.cursor = cur;
3402        }
3403    }
3404
3405    fn run_palette_command(&mut self, id: &str) {
3406        match id {
3407            "noop" => {}
3408            "save" => self.save_file(),
3409            "wq" => {
3410                self.save_file();
3411                if !self.modified {
3412                    self.quit();
3413                }
3414            }
3415            "quit" => {
3416                if self.modified {
3417                    self.message =
3418                        String::from("Unsaved changes. Use Save or Force quit.");
3419                } else {
3420                    self.quit();
3421                }
3422            }
3423            "quit!" => self.quit(),
3424            "explorer" => {
3425                if self.explorer.open {
3426                    self.explorer.close();
3427                } else {
3428                    self.explorer.toggle_at(self.filename.as_ref());
3429                    self.mode = Mode::Explorer;
3430                }
3431            }
3432            "scm" => self.toggle_scm(),
3433            "git" | "git_workbench" => self.open_git_workbench(),
3434            "settings" => self.open_settings(),
3435            "preview" => self.toggle_preview(),
3436            "terminal" => self.toggle_terminal_side(),
3437            "terminal_full" => self.toggle_terminal_full(),
3438            "xlc" => self.enter_xlc(None),
3439            "tab_next" => self.next_tab(),
3440            "tab_prev" => self.prev_tab(),
3441            "tab_close" => self.close_current_tab(),
3442            "problems" => self.open_problems_palette(),
3443            "files" => self.open_file_palette(),
3444            "workspace_find" => self.open_workspace_search(),
3445            "symbols" => self.open_document_symbols(),
3446            "workspace_symbols" => self.open_workspace_symbols(),
3447            "split_v" => self.split_vertical(),
3448            "split_h" => self.split_horizontal(),
3449            "split_close" => self.close_split(),
3450            "help" => {
3451                self.enter_xlc(None);
3452                self.xlc.input = "help".into();
3453                self.execute_xlc();
3454            }
3455            "lsp_def" => {
3456                let path = self.filename.as_ref().map(|p| p.display().to_string());
3457                if let Some(path) = path {
3458                    let c = self.buffer.cursor();
3459                    self.push_jump();
3460                    self.sync_lsp_document();
3461                    self.lsp.request_definition(&path, c.row, c.col);
3462                    self.message = String::from("Requested definition…");
3463                }
3464            }
3465            "lsp_peek" => self.request_peek_definition(),
3466            "format" => self.format_document(),
3467            "code_action" => self.request_code_actions(),
3468            id if id.starts_with("theme:") => {
3469                let name = &id[6..];
3470                if let Some(t) = theme::find(name) {
3471                    self.theme = t;
3472                    config::save_theme(t.name);
3473                    set_cursor_esc(t.cursor);
3474                    self.message = format!("Theme: {}", t.name);
3475                }
3476            }
3477            _ => {
3478                self.message = format!("Unknown command: {}", id);
3479            }
3480        }
3481    }
3482
3483    pub fn diag_next(&mut self) {
3484        if self.lsp.diagnostics.is_empty() {
3485            self.message = String::from("No diagnostics");
3486            return;
3487        }
3488        let cur = self.buffer.cursor();
3489        let mut diags = self.lsp.diagnostics.clone();
3490        diags.sort_by_key(|d| (d.row, d.col_start));
3491        let next = diags
3492            .iter()
3493            .find(|d| d.row > cur.row || (d.row == cur.row && d.col_start > cur.col))
3494            .or_else(|| diags.first());
3495        if let Some(d) = next {
3496            self.push_jump();
3497            self.buffer.cursor.row = d.row;
3498            self.buffer.cursor.col = d.col_start;
3499            self.buffer.clamp_col();
3500            self.update_scroll();
3501            self.message = format!("[{:?}] {}", d.severity, d.message);
3502        }
3503    }
3504
3505    pub fn diag_prev(&mut self) {
3506        if self.lsp.diagnostics.is_empty() {
3507            self.message = String::from("No diagnostics");
3508            return;
3509        }
3510        let cur = self.buffer.cursor();
3511        let mut diags = self.lsp.diagnostics.clone();
3512        diags.sort_by_key(|d| (d.row, d.col_start));
3513        let prev = diags
3514            .iter()
3515            .rev()
3516            .find(|d| d.row < cur.row || (d.row == cur.row && d.col_start < cur.col))
3517            .or_else(|| diags.last());
3518        if let Some(d) = prev {
3519            self.push_jump();
3520            self.buffer.cursor.row = d.row;
3521            self.buffer.cursor.col = d.col_start;
3522            self.buffer.clamp_col();
3523            self.update_scroll();
3524            self.message = format!("[{:?}] {}", d.severity, d.message);
3525        }
3526    }
3527
3528    /// Jump to next git gutter change (from `git diff HEAD`).
3529    pub fn git_change_next(&mut self) {
3530        self.refresh_git();
3531        if self.git.signs.is_empty() {
3532            self.message = String::from("No git changes");
3533            return;
3534        }
3535        let cur = self.buffer.cursor.row;
3536        let mut rows: Vec<usize> = self.git.signs.keys().copied().collect();
3537        rows.sort_unstable();
3538        let next = rows.iter().copied().find(|r| *r > cur).or_else(|| rows.first().copied());
3539        if let Some(row) = next {
3540            self.push_jump();
3541            self.buffer.cursor.row = row;
3542            self.buffer.move_to_line_start();
3543            self.update_scroll();
3544            let sign = self.git.sign_at(row).map(|s| format!("{s:?}")).unwrap_or_default();
3545            self.message = format!("Git change · L{} · {sign}", row + 1);
3546        }
3547    }
3548
3549    /// Jump to previous git gutter change.
3550    pub fn git_change_prev(&mut self) {
3551        self.refresh_git();
3552        if self.git.signs.is_empty() {
3553            self.message = String::from("No git changes");
3554            return;
3555        }
3556        let cur = self.buffer.cursor.row;
3557        let mut rows: Vec<usize> = self.git.signs.keys().copied().collect();
3558        rows.sort_unstable();
3559        let prev = rows
3560            .iter()
3561            .rev()
3562            .copied()
3563            .find(|r| *r < cur)
3564            .or_else(|| rows.last().copied());
3565        if let Some(row) = prev {
3566            self.push_jump();
3567            self.buffer.cursor.row = row;
3568            self.buffer.move_to_line_start();
3569            self.update_scroll();
3570            let sign = self.git.sign_at(row).map(|s| format!("{s:?}")).unwrap_or_default();
3571            self.message = format!("Git change · L{} · {sign}", row + 1);
3572        }
3573    }
3574
3575    /// Force-reload current file from disk (discards local unsaved edits).
3576    pub fn reload_from_disk(&mut self) {
3577        let Some(path) = self.filename.clone() else {
3578            self.message = String::from("No file to reload");
3579            return;
3580        };
3581        let path_s = path.display().to_string();
3582        match std::fs::read_to_string(&path) {
3583            Ok(content) => {
3584                let cursor = self.buffer.cursor();
3585                let scroll = self.scroll;
3586                self.buffer = Buffer::from_string(&content);
3587                self.buffer.cursor.row =
3588                    cursor.row.min(self.buffer.line_count().saturating_sub(1));
3589                self.buffer.cursor.col = cursor.col;
3590                self.buffer.clamp_col();
3591                self.scroll = scroll.min(self.buffer.line_count().saturating_sub(1));
3592                self.modified = false;
3593                self.record_mtime();
3594                self.undo_stack = UndoStack::new();
3595                self.undo_stack.push(self.buffer.snapshot());
3596                if let Some(p) = self.filename.clone() {
3597                    let text = self.buffer.text();
3598                    self.undo_stack
3599                        .attach_file(&p, self.undo_caching, &text);
3600                }
3601                self.rebuild_folds();
3602                self.refresh_git();
3603                self.lsp_restart_for_current();
3604                self.sync_lsp_document();
3605                self.message = format!("↻ Reloaded {path_s}");
3606            }
3607            Err(e) => {
3608                self.message = format!("Reload failed: {e}");
3609            }
3610        }
3611    }
3612
3613    /// Run a simple git remote command (fetch / pull / push) from workspace root.
3614    /// Network git ops run in the background (workbench runner + spinner);
3615    /// the result lands in the status line via `poll_loading` in the frontend.
3616    pub fn git_remote(&mut self, action: &str) {
3617        use crate::git_workbench::RemoteAction;
3618        if self.git_wb.root.is_none() {
3619            let hint = self.filename.as_deref();
3620            self.git_wb.root = crate::git_ops::find_git_root(hint);
3621        }
3622        if self.git_wb.root.is_none() {
3623            self.message = String::from("Not a git repository");
3624            return;
3625        }
3626        let act = match action {
3627            "fetch" => RemoteAction::Fetch,
3628            "pull" => RemoteAction::Pull,
3629            "push" => RemoteAction::Push,
3630            _ => {
3631                self.message = format!("unknown git action: {action}");
3632                return;
3633            }
3634        };
3635        self.message = self.git_wb.remote_action(act);
3636    }
3637
3638    pub fn toggle_relative_number(&mut self) {
3639        self.relative_number = !self.relative_number;
3640        // Persist like theme changes so `SPC t r` survives restart.
3641        let mut cfg = config::load();
3642        cfg.relative_number = self.relative_number;
3643        config::save(&cfg);
3644        self.message = if self.relative_number {
3645            "relative_number on (saved)".into()
3646        } else {
3647            "relative_number off (saved)".into()
3648        };
3649    }
3650
3651    pub fn toggle_inlay_hints(&mut self) {
3652        self.inlay_hints_enabled = !self.inlay_hints_enabled;
3653        self.message = if self.inlay_hints_enabled {
3654            "inlay hints on".into()
3655        } else {
3656            "inlay hints off".into()
3657        };
3658    }
3659
3660    pub fn prompt_rename(&mut self) {
3661        self.enter_xlc(Some("Rename "));
3662    }
3663
3664    /// Switch to tab index if it exists (0-based).
3665    pub fn goto_tab(&mut self, idx: usize) {
3666        if idx < self.buffers.len() {
3667            self.save_state_to_tab();
3668            self.current_buffer = idx;
3669            self.restore_state_from_tab();
3670            self.message = format!("Tab {}", idx + 1);
3671        } else {
3672            self.message = format!("No tab {}", idx + 1);
3673        }
3674    }
3675
3676    pub fn request_hover(&mut self) {
3677        self.sync_lsp_document();
3678        if let Some(ref path) = self.filename {
3679            let c = self.buffer.cursor();
3680            self.lsp
3681                .request_hover(&path.display().to_string(), c.row, c.col);
3682            self.message = String::from("Hover…");
3683        }
3684    }
3685
3686    /// Select word under cursor (double-click / `viw`-ish helper).
3687    pub fn select_word_under_cursor(&mut self) {
3688        if let Some(range) = ops::range_for_textobject(&self.buffer, TextObject::InnerWord) {
3689            self.visual_anchor = Some(range.start);
3690            self.buffer.cursor = Position::new(range.end.row, range.end.col.saturating_sub(1));
3691            self.mode = Mode::Visual;
3692            self.message = String::from("-- VISUAL --");
3693        }
3694    }
3695
3696    pub fn enter_visual(&mut self) {
3697        self.mode = Mode::Visual;
3698        self.visual_anchor = Some(self.buffer.cursor());
3699        self.message = String::from("-- VISUAL --");
3700    }
3701
3702    pub fn enter_visual_line(&mut self) {
3703        self.mode = Mode::VisualLine;
3704        self.visual_anchor = Some(self.buffer.cursor());
3705        self.message = String::from("-- VISUAL LINE --");
3706    }
3707
3708    pub fn enter_visual_block(&mut self) {
3709        self.mode = Mode::VisualBlock;
3710        self.visual_anchor = Some(self.buffer.cursor());
3711        self.message = String::from("-- VISUAL BLOCK --");
3712    }
3713
3714    pub fn enter_xlc(&mut self, prompt: Option<&str>) {
3715        self.mode = Mode::XlcInput;
3716        self.xlc.open_panel(prompt);
3717    }
3718
3719    pub fn close_xlc(&mut self) {
3720        self.xlc.close();
3721        self.mode = Mode::Normal;
3722    }
3723
3724    /// Start incremental `/` (forward) or `?` (backward) search.
3725    pub fn enter_search(&mut self) {
3726        self.enter_search_dir(true);
3727    }
3728
3729    pub fn enter_search_backward(&mut self) {
3730        self.enter_search_dir(false);
3731    }
3732
3733    fn enter_search_dir(&mut self, forward: bool) {
3734        self.completions.deactivate();
3735        self.pending_key = None;
3736        self.pending_ft = None;
3737        self.pending_hints.clear();
3738        self.count = None;
3739        self.clear_operator_pending();
3740        self.search_forward = forward;
3741        self.search_origin = Some(self.buffer.cursor());
3742        self.search_scroll_origin = self.scroll;
3743        self.search_pattern_backup = self.search_pattern.clone();
3744        self.search_input.clear();
3745        self.mode = Mode::Search;
3746        self.message = if forward {
3747            String::from("Search / — Enter accept · Esc cancel · ↑↓ cycle")
3748        } else {
3749            String::from("Search ? — reverse · Enter accept · Esc cancel")
3750        };
3751    }
3752
3753    /// Commit live search input as the new pattern and leave Search mode.
3754    pub fn commit_search(&mut self) {
3755        let pattern = self.search_input.clone();
3756        if pattern.is_empty() {
3757            // Empty Enter reuses previous pattern (vim-like).
3758            if let Some(prev) = self.search_pattern.clone() {
3759                self.push_jump();
3760                self.recompute_search(&prev, false);
3761                if self.search_matches.is_empty() {
3762                    self.message = format!("Pattern not found: {}", prev);
3763                } else {
3764                    self.search_next();
3765                    self.message = format!(
3766                        "/{}/  {}/{}",
3767                        prev,
3768                        self.search_current + 1,
3769                        self.search_matches.len()
3770                    );
3771                }
3772            } else {
3773                self.message = String::from("No previous search pattern");
3774            }
3775        } else {
3776            self.push_jump();
3777            self.search_pattern = Some(pattern.clone());
3778            self.recompute_search(&pattern, true);
3779            if self.search_matches.is_empty() {
3780                self.message = format!("Pattern not found: {}", pattern);
3781            } else {
3782                let slash = if self.search_forward { '/' } else { '?' };
3783                self.message = format!(
3784                    "{}{}/  {}/{}",
3785                    slash,
3786                    pattern,
3787                    self.search_current + 1,
3788                    self.search_matches.len()
3789                );
3790            }
3791        }
3792        self.search_input.clear();
3793        self.search_origin = None;
3794        self.search_pattern_backup = None;
3795        self.mode = Mode::Normal;
3796    }
3797
3798    /// Cancel search: restore cursor, restore previous committed pattern.
3799    pub fn cancel_search(&mut self) {
3800        if let Some(origin) = self.search_origin.take() {
3801            self.buffer.cursor = origin;
3802            self.scroll = self.search_scroll_origin;
3803        }
3804        self.search_input.clear();
3805        self.search_pattern = self.search_pattern_backup.take();
3806        self.search_matches.clear();
3807        self.search_current = 0;
3808        if let Some(ref pat) = self.search_pattern.clone() {
3809            // Rebuild match list for n/N without moving the restored cursor.
3810            self.collect_matches(pat);
3811            let cur = self.buffer.cursor();
3812            if let Some(idx) = self
3813                .search_matches
3814                .iter()
3815                .position(|p| p.row == cur.row && p.col == cur.col)
3816            {
3817                self.search_current = idx;
3818            }
3819        }
3820        self.mode = Mode::Normal;
3821        self.message = String::from("Search cancelled");
3822    }
3823
3824    /// Update live query while typing in Search mode.
3825    pub fn update_search_input(&mut self) {
3826        let pattern = self.search_input.clone();
3827        if pattern.is_empty() {
3828            self.search_matches.clear();
3829            self.search_current = 0;
3830            if let Some(origin) = self.search_origin {
3831                self.buffer.cursor = origin;
3832                self.scroll = self.search_scroll_origin;
3833            }
3834            self.message = String::from("Search — type to filter, Enter accept, Esc cancel");
3835            return;
3836        }
3837        self.recompute_search(&pattern, true);
3838        if self.search_matches.is_empty() {
3839            self.message = format!("/{}/  0 matches", pattern);
3840        } else {
3841            self.message = format!(
3842                "/{}/  {}/{}",
3843                pattern,
3844                self.search_current + 1,
3845                self.search_matches.len()
3846            );
3847        }
3848    }
3849
3850    /// Pattern currently used for highlighting (live input or committed).
3851    pub fn active_search_pattern(&self) -> Option<&str> {
3852        if self.mode == Mode::Search {
3853            if self.search_input.is_empty() {
3854                None
3855            } else {
3856                Some(self.search_input.as_str())
3857            }
3858        } else {
3859            self.search_pattern.as_deref()
3860        }
3861    }
3862
3863    pub fn search_pattern_len_chars(&self) -> usize {
3864        self.active_search_pattern()
3865            .map(|p| p.chars().count())
3866            .unwrap_or(0)
3867    }
3868
3869    /// Matches on `row` plus the global index of the first one. `search_matches`
3870    /// is built in row order by `collect_matches`, so this binary-searches the
3871    /// row's slice instead of the renderer scanning every match for every
3872    /// character of every visible line. The base index lets callers keep
3873    /// comparing against `search_current` (a global index).
3874    pub fn search_matches_row_slice(&self, row: usize) -> (usize, &[Position]) {
3875        let lo = self.search_matches.partition_point(|p| p.row < row);
3876        let hi = self.search_matches.partition_point(|p| p.row <= row);
3877        (lo, &self.search_matches[lo..hi])
3878    }
3879
3880    pub fn is_current_search_match(&self, row: usize, col: usize) -> bool {
3881        self.search_matches
3882            .get(self.search_current)
3883            .map(|p| p.row == row && p.col == col)
3884            .unwrap_or(false)
3885    }
3886
3887    pub fn selected_range(&self) -> Option<(Position, Position)> {
3888        let anchor = self.visual_anchor?;
3889        let cursor = self.buffer.cursor();
3890        if self.mode == Mode::VisualLine {
3891            let (start_row, end_row) = if anchor.row <= cursor.row {
3892                (anchor.row, cursor.row)
3893            } else {
3894                (cursor.row, anchor.row)
3895            };
3896            Some((
3897                Position::new(start_row, 0),
3898                Position::new(end_row, self.buffer.line(end_row).chars().count()),
3899            ))
3900        } else if self.mode == Mode::VisualBlock {
3901            // For highlight compatibility, return unordered corners; UI uses block_range
3902            if anchor.row < cursor.row || (anchor.row == cursor.row && anchor.col <= cursor.col) {
3903                Some((anchor, cursor))
3904            } else {
3905                Some((cursor, anchor))
3906            }
3907        } else if anchor.row < cursor.row || (anchor.row == cursor.row && anchor.col <= cursor.col)
3908        {
3909            Some((anchor, cursor))
3910        } else {
3911            Some((cursor, anchor))
3912        }
3913    }
3914
3915    pub fn execute_xlc(&mut self) {
3916        let cmd = self.xlc.execute();
3917        match cmd {
3918            XlcCmd::Save => self.save_file(),
3919            XlcCmd::SaveAs(path) => {
3920                self.filename = Some(PathBuf::from(&path));
3921                self.save_file();
3922            }
3923            XlcCmd::SaveAndQuit => {
3924                self.save_file();
3925                if !self.modified {
3926                    self.quit();
3927                } else {
3928                    self.xlc.add_output("Save failed; not quitting.");
3929                }
3930            }
3931            XlcCmd::Quit => {
3932                if self.modified {
3933                    self.message = String::from("Unsaved changes. Use :w first or :q! to force quit.");
3934                    self.xlc.add_output("Unsaved changes. Use w to save first, or q! to force quit.");
3935                } else {
3936                    self.quit();
3937                }
3938            }
3939            XlcCmd::ForceQuit => self.quit(),
3940            XlcCmd::Open(path) => self.open_in_place(&path),
3941            XlcCmd::Move(dest) => self.move_file(&dest),
3942            XlcCmd::Rename(name) => {
3943                if let Some(ref path) = self.filename {
3944                    let parent = path.parent()
3945                        .map(|p| p.to_path_buf())
3946                        .unwrap_or_else(|| {
3947                            env::current_dir().unwrap_or_default()
3948                        });
3949                    let new_path = parent.join(name);
3950                    self.move_file(&new_path.display().to_string());
3951                } else {
3952                    self.xlc.add_output("No file to rename.");
3953                }
3954            }
3955            XlcCmd::DeleteFile => {
3956                if let Some(ref path) = self.filename {
3957                    match fs::remove_file(path) {
3958                        Ok(_) => self.xlc.add_output(&format!("Deleted: {}", path.display())),
3959                        Err(e) => self.xlc.add_output(&format!("Error: {}", e)),
3960                    }
3961                } else {
3962                    self.xlc.add_output("No file to delete.");
3963                }
3964            }
3965            XlcCmd::Pwd => {
3966                let cwd = env::current_dir()
3967                    .map(|p| p.display().to_string())
3968                    .unwrap_or_else(|_| "?".to_string());
3969                self.xlc.add_output(&cwd);
3970            }
3971            XlcCmd::Ls => {
3972                if let Ok(entries) = std::fs::read_dir(".") {
3973                    for entry in entries.flatten() {
3974                        let meta = entry.file_type().ok();
3975                        let name = entry.file_name();
3976                        let prefix = if meta.map(|m| m.is_dir()).unwrap_or(false) { "/" } else { "" };
3977                        self.xlc.add_output(&format!("  {}{}", name.to_string_lossy(), prefix));
3978                    }
3979                } else {
3980                    self.xlc.add_output("Could not list directory.");
3981                }
3982            }
3983            XlcCmd::Help => {
3984                self.xlc.add_output("=== xei Commands ===");
3985                self.xlc.add_output("  w, save         Save current file");
3986                self.xlc.add_output("  w <path>        Save to a new path");
3987                self.xlc.add_output("  e, open <file>  Open a file");
3988                self.xlc.add_output("  mv, move <dest> Move/rename current file");
3989                self.xlc.add_output("  rename <name>   Rename in same directory");
3990                self.xlc.add_output("  rm              Delete current file");
3991                self.xlc.add_output("  pwd             Show working directory");
3992                self.xlc.add_output("  ls              List files");
3993                self.xlc.add_output("  q               Quit (with unsaved warning)");
3994                self.xlc.add_output("  q!              Force quit");
3995                self.xlc.add_output("  wq, x           Save and quit");
3996                self.xlc.add_output("  find, / <pat>   Search in buffer");
3997                self.xlc.add_output("  theme [name]    Switch or list themes");
3998                self.xlc.add_output("  bd              Close current tab");
3999                self.xlc.add_output("  <number>        Go to line (e.g. :42)");
4000                self.xlc.add_output("  s/pat/repl/g    Substitute on line");
4001                self.xlc.add_output("  %s/pat/repl/g   Substitute in file");
4002                self.xlc.add_output("  problems        Diagnostics list");
4003                self.xlc.add_output("  preview         Pretty preview (md/json)");
4004                self.xlc.add_output("  git             Full Git workbench");
4005                self.xlc.add_output("  screensaver     xeifetch splash (Esc exit)");
4006                self.xlc.add_output("  xeifetch / ss   Alias for screensaver");
4007                self.xlc.add_output("  bench           Self-benchmark (r rerun · Esc exit)");
4008                self.xlc.add_output("  status          Toggle live CPU/MEM/GPU readout");
4009                self.xlc.add_output("  pet [path.gif]  Desktop pet (Kitty/Ghostty)");
4010                self.xlc.add_output("  settings        Settings panel (Ctrl+,)");
4011                self.xlc.add_output("  gh-login / gha  GitHub CLI browser login");
4012                self.xlc.add_output("  gh-logout       GitHub CLI logout");
4013                self.xlc.add_output("  gh-status       GitHub auth status");
4014                self.xlc.add_output("  Rename <name>   LSP rename");
4015                self.xlc.add_output("  dap / debug     Toggle debug panel");
4016                self.xlc.add_output("  dap start/stop  Start / stop DAP session");
4017                self.xlc.add_output("  bp              Toggle breakpoint");
4018                self.xlc.add_output("  bp if <expr>    Conditional breakpoint");
4019                self.xlc.add_output("  bp log <msg>    Logpoint");
4020                self.xlc.add_output("  launch <prog>   DAP launch program");
4021                self.xlc.add_output("  DapConfig [n]   launch.json configs");
4022                self.xlc.add_output("  eval <expr>     DAP evaluate (stopped)");
4023                self.xlc.add_output("  DapAttach …     attach pid <n> | port <n> [lang]");
4024                self.xlc.add_output("  calls           Call hierarchy (incoming)");
4025                self.xlc.add_output("  rebase [N]      Interactive rebase last N commits");
4026                self.xlc.add_output("  rebase-abort    Abort in-progress rebase");
4027                self.xlc.add_output("  codelens        Toggle LSP code lenses");
4028                self.xlc.add_output("  pr <n>          Open PR review surface");
4029                self.xlc.add_output("  hooks           Reload ~/.xei/hooks.toml");
4030                self.xlc.add_output("  help, h, ?      Show this help");
4031            }
4032            XlcCmd::DapPanel => {
4033                self.toggle_debug_panel();
4034                self.xlc.add_output("Debug panel (F5 start · F9 bp · F10/F11 step)");
4035            }
4036            XlcCmd::DapStart => {
4037                self.dap_start_or_continue();
4038                self.xlc.add_output(&self.message.clone());
4039            }
4040            XlcCmd::DapStop => {
4041                self.dap_stop();
4042                self.xlc.add_output("Debug stopped");
4043            }
4044            XlcCmd::DapLaunch(prog) => {
4045                self.dap_launch_program(&prog);
4046                self.xlc.add_output(&self.message.clone());
4047            }
4048            XlcCmd::DapBreakpoint => {
4049                self.dap_toggle_breakpoint();
4050                self.xlc.add_output(&self.message.clone());
4051            }
4052            XlcCmd::DapCondition(expr) => {
4053                self.dap_set_condition(&expr);
4054                self.xlc.add_output(&self.message.clone());
4055            }
4056            XlcCmd::DapLogpoint(msg) => {
4057                self.dap_set_logpoint(&msg);
4058                self.xlc.add_output(&self.message.clone());
4059            }
4060            XlcCmd::DapConfig(name) => {
4061                if name.is_none() {
4062                    self.dap_list_configs();
4063                } else {
4064                    self.dap_launch_config(name.as_deref());
4065                    self.xlc.add_output(&self.message.clone());
4066                }
4067            }
4068            XlcCmd::DapEval(expr) => {
4069                self.dap_evaluate(&expr);
4070                self.xlc.add_output(&self.message.clone());
4071            }
4072            XlcCmd::DapAttach(spec) => {
4073                self.dap_attach(&spec);
4074                self.xlc.add_output(&self.message.clone());
4075            }
4076            XlcCmd::Rebase(n) => {
4077                self.open_rebase(n);
4078                self.xlc.add_output(&self.message.clone());
4079            }
4080            XlcCmd::RebaseAbort => {
4081                let hint = self.filename.as_deref();
4082                if let Some(root) = crate::git_ops::find_git_root(hint) {
4083                    match crate::rebase::rebase_abort(&root) {
4084                        Ok(m) => {
4085                            self.message = m.clone();
4086                            self.xlc.add_output(&m);
4087                        }
4088                        Err(e) => {
4089                            self.message = e.clone();
4090                            self.xlc.add_output(&e);
4091                        }
4092                    }
4093                } else {
4094                    self.xlc.add_output("Not a git repository");
4095                }
4096            }
4097            XlcCmd::RebaseContinue => {
4098                let hint = self.filename.as_deref();
4099                if let Some(root) = crate::git_ops::find_git_root(hint) {
4100                    match crate::rebase::rebase_continue(&root) {
4101                        Ok(m) => {
4102                            self.message = m.clone();
4103                            self.xlc.add_output(&m);
4104                        }
4105                        Err(e) => {
4106                            self.message = e.clone();
4107                            self.xlc.add_output(&e);
4108                        }
4109                    }
4110                } else {
4111                    self.xlc.add_output("Not a git repository");
4112                }
4113            }
4114            XlcCmd::CallHierarchy => {
4115                self.open_call_hierarchy(false);
4116                self.xlc.add_output(&self.message.clone());
4117            }
4118            XlcCmd::CodeLens => {
4119                self.toggle_code_lens();
4120                self.xlc.add_output(&self.message.clone());
4121            }
4122            XlcCmd::PrReview(n) => {
4123                if n == 0 {
4124                    self.xlc.add_output("Usage: pr <number>");
4125                } else {
4126                    self.open_pr_review(n);
4127                    self.xlc.add_output(&self.message.clone());
4128                }
4129            }
4130            XlcCmd::Update => {
4131                // No check result yet (throttled/first run)? Force one now and
4132                // install automatically when it lands.
4133                self.message = if self.update.latest.is_none() && !self.update.installing {
4134                    self.update
4135                        .check_now_and_install(env!("CARGO_PKG_VERSION"))
4136                } else {
4137                    self.update.start_install()
4138                };
4139                self.xlc.add_output(&self.message.clone());
4140            }
4141            XlcCmd::BlankTab => {
4142                self.open_blank_tab();
4143            }
4144            XlcCmd::Bench => {
4145                self.run_bench();
4146            }
4147            XlcCmd::StatusMetrics => {
4148                self.toggle_status_metrics();
4149            }
4150            XlcCmd::HooksReload => {
4151                self.reload_hooks();
4152                self.xlc.add_output(&self.message.clone());
4153            }
4154            XlcCmd::Preview => {
4155                self.toggle_preview();
4156                self.xlc.add_output("Preview toggled (Ctrl+Shift+V / Esc)");
4157            }
4158            XlcCmd::GhLogin => {
4159                self.xlc.add_output("Starting browser login (non-blocking)…");
4160                self.open_git_workbench();
4161                self.git_wb.tab = crate::git_workbench::GitTab::Auth;
4162                match self.git_wb.start_browser_login() {
4163                    Ok(()) => {
4164                        self.message = "Auth · complete sign-in in browser".into();
4165                        self.xlc.add_output("Opened Auth tab — finish login in browser");
4166                    }
4167                    Err(e) => {
4168                        self.message = e.clone();
4169                        self.xlc.add_output(&e);
4170                    }
4171                }
4172            }
4173            XlcCmd::GhLogout => match crate::gh::auth_logout() {
4174                Ok(m) => {
4175                    self.message = m.clone();
4176                    self.xlc.add_output(&m);
4177                    self.git_wb.refresh_auth();
4178                }
4179                Err(e) => {
4180                    self.message = e.clone();
4181                    self.xlc.add_output(&e);
4182                }
4183            },
4184            XlcCmd::GhStatus => {
4185                let info = crate::gh::auth_status();
4186                self.git_wb.auth = info.clone();
4187                self.xlc.add_output(&info.detail);
4188                self.message = info.detail;
4189            }
4190            XlcCmd::GitWorkbench => {
4191                self.open_git_workbench();
4192                self.xlc.add_output("Git workbench (Ctrl+Shift+G)");
4193            }
4194            XlcCmd::Settings => {
4195                self.open_settings();
4196                self.xlc.add_output("Settings (Ctrl+,)");
4197            }
4198            XlcCmd::Screensaver => {
4199                self.toggle_screensaver();
4200                self.xlc.add_output("xeifetch screensaver (Esc to leave)");
4201            }
4202            XlcCmd::Pet(path) => {
4203                if path.is_empty() {
4204                    let st = if self.pet.enabled { "on" } else { "off" };
4205                    let frames = self.pet.frame_count();
4206                    let gfx = if self.pet_graphics_ok() {
4207                        "gpu+kitty ok"
4208                    } else {
4209                        "needs gpu_acc + Kitty/Ghostty"
4210                    };
4211                    let err = self.pet.load_error.as_deref().unwrap_or("");
4212                    self.xlc.add_output(&format!(
4213                        "pet {st} · path={} · frames={frames} · pos={},{} · w={} · speed={}  [{gfx}] {err}",
4214                        self.pet.path,
4215                        self.pet.x,
4216                        self.pet.y,
4217                        self.pet.width_cells,
4218                        crate::pet::PetState::speed_label(self.pet.speed),
4219                    ));
4220                    self.message =
4221                        "Use :pet ~/pic.gif · :pet on|off · Settings → Pet (needs GPU)".into();
4222                } else if matches!(path.as_str(), "off" | "disable" | "0" | "false") {
4223                    self.pet.enabled = false;
4224                    let mut cfg = config::load();
4225                    cfg.pet_enabled = false;
4226                    config::save(&cfg);
4227                    self.xlc.add_output("pet off");
4228                    self.message = "Pet off".into();
4229                } else if matches!(path.as_str(), "on" | "enable" | "1" | "true") {
4230                    if !self.pet_graphics_ok() {
4231                        self.pet.enabled = false;
4232                        self.xlc.add_output(
4233                            "pet requires gpu_acc + Kitty/Ghostty graphics (Settings → Setting)",
4234                        );
4235                        self.message = "Pet needs GPU + Kitty graphics".into();
4236                    } else if !self.pet.has_frames() {
4237                        self.pet.enabled = false;
4238                        self.xlc.add_output("pet: no frames — :pet ~/path.gif first");
4239                        self.message = "Load a GIF first: :pet ~/path.gif".into();
4240                    } else {
4241                        self.pet.enabled = true;
4242                        let mut cfg = config::load();
4243                        cfg.pet_enabled = true;
4244                        config::save(&cfg);
4245                        self.xlc.add_output("pet on");
4246                        self.message = "Pet on".into();
4247                    }
4248                } else {
4249                    if !self.pet_graphics_ok() {
4250                        self.xlc.add_output(
4251                            "pet requires gpu_acc + Kitty/Ghostty — enable gpu_acc in Settings",
4252                        );
4253                        self.message = "Pet needs GPU + Kitty graphics".into();
4254                        // Still load path so it's ready once GPU is on
4255                    }
4256                    let p = crate::pet::expand_path(&path);
4257                    let ps = p.display().to_string();
4258                    self.pet.load_path(&ps);
4259                    self.pet.enabled = self.pet.has_frames() && self.pet_graphics_ok();
4260                    let mut cfg = config::load();
4261                    cfg.pet_path = path.clone(); // keep user form (~/…)
4262                    cfg.pet_enabled = self.pet.enabled;
4263                    cfg.pet_x = self.pet.x;
4264                    cfg.pet_y = self.pet.y;
4265                    cfg.pet_width_cells = self.pet.width_cells;
4266                    cfg.pet_speed = self.pet.speed;
4267                    config::save(&cfg);
4268                    if let Some(ref e) = self.pet.load_error {
4269                        self.xlc.add_output(&format!("pet load error: {e}"));
4270                        self.message = e.clone();
4271                    } else if !self.pet_graphics_ok() {
4272                        self.xlc.add_output(&format!(
4273                            "pet loaded {} ({} frames) but not shown — GPU/Kitty required",
4274                            ps,
4275                            self.pet.frame_count()
4276                        ));
4277                    } else {
4278                        self.xlc.add_output(&format!(
4279                            "pet loaded {} ({} frames)",
4280                            ps,
4281                            self.pet.frame_count()
4282                        ));
4283                        self.message = format!("Pet · {} frames", self.pet.frame_count());
4284                    }
4285                }
4286            }
4287            XlcCmd::Search(pattern) => {
4288                self.search_pattern = Some(pattern.clone());
4289                self.recompute_search(&pattern, true);
4290                let n = self.search_matches.len();
4291                self.message = if n == 0 {
4292                    format!("Pattern not found: {}", pattern)
4293                } else {
4294                    format!("/{}/  1/{}", pattern, n)
4295                };
4296                self.xlc.add_output(&format!("Search /{}/ → {} match(es)", pattern, n));
4297            }
4298            XlcCmd::Theme(name) => {
4299                if name.is_empty() {
4300                    self.xlc.add_output("Available themes:");
4301                    for t in theme::all_themes() {
4302                        let marker = if self.theme.name == t.name { " *" } else { "  " };
4303                        self.xlc.add_output(&format!("{}{}", marker, t.name));
4304                    }
4305                } else if let Some(t) = theme::find(&name) {
4306                    self.theme = t;
4307                    config::save_theme(t.name);
4308                    set_cursor_esc(t.cursor);
4309                    self.message = format!("Theme: {}", t.name);
4310                    self.xlc.add_output(&format!("Switched to theme: {}", t.name));
4311                } else {
4312                    self.xlc.add_output(&format!("Unknown theme: {}. Use :theme to list.", name));
4313                }
4314            }
4315            XlcCmd::BufDelete => {
4316                self.close_current_tab();
4317                self.xlc.add_output("Buffer closed");
4318            }
4319            XlcCmd::LspStart(cmd) => {
4320                if let Some(ref path) = self.filename {
4321                    let root = path.parent().map(|p| p.display().to_string()).unwrap_or_default();
4322                    self.lsp.start(&cmd, &root, &path.display().to_string());
4323                    self.xlc.add_output(&format!("LSP started: {}", cmd));
4324                }
4325            }
4326            XlcCmd::GotoLine(n) => {
4327                self.goto_line(n);
4328                self.xlc.add_output(&format!("Jumped to line {}", n));
4329            }
4330            XlcCmd::Problems => {
4331                self.open_problems_palette();
4332            }
4333            XlcCmd::Substitute(raw) => {
4334                if let Some(cmd) = substitute::parse_substitute(&raw) {
4335                    self.apply_substitute_cmd(cmd);
4336                } else {
4337                    self.xlc.add_output("Invalid :s syntax. Use :s/pat/repl/g or :%s/pat/repl/g");
4338                    self.message = String::from("Invalid substitute");
4339                }
4340            }
4341            XlcCmd::LspRename(name) => {
4342                self.request_rename(&name);
4343            }
4344            XlcCmd::None => {
4345                self.message = String::from("Unknown command. Try :help");
4346                self.xlc.add_output("Try :help for available commands.");
4347            }
4348        }
4349    }
4350
4351    fn open_in_place(&mut self, path: &str) {
4352        self.open_new_tab(path);
4353    }
4354
4355    fn move_file(&mut self, dest: &str) {
4356        if let Some(ref path) = self.filename {
4357            let dest_path = PathBuf::from(dest);
4358            match fs::rename(path, &dest_path) {
4359                Ok(_) => {
4360                    self.filename = Some(dest_path);
4361                    self.message = format!("Moved to: {}", dest);
4362                    self.xlc.add_output(&format!("Moved to: {}", dest));
4363                }
4364                Err(e) => {
4365                    self.xlc.add_output(&format!("Error moving: {}", e));
4366                }
4367            }
4368        } else {
4369            self.xlc.add_output("No file to move.");
4370        }
4371    }
4372
4373    /// Recompute matches for `pattern`. If `jump`, move cursor to nearest match
4374    /// in the active search direction from origin/cursor.
4375    pub fn recompute_search(&mut self, pattern: &str, jump: bool) {
4376        self.collect_matches(pattern);
4377        if self.search_matches.is_empty() {
4378            self.search_current = 0;
4379            return;
4380        }
4381        let from = self
4382            .search_origin
4383            .unwrap_or_else(|| self.buffer.cursor());
4384        let idx = if self.search_forward {
4385            self.search_matches
4386                .iter()
4387                .position(|p| p.row > from.row || (p.row == from.row && p.col >= from.col))
4388                .unwrap_or(0)
4389        } else {
4390            self.search_matches
4391                .iter()
4392                .rposition(|p| p.row < from.row || (p.row == from.row && p.col <= from.col))
4393                .unwrap_or(self.search_matches.len() - 1)
4394        };
4395        self.search_current = idx;
4396        if jump {
4397            let pos = self.search_matches[idx];
4398            self.buffer.cursor = pos;
4399            self.update_scroll();
4400        }
4401    }
4402
4403    fn collect_matches(&mut self, pattern: &str) {
4404        self.search_matches.clear();
4405        if pattern.is_empty() {
4406            return;
4407        }
4408        let smart_case = !pattern.chars().any(|c| c.is_uppercase());
4409        let pat_lower = if smart_case {
4410            pattern.to_lowercase()
4411        } else {
4412            String::new()
4413        };
4414
4415        for (row, line) in self.buffer.lines().iter().enumerate() {
4416            if smart_case {
4417                // Case-insensitive: walk char-by-char comparing lowered windows.
4418                let line_chars: Vec<char> = line.chars().collect();
4419                let pat_chars: Vec<char> = pat_lower.chars().collect();
4420                if pat_chars.is_empty() {
4421                    continue;
4422                }
4423                let plen = pat_chars.len();
4424                if line_chars.len() < plen {
4425                    continue;
4426                }
4427                let line_lower: Vec<char> = line_chars.iter().map(|c| c.to_lowercase().next().unwrap_or(*c)).collect();
4428                let mut i = 0;
4429                while i + plen <= line_lower.len() {
4430                    if line_lower[i..i + plen] == pat_chars[..] {
4431                        self.search_matches.push(Position::new(row, i));
4432                        i += 1; // overlapping allowed (vim default for most)
4433                    } else {
4434                        i += 1;
4435                    }
4436                }
4437            } else {
4438                let mut search_from = 0usize;
4439                while search_from <= line.len() {
4440                    if let Some(byte_rel) = line[search_from..].find(pattern) {
4441                        let byte_abs = search_from + byte_rel;
4442                        let col = line[..byte_abs].chars().count();
4443                        self.search_matches.push(Position::new(row, col));
4444                        search_from = byte_abs + pattern.len().max(1);
4445                    } else {
4446                        break;
4447                    }
4448                }
4449            }
4450        }
4451    }
4452
4453    /// Backward-compatible alias.
4454    pub fn perform_search(&mut self) {
4455        if let Some(pat) = self.search_pattern.clone() {
4456            self.recompute_search(&pat, true);
4457        }
4458    }
4459
4460    pub fn search_next(&mut self) {
4461        // `n` follows the direction used when the pattern was committed.
4462        if self.search_forward {
4463            self.search_step(true);
4464        } else {
4465            self.search_step(false);
4466        }
4467    }
4468
4469    pub fn search_prev(&mut self) {
4470        // `N` is opposite of search direction.
4471        if self.search_forward {
4472            self.search_step(false);
4473        } else {
4474            self.search_step(true);
4475        }
4476    }
4477
4478    fn search_step(&mut self, forward: bool) {
4479        if let Some(pat) = self.search_pattern.clone() {
4480            let cur = self.buffer.cursor();
4481            self.collect_matches(&pat);
4482            if self.search_matches.is_empty() {
4483                self.message = format!("Pattern not found: {}", pat);
4484                return;
4485            }
4486            let idx = if forward {
4487                self.search_matches
4488                    .iter()
4489                    .position(|p| p.row > cur.row || (p.row == cur.row && p.col > cur.col))
4490                    .unwrap_or(0)
4491            } else {
4492                self.search_matches
4493                    .iter()
4494                    .rposition(|p| p.row < cur.row || (p.row == cur.row && p.col < cur.col))
4495                    .unwrap_or(self.search_matches.len() - 1)
4496            };
4497            self.search_current = idx;
4498            let pos = self.search_matches[idx];
4499            let wrapped = if forward {
4500                idx == 0 && (pos.row < cur.row || (pos.row == cur.row && pos.col <= cur.col))
4501            } else {
4502                idx == self.search_matches.len() - 1
4503                    && (pos.row > cur.row || (pos.row == cur.row && pos.col >= cur.col))
4504            };
4505            self.buffer.cursor = pos;
4506            self.update_scroll();
4507            let slash = if self.search_forward { '/' } else { '?' };
4508            self.message = if wrapped {
4509                if forward {
4510                    format!(
4511                        "search hit BOTTOM, continuing at TOP  {}/{}",
4512                        idx + 1,
4513                        self.search_matches.len()
4514                    )
4515                } else {
4516                    format!(
4517                        "search hit TOP, continuing at BOTTOM  {}/{}",
4518                        idx + 1,
4519                        self.search_matches.len()
4520                    )
4521                }
4522            } else {
4523                format!("{}{}/  {}/{}", slash, pat, idx + 1, self.search_matches.len())
4524            };
4525        } else {
4526            self.message = String::from("No search pattern — press / or ? first");
4527        }
4528    }
4529
4530    /// Search for the word under the cursor (`*` in vim).
4531    pub fn search_word_under_cursor(&mut self) {
4532        let word = self.word_under_cursor();
4533        if word.is_empty() {
4534            self.message = String::from("No word under cursor");
4535            return;
4536        }
4537        self.push_jump();
4538        self.search_pattern = Some(word.clone());
4539        self.search_forward = true;
4540        self.recompute_search(&word, true);
4541        // Advance to next occurrence after current position
4542        if self.search_matches.len() > 1 {
4543            self.search_next();
4544        } else if self.search_matches.is_empty() {
4545            self.message = format!("Pattern not found: {}", word);
4546        } else {
4547            self.message = format!("/{}/  1/1", word);
4548        }
4549    }
4550
4551    fn word_under_cursor(&self) -> String {
4552        let line = self.buffer.line(self.buffer.cursor.row);
4553        let chars: Vec<char> = line.chars().collect();
4554        if chars.is_empty() {
4555            return String::new();
4556        }
4557        let mut col = self.buffer.cursor.col.min(chars.len().saturating_sub(1));
4558        if col < chars.len() && !(chars[col].is_alphanumeric() || chars[col] == '_') {
4559            // Try char before cursor
4560            if col > 0 && (chars[col - 1].is_alphanumeric() || chars[col - 1] == '_') {
4561                col -= 1;
4562            } else {
4563                return String::new();
4564            }
4565        }
4566        let mut start = col;
4567        while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') {
4568            start -= 1;
4569        }
4570        let mut end = col;
4571        while end < chars.len() && (chars[end].is_alphanumeric() || chars[end] == '_') {
4572            end += 1;
4573        }
4574        chars[start..end].iter().collect()
4575    }
4576
4577    pub fn save_file(&mut self) {
4578        if let Some(path) = self.filename.clone() {
4579            match fs::write(&path, self.buffer.text()) {
4580                Ok(_) => {
4581                    self.modified = false;
4582                    if self.current_buffer < self.buffers.len() {
4583                        self.buffers[self.current_buffer].modified = false;
4584                        self.buffers[self.current_buffer].filename = Some(path.clone());
4585                    }
4586                    self.record_mtime();
4587                    self.refresh_git();
4588                    self.save_session();
4589                    self.message = format!("✓ Saved: {}", path.display());
4590                    self.xlc.add_output(&format!("✓ Saved: {}", path.display()));
4591                    self.fire_hook(crate::hooks::HookEvent::Save);
4592                }
4593                Err(e) => {
4594                    self.message = format!("✗ Error: {}", e);
4595                    self.xlc.add_output(&format!("✗ Error: {}", e));
4596                }
4597            }
4598        } else {
4599            self.message = String::from("No filename. Use :w <filename>");
4600            self.xlc.add_output("No filename. Use: w <path>");
4601        }
4602    }
4603
4604    pub fn move_left(&mut self) {
4605        self.buffer.move_left();
4606    }
4607
4608    pub fn move_right(&mut self) {
4609        self.buffer.move_right();
4610    }
4611
4612    pub fn move_up(&mut self) {
4613        self.buffer.move_up();
4614        self.update_scroll();
4615    }
4616
4617    pub fn move_down(&mut self) {
4618        self.buffer.move_down();
4619        self.update_scroll();
4620    }
4621
4622    pub fn update_scroll(&mut self) {
4623        let cursor_row = self.buffer.cursor.row;
4624        let visible_height = self.viewport.height.max(1) as usize;
4625        // Soft-wrap-aware: viewport width minus gutter (~5 cols).
4626        let text_width = self
4627            .viewport
4628            .width
4629            .saturating_sub(5)
4630            .max(1) as usize;
4631
4632        let wrap = self.wrap_lines;
4633        let wrap_rows = |row: usize| -> usize {
4634            if !wrap {
4635                return 1;
4636            }
4637            let vis = Self::line_visual_width(&self.buffer, row);
4638            if vis == 0 {
4639                1
4640            } else {
4641                (vis + text_width - 1) / text_width
4642            }
4643        };
4644
4645        if cursor_row < self.scroll {
4646            self.scroll = cursor_row;
4647        }
4648
4649        // Ensure the cursor's wrap segment is on-screen.
4650        let screen_col = self
4651            .buffer
4652            .buffer_col_to_screen_col(cursor_row, self.buffer.cursor.col);
4653        let cursor_wrap = if wrap { screen_col / text_width } else { 0 };
4654
4655        // Horizontal-scroll mode: pan so the cursor stays visible.
4656        if !wrap {
4657            if screen_col < self.hscroll {
4658                self.hscroll = screen_col;
4659            } else if screen_col >= self.hscroll + text_width {
4660                self.hscroll = screen_col + 1 - text_width;
4661            }
4662        }
4663
4664        // Visual rows from scroll .. cursor_row-1, plus cursor wrap offset + 1
4665        let mut needed = cursor_wrap + 1;
4666        for r in self.scroll..cursor_row {
4667            needed = needed.saturating_add(wrap_rows(r));
4668        }
4669        while needed > visible_height && self.scroll < cursor_row {
4670            needed = needed.saturating_sub(wrap_rows(self.scroll));
4671            self.scroll += 1;
4672        }
4673        // Fallback: pure buffer-line window if still off (tiny viewports)
4674        if cursor_row < self.scroll {
4675            self.scroll = cursor_row;
4676        }
4677
4678        // Keep focused split pane scroll in sync
4679        if self.split.is_split() {
4680            let p = self.split.focused_pane_mut();
4681            p.scroll = self.scroll;
4682            p.tab_index = self.current_buffer;
4683        }
4684    }
4685
4686    fn line_visual_width(buffer: &crate::buffer::Buffer, row: usize) -> usize {
4687        let line = buffer.line(row);
4688        let mut vis = 0usize;
4689        for ch in line.chars() {
4690            vis += if ch == '\t' {
4691                4 - (vis % 4)
4692            } else {
4693                unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1)
4694            };
4695        }
4696        vis
4697    }
4698
4699    pub fn delete_line(&mut self) {
4700        self.push_undo();
4701        let row = self.buffer.cursor.row;
4702        let deleted = self.buffer.delete_line();
4703        self.store_yank(format!("{}\n", deleted), true);
4704        if let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) {
4705            // Deleted the whole line at `row` → remove BP on it, shift later −1
4706            self.dap.shift_breakpoints(&path, row, -1);
4707        }
4708    }
4709
4710    pub fn delete_word(&mut self) {
4711        self.push_undo();
4712        let deleted = self.buffer.delete_word();
4713        self.store_yank(deleted, false);
4714    }
4715
4716    /// `p` — put after cursor / below line
4717    pub fn paste(&mut self) {
4718        self.paste_impl(false);
4719    }
4720
4721    /// `P` — put before cursor / above line
4722    pub fn paste_before(&mut self) {
4723        self.paste_impl(true);
4724    }
4725
4726    fn paste_impl(&mut self, before: bool) {
4727        let Some(val) = self.registers.load_for_put() else {
4728            // fallback yank_buffer
4729            if let Some(text) = self.yank_buffer.clone() {
4730                self.paste_text(&text, text.contains('\n'), before);
4731            }
4732            return;
4733        };
4734        self.paste_text(&val.text, val.linewise, before);
4735    }
4736
4737    fn paste_text(&mut self, text: &str, linewise: bool, before: bool) {
4738        if text.is_empty() {
4739            return;
4740        }
4741        self.push_undo();
4742        if linewise {
4743            let lines: Vec<&str> = text.trim_end_matches('\n').split('\n').collect();
4744            if before {
4745                let row = self.buffer.cursor.row;
4746                for (i, line) in lines.iter().enumerate() {
4747                    self.buffer.insert_line_at(row + i, line.to_string());
4748                }
4749                self.buffer.cursor.row = row;
4750                self.buffer.cursor.col = 0;
4751            } else {
4752                for line in lines {
4753                    self.buffer.paste_line_after(line);
4754                }
4755            }
4756        } else {
4757            // Charwise: `p` inserts after the cursor char, `P` before it.
4758            if !before && self.buffer.cursor.col < self.buffer.current_line_len() {
4759                self.buffer.move_right();
4760            }
4761            // Bulk insert (O(n)) instead of char-by-char (O(n²) on long lines).
4762            let clean = text.replace('\r', "");
4763            self.buffer.insert_str(&clean);
4764            // Vim leaves the cursor ON the last pasted character (unless the
4765            // paste ended on a newline, which lands the cursor at col 0).
4766            if !clean.is_empty() && !clean.ends_with('\n') {
4767                self.buffer.move_left();
4768            }
4769        }
4770        self.update_scroll();
4771        self.message = String::from("Pasted");
4772    }
4773
4774    pub fn yank_selection(&mut self) {
4775        if let Some((start, end)) = self.selected_range() {
4776            let mut lines: Vec<String> = Vec::new();
4777            for row in start.row..=end.row {
4778                let chars: Vec<char> = self.buffer.line(row).chars().collect();
4779                let s = if row == start.row && row == end.row {
4780                    let to = (end.col + 1).min(chars.len());
4781                    let from = start.col.min(to);
4782                    chars[from..to].iter().collect()
4783                } else if row == start.row {
4784                    let from = start.col.min(chars.len());
4785                    chars[from..].iter().collect()
4786                } else if row == end.row {
4787                    let to = (end.col + 1).min(chars.len());
4788                    chars[..to].iter().collect()
4789                } else {
4790                    chars.iter().collect()
4791                };
4792                lines.push(s);
4793            }
4794            let linewise = self.mode == Mode::VisualLine;
4795            let text = lines.join("\n");
4796            let label = self.registers.active_label();
4797            self.store_yank(
4798                if linewise {
4799                    format!("{}\n", text)
4800                } else {
4801                    text
4802                },
4803                linewise,
4804            );
4805            self.enter_normal();
4806            self.message = format!("Yanked → {}", label);
4807        }
4808    }
4809
4810    pub fn delete_selection(&mut self) {
4811        if let Some((start, end)) = self.selected_range() {
4812            self.push_undo();
4813            let mut deleted_text = String::new();
4814
4815            if self.mode == Mode::VisualLine {
4816                self.buffer.cursor.row = start.row;
4817                let count = end.row - start.row + 1;
4818                for _ in 0..count {
4819                    let line = self.buffer.delete_line();
4820                    if !deleted_text.is_empty() { deleted_text.push('\n'); }
4821                    deleted_text.push_str(&line);
4822                }
4823                self.store_yank(format!("{}\n", deleted_text), true);
4824                self.enter_normal();
4825                self.message = String::from("Deleted");
4826                return;
4827            }
4828
4829            if start.row == end.row {
4830                let line = self.buffer.line(start.row);
4831                let deleted: String = line.chars().skip(start.col).take(end.col.saturating_sub(start.col) + 1).collect();
4832                let prefix: String = line.chars().take(start.col).collect();
4833                let suffix: String = line.chars().skip(end.col + 1).collect();
4834                self.buffer.set_line(start.row, prefix + &suffix);
4835                deleted_text = deleted;
4836            } else {
4837                let first_chars: Vec<char> = self.buffer.line(start.row).chars().collect();
4838                let last_chars: Vec<char> = self.buffer.line(end.row).chars().collect();
4839
4840                deleted_text.push_str(&first_chars[start.col.min(first_chars.len())..].iter().collect::<String>());
4841                for row in (start.row + 1)..end.row {
4842                    deleted_text.push('\n');
4843                    deleted_text.push_str(self.buffer.line(row));
4844                }
4845                deleted_text.push('\n');
4846                let last_end = (end.col + 1).min(last_chars.len());
4847                deleted_text.push_str(&last_chars[..last_end].iter().collect::<String>());
4848
4849                let first_prefix: String = first_chars.iter().take(start.col).collect();
4850                let last_suffix: String = last_chars.iter().skip(end.col + 1).collect();
4851
4852                self.buffer.cursor.row = end.row;
4853                for _row in (start.row + 1..=end.row).rev() {
4854                    self.buffer.cursor.row = _row;
4855                    self.buffer.delete_line();
4856                }
4857                self.buffer.cursor.row = start.row;
4858                self.buffer.set_line(start.row, first_prefix + &last_suffix);
4859            }
4860
4861            self.store_yank(deleted_text, false);
4862            self.buffer.cursor = Position::new(start.row, start.col);
4863            self.buffer.clamp_col();
4864            self.enter_normal();
4865            self.message = String::from("Deleted");
4866        }
4867    }
4868
4869    pub fn record_mtime(&mut self) {
4870        if let Some(ref path) = self.filename {
4871            self.file_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
4872        }
4873    }
4874
4875    /// Live-refresh: if the open file changed on disk, reload the buffer.
4876    /// Preserves cursor/scroll as much as possible; rebuilds folds / git / LSP.
4877    pub fn check_external_change(&mut self) {
4878        // Also refresh other open tabs' mtimes lightly — only reload the *active* buffer.
4879        self.check_active_file_external();
4880        if self.debug && !self.lsp.diagnostics.is_empty() {
4881            let rows: Vec<String> = self
4882                .lsp
4883                .diagnostics
4884                .iter()
4885                .map(|d| d.row.to_string())
4886                .collect();
4887            self.xlc.add_output(&format!("diag rows: {}", rows.join(",")));
4888        }
4889    }
4890
4891    fn check_active_file_external(&mut self) {
4892        let Some(path) = self.filename.clone() else {
4893            return;
4894        };
4895        let path_s = path.display().to_string();
4896        let Ok(meta) = std::fs::metadata(&path) else {
4897            return;
4898        };
4899        let Ok(mtime) = meta.modified() else {
4900            return;
4901        };
4902        let Some(prev) = self.file_mtime else {
4903            // First observation — just record
4904            self.file_mtime = Some(mtime);
4905            return;
4906        };
4907        if prev == mtime {
4908            return;
4909        }
4910
4911        let Ok(content) = std::fs::read_to_string(&path) else {
4912            // File deleted or unreadable — keep buffer, warn
4913            self.file_mtime = Some(mtime);
4914            self.message = format!("⚠ File missing or unreadable: {path_s}");
4915            return;
4916        };
4917
4918        let had_local_edits = self.modified;
4919        let cursor = self.buffer.cursor();
4920        let scroll = self.scroll;
4921
4922        self.buffer = Buffer::from_string(&content);
4923        // Restore cursor within new bounds
4924        self.buffer.cursor.row = cursor.row.min(self.buffer.line_count().saturating_sub(1));
4925        self.buffer.cursor.col = cursor.col;
4926        self.buffer.clamp_col();
4927        self.scroll = scroll.min(self.buffer.line_count().saturating_sub(1));
4928        self.modified = false;
4929        self.file_mtime = Some(mtime);
4930        self.undo_stack = UndoStack::new();
4931        self.undo_stack.push(self.buffer.snapshot());
4932        self.rebuild_folds();
4933        self.refresh_git();
4934        self.lsp_restart_for_current();
4935        self.sync_lsp_document();
4936
4937        self.message = if had_local_edits {
4938            "↻ Live reload (disk won — local unsaved edits discarded)".into()
4939        } else {
4940            "↻ Live reload".into()
4941        };
4942    }
4943
4944    pub fn save_state_to_tab(&mut self) {
4945        if self.current_buffer < self.buffers.len() {
4946            let tab = &mut self.buffers[self.current_buffer];
4947            tab.buffer = self.buffer.clone();
4948            tab.filename = self.filename.clone();
4949            tab.scroll = self.scroll;
4950            tab.modified = self.modified;
4951            tab.undo_stack = self.undo_stack.clone();
4952            tab.file_mtime = self.file_mtime;
4953        }
4954    }
4955
4956    pub fn restore_state_from_tab(&mut self) {
4957        if let Some(tab) = self.buffers.get(self.current_buffer).cloned() {
4958            self.buffer = tab.buffer;
4959            self.filename = tab.filename;
4960            self.scroll = tab.scroll;
4961            self.modified = tab.modified;
4962            self.undo_stack = tab.undo_stack;
4963            self.file_mtime = tab.file_mtime;
4964        }
4965    }
4966
4967    pub fn open_new_tab(&mut self, path: &str) {
4968        self.save_state_to_tab();
4969
4970        let pathbuf = PathBuf::from(path);
4971        let abs_path = if pathbuf.is_absolute() {
4972            pathbuf
4973        } else {
4974            env::current_dir().unwrap_or_default().join(&pathbuf)
4975        };
4976
4977        for (i, tab) in self.buffers.iter().enumerate() {
4978            if tab.filename.as_ref() == Some(&abs_path) {
4979                self.current_buffer = i;
4980                self.restore_state_from_tab();
4981                self.lsp_restart_for_current();
4982                self.refresh_git();
4983                self.sync_focused_pane_tab();
4984                self.message = format!("Switched to: {}", abs_path.display());
4985                return;
4986            }
4987        }
4988
4989        let content = fs::read_to_string(&abs_path).unwrap_or_default();
4990        let buffer = Buffer::from_string(&content);
4991        let mtime = std::fs::metadata(&abs_path).ok().and_then(|m| m.modified().ok());
4992        let mut undo = UndoStack::new();
4993        undo.push(buffer.snapshot());
4994        undo.attach_file(&abs_path, self.undo_caching, &content);
4995
4996        self.buffers.push(BufferTab {
4997            buffer,
4998            filename: Some(abs_path.clone()),
4999            scroll: 0,
5000            modified: false,
5001            undo_stack: undo,
5002            file_mtime: mtime,
5003        });
5004        self.current_buffer = self.buffers.len() - 1;
5005        self.restore_state_from_tab();
5006        let text = self.buffer.text();
5007        self.lsp
5008            .auto_start_with_text(&abs_path.display().to_string(), Some(&text));
5009        self.lsp_synced_path = Some(abs_path.clone());
5010        self.lsp_synced_hash = text_hash(&text);
5011        self.refresh_git();
5012        self.sync_focused_pane_tab();
5013        self.message = format!("Opened: {}", abs_path.display());
5014        self.fire_hook(crate::hooks::HookEvent::Open);
5015    }
5016
5017    pub fn next_tab(&mut self) {
5018        if self.buffers.len() < 2 {
5019            return;
5020        }
5021        self.save_state_to_tab();
5022        self.current_buffer = (self.current_buffer + 1) % self.buffers.len();
5023        self.restore_state_from_tab();
5024        self.lsp_restart_for_current();
5025        self.refresh_git();
5026        self.sync_focused_pane_tab();
5027    }
5028
5029    pub fn prev_tab(&mut self) {
5030        if self.buffers.len() < 2 {
5031            return;
5032        }
5033        self.save_state_to_tab();
5034        if self.current_buffer == 0 {
5035            self.current_buffer = self.buffers.len() - 1;
5036        } else {
5037            self.current_buffer -= 1;
5038        }
5039        self.restore_state_from_tab();
5040        self.lsp_restart_for_current();
5041        self.refresh_git();
5042        self.sync_focused_pane_tab();
5043    }
5044
5045    pub fn lsp_restart_for_current(&mut self) {
5046        if let Some(ref path) = self.filename {
5047            let p = path.display().to_string();
5048            // Always open with live buffer text so unsaved edits aren't lost.
5049            let text = self.buffer.text();
5050            self.lsp.auto_start_with_text(&p, Some(&text));
5051            self.lsp_synced_path = Some(path.clone());
5052            self.lsp_synced_hash = text_hash(&text);
5053        } else {
5054            // No file — drop per-document state so stale diagnostics from the
5055            // previous buffer don't paint the empty one.
5056            self.lsp.diagnostics.clear();
5057            self.lsp.semantic_tokens.clear();
5058            self.lsp.inlay_hints.clear();
5059        }
5060    }
5061
5062    pub fn format_document(&mut self) {
5063        let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
5064            self.message = String::from("No file to format");
5065            return;
5066        };
5067        if !self.lsp.server_running {
5068            self.message = String::from("LSP not running");
5069            return;
5070        }
5071        self.sync_lsp_document();
5072        self.lsp.request_formatting(&path);
5073        self.message = String::from("Formatting…");
5074    }
5075
5076    pub fn request_code_actions(&mut self) {
5077        let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
5078            self.message = String::from("No file");
5079            return;
5080        };
5081        if !self.lsp.server_running {
5082            self.message = String::from("LSP not running");
5083            return;
5084        }
5085        self.sync_lsp_document();
5086        let c = self.buffer.cursor();
5087        self.lsp.request_code_action(&path, c.row, c.col);
5088        self.message = String::from("Code actions…");
5089    }
5090
5091    /// Apply multi-file full-text edits (rename / format / code action).
5092    pub fn apply_file_edits(&mut self, edits: Vec<crate::lsp::FileEdit>) {
5093        if edits.is_empty() {
5094            self.message = String::from("No edits to apply");
5095            return;
5096        }
5097        let n = edits.len();
5098        let cur_path = self
5099            .filename
5100            .as_ref()
5101            .map(|p| p.display().to_string())
5102            .unwrap_or_default();
5103        for edit in edits {
5104            let is_current = edit.path == cur_path
5105                || self
5106                    .filename
5107                    .as_ref()
5108                    .and_then(|p| p.canonicalize().ok())
5109                    .and_then(|p| {
5110                        std::path::Path::new(&edit.path)
5111                            .canonicalize()
5112                            .ok()
5113                            .map(|e| e == p)
5114                    })
5115                    .unwrap_or(false);
5116
5117            if is_current {
5118                self.push_undo();
5119                let row = self.buffer.cursor.row;
5120                let col = self.buffer.cursor.col;
5121                self.buffer = crate::buffer::Buffer::from_string(&edit.text);
5122                self.buffer.cursor.row = row.min(self.buffer.line_count().saturating_sub(1));
5123                self.buffer.cursor.col = col;
5124                self.buffer.clamp_col();
5125                self.modified = true;
5126                self.update_scroll();
5127                // keep LSP in sync
5128                self.lsp_synced_hash = 0; // force didChange
5129                self.sync_lsp_document();
5130            } else {
5131                // Write other files to disk and refresh if open in a tab
5132                if let Err(e) = std::fs::write(&edit.path, &edit.text) {
5133                    self.message = format!("Edit failed {}: {e}", edit.path);
5134                    continue;
5135                }
5136                // Update open tab if present
5137                for tab in &mut self.buffers {
5138                    if tab
5139                        .filename
5140                        .as_ref()
5141                        .map(|p| p.display().to_string() == edit.path)
5142                        .unwrap_or(false)
5143                    {
5144                        tab.buffer = crate::buffer::Buffer::from_string(&edit.text);
5145                        tab.modified = false;
5146                    }
5147                }
5148            }
5149        }
5150        self.message = format!("Applied {n} file edit(s)");
5151    }
5152
5153    pub fn open_code_actions_palette(&mut self) {
5154        let actions = std::mem::take(&mut self.lsp.pending_code_actions);
5155        if actions.is_empty() {
5156            return;
5157        }
5158        self.code_action_bank = actions;
5159        let items: Vec<crate::palette::PaletteItem> = self
5160            .code_action_bank
5161            .iter()
5162            .enumerate()
5163            .map(|(i, a)| {
5164                let detail = if !a.kind.is_empty() {
5165                    a.kind.clone()
5166                } else if !a.edits.is_empty() {
5167                    format!("{} file(s)", a.edits.len())
5168                } else {
5169                    a.command.clone().unwrap_or_default()
5170                };
5171                crate::palette::PaletteItem {
5172                    label: a.title.clone(),
5173                    detail,
5174                    action: crate::palette::PaletteAction::CodeAction(i),
5175                }
5176            })
5177            .collect();
5178        self.palette.open_code_actions(items);
5179        self.mode = Mode::Palette;
5180        self.message = format!("Code actions — {} items", self.code_action_bank.len());
5181    }
5182
5183    pub fn apply_code_action(&mut self, index: usize) {
5184        let Some(action) = self.code_action_bank.get(index).cloned() else {
5185            return;
5186        };
5187        self.code_action_bank.clear();
5188        if !action.edits.is_empty() {
5189            self.apply_file_edits(action.edits);
5190            return;
5191        }
5192        if let Some(cmd) = action.command {
5193            self.lsp
5194                .execute_command(&cmd, action.command_args_json.as_deref());
5195            self.message = format!("Running {cmd}…");
5196            return;
5197        }
5198        self.message = String::from("Code action had no edit/command");
5199    }
5200
5201    pub fn close_current_tab(&mut self) {
5202        // Persist or discard the closing buffer's history (undo_caching).
5203        self.save_state_to_tab();
5204        if let Some(tab) = self.buffers.get_mut(self.current_buffer) {
5205            if tab.filename.is_some() {
5206                let text = tab.buffer.text();
5207                tab.undo_stack.finish(self.undo_caching, &text);
5208            }
5209        }
5210        if self.buffers.len() <= 1 {
5211            self.lsp.shutdown();
5212            self.buffer = Buffer::new();
5213            self.filename = None;
5214            self.scroll = 0;
5215            self.modified = false;
5216            self.undo_stack = UndoStack::new();
5217            self.undo_stack.push(self.buffer.snapshot());
5218            self.file_mtime = None;
5219            self.buffers[0] = BufferTab {
5220                buffer: self.buffer.clone(),
5221                filename: None,
5222                scroll: 0,
5223                modified: false,
5224                undo_stack: self.undo_stack.clone(),
5225                file_mtime: None,
5226            };
5227            return;
5228        }
5229
5230        self.buffers.remove(self.current_buffer);
5231        if self.current_buffer >= self.buffers.len() {
5232            self.current_buffer = self.buffers.len() - 1;
5233        }
5234        self.restore_state_from_tab();
5235        // Re-point the LSP at the newly current tab (same language → reuse the
5236        // running server; different → restart). The old unconditional shutdown
5237        // left the surviving tabs with no LSP at all.
5238        self.lsp_restart_for_current();
5239        self.refresh_git();
5240        self.message = String::from("Buffer closed");
5241    }
5242}
5243
5244pub fn set_cursor_esc(color: ratatui::style::Color) {
5245    use ratatui::style::Color;
5246    if let Color::Rgb(r, g, b) = color {
5247        print!("\x1b]12;rgb:{:02x}{:02x}/{:02x}{:02x}/{:02x}{:02x}\x1b\\", r, r, g, g, b, b);
5248        let _ = std::io::stdout().flush();
5249    }
5250}
5251
5252#[cfg(test)]
5253mod tests {
5254    use super::*;
5255
5256    fn app_with(text: &str) -> App {
5257        let mut app = App::new();
5258        app.buffer = Buffer::from_string(text);
5259        app.viewport = EditorViewport {
5260            x: 0,
5261            y: 0,
5262            width: 80,
5263            height: 24,
5264            text_x: 5,
5265            text_y: 0,
5266        };
5267        app
5268    }
5269
5270    #[test]
5271    fn hscroll_follows_cursor_when_wrap_off() {
5272        let long = "x".repeat(300);
5273        let mut app = app_with(&long);
5274        app.wrap_lines = false;
5275        // viewport width 80 − 5 gutter = 75 text cols
5276        app.buffer.cursor.col = 200;
5277        app.update_scroll();
5278        assert_eq!(app.hscroll, 200 + 1 - 75);
5279        // Moving back left pulls the pan window back.
5280        app.buffer.cursor.col = 10;
5281        app.update_scroll();
5282        assert_eq!(app.hscroll, 10);
5283        // Wrap mode never pans.
5284        app.wrap_lines = true;
5285        app.hscroll = 0;
5286        app.buffer.cursor.col = 250;
5287        app.update_scroll();
5288        assert_eq!(app.hscroll, 0);
5289    }
5290
5291    #[test]
5292    fn split_panes_keep_independent_cursors() {
5293        let text = vec!["word here"; 50].join("\n");
5294        let mut app = app_with(&text);
5295        app.buffer.cursor.row = 10;
5296        app.buffer.cursor.col = 3;
5297        app.split_vertical();
5298        // Pane 1: move somewhere else.
5299        app.focus_other_pane();
5300        app.buffer.cursor.row = 40;
5301        app.buffer.cursor.col = 7;
5302        // Back to pane 0 — its cursor must be restored.
5303        app.focus_other_pane();
5304        assert_eq!((app.buffer.cursor.row, app.buffer.cursor.col), (10, 3));
5305        // And pane 1 kept its own.
5306        app.focus_other_pane();
5307        assert_eq!((app.buffer.cursor.row, app.buffer.cursor.col), (40, 7));
5308    }
5309
5310    #[test]
5311    fn close_split_keeps_the_other_pane() {
5312        let text = vec!["line"; 100].join("\n");
5313        let mut app = app_with(&text);
5314        app.buffer.cursor.row = 8;
5315        app.split_vertical();
5316        app.focus_pane(1);
5317        // Pane 0 (the unfocused one) sits at scroll 5.
5318        app.split.panes[0].scroll = 5;
5319        app.close_split();
5320        assert!(!app.split.is_split());
5321        // Vim C-w q: the focused pane closes; the *other* view survives.
5322        assert_eq!(app.scroll, 5);
5323    }
5324
5325    #[test]
5326    fn search_finds_all_matches_char_safe() {
5327        let mut app = app_with("hello\nhello world\nHELLO");
5328        app.search_pattern = Some("hello".into());
5329        app.collect_matches("hello");
5330        // smart-case: all lowercase → case-insensitive → 3 matches
5331        assert_eq!(app.search_matches.len(), 3);
5332        assert_eq!(app.search_matches[0], Position::new(0, 0));
5333        assert_eq!(app.search_matches[1], Position::new(1, 0));
5334        assert_eq!(app.search_matches[2], Position::new(2, 0));
5335    }
5336
5337    #[test]
5338    fn search_case_sensitive_when_pattern_has_upper() {
5339        let mut app = app_with("hello\nHELLO\nHello");
5340        app.collect_matches("Hello");
5341        assert_eq!(app.search_matches.len(), 1);
5342        assert_eq!(app.search_matches[0], Position::new(2, 0));
5343    }
5344
5345    #[test]
5346    fn search_utf8_char_indices() {
5347        let mut app = app_with("안녕 hello 안녕");
5348        app.collect_matches("안녕");
5349        assert_eq!(app.search_matches.len(), 2);
5350        assert_eq!(app.search_matches[0].col, 0);
5351        // "안녕 " = 3 chars, then "hello " = 6, second at col 9
5352        assert_eq!(app.search_matches[1].col, 9);
5353    }
5354
5355    #[test]
5356    fn enter_search_cancel_restores_cursor() {
5357        let mut app = app_with("abc\ndef\nghi");
5358        app.buffer.cursor = Position::new(1, 1);
5359        app.scroll = 0;
5360        app.enter_search();
5361        app.search_input = "ghi".into();
5362        app.update_search_input();
5363        assert_eq!(app.buffer.cursor.row, 2);
5364        app.cancel_search();
5365        assert_eq!(app.mode, Mode::Normal);
5366        assert_eq!(app.buffer.cursor, Position::new(1, 1));
5367        assert!(app.search_input.is_empty());
5368    }
5369
5370    #[test]
5371    fn commit_search_keeps_pattern_for_n() {
5372        let mut app = app_with("foo bar foo");
5373        app.enter_search();
5374        app.search_input = "foo".into();
5375        app.update_search_input();
5376        app.commit_search();
5377        assert_eq!(app.mode, Mode::Normal);
5378        assert_eq!(app.search_pattern.as_deref(), Some("foo"));
5379        assert_eq!(app.search_matches.len(), 2);
5380        let first = app.buffer.cursor;
5381        app.search_next();
5382        assert_ne!(app.buffer.cursor, first);
5383    }
5384
5385    #[test]
5386    fn search_jumps_to_nearest_from_origin() {
5387        let mut app = app_with("aa\nbb\naa\ncc\naa");
5388        app.buffer.cursor = Position::new(1, 0); // on "bb"
5389        app.enter_search();
5390        app.search_input = "aa".into();
5391        app.update_search_input();
5392        // nearest at-or-after origin (row 1) is row 2
5393        assert_eq!(app.buffer.cursor.row, 2);
5394    }
5395
5396    #[test]
5397    fn paste_before_charwise_cursor_on_last_char() {
5398        let mut app = app_with("abc");
5399        app.buffer.cursor = Position::new(0, 1); // on 'b'
5400        app.registers.select('z');
5401        app.registers.store("XY".into(), false);
5402        app.registers.select('z');
5403        app.paste_before();
5404        assert_eq!(app.buffer.line(0), "aXYbc");
5405        // vim: cursor ends on the last pasted char ('Y')
5406        assert_eq!(app.buffer.cursor, Position::new(0, 2));
5407    }
5408
5409    #[test]
5410    fn paste_after_charwise_cursor_on_last_char() {
5411        let mut app = app_with("ab");
5412        app.buffer.cursor = Position::new(0, 0); // on 'a'
5413        app.registers.select('z');
5414        app.registers.store("XY".into(), false);
5415        app.registers.select('z');
5416        app.paste();
5417        assert_eq!(app.buffer.line(0), "aXYb");
5418        assert_eq!(app.buffer.cursor, Position::new(0, 2));
5419    }
5420
5421    #[test]
5422    fn close_tab_keeps_remaining_tab_state() {
5423        let dir = std::env::temp_dir();
5424        let f1 = dir.join("xei_test_close_a.rs");
5425        let f2 = dir.join("xei_test_close_b.rs");
5426        let _ = std::fs::write(&f1, "fn a() {}");
5427        let _ = std::fs::write(&f2, "fn b() {}");
5428        let mut app = App::open_file(f1.to_str().unwrap());
5429        app.open_new_tab(f2.to_str().unwrap());
5430        assert_eq!(app.buffers.len(), 2);
5431        app.close_current_tab();
5432        assert_eq!(app.buffers.len(), 1);
5433        assert_eq!(app.filename.as_deref(), Some(f1.as_path()));
5434        assert_eq!(app.buffer.line(0), "fn a() {}");
5435        let _ = std::fs::remove_file(&f1);
5436        let _ = std::fs::remove_file(&f2);
5437    }
5438
5439    #[test]
5440    fn xlc_wq_is_save_and_quit() {
5441        let dir = std::env::temp_dir().join("xei_test_wq.txt");
5442        let _ = std::fs::write(&dir, "data");
5443        let mut app = App::open_file(dir.to_str().unwrap());
5444        app.buffer.insert_char('!');
5445        app.modified = true;
5446        app.xlc.input = "wq".into();
5447        app.execute_xlc();
5448        assert!(!app.running);
5449        assert!(!app.modified);
5450        let _ = std::fs::remove_file(&dir);
5451    }
5452}