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 SourceControl,
48 GitWorkbench,
50 Settings,
52 Preview,
54 WorkspaceSearch,
56 Screensaver,
58 Debug,
60 CallHierarchy,
62 Rebase,
64 PrReview,
66 Bench,
68}
69
70#[derive(Debug, Clone, Copy, Default)]
74pub struct ProcMetrics {
75 pub cpu_pct: f32,
77 pub cores: u32,
79 pub mem_pct: f32,
80 pub mem_mb: f32,
81 pub gpu_pct: Option<f32>,
82 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 pub yank_buffer: Option<String>,
97 pub registers: Registers,
98 pub marks: Marks,
99 pub jumps: JumpList,
100 pub last_find: Option<LastFind>,
101 pub pending_register: bool,
103 pub pending_mark_set: bool,
105 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 pub which_key: crate::which_key::WhichKeyState,
113 pub visual_anchor: Option<Position>,
114 pub search_pattern: Option<String>,
116 pub search_input: String,
118 pub search_matches: Vec<Position>,
119 pub search_current: usize,
120 pub search_origin: Option<Position>,
122 pub search_scroll_origin: usize,
123 search_pattern_backup: Option<String>,
125 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 pub show_metrics: bool,
153 pub metrics: ProcMetrics,
154 pub bench_report: Option<crate::bench::BenchReport>,
156 pub last_change: Option<LastChange>,
158 pub pending_operator: Option<Operator>,
160 pub pending_to_mod: Option<char>,
162 pub tab_hit_regions: Vec<(u16, u16, usize)>, pub tab_bar_y: u16,
165 pub screen_row_to_buffer: Vec<usize>,
168 pub screen_row_visual_base: Vec<usize>,
171 pub palette: Palette,
172 pub hover_text: Option<String>,
174 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 pub wrap_lines: bool,
182 pub undo_caching: bool,
184 pub gpu_graphics: bool,
186 pub gpu_hyperlinks: bool,
187 pub hscroll: usize,
189 pub syntax_seen_version: u64,
191 lsp_synced_version: u64,
193 pub git: GitGutter,
195 pub blame: GitBlame,
197 pub folds: FoldState,
199 pub multi: MultiCursor,
201 pub scm: ScmPanel,
203 pub git_wb: GitWorkbench,
205 pub settings: SettingsPanel,
207 pub preview: PreviewState,
209 pub preview_image: Option<crate::media::ImageAsset>,
211 pub preview_audio: Option<crate::media::AudioPlayer>,
213 pub split: crate::split::SplitState,
215 pub peek: crate::peek::PeekState,
217 pub workspace_search: crate::workspace_search::WorkspaceSearch,
219 pub screensaver: crate::screensaver::Screensaver,
221 pub pet: crate::pet::PetState,
223 pub pane_hit_regions: Vec<(u16, u16, u16, u16, usize)>,
225 pub split_sep_hit: Option<SplitSepHit>,
227 pub git_log_hits: Vec<(u16, u16, u16, u16, usize)>,
229 pub git_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
231 pub dap_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
233 pub dap_row_hits: Vec<(u16, u16, u16, u16, usize)>,
235 pub dap_panel_rect: Option<(u16, u16, u16, u16)>,
237 pub terminal_rect: Option<(u16, u16, u16, u16)>,
239 pub preview_gfx: Vec<(String, u16, u16, u16, u16)>,
241 pub pr_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
243 pub pr_row_hits: Vec<(u16, u16, u16, u16, usize)>,
245 pub git_pane_hits: Vec<(u16, u16, u16, u16, u8)>,
247 pub editor_ctx: Option<EditorContextMenu>,
249 pub inlay_hints_enabled: bool,
251 pub code_action_bank: Vec<crate::lsp::CodeActionItem>,
253 pub gpu_acc: bool,
255 pub key_hints: bool,
257 pub dap: crate::dap::DapClient,
259 pub call_hierarchy: crate::call_hierarchy::CallHierarchyState,
261 pub rebase: crate::rebase::RebaseState,
263 pub pr_review: crate::pr_review::PrReviewState,
265 pub hooks: crate::hooks::HooksConfig,
267 pub update: crate::update::UpdateState,
269 hook_msg_tx: std::sync::mpsc::Sender<String>,
271 hook_msg_rx: std::sync::mpsc::Receiver<String>,
272 #[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 pub code_lens_enabled: bool,
285 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 pub cell_px: u32,
295 pub cell_px_h: u32,
296 pub term_modern: bool,
297 pub term_kitty_graphics: bool,
299 pub replaying_macro: bool,
301 pub rename_pending: bool,
303 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 Split,
327}
328
329#[derive(Clone, Copy, Debug)]
331pub struct SplitSepHit {
332 pub vertical: bool,
334 pub pos: u16,
336 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#[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 pub text_x: u16,
408 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
559fn 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 self.pet.enabled = cfg.pet_enabled && self.pet_graphics_ok() && self.pet.has_frames();
618 }
619
620 pub fn pet_graphics_ok(&self) -> bool {
622 self.gpu_acc && self.term_kitty_graphics
623 }
624
625 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 if w <= 80 && h <= 24 && self.screen_width == 80 && self.screen_height == 24 {
632 }
634 let max_x = w.saturating_sub(self.pet.width_cells.max(1));
635 let max_y = h.saturating_sub(2); (max_x, max_y)
637 }
638
639 pub fn pet_screen_xy(&self) -> (u16, u16) {
641 self.pet.screen_xy(self.screen_width, self.screen_height)
642 }
643
644 pub fn toggle_status_metrics(&mut self) {
646 self.show_metrics = !self.show_metrics;
647 if self.show_metrics {
648 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 pub fn set_metrics(&mut self, m: ProcMetrics) {
658 self.metrics = m;
659 }
660
661 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn dap_pause(&mut self) {
1048 self.dap.pause();
1049 self.message = "⏸ pause requested".into();
1050 }
1051
1052 pub fn dap_evaluate(&mut self, expr: &str) {
1054 self.dap.evaluate(expr);
1055 self.message = format!("eval: {expr}");
1056 }
1057
1058 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if self.scm.open && !self.scm.closing {
1608 self.scm.close_immediate();
1610 }
1611 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 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 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 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 self.lsp_restart_for_current();
1721 }
1722
1723 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 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 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 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 pub fn clear_which_key(&mut self) {
1758 self.which_key.clear();
1759 self.pending_hints.clear();
1760 }
1761
1762 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 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 pub fn toggle_terminal_full(&mut self) {
1799 if self.terminal.open && self.terminal.full_panel {
1800 self.request_close_pane_terminal();
1802 return;
1803 }
1804 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 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 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 _ => 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 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 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 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 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 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 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 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 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 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 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 }
2174
2175 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 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 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 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 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 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 pub fn clipboard_copy(&mut self) {
2418 if matches!(self.mode, Mode::Visual | Mode::VisualLine) {
2419 self.yank_selection();
2420 self.message = String::from("Copied to clipboard");
2422 return;
2423 }
2424 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 pub fn clipboard_paste(&mut self) {
2437 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 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 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 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 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 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 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 if let Some(ref path) = j.path {
2622 if self.filename.as_ref() != Some(path) {
2623 let path_str = path.display().to_string();
2624 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if self.terminal.pane_bound.is_some() {
3296 self.terminal.pane_bound = None; }
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 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, };
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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn commit_search(&mut self) {
3755 let pattern = self.search_input.clone();
3756 if pattern.is_empty() {
3757 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 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 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 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 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 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 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 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 }
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(); 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 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 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; } 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 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 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 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 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 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 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 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 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 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 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 if cursor_row < self.scroll {
4675 self.scroll = cursor_row;
4676 }
4677
4678 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 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 pub fn paste(&mut self) {
4718 self.paste_impl(false);
4719 }
4720
4721 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 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 if !before && self.buffer.cursor.col < self.buffer.current_line_len() {
4759 self.buffer.move_right();
4760 }
4761 let clean = text.replace('\r', "");
4763 self.buffer.insert_str(&clean);
4764 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 pub fn check_external_change(&mut self) {
4878 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 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 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 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 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 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 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 self.lsp_synced_hash = 0; self.sync_lsp_document();
5130 } else {
5131 if let Err(e) = std::fs::write(&edit.path, &edit.text) {
5133 self.message = format!("Edit failed {}: {e}", edit.path);
5134 continue;
5135 }
5136 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 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 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 app.buffer.cursor.col = 200;
5277 app.update_scroll();
5278 assert_eq!(app.hscroll, 200 + 1 - 75);
5279 app.buffer.cursor.col = 10;
5281 app.update_scroll();
5282 assert_eq!(app.hscroll, 10);
5283 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 app.focus_other_pane();
5300 app.buffer.cursor.row = 40;
5301 app.buffer.cursor.col = 7;
5302 app.focus_other_pane();
5304 assert_eq!((app.buffer.cursor.row, app.buffer.cursor.col), (10, 3));
5305 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 app.split.panes[0].scroll = 5;
5319 app.close_split();
5320 assert!(!app.split.is_split());
5321 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 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 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); app.enter_search();
5390 app.search_input = "aa".into();
5391 app.update_search_input();
5392 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); 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 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); 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}