use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::buffer::{Buffer, Position};
use crate::completion::Completions;
use crate::config;
use crate::explorer::Explorer;
use crate::fold::FoldState;
use crate::git::{GitBlame, GitGutter};
use crate::multi_cursor::MultiCursor;
use crate::lsp::LspClient;
use crate::git_workbench::GitWorkbench;
use crate::preview::PreviewState;
use crate::scm::ScmPanel;
use crate::session::{self, Session, SessionFile};
use crate::settings::SettingsPanel;
use crate::nav::{FindKind, Jump, JumpList, LastFind, Marks};
use crate::ops::{
self, delete_range, extract_text, range_for_motion, range_for_textobject, LastChange, Motion,
Operator, TextObject,
};
use crate::macros::MacroBank;
use crate::palette::{Palette, PaletteAction};
use crate::registers::Registers;
use crate::substitute::{self, SubstituteCmd};
use crate::syntax::SyntaxEngine;
use crate::term::Terminal;
use crate::theme::{self, Theme, OCEAN};
use crate::undo::UndoStack;
use crate::xlc::{Xlc, XlcCmd};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Normal,
Insert,
Visual,
VisualLine,
VisualBlock,
XlcInput,
Search,
Explorer,
Terminal,
Palette,
SourceControl,
GitWorkbench,
Settings,
Preview,
WorkspaceSearch,
Screensaver,
Debug,
CallHierarchy,
Rebase,
PrReview,
Bench,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ProcMetrics {
pub cpu_pct: f32,
pub cores: u32,
pub mem_pct: f32,
pub mem_mb: f32,
pub gpu_pct: Option<f32>,
pub sampled: bool,
}
pub struct App {
pub running: bool,
pub mode: Mode,
pub buffer: Buffer,
pub message: String,
pub filename: Option<PathBuf>,
pub scroll: usize,
pub xlc: Xlc,
pub undo_stack: UndoStack,
pub yank_buffer: Option<String>,
pub registers: Registers,
pub marks: Marks,
pub jumps: JumpList,
pub last_find: Option<LastFind>,
pub pending_register: bool,
pub pending_mark_set: bool,
pub pending_mark_jump: Option<bool>,
pub pending_key: Option<char>,
pub pending_ft: Option<char>,
pub count: Option<usize>,
pub pending_hints: Vec<(&'static str, &'static str)>,
pub which_key: crate::which_key::WhichKeyState,
pub visual_anchor: Option<Position>,
pub search_pattern: Option<String>,
pub search_input: String,
pub search_matches: Vec<Position>,
pub search_current: usize,
pub search_origin: Option<Position>,
pub search_scroll_origin: usize,
search_pattern_backup: Option<String>,
pub search_forward: bool,
pub completions: Completions,
pub modified: bool,
pub mouse: MouseState,
pub viewport: EditorViewport,
pub explorer: Explorer,
pub terminal: Terminal,
pub explorer_width: u16,
pub terminal_width: u16,
pub resize_target: Option<ResizeTarget>,
pub explorer_separator_x: u16,
pub terminal_separator_x: u16,
pub screen_width: u16,
pub screen_height: u16,
pub theme: &'static Theme,
pub xlc_height: u16,
pub xlc_separator_y: u16,
pub file_mtime: Option<std::time::SystemTime>,
pub buffers: Vec<BufferTab>,
pub current_buffer: usize,
pub syntax: SyntaxEngine,
pub lsp: LspClient,
pub debug: bool,
pub show_metrics: bool,
pub metrics: ProcMetrics,
pub bench_report: Option<crate::bench::BenchReport>,
pub last_change: Option<LastChange>,
pub pending_operator: Option<Operator>,
pub pending_to_mod: Option<char>,
pub tab_hit_regions: Vec<(u16, u16, usize)>, pub tab_bar_y: u16,
pub screen_row_to_buffer: Vec<usize>,
pub screen_row_visual_base: Vec<usize>,
pub palette: Palette,
pub hover_text: Option<String>,
pub last_click: Option<(u16, u16, std::time::Instant)>,
pub macros: MacroBank,
pub tab_width: usize,
pub clipboard_sync: bool,
pub relative_number: bool,
pub wrap_lines: bool,
pub undo_caching: bool,
pub gpu_graphics: bool,
pub gpu_hyperlinks: bool,
pub hscroll: usize,
pub syntax_seen_version: u64,
lsp_synced_version: u64,
pub git: GitGutter,
pub blame: GitBlame,
pub folds: FoldState,
pub multi: MultiCursor,
pub scm: ScmPanel,
pub git_wb: GitWorkbench,
pub settings: SettingsPanel,
pub preview: PreviewState,
pub preview_image: Option<crate::media::ImageAsset>,
pub preview_audio: Option<crate::media::AudioPlayer>,
pub split: crate::split::SplitState,
pub peek: crate::peek::PeekState,
pub workspace_search: crate::workspace_search::WorkspaceSearch,
pub screensaver: crate::screensaver::Screensaver,
pub pet: crate::pet::PetState,
pub pane_hit_regions: Vec<(u16, u16, u16, u16, usize)>,
pub split_sep_hit: Option<SplitSepHit>,
pub git_log_hits: Vec<(u16, u16, u16, u16, usize)>,
pub git_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
pub dap_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
pub dap_row_hits: Vec<(u16, u16, u16, u16, usize)>,
pub dap_panel_rect: Option<(u16, u16, u16, u16)>,
pub terminal_rect: Option<(u16, u16, u16, u16)>,
pub preview_gfx: Vec<(String, u16, u16, u16, u16)>,
pub pr_tab_hits: Vec<(u16, u16, u16, u16, u8)>,
pub pr_row_hits: Vec<(u16, u16, u16, u16, usize)>,
pub git_pane_hits: Vec<(u16, u16, u16, u16, u8)>,
pub editor_ctx: Option<EditorContextMenu>,
pub inlay_hints_enabled: bool,
pub code_action_bank: Vec<crate::lsp::CodeActionItem>,
pub gpu_acc: bool,
pub key_hints: bool,
pub dap: crate::dap::DapClient,
pub call_hierarchy: crate::call_hierarchy::CallHierarchyState,
pub rebase: crate::rebase::RebaseState,
pub pr_review: crate::pr_review::PrReviewState,
pub hooks: crate::hooks::HooksConfig,
pub update: crate::update::UpdateState,
hook_msg_tx: std::sync::mpsc::Sender<String>,
hook_msg_rx: std::sync::mpsc::Receiver<String>,
#[allow(clippy::type_complexity)]
git_refresh_rx: Option<
std::sync::mpsc::Receiver<(
u64,
String,
(bool, std::collections::HashMap<usize, crate::git::GitSign>),
Option<(bool, std::collections::HashMap<usize, crate::git::BlameLine>)>,
)>,
>,
git_refresh_gen: u64,
pub code_lens_enabled: bool,
pub term_caps_summary: String,
pub term_sync: bool,
pub term_undercurl: bool,
pub term_underline_color: bool,
pub term_hyperlinks: bool,
pub cell_px: u32,
pub cell_px_h: u32,
pub term_modern: bool,
pub term_kitty_graphics: bool,
pub replaying_macro: bool,
pub rename_pending: bool,
lsp_synced_path: Option<PathBuf>,
lsp_synced_hash: u64,
}
#[derive(Clone)]
pub struct BufferTab {
pub buffer: Buffer,
pub filename: Option<PathBuf>,
pub scroll: usize,
pub modified: bool,
pub undo_stack: UndoStack,
pub file_mtime: Option<std::time::SystemTime>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResizeTarget {
Explorer,
Terminal,
Xlc,
Split,
}
#[derive(Clone, Copy, Debug)]
pub struct SplitSepHit {
pub vertical: bool,
pub pos: u16,
pub area_x: u16,
pub area_y: u16,
pub area_w: u16,
pub area_h: u16,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct MouseState {
pub dragging: bool,
pub drag_anchor: Option<Position>,
}
#[derive(Debug, Clone)]
pub struct EditorContextMenu {
pub x: u16,
pub y: u16,
pub sel: usize,
pub items: Vec<EditorCtxItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorCtxItem {
Cut,
Copy,
Paste,
SelectAll,
Undo,
Redo,
GoToDefinition,
FormatDocument,
CommandPalette,
}
impl EditorCtxItem {
pub fn label(self) -> &'static str {
match self {
EditorCtxItem::Cut => "Cut",
EditorCtxItem::Copy => "Copy",
EditorCtxItem::Paste => "Paste",
EditorCtxItem::SelectAll => "Select All",
EditorCtxItem::Undo => "Undo",
EditorCtxItem::Redo => "Redo",
EditorCtxItem::GoToDefinition => "Go to Definition",
EditorCtxItem::FormatDocument => "Format Document",
EditorCtxItem::CommandPalette => "Command Palette…",
}
}
pub fn key_hint(self) -> &'static str {
match self {
EditorCtxItem::Cut => "⌘X",
EditorCtxItem::Copy => "⌘C",
EditorCtxItem::Paste => "⌘V",
EditorCtxItem::SelectAll => "⌘A",
EditorCtxItem::Undo => "u",
EditorCtxItem::Redo => "^R",
EditorCtxItem::GoToDefinition => "gd",
EditorCtxItem::FormatDocument => "^⇧I",
EditorCtxItem::CommandPalette => "⇧⌘P",
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct EditorViewport {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
pub text_x: u16,
pub text_y: u16,
}
impl Default for App {
fn default() -> Self {
let (hook_msg_tx, hook_msg_rx) = std::sync::mpsc::channel();
Self {
running: true,
mode: Mode::Normal,
buffer: Buffer::new(),
message: String::from("Welcome to xei! i=insert :=XLC h/j/k/l=move"),
filename: None,
scroll: 0,
xlc: Xlc::new(),
undo_stack: UndoStack::new(),
yank_buffer: None,
registers: Registers::new(),
marks: Marks::new(),
jumps: JumpList::new(),
last_find: None,
pending_register: false,
pending_mark_set: false,
pending_mark_jump: None,
pending_key: None,
pending_ft: None,
count: None,
pending_hints: Vec::new(),
which_key: crate::which_key::WhichKeyState::default(),
visual_anchor: None,
search_pattern: None,
search_input: String::new(),
search_matches: Vec::new(),
search_current: 0,
search_origin: None,
search_scroll_origin: 0,
search_pattern_backup: None,
search_forward: true,
completions: Completions::new(),
modified: false,
mouse: MouseState::default(),
viewport: EditorViewport::default(),
explorer: Explorer::new(),
terminal: Terminal::new(),
explorer_width: 22,
terminal_width: 30,
resize_target: None,
explorer_separator_x: 0,
terminal_separator_x: 0,
screen_width: 80,
screen_height: 24,
theme: &OCEAN,
xlc_height: 11,
xlc_separator_y: 0,
file_mtime: None,
buffers: vec![BufferTab {
buffer: Buffer::new(),
filename: None,
scroll: 0,
modified: false,
undo_stack: UndoStack::new(),
file_mtime: None,
}],
current_buffer: 0,
syntax: SyntaxEngine::new(),
lsp: LspClient::new(),
debug: false,
show_metrics: false,
metrics: ProcMetrics::default(),
bench_report: None,
last_change: None,
pending_operator: None,
pending_to_mod: None,
tab_hit_regions: Vec::new(),
tab_bar_y: 0,
screen_row_to_buffer: Vec::new(),
screen_row_visual_base: Vec::new(),
palette: Palette::new(),
hover_text: None,
last_click: None,
macros: MacroBank::new(),
tab_width: 4,
clipboard_sync: true,
relative_number: false,
wrap_lines: true,
undo_caching: false,
gpu_graphics: true,
gpu_hyperlinks: true,
hscroll: 0,
syntax_seen_version: 0,
lsp_synced_version: 0,
git: GitGutter::new(),
blame: GitBlame::default(),
folds: FoldState::new(),
multi: MultiCursor::new(),
scm: ScmPanel::new(),
git_wb: GitWorkbench::new(),
settings: SettingsPanel::new(),
preview: PreviewState::new(),
preview_image: None,
preview_audio: None,
split: crate::split::SplitState::new(),
peek: crate::peek::PeekState::new(),
workspace_search: crate::workspace_search::WorkspaceSearch::new(),
screensaver: crate::screensaver::Screensaver::new(),
pet: crate::pet::PetState::new(),
pane_hit_regions: Vec::new(),
split_sep_hit: None,
git_log_hits: Vec::new(),
git_tab_hits: Vec::new(),
dap_tab_hits: Vec::new(),
dap_row_hits: Vec::new(),
dap_panel_rect: None,
terminal_rect: None,
preview_gfx: Vec::new(),
pr_tab_hits: Vec::new(),
pr_row_hits: Vec::new(),
git_pane_hits: Vec::new(),
editor_ctx: None,
inlay_hints_enabled: true,
code_action_bank: Vec::new(),
gpu_acc: true,
key_hints: true,
dap: crate::dap::DapClient::new(),
call_hierarchy: crate::call_hierarchy::CallHierarchyState::new(),
rebase: crate::rebase::RebaseState::new(),
pr_review: crate::pr_review::PrReviewState::new(),
hooks: crate::hooks::HooksConfig::load(),
update: crate::update::UpdateState::new(),
hook_msg_tx,
hook_msg_rx,
git_refresh_rx: None,
git_refresh_gen: 0,
code_lens_enabled: true,
term_caps_summary: String::new(),
term_sync: false,
term_undercurl: false,
term_underline_color: false,
cell_px: 0,
cell_px_h: 0,
term_hyperlinks: false,
term_modern: false,
term_kitty_graphics: false,
replaying_macro: false,
rename_pending: false,
lsp_synced_path: None,
lsp_synced_hash: 0,
}
}
}
fn text_hash(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in s.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
impl App {
pub fn apply_config(&mut self) {
let cfg = config::load();
self.tab_width = cfg.tab_width;
self.clipboard_sync = cfg.clipboard_sync;
self.relative_number = cfg.relative_number;
self.wrap_lines = cfg.wrap_lines;
if self.wrap_lines {
self.hscroll = 0;
}
self.undo_caching = cfg.undo_caching;
self.gpu_graphics = cfg.gpu_graphics;
self.gpu_hyperlinks = cfg.gpu_hyperlinks;
self.gpu_acc = cfg.gpu_acc;
self.key_hints = cfg.key_hints;
self.lsp
.apply_config(cfg.lsp_enabled, cfg.lsp_servers.clone());
if let Some(t) = theme::find(&cfg.theme) {
self.theme = t;
}
self.apply_pet_from_config(&cfg);
}
pub fn apply_pet_from_config(&mut self, cfg: &config::Config) {
self.pet.x = cfg.pet_x;
self.pet.y = cfg.pet_y;
let new_w = cfg.pet_width_cells.max(4);
if new_w != self.pet.width_cells {
self.pet.width_cells = new_w;
self.pet.invalidate_display_cache();
} else {
self.pet.width_cells = new_w;
}
self.pet.speed = crate::pet::PetState::clamp_speed(cfg.pet_speed);
let path = crate::pet::expand_path(&cfg.pet_path);
let path_s = path.display().to_string();
if !cfg.pet_path.is_empty()
&& (self.pet.path != path_s || !self.pet.has_frames())
{
self.pet.load_path(&path_s);
}
if cfg.pet_path.is_empty() {
self.pet.path.clear();
}
self.pet.enabled = cfg.pet_enabled && self.pet_graphics_ok() && self.pet.has_frames();
}
pub fn pet_graphics_ok(&self) -> bool {
self.gpu_acc && self.term_kitty_graphics
}
pub fn pet_pos_max(&self) -> (u16, u16) {
let w = self.screen_width.max(1);
let h = self.screen_height.max(1);
if w <= 80 && h <= 24 && self.screen_width == 80 && self.screen_height == 24 {
}
let max_x = w.saturating_sub(self.pet.width_cells.max(1));
let max_y = h.saturating_sub(2); (max_x, max_y)
}
pub fn pet_screen_xy(&self) -> (u16, u16) {
self.pet.screen_xy(self.screen_width, self.screen_height)
}
pub fn toggle_status_metrics(&mut self) {
self.show_metrics = !self.show_metrics;
if self.show_metrics {
self.metrics.sampled = false;
self.message = "status: live CPU/MEM/GPU on — :status to hide".into();
} else {
self.message = "status: metrics off".into();
}
}
pub fn set_metrics(&mut self, m: ProcMetrics) {
self.metrics = m;
}
pub fn run_bench(&mut self) {
let report = crate::bench::run(self);
self.message = format!("bench: {:.1} ms total · r rerun · Esc exit", report.total_ms);
self.bench_report = Some(report);
self.mode = Mode::Bench;
}
pub fn exit_bench(&mut self) {
if self.mode == Mode::Bench {
self.mode = Mode::Normal;
self.message.clear();
}
}
pub fn toggle_screensaver(&mut self) {
if self.mode == Mode::Screensaver {
self.screensaver.close();
self.mode = Mode::Normal;
self.message.clear();
} else {
if self.palette.open {
self.palette.close();
}
self.screensaver.open();
self.mode = Mode::Screensaver;
self.message = "xeifetch · Esc exit · weather loading…".into();
}
}
pub fn new() -> Self {
let mut app = Self::default();
app.apply_config();
app.dap.load_persisted_breakpoints();
app
}
pub fn open_file(path: &str) -> Self {
let pathbuf = PathBuf::from(path);
let abs_path = if pathbuf.is_absolute() {
pathbuf
} else {
env::current_dir()
.unwrap_or_default()
.join(&pathbuf)
};
let content = fs::read_to_string(&abs_path).unwrap_or_default();
let message = format!("Opened: {}", abs_path.display());
let buffer = Buffer::from_string(&content);
let mut undo = UndoStack::new();
undo.push(buffer.snapshot());
let mtime = std::fs::metadata(&abs_path).ok().and_then(|m| m.modified().ok());
let mut app = Self {
buffer: buffer.clone(),
filename: Some(abs_path.clone()),
message,
modified: false,
undo_stack: undo.clone(),
file_mtime: mtime,
buffers: vec![BufferTab {
buffer,
filename: Some(abs_path.clone()),
scroll: 0,
modified: false,
undo_stack: undo,
file_mtime: mtime,
}],
current_buffer: 0,
..Self::default()
};
app.apply_config();
{
let text = app.buffer.text();
app.undo_stack
.attach_file(&abs_path, app.undo_caching, &text);
app.lsp
.auto_start_with_text(&abs_path.display().to_string(), Some(&text));
app.lsp_synced_path = Some(abs_path.clone());
app.lsp_synced_hash = text_hash(&text);
}
app.refresh_git();
app
}
pub fn restore_session(&mut self) {
let session = session::load();
if session.files.is_empty() {
return;
}
for (i, f) in session.files.iter().enumerate() {
if i == 0 {
let content = fs::read_to_string(&f.path).unwrap_or_default();
self.buffer = Buffer::from_string(&content);
self.filename = Some(PathBuf::from(&f.path));
self.buffer.cursor.row = f.row.min(self.buffer.line_count().saturating_sub(1));
let line_len = self.buffer.line(self.buffer.cursor.row).chars().count();
self.buffer.cursor.col = f.col.min(line_len);
self.modified = false;
if !self.buffers.is_empty() {
self.buffers[0].buffer = self.buffer.clone();
self.buffers[0].filename = self.filename.clone();
self.buffers[0].modified = false;
}
} else {
self.open_new_tab(&f.path);
self.buffer.cursor.row = f.row.min(self.buffer.line_count().saturating_sub(1));
let line_len = self.buffer.line(self.buffer.cursor.row).chars().count();
self.buffer.cursor.col = f.col.min(line_len);
}
}
let active = session.active.min(self.buffers.len().saturating_sub(1));
if active != self.current_buffer {
self.save_state_to_tab();
self.current_buffer = active;
self.restore_state_from_tab();
}
if let Some(ref p) = self.filename {
let text = self.buffer.text();
self.lsp
.auto_start_with_text(&p.display().to_string(), Some(&text));
self.lsp_synced_path = Some(p.clone());
self.lsp_synced_hash = text_hash(&text);
}
self.refresh_git();
self.dap.load_persisted_breakpoints();
self.message = format!("Restored session ({} file(s))", session.files.len());
}
pub fn save_session(&self) {
let mut files = Vec::new();
for (i, tab) in self.buffers.iter().enumerate() {
let Some(ref path) = tab.filename else {
continue;
};
let (row, col) = if i == self.current_buffer {
(self.buffer.cursor.row, self.buffer.cursor.col)
} else {
(tab.buffer.cursor.row, tab.buffer.cursor.col)
};
files.push(SessionFile {
path: path.display().to_string(),
row,
col,
});
}
if files.is_empty() {
return;
}
let active = self
.buffers
.iter()
.enumerate()
.filter(|(_, t)| t.filename.is_some())
.position(|(i, _)| i == self.current_buffer)
.unwrap_or(0);
session::save(&Session { files, active });
let _ = self.dap.persist_breakpoints();
}
pub fn refresh_git(&mut self) {
if let Some(ref p) = self.filename {
let path = p.display().to_string();
let want_blame = self.blame.enabled || self.blame.open;
self.git_refresh_gen = self.git_refresh_gen.wrapping_add(1);
let generation = self.git_refresh_gen;
let (tx, rx) = std::sync::mpsc::channel();
self.git_refresh_rx = Some(rx);
std::thread::spawn(move || {
let gutter = crate::git::compute_gutter(&path);
let blame = if want_blame {
Some(crate::git::compute_blame(&path))
} else {
None
};
let _ = tx.send((generation, path, gutter, blame));
});
} else {
self.git.clear();
self.blame.clear();
self.blame.enabled = false;
}
self.rebuild_folds();
}
pub fn poll_git_refresh(&mut self) -> bool {
use std::sync::mpsc::TryRecvError;
let Some(rx) = self.git_refresh_rx.take() else {
return false;
};
match rx.try_recv() {
Ok((generation, path, (g_avail, signs), blame)) => {
if generation != self.git_refresh_gen {
return false;
}
self.git.path = path.clone();
self.git.available = g_avail;
self.git.signs = signs;
if let Some((b_avail, lines)) = blame {
self.blame.path = path;
self.blame.available = b_avail;
self.blame.lines = lines;
if !b_avail && self.blame.open {
self.blame.close_panel();
self.blame.enabled = false;
self.message = "Blame unavailable (not a git file?)".into();
}
}
true
}
Err(TryRecvError::Empty) => {
self.git_refresh_rx = Some(rx);
false
}
Err(TryRecvError::Disconnected) => false,
}
}
pub fn rebuild_folds(&mut self) {
let lines = self.buffer.lines();
self.folds.rebuild(&lines, self.tab_width.max(1));
}
pub fn toggle_blame(&mut self) {
let path = self
.filename
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
if path.is_empty() {
self.message = "No file for blame".into();
return;
}
self.message = self.blame.toggle_panel(&path);
}
pub fn toggle_debug_panel(&mut self) {
if self.mode == Mode::Debug {
self.mode = Mode::Normal;
self.message = "Debug unfocused · Ctrl+Shift+D refocus · q in panel closes".into();
} else if self.dap.panel_open {
self.mode = Mode::Debug;
self.message = "Debug · F5 start · F9 bp · F10/F11 step · Esc unfocus".into();
} else {
self.dap.panel_open = true;
self.dap.arm_panel_animation();
self.mode = Mode::Debug;
self.message = "Debug · F5 start · F9 bp · F10/F11 step · Esc unfocus".into();
}
}
pub fn close_debug_panel(&mut self) {
self.dap.panel_open = false;
if self.mode == Mode::Debug {
self.mode = Mode::Normal;
}
self.message = "Debug panel closed".into();
}
pub fn open_blank_tab(&mut self) {
self.save_state_to_tab();
let buffer = Buffer::new();
let mut undo = UndoStack::new();
undo.push(buffer.snapshot());
self.buffers.push(crate::BufferTab {
buffer,
filename: None,
scroll: 0,
modified: false,
undo_stack: undo,
file_mtime: None,
});
self.current_buffer = self.buffers.len() - 1;
self.restore_state_from_tab();
self.split.clamp_tabs(self.buffers.len());
self.refresh_git();
self.mode = Mode::Normal;
self.message = "New tab · i insert · Ctrl+P files · :e <file>".into();
}
pub fn dap_toggle_breakpoint(&mut self) {
let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
self.message = "No file for breakpoint".into();
return;
};
let line = self.buffer.cursor.row;
let on = self.dap.toggle_breakpoint(&path, line);
self.message = if on {
format!("● Breakpoint L{}", line + 1)
} else {
format!("○ Cleared BP L{}", line + 1)
};
}
pub fn dap_start_or_continue(&mut self) {
use crate::dap::DapState;
match self.dap.state {
DapState::Stopped => {
self.dap.continue_exec();
self.message = "→ continue".into();
}
DapState::Running | DapState::Starting => {
self.message = format!("DAP {}", self.dap.state.label());
}
DapState::Idle | DapState::Ending => {
let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
self.message = "Open a file to debug".into();
return;
};
let cwd = self
.filename
.as_ref()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let ext = self.file_extension();
let lang = ext.as_deref().map(|e| match e {
"py" | "pyw" => "python",
"rs" => "rust",
"go" => "go",
"c" | "h" | "cc" | "cpp" | "cxx" | "hpp" => "cpp",
"js" | "mjs" | "cjs" | "ts" | "tsx" => "node",
_ => "unknown",
});
let was_closed = !self.dap.panel_open;
match self.dap.start(&path, cwd.as_deref(), lang, &[]) {
Ok(()) => {
if was_closed {
self.dap.arm_panel_animation();
}
self.mode = Mode::Debug;
self.message = format!(
"▶ DAP {} · {}",
self.dap.adapter_name,
self.dap.last_program.as_deref().unwrap_or(&path)
);
}
Err(e) => {
self.message = e;
}
}
}
}
}
pub fn dap_launch_program(&mut self, program_line: &str) {
let mut parts = program_line.split_whitespace();
let Some(program) = parts.next() else {
self.message = "DapLaunch: missing program".into();
return;
};
let args: Vec<String> = parts.map(|s| s.to_string()).collect();
let cwd = Path::new(program)
.parent()
.map(|p| p.to_path_buf())
.or_else(|| {
self.filename
.as_ref()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
});
let was_closed = !self.dap.panel_open;
match self.dap.start(program, cwd.as_deref(), None, &args) {
Ok(()) => {
if was_closed {
self.dap.arm_panel_animation();
}
self.mode = Mode::Debug;
self.message = format!("▶ DAP launch {program_line}");
}
Err(e) => self.message = e,
}
}
pub fn dap_pause(&mut self) {
self.dap.pause();
self.message = "⏸ pause requested".into();
}
pub fn dap_evaluate(&mut self, expr: &str) {
self.dap.evaluate(expr);
self.message = format!("eval: {expr}");
}
pub fn dap_set_condition(&mut self, condition: &str) {
let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
self.message = "No file for breakpoint".into();
return;
};
let line = self.buffer.cursor.row;
let cond = condition.trim();
if cond.is_empty() {
self.dap.set_breakpoint_condition(&path, line, None);
self.message = format!("○ condition cleared L{}", line + 1);
} else {
self.dap
.set_breakpoint_condition(&path, line, Some(cond.to_string()));
self.message = format!("● L{} if {cond}", line + 1);
}
}
pub fn dap_set_logpoint(&mut self, msg: &str) {
let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
self.message = "No file for logpoint".into();
return;
};
let line = self.buffer.cursor.row;
let m = msg.trim();
if m.is_empty() {
self.dap.set_breakpoint_log(&path, line, None);
self.message = format!("○ logpoint cleared L{}", line + 1);
} else {
self.dap
.set_breakpoint_log(&path, line, Some(m.to_string()));
self.message = format!("● L{} log {m}", line + 1);
}
}
pub fn dap_launch_config(&mut self, name: Option<&str>) {
let hint = self.filename.as_deref();
let configs = crate::dap::load_launch_configs(hint);
if configs.is_empty() {
self.message = "No .vscode/launch.json configurations found".into();
return;
}
let cfg = if let Some(n) = name {
configs.iter().find(|c| c.name == n)
} else {
configs.first()
};
let Some(cfg) = cfg else {
let names: Vec<_> = configs.iter().map(|c| c.name.as_str()).collect();
self.message = format!("Unknown config. Available: {}", names.join(", "));
return;
};
let was_closed = !self.dap.panel_open;
let result = if cfg.request == "attach" {
self.dap_attach_from_config(cfg)
} else {
if cfg.program.is_empty() {
self.message = format!("Config '{}' has no program", cfg.name);
return;
}
let cwd = cfg
.cwd
.as_ref()
.map(PathBuf::from)
.or_else(|| {
self.filename
.as_ref()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
});
let lang = match cfg.adapter_type.as_str() {
"python" | "debugpy" => Some("python"),
"go" | "delve" => Some("go"),
"lldb" | "cppdbg" | "codelldb" => Some("rust"),
"node" | "pwa-node" => Some("node"),
_ => None,
};
self.dap
.start(&cfg.program, cwd.as_deref(), lang, &cfg.args)
};
match result {
Ok(()) => {
if was_closed {
self.dap.arm_panel_animation();
}
self.mode = Mode::Debug;
self.message = format!("▶ launch.json · {}", cfg.name);
}
Err(e) => self.message = e,
}
}
fn dap_attach_from_config(&mut self, cfg: &crate::dap::LaunchConfig) -> Result<(), String> {
let lang = match cfg.adapter_type.as_str() {
"python" | "debugpy" => Some("python"),
"node" | "pwa-node" => Some("node"),
"lldb" | "cppdbg" | "codelldb" => Some("native"),
other if !other.is_empty() => Some(other),
_ => None,
};
if let Some(pid) = cfg.pid {
return self.dap.attach_pid(pid);
}
if let Some(port) = cfg.port {
return self
.dap
.attach_port(port, lang, cfg.host.as_deref());
}
if let Some(port) = cfg.program.parse::<u16>().ok().or_else(|| {
cfg.program
.rsplit(':')
.next()
.and_then(|s| s.parse().ok())
}) {
let host = if cfg.program.contains(':') {
cfg.program.split(':').next()
} else {
None
};
return self.dap.attach_port(port, lang, host);
}
if let Ok(pid) = cfg.program.parse::<u32>() {
return self.dap.attach_pid(pid);
}
Err(format!(
"Attach config '{}' needs port, processId/pid, or program=port|pid",
cfg.name
))
}
pub fn dap_attach(&mut self, spec: &str) {
let parts: Vec<&str> = spec.split_whitespace().collect();
if parts.is_empty() {
self.message = "Usage: DapAttach pid <n> | DapAttach port <n> [python|node]".into();
return;
}
let was_closed = !self.dap.panel_open;
let result = match parts[0] {
"pid" => {
let Some(pid) = parts.get(1).and_then(|s| s.parse::<u32>().ok()) else {
self.message = "Usage: DapAttach pid <n>".into();
return;
};
self.dap.attach_pid(pid)
}
"port" => {
let Some(port) = parts.get(1).and_then(|s| s.parse::<u16>().ok()) else {
self.message = "Usage: DapAttach port <n> [python|node]".into();
return;
};
let lang = parts.get(2).copied();
self.dap.attach_port(port, lang, None)
}
n if n.parse::<u32>().is_ok() => {
let num: u32 = n.parse().unwrap();
if num <= 65535 {
self.dap.attach_port(num as u16, Some("python"), None)
} else {
self.dap.attach_pid(num)
}
}
_ => {
self.message = "Usage: DapAttach pid <n> | DapAttach port <n> [lang]".into();
return;
}
};
match result {
Ok(()) => {
if was_closed {
self.dap.arm_panel_animation();
}
self.mode = Mode::Debug;
self.message = format!("▶ attach · {spec}");
}
Err(e) => self.message = e,
}
}
pub fn dap_list_configs(&mut self) {
let hint = self.filename.as_deref();
let configs = crate::dap::load_launch_configs(hint);
if configs.is_empty() {
self.message = "No launch.json configs".into();
self.xlc.add_output("No .vscode/launch.json found");
return;
}
self.xlc.add_output("=== launch.json ===");
for c in &configs {
self.xlc.add_output(&format!(
" {} [{}] {}",
c.name, c.request, c.program
));
}
self.message = format!("{} launch config(s) — :DapConfig <name>", configs.len());
}
pub fn dap_stop(&mut self) {
self.dap.stop();
self.message = "■ Debug stopped".into();
}
pub fn dap_step_over(&mut self) {
self.dap.step_over();
self.message = "→ step over".into();
}
pub fn dap_step_into(&mut self) {
self.dap.step_into();
self.message = "→ step into".into();
}
pub fn dap_step_out(&mut self) {
self.dap.step_out();
self.message = "→ step out".into();
}
pub fn open_call_hierarchy(&mut self, outgoing: bool) {
let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
self.message = "No file for call hierarchy".into();
return;
};
if !self.lsp.server_running {
self.message = "LSP not running".into();
return;
}
let dir = if outgoing {
crate::call_hierarchy::CallDirection::Outgoing
} else {
crate::call_hierarchy::CallDirection::Incoming
};
let c = self.buffer.cursor();
let word = {
let w = self.word_under_cursor();
if w.is_empty() {
"?".into()
} else {
w
}
};
self.sync_lsp_document();
self.call_hierarchy.begin(&word, dir);
self.mode = Mode::CallHierarchy;
self.lsp
.request_call_hierarchy(&path, c.row, c.col, dir);
self.message = format!("Call hierarchy ({})…", dir.label());
}
pub fn toggle_call_direction(&mut self) {
if !self.call_hierarchy.open {
return;
}
let dir = self.call_hierarchy.direction.toggle();
let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
return;
};
let c = self.buffer.cursor();
let name = self.call_hierarchy.root_name.clone();
self.call_hierarchy.begin(&name, dir);
self.lsp
.request_call_hierarchy(&path, c.row, c.col, dir);
}
pub fn poll_call_hierarchy(&mut self) {
if !self.lsp.call_hierarchy_ready {
return;
}
self.lsp.call_hierarchy_ready = false;
let items = std::mem::take(&mut self.lsp.pending_call_hierarchy);
if let Some(dir) = self.lsp.pending_call_direction {
self.call_hierarchy.direction = dir;
}
if let Some(first) = items.first() {
if self.call_hierarchy.root_name == "?" || self.call_hierarchy.root_name.is_empty() {
self.call_hierarchy.root_name = first.name.clone();
}
}
self.call_hierarchy.set_items(items);
self.message = self.call_hierarchy.message.clone();
}
pub fn open_rebase(&mut self, count: usize) {
let hint = self.filename.as_deref();
let Some(root) = crate::git_ops::find_git_root(hint) else {
self.message = "Not a git repository".into();
return;
};
match self.rebase.open_for(&root, count) {
Ok(()) => {
self.mode = Mode::Rebase;
self.message = self.rebase.message.clone();
}
Err(e) => self.message = e,
}
}
pub fn run_rebase_plan(&mut self) {
match self.rebase.run() {
Ok(msg) => {
self.mode = Mode::Normal;
self.message = msg;
}
Err(e) => self.message = e,
}
}
pub fn open_pr_review(&mut self, number: u64) {
let hint = self.filename.as_deref();
let Some(root) = crate::git_ops::find_git_root(hint).or_else(|| {
self.git_wb.root.clone()
}) else {
self.message = "Not a git repository".into();
return;
};
match self.pr_review.open_pr(&root, number) {
Ok(()) => {
self.mode = Mode::PrReview;
self.message = self.pr_review.message.clone();
}
Err(e) => self.message = e,
}
}
pub fn open_pr_review_selected(&mut self) {
let idxs: Vec<usize> = if !self.git_wb.pr_filter.is_empty() {
self.git_wb.pr_filtered.clone()
} else {
(0..self.git_wb.prs.len()).collect()
};
let num = idxs
.get(self.git_wb.pr_sel)
.and_then(|&i| self.git_wb.prs.get(i))
.map(|p| p.number)
.or_else(|| self.git_wb.prs.get(self.git_wb.pr_sel).map(|p| p.number));
if let Some(n) = num {
self.open_pr_review(n);
} else {
self.message = "No PR selected".into();
}
}
pub fn toggle_code_lens(&mut self) {
self.code_lens_enabled = !self.code_lens_enabled;
self.message = if self.code_lens_enabled {
self.lsp.mark_code_lens_dirty();
"code lens on".into()
} else {
"code lens off".into()
};
}
pub fn reload_hooks(&mut self) {
self.hooks = crate::hooks::HooksConfig::load();
self.message = format!(
"hooks reloaded · enabled={}",
self.hooks.enabled
);
}
fn fire_hook(&mut self, event: crate::hooks::HookEvent) {
if !self.hooks.has_hook(event) {
return;
}
let cfg = self.hooks.clone();
let file = self.filename.clone();
let tx = self.hook_msg_tx.clone();
std::thread::spawn(move || {
if let Some(msg) = crate::hooks::run_hooks(&cfg, event, file.as_deref()) {
let _ = tx.send(msg);
}
});
}
pub fn poll_hook_messages(&mut self) {
while let Ok(msg) = self.hook_msg_rx.try_recv() {
self.message = msg;
}
}
pub fn dap_apply_stopped_location(&mut self) {
if !self.dap.location_dirty {
return;
}
self.dap.location_dirty = false;
let Some(path) = self.dap.current_path.clone() else {
return;
};
let Some(line) = self.dap.current_line else {
return;
};
let same = self
.filename
.as_ref()
.map(|p| {
let a = std::fs::canonicalize(p).unwrap_or_else(|_| p.clone());
let b = std::fs::canonicalize(&path).unwrap_or_else(|_| PathBuf::from(&path));
a == b
})
.unwrap_or(false);
if !same && Path::new(&path).is_file() {
self.open_new_tab(&path);
}
if self.buffer.line_count() == 0 {
return;
}
self.buffer.cursor.row = line.min(self.buffer.line_count().saturating_sub(1));
self.buffer.move_to_line_start();
self.update_scroll();
}
pub fn fold_toggle(&mut self) {
let row = self.buffer.cursor.row;
self.rebuild_folds();
if let Some(msg) = self.folds.toggle(row) {
self.message = msg.into();
if self.folds.is_hidden(self.buffer.cursor.row) {
for r in &self.folds.ranges {
if self.folds.is_closed(r.start)
&& self.buffer.cursor.row > r.start
&& self.buffer.cursor.row <= r.end
{
self.buffer.cursor.row = r.start;
self.buffer.clamp_col();
break;
}
}
}
self.update_scroll();
} else {
self.message = "No fold here".into();
}
}
pub fn fold_close(&mut self) {
self.rebuild_folds();
if self.folds.close_at(self.buffer.cursor.row) {
self.message = "fold closed".into();
} else {
self.message = "No fold here".into();
}
}
pub fn fold_open(&mut self) {
if self.folds.open_at(self.buffer.cursor.row) {
self.message = "fold opened".into();
} else {
self.message = "No closed fold here".into();
}
}
pub fn fold_close_all(&mut self) {
self.rebuild_folds();
self.folds.close_all();
self.message = "all folds closed".into();
self.update_scroll();
}
pub fn fold_open_all(&mut self) {
self.folds.open_all();
self.message = "all folds opened".into();
}
pub fn toggle_scm(&mut self) {
if self.mode == Mode::GitWorkbench {
self.leave_git_workbench_to_scm();
return;
}
if self.scm.open && self.mode == Mode::SourceControl {
if self.scm.closing {
let hint = self.filename.as_deref();
self.scm.open_and_refresh(hint);
return;
}
self.close_scm();
return;
}
if self.palette.open {
self.palette.close();
}
if self.preview.open {
self.preview.close_immediate();
}
if self.git_wb.open {
self.git_wb.close();
}
let hint = self.filename.as_deref();
self.scm.open_and_refresh(hint);
self.mode = Mode::SourceControl;
if let Some(ref err) = self.scm.error {
self.message = err.clone();
} else {
let n = self.scm.total_files();
let branch = if self.scm.branch.is_empty() {
"git".into()
} else {
self.scm.branch.clone()
};
self.message = format!(
"SCM · {} · {} change(s) · Ctrl+Shift+G full Git",
branch, n
);
}
}
pub fn close_scm(&mut self) {
if !self.scm.open {
if self.mode == Mode::SourceControl {
self.mode = Mode::Normal;
}
return;
}
self.scm.close();
}
pub fn close_scm_immediate(&mut self) {
self.scm.close_immediate();
if matches!(self.mode, Mode::SourceControl) {
self.mode = Mode::Normal;
}
}
pub fn open_git_workbench(&mut self) {
let from_scm = self.mode == Mode::SourceControl || self.scm.open;
if self.palette.open {
self.palette.close();
}
if self.preview.open {
self.preview.close_immediate();
}
if self.scm.open && !self.scm.closing {
self.scm.close_immediate();
}
let cwd = env::current_dir().ok();
let hint = self
.filename
.as_deref()
.or(cwd.as_deref());
self.git_wb.open_at(hint, from_scm);
self.mode = Mode::GitWorkbench;
let b = if self.git_wb.branch.is_empty() {
"git".into()
} else {
self.git_wb.branch.clone()
};
let root_note = self
.git_wb
.root
.as_ref()
.and_then(|r| r.file_name())
.and_then(|n| n.to_str())
.unwrap_or(".");
self.message = format!(
"Git · {} @ {} · Status ready · Esc back",
b, root_note
);
}
pub fn toggle_git_workbench(&mut self) {
if self.mode == Mode::GitWorkbench {
self.close_git_workbench();
} else {
self.open_git_workbench();
}
}
pub fn close_git_workbench(&mut self) {
let back_to_scm = self.git_wb.from_scm;
self.git_wb.close();
if back_to_scm {
let hint = self.filename.as_deref();
self.scm.open_and_refresh(hint);
self.mode = Mode::SourceControl;
self.message = String::from("Source Control");
} else {
self.mode = Mode::Normal;
self.message.clear();
}
}
fn leave_git_workbench_to_scm(&mut self) {
self.git_wb.from_scm = true;
self.close_git_workbench();
}
pub fn open_settings(&mut self) {
if self.mode == Mode::Settings {
self.close_settings();
return;
}
if self.palette.open {
self.palette.close();
}
if self.preview.open {
self.preview.close_immediate();
}
if self.git_wb.open {
self.git_wb.close();
}
if self.scm.open {
self.scm.close_immediate();
}
self.settings.open_panel();
self.mode = Mode::Settings;
self.message = format!(
"Settings · {} · Tab pages · Enter apply · s save · Esc",
crate::settings::SettingsPanel::version_string()
);
}
pub fn close_settings(&mut self) {
self.settings.close();
self.mode = Mode::Normal;
self.message.clear();
}
pub fn apply_settings_draft(&mut self) {
let cfg = self.settings.draft.clone();
self.tab_width = cfg.tab_width;
self.clipboard_sync = cfg.clipboard_sync;
self.relative_number = cfg.relative_number;
self.wrap_lines = cfg.wrap_lines;
if self.wrap_lines {
self.hscroll = 0;
}
self.undo_caching = cfg.undo_caching;
self.gpu_graphics = cfg.gpu_graphics;
self.gpu_hyperlinks = cfg.gpu_hyperlinks;
self.gpu_acc = cfg.gpu_acc;
self.key_hints = cfg.key_hints;
self.lsp
.apply_config(cfg.lsp_enabled, cfg.lsp_servers.clone());
if let Some(t) = theme::find(&cfg.theme) {
self.theme = t;
set_cursor_esc(t.cursor);
}
self.apply_pet_from_config(&cfg);
self.lsp_restart_for_current();
}
pub fn set_hints(&mut self, hints: Vec<(&'static str, &'static str)>) {
if self.key_hints {
self.pending_hints = hints;
} else {
self.pending_hints.clear();
}
}
pub fn begin_chord(
&mut self,
title: &str,
hints: Vec<(&'static str, &'static str)>,
) {
self.which_key.begin_prefix(title);
self.set_hints(hints);
}
pub fn begin_leader(&mut self) {
self.which_key.begin_leader();
self.set_hints(crate::which_key::leader_hints(""));
self.message = String::from("-- SPC --");
}
pub fn leader_enter_sub(&mut self, key: char, label: &str) {
self.which_key.enter_leader_sub(key, label);
self.set_hints(crate::which_key::leader_hints(&key.to_string()));
self.message = format!("-- SPC {label} --");
}
pub fn clear_which_key(&mut self) {
self.which_key.clear();
self.pending_hints.clear();
}
pub fn which_key_visible(&self) -> bool {
if !self.key_hints || self.pending_hints.is_empty() {
return false;
}
if !self.which_key.ready() {
return false;
}
self.which_key.is_leader()
|| self.pending_key.is_some()
|| self.pending_operator.is_some()
|| self.pending_register
|| self.pending_mark_set
|| self.pending_mark_jump.is_some()
|| self.split.pending_chord
|| self.pending_to_mod.is_some()
}
pub fn toggle_terminal_side(&mut self) {
if self.terminal.open && !self.terminal.full_panel {
self.terminal.open = false;
self.terminal.shutdown();
self.mode = Mode::Normal;
} else {
self.terminal.full_panel = false;
self.terminal.pane_bound = None;
self.terminal.close_confirm = false;
self.terminal.open = true;
self.terminal.start(self.filename.as_ref());
self.mode = Mode::Terminal;
}
}
pub fn toggle_terminal_full(&mut self) {
if self.terminal.open && self.terminal.full_panel {
self.request_close_pane_terminal();
return;
}
if !self.split.is_split() {
let tab = self.current_buffer;
let scroll = self.scroll;
let cur = (self.buffer.cursor.row, self.buffer.cursor.col);
self.split
.open_split(crate::split::SplitKind::Vertical, tab, scroll, cur);
self.split.set_focus(1);
self.sync_split_from_active();
}
self.terminal.full_panel = true;
self.terminal.pane_bound =
Some(self.split.focus.min(self.split.panes.len().saturating_sub(1)));
self.terminal.close_confirm = false;
self.terminal.open = true;
if matches!(self.mode, Mode::Terminal | Mode::Insert) {
self.mode = Mode::Normal;
}
self.message =
"Terminal focused · keys → shell (Ctrl+C works) · ^⇧W close · ^W w other pane"
.into();
}
pub fn terminal_window_focused(&self) -> bool {
if !self.terminal.open || !self.terminal.full_panel {
return false;
}
match self.terminal.pane_bound {
Some(i) if self.split.is_split() => {
self.split.focus.min(self.split.panes.len().saturating_sub(1)) == i
}
_ => true,
}
}
pub fn request_close_pane_terminal(&mut self) {
if !self.terminal.open || !self.terminal.full_panel {
return;
}
if self.terminal.close_confirm {
self.terminal.close_confirm = false;
self.message = "Close cancelled".into();
return;
}
self.terminal.close_confirm = true;
self.message = "Close terminal? [y]es / [n]o · Ctrl+Shift+W cancel".into();
}
pub fn confirm_close_pane_terminal(&mut self, yes: bool) {
self.terminal.close_confirm = false;
if !yes {
self.message = "Close cancelled".into();
return;
}
if self.terminal.open && self.terminal.full_panel {
self.terminal.open = false;
self.terminal.full_panel = false;
self.terminal.pane_bound = None;
self.terminal.shutdown();
if matches!(self.mode, Mode::Terminal) {
self.mode = Mode::Normal;
}
self.message = "Terminal window closed".into();
}
}
pub fn gpu_active(&self) -> bool {
self.gpu_acc
&& (self.term_modern
|| self.term_sync
|| self.term_underline_color
|| self.term_undercurl)
}
pub fn set_term_caps(
&mut self,
summary: String,
sync: bool,
undercurl: bool,
underline_color: bool,
hyperlinks: bool,
modern: bool,
kitty_graphics: bool,
) {
self.term_caps_summary = summary;
self.term_sync = sync;
self.term_kitty_graphics = kitty_graphics;
self.term_undercurl = undercurl;
self.term_underline_color = underline_color;
self.term_hyperlinks = hyperlinks;
self.term_modern = modern;
}
pub fn save_settings(&mut self) {
self.settings.save();
self.apply_settings_draft();
self.message = self
.settings
.status
.clone()
.unwrap_or_else(|| "Settings saved".into());
}
pub fn scm_refresh(&mut self) {
let hint = self.filename.as_deref();
self.scm.refresh(hint);
self.refresh_git();
}
pub fn scm_commit(&mut self) {
match self.scm.commit(false) {
Ok(()) => {
let summary = self
.scm
.last_result
.clone()
.unwrap_or_else(|| "Committed".into());
self.message = format!("✓ {}", summary);
self.refresh_git();
}
Err(e) => {
self.message = e;
}
}
}
pub fn scm_stage_selected(&mut self) {
match self.scm.stage_selected() {
Ok(()) => {
self.message = "Staged/unstaged".into();
self.refresh_git();
}
Err(e) => self.message = e,
}
}
pub fn scm_stage_all(&mut self) {
match self.scm.stage_all() {
Ok(()) => {
self.message = self
.scm
.last_result
.clone()
.unwrap_or_else(|| "Staged all".into());
self.refresh_git();
}
Err(e) => self.message = e,
}
}
pub fn scm_open_selected_file(&mut self) {
let Some(entry) = self.scm.entry_at(self.scm.selected).cloned() else {
return;
};
let path = if let Some(ref root) = self.scm.root {
root.join(&entry.path)
} else {
PathBuf::from(&entry.path)
};
let path_str = path.display().to_string();
self.close_scm_immediate();
self.open_new_tab(&path_str);
}
pub fn toggle_preview(&mut self) {
if self.preview.open && self.mode == Mode::Preview {
if self.preview.closing {
let text = self.buffer.text();
let ext = self.file_extension();
self.preview.base_dir = self
.filename
.as_ref()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
self.preview.cell_dims =
(self.cell_px_or_default(), self.cell_px_h_or_default());
self.preview.open_for(&text, ext.as_deref());
return;
}
self.close_preview();
return;
}
if self.scm.open {
self.close_scm_immediate();
}
if self.palette.open {
self.palette.close();
}
if let Some(ref path) = self.filename.clone() {
if crate::media::is_media_path(path) {
match self.open_media_preview(path) {
Ok(()) => return,
Err(e) => {
self.message = e;
return;
}
}
}
}
let text = self.buffer.text();
let ext = self.file_extension();
self.clear_media_handles();
self.preview.open_for(&text, ext.as_deref());
self.mode = Mode::Preview;
let kind = self
.preview
.kind
.map(|k| k.label())
.unwrap_or("Preview");
self.message = format!("Preview · {kind} — Esc close · j/k scroll · r refresh");
}
pub fn cell_px_or_default(&self) -> u32 {
if self.cell_px >= 4 { self.cell_px } else { 14 }
}
pub fn cell_px_h_or_default(&self) -> u32 {
if self.cell_px_h >= 6 {
self.cell_px_h
} else {
self.cell_px_or_default() * 2
}
}
pub fn open_media_preview(&mut self, path: &std::path::Path) -> Result<(), String> {
self.clear_media_handles();
self.preview.open_path(path)?;
let kind = self.preview.kind;
match kind {
Some(crate::preview::PreviewKind::Image) => {
match crate::media::ImageAsset::load(path, self.cell_px_or_default()) {
Ok(img) => {
self.message = format!(
"Image · {}×{} · ←/→ resize · Esc close",
img.src_w, img.src_h
);
self.preview_image = Some(img);
}
Err(e) => {
self.preview.lines.push(crate::preview::PreviewLine {
spans: vec![(format!(" load error: {e}"), crate::preview::PreviewStyle::AlertWarning)],
image: None,
});
self.message = e;
}
}
}
Some(crate::preview::PreviewKind::Audio) => {
self.preview_audio = Some(crate::media::AudioPlayer::new(path.to_path_buf()));
self.message = "Audio · Space play/stop · Esc close".into();
}
Some(k) => {
self.message = format!("Preview · {} — Esc close · j/k scroll", k.label());
}
None => {}
}
self.mode = Mode::Preview;
Ok(())
}
pub fn clear_media_handles(&mut self) {
if let Some(mut a) = self.preview_audio.take() {
a.stop();
}
self.preview_image = None;
}
pub fn close_preview(&mut self) {
if !self.preview.open {
self.mode = Mode::Normal;
return;
}
self.clear_media_handles();
self.preview.close();
}
pub fn close_preview_immediate(&mut self) {
self.clear_media_handles();
self.preview.close_immediate();
self.mode = Mode::Normal;
}
pub fn refresh_preview_if_open(&mut self) {
if self.preview.open && !self.preview.closing {
let text = self.buffer.text();
let ext = self.file_extension();
self.preview.rebuild(&text, ext.as_deref());
}
}
pub fn settle_anims(&mut self) {
if self.scm.take_just_closed() {
self.mode = Mode::Normal;
}
if self.preview.take_just_closed() {
self.clear_media_handles();
self.mode = Mode::Normal;
}
}
pub fn breadcrumbs(&self) -> Vec<String> {
let Some(ref path) = self.filename else {
return vec!["untitled".into()];
};
let mut parts: Vec<String> = Vec::new();
for c in path.components() {
match c {
std::path::Component::Normal(s) => {
parts.push(s.to_string_lossy().into_owned());
}
std::path::Component::RootDir => parts.push("/".into()),
std::path::Component::Prefix(p) => {
parts.push(p.as_os_str().to_string_lossy().into_owned());
}
_ => {}
}
}
if parts.len() > 4 {
let tail: Vec<_> = parts.into_iter().rev().take(4).collect::<Vec<_>>();
let mut v: Vec<_> = tail.into_iter().rev().collect();
v.insert(0, "…".into());
v
} else if parts.is_empty() {
vec!["untitled".into()]
} else {
parts
}
}
pub fn file_extension(&self) -> Option<String> {
self.filename
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
}
pub fn file_name(&self) -> &str {
self.filename
.as_ref()
.and_then(|p| p.file_stem())
.and_then(|s| s.to_str())
.unwrap_or("untitled")
}
pub fn push_undo(&mut self) {
self.undo_stack.push(self.buffer.snapshot());
self.modified = true;
if self.current_buffer < self.buffers.len() {
self.buffers[self.current_buffer].modified = true;
}
}
pub fn sync_lsp_document(&mut self) {
if !self.lsp.server_running {
return;
}
let Some(path) = self.filename.clone() else {
return;
};
let path_str = path.display().to_string();
if !crate::lsp::has_server_for(&path_str) {
return;
}
let path_changed = self.lsp_synced_path.as_ref() != Some(&path);
if !path_changed && self.lsp_synced_version == self.buffer.version() {
return;
}
let text = self.buffer.text();
let hash = text_hash(&text);
if path_changed || self.lsp_synced_hash != hash {
self.lsp.notify_change(&path_str, &text);
self.lsp_synced_path = Some(path);
self.lsp_synced_hash = hash;
}
self.lsp_synced_version = self.buffer.version();
}
pub fn undo(&mut self) {
let current = self.buffer.snapshot();
if let Some(snap) = self.undo_stack.undo(current) {
self.buffer.restore(&snap);
self.message = String::from("UNDO");
} else {
self.message = String::from("Already at oldest change");
}
}
pub fn redo(&mut self) {
let current = self.buffer.snapshot();
if let Some(snap) = self.undo_stack.redo(current) {
self.buffer.restore(&snap);
self.modified = true;
self.message = String::from("REDO");
} else {
self.message = String::from("Already at newest change");
}
}
pub fn apply_operator_motion(&mut self, op: Operator, motion: Motion, count: usize) {
let count = count.max(1);
let range = range_for_motion(&self.buffer, motion, count);
self.apply_operator_range(op, range, true);
self.last_change = Some(LastChange::Operator { op, motion, count });
self.clear_operator_pending();
}
pub fn apply_operator_textobject(&mut self, op: Operator, obj: TextObject, count: usize) {
let count = count.max(1);
for i in 0..count {
let Some(range) = range_for_textobject(&self.buffer, obj) else {
if i == 0 {
self.message = String::from("Text object not found");
}
break;
};
let record = i == 0;
self.apply_operator_range(op, range, record);
if op == Operator::Yank {
break;
}
if op == Operator::Change {
break;
}
}
self.last_change = Some(LastChange::TextObject { op, obj, count });
self.clear_operator_pending();
}
fn apply_operator_range(
&mut self,
op: Operator,
range: ops::EditRange,
push_undo_first: bool,
) {
match op {
Operator::Yank => {
let text = extract_text(&self.buffer, range);
let linewise = range.linewise;
let stored = if linewise && !text.ends_with('\n') {
format!("{}\n", text)
} else {
text
};
let label = self.registers.active_label();
self.store_yank(stored, linewise);
self.message = format!("Yanked → {}", label);
}
Operator::Delete => {
if push_undo_first {
self.push_undo();
}
let text = delete_range(&mut self.buffer, range);
let linewise = range.linewise;
let stored = if linewise && !text.ends_with('\n') {
format!("{}\n", text)
} else {
text
};
self.store_yank(stored, linewise);
self.update_scroll();
self.message = String::from("Deleted");
}
Operator::Change => {
if push_undo_first {
self.push_undo();
}
let text = delete_range(&mut self.buffer, range);
self.store_yank(text, range.linewise);
self.mode = Mode::Insert;
self.message = String::from("-- INSERT --");
self.update_scroll();
}
}
}
pub fn store_yank(&mut self, text: String, linewise: bool) {
self.registers.store(text.clone(), linewise);
self.yank_buffer = Some(text);
}
pub fn apply_substitute_cmd(&mut self, cmd: SubstituteCmd) {
self.push_undo();
let lines: Vec<String> = self.buffer.lines().to_vec();
let row = self.buffer.cursor.row;
let (new_lines, n) = substitute::apply_substitute(&lines, &cmd, row);
let text = new_lines.join("\n");
let col = self.buffer.cursor.col;
self.buffer = Buffer::from_string(&text);
self.buffer.cursor.row = row.min(self.buffer.line_count().saturating_sub(1));
self.buffer.cursor.col = col;
self.buffer.clamp_col();
self.message = format!("{} substitution(s)", n);
self.xlc.add_output(&format!("{} substitution(s) on {}", n, if cmd.global_file { "file" } else { "line" }));
self.sync_lsp_document();
}
pub fn block_range(&self) -> Option<(usize, usize, usize, usize)> {
let anchor = self.visual_anchor?;
let cur = self.buffer.cursor();
let (r0, r1) = if anchor.row <= cur.row {
(anchor.row, cur.row)
} else {
(cur.row, anchor.row)
};
let (c0, c1) = if anchor.col <= cur.col {
(anchor.col, cur.col)
} else {
(cur.col, anchor.col)
};
Some((r0, r1, c0, c1))
}
pub fn yank_block(&mut self) {
let Some((r0, r1, c0, c1)) = self.block_range() else {
return;
};
let mut lines = Vec::new();
for row in r0..=r1 {
let chars: Vec<char> = self.buffer.line(row).chars().collect();
let s = c0.min(chars.len());
let e = (c1 + 1).min(chars.len());
if s < e {
lines.push(chars[s..e].iter().collect::<String>());
} else {
lines.push(String::new());
}
}
self.store_yank(lines.join("\n"), false);
self.enter_normal();
self.message = String::from("Yanked block");
}
pub fn delete_block(&mut self) {
let Some((r0, r1, c0, c1)) = self.block_range() else {
return;
};
self.push_undo();
let mut yanked = Vec::new();
for row in r0..=r1 {
let chars: Vec<char> = self.buffer.line(row).chars().collect();
let s = c0.min(chars.len());
let e = (c1 + 1).min(chars.len());
if s < e {
yanked.push(chars[s..e].iter().collect::<String>());
let new_line: String = chars[..s].iter().chain(chars[e..].iter()).collect();
self.buffer.set_line(row, new_line);
} else {
yanked.push(String::new());
}
}
self.store_yank(yanked.join("\n"), false);
self.buffer.cursor = Position::new(r0, c0);
self.buffer.clamp_col();
self.enter_normal();
self.message = String::from("Deleted block");
}
pub fn request_references(&mut self) {
self.sync_lsp_document();
if let Some(ref path) = self.filename.clone() {
let c = self.buffer.cursor();
self.lsp
.request_references(&path.display().to_string(), c.row, c.col);
self.message = String::from("Finding references…");
}
}
pub fn request_rename(&mut self, new_name: &str) {
if new_name.is_empty() {
self.message = String::from("Empty name");
return;
}
self.sync_lsp_document();
if let Some(ref path) = self.filename.clone() {
let c = self.buffer.cursor();
self.lsp
.request_rename(&path.display().to_string(), c.row, c.col, new_name);
self.message = format!("Renaming to {}…", new_name);
}
}
pub fn clipboard_copy(&mut self) {
if matches!(self.mode, Mode::Visual | Mode::VisualLine) {
self.yank_selection();
self.message = String::from("Copied to clipboard");
return;
}
let line = self.buffer.line(self.buffer.cursor.row).to_string();
let text = if line.ends_with('\n') {
line
} else {
format!("{}\n", line)
};
self.store_yank(text, true);
self.message = String::from("Copied line to clipboard");
}
pub fn clipboard_paste(&mut self) {
self.registers.select('+');
if self.mode == Mode::Insert {
if let Some(val) = self.registers.load_for_put() {
self.push_undo();
for c in val.text.chars() {
if c == '\n' {
self.buffer.insert_newline_with_indent(false);
} else if c != '\r' {
self.buffer.insert_char(c);
}
}
self.update_scroll();
self.message = String::from("Pasted from clipboard");
} else {
self.message = String::from("Clipboard empty");
}
} else {
if matches!(self.mode, Mode::Visual | Mode::VisualLine) {
self.delete_selection();
}
self.registers.select('+');
self.paste();
self.message = String::from("Pasted from clipboard");
}
}
pub fn paste_text_at_cursor(&mut self, text: &str) {
if text.is_empty() {
return;
}
self.push_undo();
let clean = text.replace('\r', "");
self.buffer.insert_str(&clean);
self.update_scroll();
self.sync_lsp_document();
self.message = String::from("Pasted");
}
pub fn select_all(&mut self) {
let last = self.buffer.line_count().saturating_sub(1);
let end_col = self.buffer.line(last).chars().count();
self.visual_anchor = Some(Position::new(0, 0));
self.buffer.cursor = Position::new(last, end_col);
self.mode = Mode::Visual;
self.completions.deactivate();
self.message = String::from("-- VISUAL -- select all");
}
pub fn open_editor_ctx(&mut self, x: u16, y: u16) {
let mut items = vec![
EditorCtxItem::Cut,
EditorCtxItem::Copy,
EditorCtxItem::Paste,
EditorCtxItem::SelectAll,
EditorCtxItem::Undo,
EditorCtxItem::Redo,
];
if self.filename.is_some() {
items.push(EditorCtxItem::GoToDefinition);
items.push(EditorCtxItem::FormatDocument);
}
items.push(EditorCtxItem::CommandPalette);
self.editor_ctx = Some(EditorContextMenu {
x,
y,
sel: 0,
items,
});
self.message = "Menu · j/k · Enter · Esc".into();
}
pub fn close_editor_ctx(&mut self) {
self.editor_ctx = None;
}
pub fn run_editor_ctx_action(&mut self) -> Result<String, String> {
let menu = self
.editor_ctx
.clone()
.ok_or_else(|| "No menu".to_string())?;
let item = *menu
.items
.get(menu.sel)
.ok_or_else(|| "No item".to_string())?;
self.editor_ctx = None;
match item {
EditorCtxItem::Cut => {
if matches!(self.mode, Mode::Visual | Mode::VisualLine | Mode::VisualBlock) {
if self.mode == Mode::VisualBlock {
self.delete_block();
} else {
self.delete_selection();
}
Ok("Cut".into())
} else {
self.delete_line();
Ok(self.message.clone())
}
}
EditorCtxItem::Copy => {
self.clipboard_copy();
Ok(self.message.clone())
}
EditorCtxItem::Paste => {
self.clipboard_paste();
Ok(self.message.clone())
}
EditorCtxItem::SelectAll => {
self.select_all();
Ok("Select all".into())
}
EditorCtxItem::Undo => {
self.undo();
Ok(self.message.clone())
}
EditorCtxItem::Redo => {
self.redo();
Ok(self.message.clone())
}
EditorCtxItem::GoToDefinition => {
let path = self
.filename
.as_ref()
.map(|p| p.display().to_string())
.ok_or_else(|| "No file".to_string())?;
let c = self.buffer.cursor();
self.push_jump();
self.sync_lsp_document();
self.lsp.request_definition(&path, c.row, c.col);
Ok("Requested definition…".into())
}
EditorCtxItem::FormatDocument => {
self.format_document();
Ok(self.message.clone())
}
EditorCtxItem::CommandPalette => {
self.open_command_palette();
Ok("Command palette".into())
}
}
}
pub fn push_jump(&mut self) {
self.jumps.push(Jump {
pos: self.buffer.cursor(),
scroll: self.scroll,
path: self.filename.clone(),
});
}
pub fn jump_back(&mut self) {
let current = Jump {
pos: self.buffer.cursor(),
scroll: self.scroll,
path: self.filename.clone(),
};
if let Some(j) = self.jumps.back(current) {
self.apply_jump(j);
self.message = String::from("Jump ←");
} else {
self.message = String::from("Already at oldest jump");
}
}
pub fn jump_forward(&mut self) {
if let Some(j) = self.jumps.forward() {
self.apply_jump(j);
self.message = String::from("Jump →");
} else {
self.message = String::from("Already at newest jump");
}
}
fn apply_jump(&mut self, j: Jump) {
if let Some(ref path) = j.path {
if self.filename.as_ref() != Some(path) {
let path_str = path.display().to_string();
self.open_new_tab(&path_str);
}
}
self.buffer.cursor = j.pos;
self.buffer.clamp_col();
self.scroll = j.scroll;
self.update_scroll();
}
pub fn set_mark(&mut self, name: char) {
if self.marks.set(name, self.buffer.cursor(), self.filename.clone()) {
self.message = format!("Mark '{}' set", name);
} else {
self.message = String::from("Invalid mark (use a-z)");
}
self.pending_mark_set = false;
}
pub fn jump_to_mark(&mut self, name: char, linewise: bool) {
self.pending_mark_jump = None;
let Some(mark) = self.marks.get(name).cloned() else {
self.message = format!("Mark '{}' not set", name);
return;
};
self.push_jump();
if let Some(ref path) = mark.path {
if self.filename.as_ref() != Some(path) {
self.open_new_tab(&path.display().to_string());
}
}
self.buffer.cursor = mark.pos;
if linewise {
self.buffer.move_to_first_non_blank();
}
self.buffer.clamp_col();
self.update_scroll();
self.message = format!("Jump to '{}'", name);
}
pub fn record_find(&mut self, kind: FindKind, forward: bool, ch: char) {
self.last_find = Some(LastFind { ch, kind, forward });
}
pub fn repeat_find(&mut self, reverse: bool) {
let Some(lf) = self.last_find else {
self.message = String::from("No previous f/t");
return;
};
let (kind, forward, ch) = lf.repeat(reverse);
match (kind, forward) {
(FindKind::Find, true) => self.buffer.find_char_forward(ch),
(FindKind::Find, false) => self.buffer.find_char_backward(ch),
(FindKind::Till, true) => self.buffer.till_char_forward(ch),
(FindKind::Till, false) => self.buffer.till_char_backward(ch),
}
self.update_scroll();
}
pub fn clear_operator_pending(&mut self) {
self.pending_operator = None;
self.pending_to_mod = None;
self.pending_key = None;
self.pending_hints.clear();
self.which_key.clear();
}
pub fn begin_operator(&mut self, op: Operator) {
self.pending_operator = Some(op);
self.pending_to_mod = None;
self.pending_key = None;
let name = match op {
Operator::Delete => "d",
Operator::Change => "c",
Operator::Yank => "y",
};
let hints = match op {
Operator::Delete => crate::which_key::as_hints(crate::which_key::map_operator_delete()),
Operator::Change => crate::which_key::as_hints(crate::which_key::map_operator_change()),
Operator::Yank => crate::which_key::as_hints(crate::which_key::map_operator_yank()),
};
self.begin_chord(name, hints);
self.message = format!("-- {} --", name);
}
pub fn repeat_last_change(&mut self) {
let Some(change) = self.last_change.clone() else {
self.message = String::from("No change to repeat");
return;
};
match change {
LastChange::Operator { op, motion, count } => {
self.apply_operator_motion(op, motion, count);
}
LastChange::TextObject { op, obj, count } => {
self.apply_operator_textobject(op, obj, count);
}
LastChange::DeleteChar { count } => {
self.push_undo();
for _ in 0..count.max(1) {
if self.buffer.cursor.col < self.buffer.current_line_len() {
self.buffer.delete_char_at_cursor();
}
}
}
LastChange::ReplaceChar { ch } => {
self.push_undo();
self.buffer.replace_char(ch);
}
}
self.message = String::from("Repeated");
}
pub fn goto_line(&mut self, line_1based: usize) {
self.push_jump();
let target = line_1based.saturating_sub(1).min(self.buffer.line_count().saturating_sub(1));
self.buffer.cursor.row = target;
self.buffer.move_to_line_start();
self.update_scroll();
self.message = format!("Line {}", target + 1);
}
pub fn search_word_under_cursor_backward(&mut self) {
let word = self.word_under_cursor();
if word.is_empty() {
self.message = String::from("No word under cursor");
return;
}
self.push_jump();
self.search_pattern = Some(word.clone());
self.search_forward = false;
self.recompute_search(&word, true);
if self.search_matches.len() > 1 {
self.search_prev();
} else if self.search_matches.is_empty() {
self.message = format!("Pattern not found: {}", word);
} else {
self.message = format!("?{}/ 1/1", word);
}
}
pub fn quit(&mut self) {
self.save_state_to_tab();
let caching = self.undo_caching;
for tab in &mut self.buffers {
if tab.filename.is_some() {
let text = tab.buffer.text();
tab.undo_stack.finish(caching, &text);
}
}
crate::hooks::run_hooks_detached(
&self.hooks,
crate::hooks::HookEvent::Quit,
self.filename.as_deref(),
);
self.save_session();
self.running = false;
}
pub fn enter_insert(&mut self) {
self.push_undo();
self.visual_anchor = None;
self.mode = Mode::Insert;
self.message = String::from("-- INSERT --");
}
pub fn enter_normal(&mut self) {
self.mode = Mode::Normal;
self.visual_anchor = None;
self.pending_key = None;
self.pending_ft = None;
self.pending_hints.clear();
self.count = None;
self.pending_register = false;
self.pending_mark_set = false;
self.pending_mark_jump = None;
self.clear_operator_pending();
self.completions.deactivate();
self.palette.close();
self.hover_text = None;
self.message = String::new();
}
pub fn clear_multi_cursors(&mut self) {
if self.multi.is_active() {
self.multi.clear();
self.message = "Multi-cursor cleared".into();
}
}
pub fn multi_cursor_add_next(&mut self) {
let primary = self.buffer.cursor();
let Some((_, end, word)) = crate::multi_cursor::word_at(&self.buffer, primary) else {
self.message = "No word under cursor".into();
return;
};
let from = self
.multi
.extras
.last()
.copied()
.map(|p| Position {
row: p.row,
col: p.col + word.chars().count(),
})
.unwrap_or(end);
if let Some(pos) = crate::multi_cursor::find_next(&self.buffer, &word, from) {
self.multi.add(primary, pos);
self.message = format!("cursors: {}", self.multi.count(primary));
} else {
self.message = "No more matches".into();
}
}
pub fn multi_cursor_add_below(&mut self) {
let p = self.buffer.cursor();
if p.row + 1 >= self.buffer.line_count() {
self.message = "No line below".into();
return;
}
let mut np = Position {
row: p.row + 1,
col: p.col,
};
let max = self.buffer.line(np.row).chars().count();
if np.col > max {
np.col = max;
}
self.multi.add(p, np);
self.message = format!("cursors: {}", self.multi.count(p));
}
pub fn multi_cursor_add_above(&mut self) {
let p = self.buffer.cursor();
if p.row == 0 {
self.message = "No line above".into();
return;
}
let mut np = Position {
row: p.row - 1,
col: p.col,
};
let max = self.buffer.line(np.row).chars().count();
if np.col > max {
np.col = max;
}
self.multi.add(p, np);
self.message = format!("cursors: {}", self.multi.count(p));
}
pub fn multi_insert_char(&mut self, ch: char) {
if !self.multi.is_active() {
self.buffer.insert_char(ch);
return;
}
let primary = self.buffer.cursor();
let mut all = self.multi.all(primary);
all.sort_by(|a, b| b.row.cmp(&a.row).then(b.col.cmp(&a.col)));
let mut updated = Vec::with_capacity(all.len());
for pos in all {
self.buffer.cursor = pos;
self.buffer.insert_char(ch);
updated.push(self.buffer.cursor);
}
updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
updated.dedup();
if let Some(first) = updated.first().copied() {
self.buffer.cursor = first;
self.multi.set_from_all(updated);
}
self.multi.clamp_all(&self.buffer);
self.modified = true;
}
pub fn multi_backspace(&mut self) {
if !self.multi.is_active() {
self.buffer.backspace();
return;
}
let primary = self.buffer.cursor();
let mut all = self.multi.all(primary);
all.sort_by(|a, b| b.row.cmp(&a.row).then(b.col.cmp(&a.col)));
let mut updated = Vec::with_capacity(all.len());
for pos in all {
self.buffer.cursor = pos;
self.buffer.backspace();
updated.push(self.buffer.cursor);
}
updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
updated.dedup();
if let Some(first) = updated.first().copied() {
self.buffer.cursor = first;
self.multi.set_from_all(updated);
}
self.multi.clamp_all(&self.buffer);
self.modified = true;
}
pub fn multi_delete_char(&mut self) {
if !self.multi.is_active() {
self.buffer.delete_char_at_cursor();
return;
}
let primary = self.buffer.cursor();
let mut all = self.multi.all(primary);
all.sort_by(|a, b| b.row.cmp(&a.row).then(b.col.cmp(&a.col)));
let mut updated = Vec::with_capacity(all.len());
for pos in all {
self.buffer.cursor = pos;
self.buffer.delete_char_at_cursor();
updated.push(self.buffer.cursor);
}
updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
updated.dedup();
if let Some(first) = updated.first().copied() {
self.buffer.cursor = first;
self.multi.set_from_all(updated);
}
self.multi.clamp_all(&self.buffer);
self.modified = true;
}
pub fn multi_move_each(&mut self, f: impl Fn(&mut crate::buffer::Buffer)) {
if !self.multi.is_active() {
f(&mut self.buffer);
return;
}
let primary = self.buffer.cursor();
let all = self.multi.all(primary);
let mut updated = Vec::with_capacity(all.len());
for pos in all {
self.buffer.cursor = pos;
f(&mut self.buffer);
updated.push(self.buffer.cursor);
}
updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
updated.dedup();
if let Some(first) = updated.first().copied() {
self.buffer.cursor = first;
self.multi.set_from_all(updated);
}
self.multi.clamp_all(&self.buffer);
}
pub fn multi_newline(&mut self) {
if !self.multi.is_active() {
let row = self.buffer.cursor.row;
let trimmed = self.buffer.line(row).trim_end().to_string();
let ends_block = trimmed.ends_with('{')
|| trimmed.ends_with('[')
|| trimmed.ends_with('(')
|| trimmed.ends_with(':')
|| trimmed.ends_with("=>")
|| trimmed.ends_with("->");
let ends_close = trimmed.ends_with(')') || trimmed.ends_with(']');
self.buffer
.insert_newline_with_indent(ends_block && !ends_close);
if let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) {
self.dap.shift_breakpoints(&path, row, 1);
}
return;
}
let primary = self.buffer.cursor();
let mut all = self.multi.all(primary);
all.sort_by(|a, b| b.row.cmp(&a.row).then(b.col.cmp(&a.col)));
let mut updated = Vec::with_capacity(all.len());
for pos in all {
self.buffer.cursor = pos;
let trimmed = self.buffer.line(self.buffer.cursor.row).trim_end().to_string();
let ends_block = trimmed.ends_with('{')
|| trimmed.ends_with('[')
|| trimmed.ends_with('(')
|| trimmed.ends_with(':')
|| trimmed.ends_with("=>")
|| trimmed.ends_with("->");
let ends_close = trimmed.ends_with(')') || trimmed.ends_with(']');
self.buffer
.insert_newline_with_indent(ends_block && !ends_close);
updated.push(self.buffer.cursor);
}
updated.sort_by(|a, b| a.row.cmp(&b.row).then(a.col.cmp(&b.col)));
updated.dedup();
if let Some(first) = updated.first().copied() {
self.buffer.cursor = first;
self.multi.set_from_all(updated);
}
self.multi.clamp_all(&self.buffer);
self.modified = true;
}
pub fn open_file_palette(&mut self) {
let root = self
.filename
.as_ref()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_else(|| env::current_dir().unwrap_or_default());
self.palette.open_files(&root);
self.mode = Mode::Palette;
self.message = String::from("Open file — type to filter, Enter open, Esc cancel");
}
pub fn open_command_palette(&mut self) {
self.palette.open_commands();
self.mode = Mode::Palette;
self.message = String::from("Commands — type to filter, Enter run, Esc cancel");
}
pub fn open_problems_palette(&mut self) {
self.palette.open_problems(&self.lsp.diagnostics);
self.mode = Mode::Palette;
self.message = format!("Problems — {} items", self.lsp.diagnostics.len());
}
pub fn execute_palette_selection(&mut self) {
let action = self.palette.selected_action().cloned();
self.palette.close();
self.mode = Mode::Normal;
let Some(action) = action else {
return;
};
match action {
PaletteAction::OpenFile(path) => {
self.open_new_tab(&path.display().to_string());
}
PaletteAction::Goto { row, col } => {
self.push_jump();
self.buffer.cursor.row = row.min(self.buffer.line_count().saturating_sub(1));
self.buffer.cursor.col = col;
self.buffer.clamp_col();
self.update_scroll();
self.message = format!("Jumped to {}:{}", row + 1, col + 1);
}
PaletteAction::GotoFile { path, row, col } => {
self.goto_file_location(&path.display().to_string(), row, col);
}
PaletteAction::CodeAction(i) => {
self.apply_code_action(i);
}
PaletteAction::Command(id) => self.run_palette_command(id),
}
}
pub fn goto_file_location(&mut self, path: &str, row: usize, col: usize) {
self.push_jump();
let cur = self
.filename
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
if cur != path {
self.open_new_tab(path);
}
self.buffer.cursor.row = row.min(self.buffer.line_count().saturating_sub(1));
let line = self.buffer.line(self.buffer.cursor.row);
self.buffer.cursor.col = col.min(line.chars().count());
self.buffer.clamp_col();
self.update_scroll();
self.sync_split_from_active();
self.message = format!("→ {}:{}:{}", path, row + 1, col + 1);
}
pub fn project_root(&self) -> std::path::PathBuf {
if let Some(ref f) = self.filename {
if let Some(parent) = f.parent() {
let mut cur = parent.to_path_buf();
loop {
if cur.join("Cargo.toml").exists()
|| cur.join("package.json").exists()
|| cur.join(".git").exists()
|| cur.join("go.mod").exists()
|| cur.join("pyproject.toml").exists()
{
return cur;
}
if !cur.pop() {
break;
}
}
return parent.to_path_buf();
}
}
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
}
pub fn open_workspace_search(&mut self) {
let root = self.project_root();
self.workspace_search.open_at(root);
self.mode = Mode::WorkspaceSearch;
self.message = String::from("Find in files — type pattern, Enter open hit");
}
pub fn open_document_symbols(&mut self) {
let path = self.filename.as_ref().map(|p| p.display().to_string());
if let Some(path) = path {
self.sync_lsp_document();
self.lsp.request_document_symbols(&path);
self.message = String::from("Loading document symbols…");
} else {
self.message = String::from("No file for symbols");
}
}
pub fn open_workspace_symbols(&mut self) {
if !self.lsp.server_running {
self.message = String::from("LSP not running");
return;
}
self.sync_lsp_document();
self.lsp.request_workspace_symbols("");
self.message = String::from("Loading workspace symbols…");
}
pub fn apply_pending_symbols(&mut self) {
let symbols = std::mem::take(&mut self.lsp.pending_symbols);
if symbols.is_empty() {
return;
}
let cur_path = self
.filename
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
let items: Vec<crate::palette::PaletteItem> = symbols
.into_iter()
.map(|s| {
let path = if s.path.is_empty() {
cur_path.clone()
} else {
s.path.clone()
};
let detail = if s.detail.is_empty() {
format!("{} L{}", s.kind, s.row + 1)
} else {
format!("{} {} L{}", s.kind, s.detail, s.row + 1)
};
crate::palette::PaletteItem {
label: s.name,
detail,
action: PaletteAction::GotoFile {
path: std::path::PathBuf::from(path),
row: s.row,
col: s.col,
},
}
})
.collect();
self.palette.open_symbols(items);
self.mode = Mode::Palette;
self.message = format!("Symbols — {} items", self.palette.items.len());
}
pub fn request_peek_definition(&mut self) {
let path = self.filename.as_ref().map(|p| p.display().to_string());
if let Some(path) = path {
self.sync_lsp_document();
let c = self.buffer.cursor();
self.lsp.request_peek_definition(&path, c.row, c.col);
self.message = String::from("Peek definition…");
}
}
pub fn open_peek_at(&mut self, path: &str, row: usize, col: usize) {
let fallback = if self.filename.as_ref().map(|p| p.display().to_string()).as_deref()
== Some(path)
{
Some(self.buffer.text())
} else {
None
};
self.peek.open_at(
std::path::PathBuf::from(path),
row,
col,
fallback.as_deref(),
8,
);
self.message = format!(
"Peek {} — Enter open · Esc dismiss",
self.peek.path.display()
);
}
pub fn promote_peek(&mut self) {
if !self.peek.open {
return;
}
let path = self.peek.path.display().to_string();
let row = self.peek.target_row;
let col = self.peek.target_col;
self.peek.close();
self.goto_file_location(&path, row, col);
}
pub fn split_vertical(&mut self) {
self.open_split_kind(crate::split::SplitKind::Vertical, "Vertical");
}
pub fn split_horizontal(&mut self) {
self.open_split_kind(crate::split::SplitKind::Horizontal, "Horizontal");
}
fn open_split_kind(&mut self, kind: crate::split::SplitKind, label: &str) {
use crate::split::SplitAdd;
self.save_state_to_tab();
self.sync_split_from_active();
let cur = (self.buffer.cursor.row, self.buffer.cursor.col);
let r = self
.split
.open_split(kind, self.current_buffer, self.scroll, cur);
self.message = match r {
SplitAdd::Opened => {
format!("{label} split · Ctrl+W w cycle · Ctrl+W q close")
}
SplitAdd::Added => format!(
"Pane added ({}) · Ctrl+W w cycle · Ctrl+W q close",
self.split.pane_count()
),
SplitAdd::Full => format!("Max {} panes", crate::split::MAX_PANES),
SplitAdd::MixedKind => {
"Already split the other way — Ctrl+W q panes first".into()
}
};
}
pub fn close_split(&mut self) {
if !self.split.is_split() {
return;
}
let closed = self
.split
.focus
.min(self.split.panes.len().saturating_sub(1));
if self.terminal.open && self.terminal.full_panel {
match self.terminal.pane_bound {
Some(b) if b == closed => {
self.terminal.open = false;
self.terminal.full_panel = false;
self.terminal.pane_bound = None;
self.terminal.close_confirm = false;
self.terminal.shutdown();
}
Some(b) if b > closed => {
self.terminal.pane_bound = Some(b - 1);
}
_ => {}
}
}
let survivor = self.split.remove_focused();
if self.split.is_split() {
self.apply_focused_pane();
self.message = format!("Pane closed · {} left", self.split.pane_count());
return;
}
if self.terminal.pane_bound.is_some() {
self.terminal.pane_bound = None; }
if let Some(p) = survivor {
if p.tab_index != self.current_buffer && p.tab_index < self.buffers.len() {
self.save_state_to_tab();
self.current_buffer = p.tab_index;
self.restore_state_from_tab();
self.lsp_restart_for_current();
self.refresh_git();
}
let max_row = self.buffer.line_count().saturating_sub(1);
self.buffer.cursor.row = p.cursor.0.min(max_row);
self.buffer.cursor.col = p.cursor.1;
self.buffer.clamp_col();
self.scroll = p.scroll;
self.update_scroll();
}
self.message = String::from("Pane closed");
}
pub fn focus_dir(&mut self, dir: char) {
if !self.split.is_split() {
return;
}
let vertical = self.split.kind == crate::split::SplitKind::Vertical;
let delta: isize = match (vertical, dir) {
(true, 'h') | (false, 'k') => -1,
(true, 'l') | (false, 'j') => 1,
_ => return, };
let n = self.split.panes.len() as isize;
let cur = self.split.focus.min(self.split.panes.len().saturating_sub(1)) as isize;
let next = (cur + delta).clamp(0, n - 1) as usize;
if next == cur as usize {
return;
}
self.sync_split_from_active();
self.split.set_focus(next);
self.apply_focused_pane();
self.message = format!("Pane {}", next + 1);
}
pub fn focus_other_pane(&mut self) {
if !self.split.is_split() {
return;
}
self.sync_split_from_active();
self.split.focus_other();
self.apply_focused_pane();
self.message = format!("Pane {}", self.split.focus + 1);
}
pub fn focus_pane(&mut self, idx: usize) {
if !self.split.is_split() {
return;
}
self.sync_split_from_active();
self.split.set_focus(idx);
self.apply_focused_pane();
}
pub fn sync_split_from_active(&mut self) {
if !self.split.is_split() {
return;
}
let cur = (self.buffer.cursor.row, self.buffer.cursor.col);
let p = self.split.focused_pane_mut();
p.tab_index = self.current_buffer;
p.scroll = self.scroll;
p.cursor = cur;
}
pub fn apply_focused_pane(&mut self) {
if !self.split.is_split() {
return;
}
let pane = self.split.focused_pane().clone();
if pane.tab_index != self.current_buffer && pane.tab_index < self.buffers.len() {
self.save_state_to_tab();
self.current_buffer = pane.tab_index;
self.restore_state_from_tab();
self.lsp_restart_for_current();
self.refresh_git();
}
let max_row = self.buffer.line_count().saturating_sub(1);
self.buffer.cursor.row = pane.cursor.0.min(max_row);
self.buffer.cursor.col = pane.cursor.1;
self.buffer.clamp_col();
self.scroll = pane.scroll;
self.update_scroll();
}
pub fn sync_focused_pane_tab(&mut self) {
if self.split.is_split() {
let cur = (self.buffer.cursor.row, self.buffer.cursor.col);
let p = self.split.focused_pane_mut();
p.tab_index = self.current_buffer;
p.scroll = self.scroll;
p.cursor = cur;
}
}
fn run_palette_command(&mut self, id: &str) {
match id {
"noop" => {}
"save" => self.save_file(),
"wq" => {
self.save_file();
if !self.modified {
self.quit();
}
}
"quit" => {
if self.modified {
self.message =
String::from("Unsaved changes. Use Save or Force quit.");
} else {
self.quit();
}
}
"quit!" => self.quit(),
"explorer" => {
if self.explorer.open {
self.explorer.close();
} else {
self.explorer.toggle_at(self.filename.as_ref());
self.mode = Mode::Explorer;
}
}
"scm" => self.toggle_scm(),
"git" | "git_workbench" => self.open_git_workbench(),
"settings" => self.open_settings(),
"preview" => self.toggle_preview(),
"terminal" => self.toggle_terminal_side(),
"terminal_full" => self.toggle_terminal_full(),
"xlc" => self.enter_xlc(None),
"tab_next" => self.next_tab(),
"tab_prev" => self.prev_tab(),
"tab_close" => self.close_current_tab(),
"problems" => self.open_problems_palette(),
"files" => self.open_file_palette(),
"workspace_find" => self.open_workspace_search(),
"symbols" => self.open_document_symbols(),
"workspace_symbols" => self.open_workspace_symbols(),
"split_v" => self.split_vertical(),
"split_h" => self.split_horizontal(),
"split_close" => self.close_split(),
"help" => {
self.enter_xlc(None);
self.xlc.input = "help".into();
self.execute_xlc();
}
"lsp_def" => {
let path = self.filename.as_ref().map(|p| p.display().to_string());
if let Some(path) = path {
let c = self.buffer.cursor();
self.push_jump();
self.sync_lsp_document();
self.lsp.request_definition(&path, c.row, c.col);
self.message = String::from("Requested definition…");
}
}
"lsp_peek" => self.request_peek_definition(),
"format" => self.format_document(),
"code_action" => self.request_code_actions(),
id if id.starts_with("theme:") => {
let name = &id[6..];
if let Some(t) = theme::find(name) {
self.theme = t;
config::save_theme(t.name);
set_cursor_esc(t.cursor);
self.message = format!("Theme: {}", t.name);
}
}
_ => {
self.message = format!("Unknown command: {}", id);
}
}
}
pub fn diag_next(&mut self) {
if self.lsp.diagnostics.is_empty() {
self.message = String::from("No diagnostics");
return;
}
let cur = self.buffer.cursor();
let mut diags = self.lsp.diagnostics.clone();
diags.sort_by_key(|d| (d.row, d.col_start));
let next = diags
.iter()
.find(|d| d.row > cur.row || (d.row == cur.row && d.col_start > cur.col))
.or_else(|| diags.first());
if let Some(d) = next {
self.push_jump();
self.buffer.cursor.row = d.row;
self.buffer.cursor.col = d.col_start;
self.buffer.clamp_col();
self.update_scroll();
self.message = format!("[{:?}] {}", d.severity, d.message);
}
}
pub fn diag_prev(&mut self) {
if self.lsp.diagnostics.is_empty() {
self.message = String::from("No diagnostics");
return;
}
let cur = self.buffer.cursor();
let mut diags = self.lsp.diagnostics.clone();
diags.sort_by_key(|d| (d.row, d.col_start));
let prev = diags
.iter()
.rev()
.find(|d| d.row < cur.row || (d.row == cur.row && d.col_start < cur.col))
.or_else(|| diags.last());
if let Some(d) = prev {
self.push_jump();
self.buffer.cursor.row = d.row;
self.buffer.cursor.col = d.col_start;
self.buffer.clamp_col();
self.update_scroll();
self.message = format!("[{:?}] {}", d.severity, d.message);
}
}
pub fn git_change_next(&mut self) {
self.refresh_git();
if self.git.signs.is_empty() {
self.message = String::from("No git changes");
return;
}
let cur = self.buffer.cursor.row;
let mut rows: Vec<usize> = self.git.signs.keys().copied().collect();
rows.sort_unstable();
let next = rows.iter().copied().find(|r| *r > cur).or_else(|| rows.first().copied());
if let Some(row) = next {
self.push_jump();
self.buffer.cursor.row = row;
self.buffer.move_to_line_start();
self.update_scroll();
let sign = self.git.sign_at(row).map(|s| format!("{s:?}")).unwrap_or_default();
self.message = format!("Git change · L{} · {sign}", row + 1);
}
}
pub fn git_change_prev(&mut self) {
self.refresh_git();
if self.git.signs.is_empty() {
self.message = String::from("No git changes");
return;
}
let cur = self.buffer.cursor.row;
let mut rows: Vec<usize> = self.git.signs.keys().copied().collect();
rows.sort_unstable();
let prev = rows
.iter()
.rev()
.copied()
.find(|r| *r < cur)
.or_else(|| rows.last().copied());
if let Some(row) = prev {
self.push_jump();
self.buffer.cursor.row = row;
self.buffer.move_to_line_start();
self.update_scroll();
let sign = self.git.sign_at(row).map(|s| format!("{s:?}")).unwrap_or_default();
self.message = format!("Git change · L{} · {sign}", row + 1);
}
}
pub fn reload_from_disk(&mut self) {
let Some(path) = self.filename.clone() else {
self.message = String::from("No file to reload");
return;
};
let path_s = path.display().to_string();
match std::fs::read_to_string(&path) {
Ok(content) => {
let cursor = self.buffer.cursor();
let scroll = self.scroll;
self.buffer = Buffer::from_string(&content);
self.buffer.cursor.row =
cursor.row.min(self.buffer.line_count().saturating_sub(1));
self.buffer.cursor.col = cursor.col;
self.buffer.clamp_col();
self.scroll = scroll.min(self.buffer.line_count().saturating_sub(1));
self.modified = false;
self.record_mtime();
self.undo_stack = UndoStack::new();
self.undo_stack.push(self.buffer.snapshot());
if let Some(p) = self.filename.clone() {
let text = self.buffer.text();
self.undo_stack
.attach_file(&p, self.undo_caching, &text);
}
self.rebuild_folds();
self.refresh_git();
self.lsp_restart_for_current();
self.sync_lsp_document();
self.message = format!("↻ Reloaded {path_s}");
}
Err(e) => {
self.message = format!("Reload failed: {e}");
}
}
}
pub fn git_remote(&mut self, action: &str) {
use crate::git_workbench::RemoteAction;
if self.git_wb.root.is_none() {
let hint = self.filename.as_deref();
self.git_wb.root = crate::git_ops::find_git_root(hint);
}
if self.git_wb.root.is_none() {
self.message = String::from("Not a git repository");
return;
}
let act = match action {
"fetch" => RemoteAction::Fetch,
"pull" => RemoteAction::Pull,
"push" => RemoteAction::Push,
_ => {
self.message = format!("unknown git action: {action}");
return;
}
};
self.message = self.git_wb.remote_action(act);
}
pub fn toggle_relative_number(&mut self) {
self.relative_number = !self.relative_number;
let mut cfg = config::load();
cfg.relative_number = self.relative_number;
config::save(&cfg);
self.message = if self.relative_number {
"relative_number on (saved)".into()
} else {
"relative_number off (saved)".into()
};
}
pub fn toggle_inlay_hints(&mut self) {
self.inlay_hints_enabled = !self.inlay_hints_enabled;
self.message = if self.inlay_hints_enabled {
"inlay hints on".into()
} else {
"inlay hints off".into()
};
}
pub fn prompt_rename(&mut self) {
self.enter_xlc(Some("Rename "));
}
pub fn goto_tab(&mut self, idx: usize) {
if idx < self.buffers.len() {
self.save_state_to_tab();
self.current_buffer = idx;
self.restore_state_from_tab();
self.message = format!("Tab {}", idx + 1);
} else {
self.message = format!("No tab {}", idx + 1);
}
}
pub fn request_hover(&mut self) {
self.sync_lsp_document();
if let Some(ref path) = self.filename {
let c = self.buffer.cursor();
self.lsp
.request_hover(&path.display().to_string(), c.row, c.col);
self.message = String::from("Hover…");
}
}
pub fn select_word_under_cursor(&mut self) {
if let Some(range) = ops::range_for_textobject(&self.buffer, TextObject::InnerWord) {
self.visual_anchor = Some(range.start);
self.buffer.cursor = Position::new(range.end.row, range.end.col.saturating_sub(1));
self.mode = Mode::Visual;
self.message = String::from("-- VISUAL --");
}
}
pub fn enter_visual(&mut self) {
self.mode = Mode::Visual;
self.visual_anchor = Some(self.buffer.cursor());
self.message = String::from("-- VISUAL --");
}
pub fn enter_visual_line(&mut self) {
self.mode = Mode::VisualLine;
self.visual_anchor = Some(self.buffer.cursor());
self.message = String::from("-- VISUAL LINE --");
}
pub fn enter_visual_block(&mut self) {
self.mode = Mode::VisualBlock;
self.visual_anchor = Some(self.buffer.cursor());
self.message = String::from("-- VISUAL BLOCK --");
}
pub fn enter_xlc(&mut self, prompt: Option<&str>) {
self.mode = Mode::XlcInput;
self.xlc.open_panel(prompt);
}
pub fn close_xlc(&mut self) {
self.xlc.close();
self.mode = Mode::Normal;
}
pub fn enter_search(&mut self) {
self.enter_search_dir(true);
}
pub fn enter_search_backward(&mut self) {
self.enter_search_dir(false);
}
fn enter_search_dir(&mut self, forward: bool) {
self.completions.deactivate();
self.pending_key = None;
self.pending_ft = None;
self.pending_hints.clear();
self.count = None;
self.clear_operator_pending();
self.search_forward = forward;
self.search_origin = Some(self.buffer.cursor());
self.search_scroll_origin = self.scroll;
self.search_pattern_backup = self.search_pattern.clone();
self.search_input.clear();
self.mode = Mode::Search;
self.message = if forward {
String::from("Search / — Enter accept · Esc cancel · ↑↓ cycle")
} else {
String::from("Search ? — reverse · Enter accept · Esc cancel")
};
}
pub fn commit_search(&mut self) {
let pattern = self.search_input.clone();
if pattern.is_empty() {
if let Some(prev) = self.search_pattern.clone() {
self.push_jump();
self.recompute_search(&prev, false);
if self.search_matches.is_empty() {
self.message = format!("Pattern not found: {}", prev);
} else {
self.search_next();
self.message = format!(
"/{}/ {}/{}",
prev,
self.search_current + 1,
self.search_matches.len()
);
}
} else {
self.message = String::from("No previous search pattern");
}
} else {
self.push_jump();
self.search_pattern = Some(pattern.clone());
self.recompute_search(&pattern, true);
if self.search_matches.is_empty() {
self.message = format!("Pattern not found: {}", pattern);
} else {
let slash = if self.search_forward { '/' } else { '?' };
self.message = format!(
"{}{}/ {}/{}",
slash,
pattern,
self.search_current + 1,
self.search_matches.len()
);
}
}
self.search_input.clear();
self.search_origin = None;
self.search_pattern_backup = None;
self.mode = Mode::Normal;
}
pub fn cancel_search(&mut self) {
if let Some(origin) = self.search_origin.take() {
self.buffer.cursor = origin;
self.scroll = self.search_scroll_origin;
}
self.search_input.clear();
self.search_pattern = self.search_pattern_backup.take();
self.search_matches.clear();
self.search_current = 0;
if let Some(ref pat) = self.search_pattern.clone() {
self.collect_matches(pat);
let cur = self.buffer.cursor();
if let Some(idx) = self
.search_matches
.iter()
.position(|p| p.row == cur.row && p.col == cur.col)
{
self.search_current = idx;
}
}
self.mode = Mode::Normal;
self.message = String::from("Search cancelled");
}
pub fn update_search_input(&mut self) {
let pattern = self.search_input.clone();
if pattern.is_empty() {
self.search_matches.clear();
self.search_current = 0;
if let Some(origin) = self.search_origin {
self.buffer.cursor = origin;
self.scroll = self.search_scroll_origin;
}
self.message = String::from("Search — type to filter, Enter accept, Esc cancel");
return;
}
self.recompute_search(&pattern, true);
if self.search_matches.is_empty() {
self.message = format!("/{}/ 0 matches", pattern);
} else {
self.message = format!(
"/{}/ {}/{}",
pattern,
self.search_current + 1,
self.search_matches.len()
);
}
}
pub fn active_search_pattern(&self) -> Option<&str> {
if self.mode == Mode::Search {
if self.search_input.is_empty() {
None
} else {
Some(self.search_input.as_str())
}
} else {
self.search_pattern.as_deref()
}
}
pub fn search_pattern_len_chars(&self) -> usize {
self.active_search_pattern()
.map(|p| p.chars().count())
.unwrap_or(0)
}
pub fn search_matches_row_slice(&self, row: usize) -> (usize, &[Position]) {
let lo = self.search_matches.partition_point(|p| p.row < row);
let hi = self.search_matches.partition_point(|p| p.row <= row);
(lo, &self.search_matches[lo..hi])
}
pub fn is_current_search_match(&self, row: usize, col: usize) -> bool {
self.search_matches
.get(self.search_current)
.map(|p| p.row == row && p.col == col)
.unwrap_or(false)
}
pub fn selected_range(&self) -> Option<(Position, Position)> {
let anchor = self.visual_anchor?;
let cursor = self.buffer.cursor();
if self.mode == Mode::VisualLine {
let (start_row, end_row) = if anchor.row <= cursor.row {
(anchor.row, cursor.row)
} else {
(cursor.row, anchor.row)
};
Some((
Position::new(start_row, 0),
Position::new(end_row, self.buffer.line(end_row).chars().count()),
))
} else if self.mode == Mode::VisualBlock {
if anchor.row < cursor.row || (anchor.row == cursor.row && anchor.col <= cursor.col) {
Some((anchor, cursor))
} else {
Some((cursor, anchor))
}
} else if anchor.row < cursor.row || (anchor.row == cursor.row && anchor.col <= cursor.col)
{
Some((anchor, cursor))
} else {
Some((cursor, anchor))
}
}
pub fn execute_xlc(&mut self) {
let cmd = self.xlc.execute();
match cmd {
XlcCmd::Save => self.save_file(),
XlcCmd::SaveAs(path) => {
self.filename = Some(PathBuf::from(&path));
self.save_file();
}
XlcCmd::SaveAndQuit => {
self.save_file();
if !self.modified {
self.quit();
} else {
self.xlc.add_output("Save failed; not quitting.");
}
}
XlcCmd::Quit => {
if self.modified {
self.message = String::from("Unsaved changes. Use :w first or :q! to force quit.");
self.xlc.add_output("Unsaved changes. Use w to save first, or q! to force quit.");
} else {
self.quit();
}
}
XlcCmd::ForceQuit => self.quit(),
XlcCmd::Open(path) => self.open_in_place(&path),
XlcCmd::Move(dest) => self.move_file(&dest),
XlcCmd::Rename(name) => {
if let Some(ref path) = self.filename {
let parent = path.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
env::current_dir().unwrap_or_default()
});
let new_path = parent.join(name);
self.move_file(&new_path.display().to_string());
} else {
self.xlc.add_output("No file to rename.");
}
}
XlcCmd::DeleteFile => {
if let Some(ref path) = self.filename {
match fs::remove_file(path) {
Ok(_) => self.xlc.add_output(&format!("Deleted: {}", path.display())),
Err(e) => self.xlc.add_output(&format!("Error: {}", e)),
}
} else {
self.xlc.add_output("No file to delete.");
}
}
XlcCmd::Pwd => {
let cwd = env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "?".to_string());
self.xlc.add_output(&cwd);
}
XlcCmd::Ls => {
if let Ok(entries) = std::fs::read_dir(".") {
for entry in entries.flatten() {
let meta = entry.file_type().ok();
let name = entry.file_name();
let prefix = if meta.map(|m| m.is_dir()).unwrap_or(false) { "/" } else { "" };
self.xlc.add_output(&format!(" {}{}", name.to_string_lossy(), prefix));
}
} else {
self.xlc.add_output("Could not list directory.");
}
}
XlcCmd::Help => {
self.xlc.add_output("=== xei Commands ===");
self.xlc.add_output(" w, save Save current file");
self.xlc.add_output(" w <path> Save to a new path");
self.xlc.add_output(" e, open <file> Open a file");
self.xlc.add_output(" mv, move <dest> Move/rename current file");
self.xlc.add_output(" rename <name> Rename in same directory");
self.xlc.add_output(" rm Delete current file");
self.xlc.add_output(" pwd Show working directory");
self.xlc.add_output(" ls List files");
self.xlc.add_output(" q Quit (with unsaved warning)");
self.xlc.add_output(" q! Force quit");
self.xlc.add_output(" wq, x Save and quit");
self.xlc.add_output(" find, / <pat> Search in buffer");
self.xlc.add_output(" theme [name] Switch or list themes");
self.xlc.add_output(" bd Close current tab");
self.xlc.add_output(" <number> Go to line (e.g. :42)");
self.xlc.add_output(" s/pat/repl/g Substitute on line");
self.xlc.add_output(" %s/pat/repl/g Substitute in file");
self.xlc.add_output(" problems Diagnostics list");
self.xlc.add_output(" preview Pretty preview (md/json)");
self.xlc.add_output(" git Full Git workbench");
self.xlc.add_output(" screensaver xeifetch splash (Esc exit)");
self.xlc.add_output(" xeifetch / ss Alias for screensaver");
self.xlc.add_output(" bench Self-benchmark (r rerun · Esc exit)");
self.xlc.add_output(" status Toggle live CPU/MEM/GPU readout");
self.xlc.add_output(" pet [path.gif] Desktop pet (Kitty/Ghostty)");
self.xlc.add_output(" settings Settings panel (Ctrl+,)");
self.xlc.add_output(" gh-login / gha GitHub CLI browser login");
self.xlc.add_output(" gh-logout GitHub CLI logout");
self.xlc.add_output(" gh-status GitHub auth status");
self.xlc.add_output(" Rename <name> LSP rename");
self.xlc.add_output(" dap / debug Toggle debug panel");
self.xlc.add_output(" dap start/stop Start / stop DAP session");
self.xlc.add_output(" bp Toggle breakpoint");
self.xlc.add_output(" bp if <expr> Conditional breakpoint");
self.xlc.add_output(" bp log <msg> Logpoint");
self.xlc.add_output(" launch <prog> DAP launch program");
self.xlc.add_output(" DapConfig [n] launch.json configs");
self.xlc.add_output(" eval <expr> DAP evaluate (stopped)");
self.xlc.add_output(" DapAttach … attach pid <n> | port <n> [lang]");
self.xlc.add_output(" calls Call hierarchy (incoming)");
self.xlc.add_output(" rebase [N] Interactive rebase last N commits");
self.xlc.add_output(" rebase-abort Abort in-progress rebase");
self.xlc.add_output(" codelens Toggle LSP code lenses");
self.xlc.add_output(" pr <n> Open PR review surface");
self.xlc.add_output(" hooks Reload ~/.xei/hooks.toml");
self.xlc.add_output(" help, h, ? Show this help");
}
XlcCmd::DapPanel => {
self.toggle_debug_panel();
self.xlc.add_output("Debug panel (F5 start · F9 bp · F10/F11 step)");
}
XlcCmd::DapStart => {
self.dap_start_or_continue();
self.xlc.add_output(&self.message.clone());
}
XlcCmd::DapStop => {
self.dap_stop();
self.xlc.add_output("Debug stopped");
}
XlcCmd::DapLaunch(prog) => {
self.dap_launch_program(&prog);
self.xlc.add_output(&self.message.clone());
}
XlcCmd::DapBreakpoint => {
self.dap_toggle_breakpoint();
self.xlc.add_output(&self.message.clone());
}
XlcCmd::DapCondition(expr) => {
self.dap_set_condition(&expr);
self.xlc.add_output(&self.message.clone());
}
XlcCmd::DapLogpoint(msg) => {
self.dap_set_logpoint(&msg);
self.xlc.add_output(&self.message.clone());
}
XlcCmd::DapConfig(name) => {
if name.is_none() {
self.dap_list_configs();
} else {
self.dap_launch_config(name.as_deref());
self.xlc.add_output(&self.message.clone());
}
}
XlcCmd::DapEval(expr) => {
self.dap_evaluate(&expr);
self.xlc.add_output(&self.message.clone());
}
XlcCmd::DapAttach(spec) => {
self.dap_attach(&spec);
self.xlc.add_output(&self.message.clone());
}
XlcCmd::Rebase(n) => {
self.open_rebase(n);
self.xlc.add_output(&self.message.clone());
}
XlcCmd::RebaseAbort => {
let hint = self.filename.as_deref();
if let Some(root) = crate::git_ops::find_git_root(hint) {
match crate::rebase::rebase_abort(&root) {
Ok(m) => {
self.message = m.clone();
self.xlc.add_output(&m);
}
Err(e) => {
self.message = e.clone();
self.xlc.add_output(&e);
}
}
} else {
self.xlc.add_output("Not a git repository");
}
}
XlcCmd::RebaseContinue => {
let hint = self.filename.as_deref();
if let Some(root) = crate::git_ops::find_git_root(hint) {
match crate::rebase::rebase_continue(&root) {
Ok(m) => {
self.message = m.clone();
self.xlc.add_output(&m);
}
Err(e) => {
self.message = e.clone();
self.xlc.add_output(&e);
}
}
} else {
self.xlc.add_output("Not a git repository");
}
}
XlcCmd::CallHierarchy => {
self.open_call_hierarchy(false);
self.xlc.add_output(&self.message.clone());
}
XlcCmd::CodeLens => {
self.toggle_code_lens();
self.xlc.add_output(&self.message.clone());
}
XlcCmd::PrReview(n) => {
if n == 0 {
self.xlc.add_output("Usage: pr <number>");
} else {
self.open_pr_review(n);
self.xlc.add_output(&self.message.clone());
}
}
XlcCmd::Update => {
self.message = if self.update.latest.is_none() && !self.update.installing {
self.update
.check_now_and_install(env!("CARGO_PKG_VERSION"))
} else {
self.update.start_install()
};
self.xlc.add_output(&self.message.clone());
}
XlcCmd::BlankTab => {
self.open_blank_tab();
}
XlcCmd::Bench => {
self.run_bench();
}
XlcCmd::StatusMetrics => {
self.toggle_status_metrics();
}
XlcCmd::HooksReload => {
self.reload_hooks();
self.xlc.add_output(&self.message.clone());
}
XlcCmd::Preview => {
self.toggle_preview();
self.xlc.add_output("Preview toggled (Ctrl+Shift+V / Esc)");
}
XlcCmd::GhLogin => {
self.xlc.add_output("Starting browser login (non-blocking)…");
self.open_git_workbench();
self.git_wb.tab = crate::git_workbench::GitTab::Auth;
match self.git_wb.start_browser_login() {
Ok(()) => {
self.message = "Auth · complete sign-in in browser".into();
self.xlc.add_output("Opened Auth tab — finish login in browser");
}
Err(e) => {
self.message = e.clone();
self.xlc.add_output(&e);
}
}
}
XlcCmd::GhLogout => match crate::gh::auth_logout() {
Ok(m) => {
self.message = m.clone();
self.xlc.add_output(&m);
self.git_wb.refresh_auth();
}
Err(e) => {
self.message = e.clone();
self.xlc.add_output(&e);
}
},
XlcCmd::GhStatus => {
let info = crate::gh::auth_status();
self.git_wb.auth = info.clone();
self.xlc.add_output(&info.detail);
self.message = info.detail;
}
XlcCmd::GitWorkbench => {
self.open_git_workbench();
self.xlc.add_output("Git workbench (Ctrl+Shift+G)");
}
XlcCmd::Settings => {
self.open_settings();
self.xlc.add_output("Settings (Ctrl+,)");
}
XlcCmd::Screensaver => {
self.toggle_screensaver();
self.xlc.add_output("xeifetch screensaver (Esc to leave)");
}
XlcCmd::Pet(path) => {
if path.is_empty() {
let st = if self.pet.enabled { "on" } else { "off" };
let frames = self.pet.frame_count();
let gfx = if self.pet_graphics_ok() {
"gpu+kitty ok"
} else {
"needs gpu_acc + Kitty/Ghostty"
};
let err = self.pet.load_error.as_deref().unwrap_or("");
self.xlc.add_output(&format!(
"pet {st} · path={} · frames={frames} · pos={},{} · w={} · speed={} [{gfx}] {err}",
self.pet.path,
self.pet.x,
self.pet.y,
self.pet.width_cells,
crate::pet::PetState::speed_label(self.pet.speed),
));
self.message =
"Use :pet ~/pic.gif · :pet on|off · Settings → Pet (needs GPU)".into();
} else if matches!(path.as_str(), "off" | "disable" | "0" | "false") {
self.pet.enabled = false;
let mut cfg = config::load();
cfg.pet_enabled = false;
config::save(&cfg);
self.xlc.add_output("pet off");
self.message = "Pet off".into();
} else if matches!(path.as_str(), "on" | "enable" | "1" | "true") {
if !self.pet_graphics_ok() {
self.pet.enabled = false;
self.xlc.add_output(
"pet requires gpu_acc + Kitty/Ghostty graphics (Settings → Setting)",
);
self.message = "Pet needs GPU + Kitty graphics".into();
} else if !self.pet.has_frames() {
self.pet.enabled = false;
self.xlc.add_output("pet: no frames — :pet ~/path.gif first");
self.message = "Load a GIF first: :pet ~/path.gif".into();
} else {
self.pet.enabled = true;
let mut cfg = config::load();
cfg.pet_enabled = true;
config::save(&cfg);
self.xlc.add_output("pet on");
self.message = "Pet on".into();
}
} else {
if !self.pet_graphics_ok() {
self.xlc.add_output(
"pet requires gpu_acc + Kitty/Ghostty — enable gpu_acc in Settings",
);
self.message = "Pet needs GPU + Kitty graphics".into();
}
let p = crate::pet::expand_path(&path);
let ps = p.display().to_string();
self.pet.load_path(&ps);
self.pet.enabled = self.pet.has_frames() && self.pet_graphics_ok();
let mut cfg = config::load();
cfg.pet_path = path.clone(); cfg.pet_enabled = self.pet.enabled;
cfg.pet_x = self.pet.x;
cfg.pet_y = self.pet.y;
cfg.pet_width_cells = self.pet.width_cells;
cfg.pet_speed = self.pet.speed;
config::save(&cfg);
if let Some(ref e) = self.pet.load_error {
self.xlc.add_output(&format!("pet load error: {e}"));
self.message = e.clone();
} else if !self.pet_graphics_ok() {
self.xlc.add_output(&format!(
"pet loaded {} ({} frames) but not shown — GPU/Kitty required",
ps,
self.pet.frame_count()
));
} else {
self.xlc.add_output(&format!(
"pet loaded {} ({} frames)",
ps,
self.pet.frame_count()
));
self.message = format!("Pet · {} frames", self.pet.frame_count());
}
}
}
XlcCmd::Search(pattern) => {
self.search_pattern = Some(pattern.clone());
self.recompute_search(&pattern, true);
let n = self.search_matches.len();
self.message = if n == 0 {
format!("Pattern not found: {}", pattern)
} else {
format!("/{}/ 1/{}", pattern, n)
};
self.xlc.add_output(&format!("Search /{}/ → {} match(es)", pattern, n));
}
XlcCmd::Theme(name) => {
if name.is_empty() {
self.xlc.add_output("Available themes:");
for t in theme::all_themes() {
let marker = if self.theme.name == t.name { " *" } else { " " };
self.xlc.add_output(&format!("{}{}", marker, t.name));
}
} else if let Some(t) = theme::find(&name) {
self.theme = t;
config::save_theme(t.name);
set_cursor_esc(t.cursor);
self.message = format!("Theme: {}", t.name);
self.xlc.add_output(&format!("Switched to theme: {}", t.name));
} else {
self.xlc.add_output(&format!("Unknown theme: {}. Use :theme to list.", name));
}
}
XlcCmd::BufDelete => {
self.close_current_tab();
self.xlc.add_output("Buffer closed");
}
XlcCmd::LspStart(cmd) => {
if let Some(ref path) = self.filename {
let root = path.parent().map(|p| p.display().to_string()).unwrap_or_default();
self.lsp.start(&cmd, &root, &path.display().to_string());
self.xlc.add_output(&format!("LSP started: {}", cmd));
}
}
XlcCmd::GotoLine(n) => {
self.goto_line(n);
self.xlc.add_output(&format!("Jumped to line {}", n));
}
XlcCmd::Problems => {
self.open_problems_palette();
}
XlcCmd::Substitute(raw) => {
if let Some(cmd) = substitute::parse_substitute(&raw) {
self.apply_substitute_cmd(cmd);
} else {
self.xlc.add_output("Invalid :s syntax. Use :s/pat/repl/g or :%s/pat/repl/g");
self.message = String::from("Invalid substitute");
}
}
XlcCmd::LspRename(name) => {
self.request_rename(&name);
}
XlcCmd::None => {
self.message = String::from("Unknown command. Try :help");
self.xlc.add_output("Try :help for available commands.");
}
}
}
fn open_in_place(&mut self, path: &str) {
self.open_new_tab(path);
}
fn move_file(&mut self, dest: &str) {
if let Some(ref path) = self.filename {
let dest_path = PathBuf::from(dest);
match fs::rename(path, &dest_path) {
Ok(_) => {
self.filename = Some(dest_path);
self.message = format!("Moved to: {}", dest);
self.xlc.add_output(&format!("Moved to: {}", dest));
}
Err(e) => {
self.xlc.add_output(&format!("Error moving: {}", e));
}
}
} else {
self.xlc.add_output("No file to move.");
}
}
pub fn recompute_search(&mut self, pattern: &str, jump: bool) {
self.collect_matches(pattern);
if self.search_matches.is_empty() {
self.search_current = 0;
return;
}
let from = self
.search_origin
.unwrap_or_else(|| self.buffer.cursor());
let idx = if self.search_forward {
self.search_matches
.iter()
.position(|p| p.row > from.row || (p.row == from.row && p.col >= from.col))
.unwrap_or(0)
} else {
self.search_matches
.iter()
.rposition(|p| p.row < from.row || (p.row == from.row && p.col <= from.col))
.unwrap_or(self.search_matches.len() - 1)
};
self.search_current = idx;
if jump {
let pos = self.search_matches[idx];
self.buffer.cursor = pos;
self.update_scroll();
}
}
fn collect_matches(&mut self, pattern: &str) {
self.search_matches.clear();
if pattern.is_empty() {
return;
}
let smart_case = !pattern.chars().any(|c| c.is_uppercase());
let pat_lower = if smart_case {
pattern.to_lowercase()
} else {
String::new()
};
for (row, line) in self.buffer.lines().iter().enumerate() {
if smart_case {
let line_chars: Vec<char> = line.chars().collect();
let pat_chars: Vec<char> = pat_lower.chars().collect();
if pat_chars.is_empty() {
continue;
}
let plen = pat_chars.len();
if line_chars.len() < plen {
continue;
}
let line_lower: Vec<char> = line_chars.iter().map(|c| c.to_lowercase().next().unwrap_or(*c)).collect();
let mut i = 0;
while i + plen <= line_lower.len() {
if line_lower[i..i + plen] == pat_chars[..] {
self.search_matches.push(Position::new(row, i));
i += 1; } else {
i += 1;
}
}
} else {
let mut search_from = 0usize;
while search_from <= line.len() {
if let Some(byte_rel) = line[search_from..].find(pattern) {
let byte_abs = search_from + byte_rel;
let col = line[..byte_abs].chars().count();
self.search_matches.push(Position::new(row, col));
search_from = byte_abs + pattern.len().max(1);
} else {
break;
}
}
}
}
}
pub fn perform_search(&mut self) {
if let Some(pat) = self.search_pattern.clone() {
self.recompute_search(&pat, true);
}
}
pub fn search_next(&mut self) {
if self.search_forward {
self.search_step(true);
} else {
self.search_step(false);
}
}
pub fn search_prev(&mut self) {
if self.search_forward {
self.search_step(false);
} else {
self.search_step(true);
}
}
fn search_step(&mut self, forward: bool) {
if let Some(pat) = self.search_pattern.clone() {
let cur = self.buffer.cursor();
self.collect_matches(&pat);
if self.search_matches.is_empty() {
self.message = format!("Pattern not found: {}", pat);
return;
}
let idx = if forward {
self.search_matches
.iter()
.position(|p| p.row > cur.row || (p.row == cur.row && p.col > cur.col))
.unwrap_or(0)
} else {
self.search_matches
.iter()
.rposition(|p| p.row < cur.row || (p.row == cur.row && p.col < cur.col))
.unwrap_or(self.search_matches.len() - 1)
};
self.search_current = idx;
let pos = self.search_matches[idx];
let wrapped = if forward {
idx == 0 && (pos.row < cur.row || (pos.row == cur.row && pos.col <= cur.col))
} else {
idx == self.search_matches.len() - 1
&& (pos.row > cur.row || (pos.row == cur.row && pos.col >= cur.col))
};
self.buffer.cursor = pos;
self.update_scroll();
let slash = if self.search_forward { '/' } else { '?' };
self.message = if wrapped {
if forward {
format!(
"search hit BOTTOM, continuing at TOP {}/{}",
idx + 1,
self.search_matches.len()
)
} else {
format!(
"search hit TOP, continuing at BOTTOM {}/{}",
idx + 1,
self.search_matches.len()
)
}
} else {
format!("{}{}/ {}/{}", slash, pat, idx + 1, self.search_matches.len())
};
} else {
self.message = String::from("No search pattern — press / or ? first");
}
}
pub fn search_word_under_cursor(&mut self) {
let word = self.word_under_cursor();
if word.is_empty() {
self.message = String::from("No word under cursor");
return;
}
self.push_jump();
self.search_pattern = Some(word.clone());
self.search_forward = true;
self.recompute_search(&word, true);
if self.search_matches.len() > 1 {
self.search_next();
} else if self.search_matches.is_empty() {
self.message = format!("Pattern not found: {}", word);
} else {
self.message = format!("/{}/ 1/1", word);
}
}
fn word_under_cursor(&self) -> String {
let line = self.buffer.line(self.buffer.cursor.row);
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
return String::new();
}
let mut col = self.buffer.cursor.col.min(chars.len().saturating_sub(1));
if col < chars.len() && !(chars[col].is_alphanumeric() || chars[col] == '_') {
if col > 0 && (chars[col - 1].is_alphanumeric() || chars[col - 1] == '_') {
col -= 1;
} else {
return String::new();
}
}
let mut start = col;
while start > 0 && (chars[start - 1].is_alphanumeric() || chars[start - 1] == '_') {
start -= 1;
}
let mut end = col;
while end < chars.len() && (chars[end].is_alphanumeric() || chars[end] == '_') {
end += 1;
}
chars[start..end].iter().collect()
}
pub fn save_file(&mut self) {
if let Some(path) = self.filename.clone() {
match fs::write(&path, self.buffer.text()) {
Ok(_) => {
self.modified = false;
if self.current_buffer < self.buffers.len() {
self.buffers[self.current_buffer].modified = false;
self.buffers[self.current_buffer].filename = Some(path.clone());
}
self.record_mtime();
self.refresh_git();
self.save_session();
self.message = format!("✓ Saved: {}", path.display());
self.xlc.add_output(&format!("✓ Saved: {}", path.display()));
self.fire_hook(crate::hooks::HookEvent::Save);
}
Err(e) => {
self.message = format!("✗ Error: {}", e);
self.xlc.add_output(&format!("✗ Error: {}", e));
}
}
} else {
self.message = String::from("No filename. Use :w <filename>");
self.xlc.add_output("No filename. Use: w <path>");
}
}
pub fn move_left(&mut self) {
self.buffer.move_left();
}
pub fn move_right(&mut self) {
self.buffer.move_right();
}
pub fn move_up(&mut self) {
self.buffer.move_up();
self.update_scroll();
}
pub fn move_down(&mut self) {
self.buffer.move_down();
self.update_scroll();
}
pub fn update_scroll(&mut self) {
let cursor_row = self.buffer.cursor.row;
let visible_height = self.viewport.height.max(1) as usize;
let text_width = self
.viewport
.width
.saturating_sub(5)
.max(1) as usize;
let wrap = self.wrap_lines;
let wrap_rows = |row: usize| -> usize {
if !wrap {
return 1;
}
let vis = Self::line_visual_width(&self.buffer, row);
if vis == 0 {
1
} else {
(vis + text_width - 1) / text_width
}
};
if cursor_row < self.scroll {
self.scroll = cursor_row;
}
let screen_col = self
.buffer
.buffer_col_to_screen_col(cursor_row, self.buffer.cursor.col);
let cursor_wrap = if wrap { screen_col / text_width } else { 0 };
if !wrap {
if screen_col < self.hscroll {
self.hscroll = screen_col;
} else if screen_col >= self.hscroll + text_width {
self.hscroll = screen_col + 1 - text_width;
}
}
let mut needed = cursor_wrap + 1;
for r in self.scroll..cursor_row {
needed = needed.saturating_add(wrap_rows(r));
}
while needed > visible_height && self.scroll < cursor_row {
needed = needed.saturating_sub(wrap_rows(self.scroll));
self.scroll += 1;
}
if cursor_row < self.scroll {
self.scroll = cursor_row;
}
if self.split.is_split() {
let p = self.split.focused_pane_mut();
p.scroll = self.scroll;
p.tab_index = self.current_buffer;
}
}
fn line_visual_width(buffer: &crate::buffer::Buffer, row: usize) -> usize {
let line = buffer.line(row);
let mut vis = 0usize;
for ch in line.chars() {
vis += if ch == '\t' {
4 - (vis % 4)
} else {
unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1)
};
}
vis
}
pub fn delete_line(&mut self) {
self.push_undo();
let row = self.buffer.cursor.row;
let deleted = self.buffer.delete_line();
self.store_yank(format!("{}\n", deleted), true);
if let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) {
self.dap.shift_breakpoints(&path, row, -1);
}
}
pub fn delete_word(&mut self) {
self.push_undo();
let deleted = self.buffer.delete_word();
self.store_yank(deleted, false);
}
pub fn paste(&mut self) {
self.paste_impl(false);
}
pub fn paste_before(&mut self) {
self.paste_impl(true);
}
fn paste_impl(&mut self, before: bool) {
let Some(val) = self.registers.load_for_put() else {
if let Some(text) = self.yank_buffer.clone() {
self.paste_text(&text, text.contains('\n'), before);
}
return;
};
self.paste_text(&val.text, val.linewise, before);
}
fn paste_text(&mut self, text: &str, linewise: bool, before: bool) {
if text.is_empty() {
return;
}
self.push_undo();
if linewise {
let lines: Vec<&str> = text.trim_end_matches('\n').split('\n').collect();
if before {
let row = self.buffer.cursor.row;
for (i, line) in lines.iter().enumerate() {
self.buffer.insert_line_at(row + i, line.to_string());
}
self.buffer.cursor.row = row;
self.buffer.cursor.col = 0;
} else {
for line in lines {
self.buffer.paste_line_after(line);
}
}
} else {
if !before && self.buffer.cursor.col < self.buffer.current_line_len() {
self.buffer.move_right();
}
let clean = text.replace('\r', "");
self.buffer.insert_str(&clean);
if !clean.is_empty() && !clean.ends_with('\n') {
self.buffer.move_left();
}
}
self.update_scroll();
self.message = String::from("Pasted");
}
pub fn yank_selection(&mut self) {
if let Some((start, end)) = self.selected_range() {
let mut lines: Vec<String> = Vec::new();
for row in start.row..=end.row {
let chars: Vec<char> = self.buffer.line(row).chars().collect();
let s = if row == start.row && row == end.row {
let to = (end.col + 1).min(chars.len());
let from = start.col.min(to);
chars[from..to].iter().collect()
} else if row == start.row {
let from = start.col.min(chars.len());
chars[from..].iter().collect()
} else if row == end.row {
let to = (end.col + 1).min(chars.len());
chars[..to].iter().collect()
} else {
chars.iter().collect()
};
lines.push(s);
}
let linewise = self.mode == Mode::VisualLine;
let text = lines.join("\n");
let label = self.registers.active_label();
self.store_yank(
if linewise {
format!("{}\n", text)
} else {
text
},
linewise,
);
self.enter_normal();
self.message = format!("Yanked → {}", label);
}
}
pub fn delete_selection(&mut self) {
if let Some((start, end)) = self.selected_range() {
self.push_undo();
let mut deleted_text = String::new();
if self.mode == Mode::VisualLine {
self.buffer.cursor.row = start.row;
let count = end.row - start.row + 1;
for _ in 0..count {
let line = self.buffer.delete_line();
if !deleted_text.is_empty() { deleted_text.push('\n'); }
deleted_text.push_str(&line);
}
self.store_yank(format!("{}\n", deleted_text), true);
self.enter_normal();
self.message = String::from("Deleted");
return;
}
if start.row == end.row {
let line = self.buffer.line(start.row);
let deleted: String = line.chars().skip(start.col).take(end.col.saturating_sub(start.col) + 1).collect();
let prefix: String = line.chars().take(start.col).collect();
let suffix: String = line.chars().skip(end.col + 1).collect();
self.buffer.set_line(start.row, prefix + &suffix);
deleted_text = deleted;
} else {
let first_chars: Vec<char> = self.buffer.line(start.row).chars().collect();
let last_chars: Vec<char> = self.buffer.line(end.row).chars().collect();
deleted_text.push_str(&first_chars[start.col.min(first_chars.len())..].iter().collect::<String>());
for row in (start.row + 1)..end.row {
deleted_text.push('\n');
deleted_text.push_str(self.buffer.line(row));
}
deleted_text.push('\n');
let last_end = (end.col + 1).min(last_chars.len());
deleted_text.push_str(&last_chars[..last_end].iter().collect::<String>());
let first_prefix: String = first_chars.iter().take(start.col).collect();
let last_suffix: String = last_chars.iter().skip(end.col + 1).collect();
self.buffer.cursor.row = end.row;
for _row in (start.row + 1..=end.row).rev() {
self.buffer.cursor.row = _row;
self.buffer.delete_line();
}
self.buffer.cursor.row = start.row;
self.buffer.set_line(start.row, first_prefix + &last_suffix);
}
self.store_yank(deleted_text, false);
self.buffer.cursor = Position::new(start.row, start.col);
self.buffer.clamp_col();
self.enter_normal();
self.message = String::from("Deleted");
}
}
pub fn record_mtime(&mut self) {
if let Some(ref path) = self.filename {
self.file_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
}
}
pub fn check_external_change(&mut self) {
self.check_active_file_external();
if self.debug && !self.lsp.diagnostics.is_empty() {
let rows: Vec<String> = self
.lsp
.diagnostics
.iter()
.map(|d| d.row.to_string())
.collect();
self.xlc.add_output(&format!("diag rows: {}", rows.join(",")));
}
}
fn check_active_file_external(&mut self) {
let Some(path) = self.filename.clone() else {
return;
};
let path_s = path.display().to_string();
let Ok(meta) = std::fs::metadata(&path) else {
return;
};
let Ok(mtime) = meta.modified() else {
return;
};
let Some(prev) = self.file_mtime else {
self.file_mtime = Some(mtime);
return;
};
if prev == mtime {
return;
}
let Ok(content) = std::fs::read_to_string(&path) else {
self.file_mtime = Some(mtime);
self.message = format!("⚠ File missing or unreadable: {path_s}");
return;
};
let had_local_edits = self.modified;
let cursor = self.buffer.cursor();
let scroll = self.scroll;
self.buffer = Buffer::from_string(&content);
self.buffer.cursor.row = cursor.row.min(self.buffer.line_count().saturating_sub(1));
self.buffer.cursor.col = cursor.col;
self.buffer.clamp_col();
self.scroll = scroll.min(self.buffer.line_count().saturating_sub(1));
self.modified = false;
self.file_mtime = Some(mtime);
self.undo_stack = UndoStack::new();
self.undo_stack.push(self.buffer.snapshot());
self.rebuild_folds();
self.refresh_git();
self.lsp_restart_for_current();
self.sync_lsp_document();
self.message = if had_local_edits {
"↻ Live reload (disk won — local unsaved edits discarded)".into()
} else {
"↻ Live reload".into()
};
}
pub fn save_state_to_tab(&mut self) {
if self.current_buffer < self.buffers.len() {
let tab = &mut self.buffers[self.current_buffer];
tab.buffer = self.buffer.clone();
tab.filename = self.filename.clone();
tab.scroll = self.scroll;
tab.modified = self.modified;
tab.undo_stack = self.undo_stack.clone();
tab.file_mtime = self.file_mtime;
}
}
pub fn restore_state_from_tab(&mut self) {
if let Some(tab) = self.buffers.get(self.current_buffer).cloned() {
self.buffer = tab.buffer;
self.filename = tab.filename;
self.scroll = tab.scroll;
self.modified = tab.modified;
self.undo_stack = tab.undo_stack;
self.file_mtime = tab.file_mtime;
}
}
pub fn open_new_tab(&mut self, path: &str) {
self.save_state_to_tab();
let pathbuf = PathBuf::from(path);
let abs_path = if pathbuf.is_absolute() {
pathbuf
} else {
env::current_dir().unwrap_or_default().join(&pathbuf)
};
for (i, tab) in self.buffers.iter().enumerate() {
if tab.filename.as_ref() == Some(&abs_path) {
self.current_buffer = i;
self.restore_state_from_tab();
self.lsp_restart_for_current();
self.refresh_git();
self.sync_focused_pane_tab();
self.message = format!("Switched to: {}", abs_path.display());
return;
}
}
let content = fs::read_to_string(&abs_path).unwrap_or_default();
let buffer = Buffer::from_string(&content);
let mtime = std::fs::metadata(&abs_path).ok().and_then(|m| m.modified().ok());
let mut undo = UndoStack::new();
undo.push(buffer.snapshot());
undo.attach_file(&abs_path, self.undo_caching, &content);
self.buffers.push(BufferTab {
buffer,
filename: Some(abs_path.clone()),
scroll: 0,
modified: false,
undo_stack: undo,
file_mtime: mtime,
});
self.current_buffer = self.buffers.len() - 1;
self.restore_state_from_tab();
let text = self.buffer.text();
self.lsp
.auto_start_with_text(&abs_path.display().to_string(), Some(&text));
self.lsp_synced_path = Some(abs_path.clone());
self.lsp_synced_hash = text_hash(&text);
self.refresh_git();
self.sync_focused_pane_tab();
self.message = format!("Opened: {}", abs_path.display());
self.fire_hook(crate::hooks::HookEvent::Open);
}
pub fn next_tab(&mut self) {
if self.buffers.len() < 2 {
return;
}
self.save_state_to_tab();
self.current_buffer = (self.current_buffer + 1) % self.buffers.len();
self.restore_state_from_tab();
self.lsp_restart_for_current();
self.refresh_git();
self.sync_focused_pane_tab();
}
pub fn prev_tab(&mut self) {
if self.buffers.len() < 2 {
return;
}
self.save_state_to_tab();
if self.current_buffer == 0 {
self.current_buffer = self.buffers.len() - 1;
} else {
self.current_buffer -= 1;
}
self.restore_state_from_tab();
self.lsp_restart_for_current();
self.refresh_git();
self.sync_focused_pane_tab();
}
pub fn lsp_restart_for_current(&mut self) {
if let Some(ref path) = self.filename {
let p = path.display().to_string();
let text = self.buffer.text();
self.lsp.auto_start_with_text(&p, Some(&text));
self.lsp_synced_path = Some(path.clone());
self.lsp_synced_hash = text_hash(&text);
} else {
self.lsp.diagnostics.clear();
self.lsp.semantic_tokens.clear();
self.lsp.inlay_hints.clear();
}
}
pub fn format_document(&mut self) {
let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
self.message = String::from("No file to format");
return;
};
if !self.lsp.server_running {
self.message = String::from("LSP not running");
return;
}
self.sync_lsp_document();
self.lsp.request_formatting(&path);
self.message = String::from("Formatting…");
}
pub fn request_code_actions(&mut self) {
let Some(path) = self.filename.as_ref().map(|p| p.display().to_string()) else {
self.message = String::from("No file");
return;
};
if !self.lsp.server_running {
self.message = String::from("LSP not running");
return;
}
self.sync_lsp_document();
let c = self.buffer.cursor();
self.lsp.request_code_action(&path, c.row, c.col);
self.message = String::from("Code actions…");
}
pub fn apply_file_edits(&mut self, edits: Vec<crate::lsp::FileEdit>) {
if edits.is_empty() {
self.message = String::from("No edits to apply");
return;
}
let n = edits.len();
let cur_path = self
.filename
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default();
for edit in edits {
let is_current = edit.path == cur_path
|| self
.filename
.as_ref()
.and_then(|p| p.canonicalize().ok())
.and_then(|p| {
std::path::Path::new(&edit.path)
.canonicalize()
.ok()
.map(|e| e == p)
})
.unwrap_or(false);
if is_current {
self.push_undo();
let row = self.buffer.cursor.row;
let col = self.buffer.cursor.col;
self.buffer = crate::buffer::Buffer::from_string(&edit.text);
self.buffer.cursor.row = row.min(self.buffer.line_count().saturating_sub(1));
self.buffer.cursor.col = col;
self.buffer.clamp_col();
self.modified = true;
self.update_scroll();
self.lsp_synced_hash = 0; self.sync_lsp_document();
} else {
if let Err(e) = std::fs::write(&edit.path, &edit.text) {
self.message = format!("Edit failed {}: {e}", edit.path);
continue;
}
for tab in &mut self.buffers {
if tab
.filename
.as_ref()
.map(|p| p.display().to_string() == edit.path)
.unwrap_or(false)
{
tab.buffer = crate::buffer::Buffer::from_string(&edit.text);
tab.modified = false;
}
}
}
}
self.message = format!("Applied {n} file edit(s)");
}
pub fn open_code_actions_palette(&mut self) {
let actions = std::mem::take(&mut self.lsp.pending_code_actions);
if actions.is_empty() {
return;
}
self.code_action_bank = actions;
let items: Vec<crate::palette::PaletteItem> = self
.code_action_bank
.iter()
.enumerate()
.map(|(i, a)| {
let detail = if !a.kind.is_empty() {
a.kind.clone()
} else if !a.edits.is_empty() {
format!("{} file(s)", a.edits.len())
} else {
a.command.clone().unwrap_or_default()
};
crate::palette::PaletteItem {
label: a.title.clone(),
detail,
action: crate::palette::PaletteAction::CodeAction(i),
}
})
.collect();
self.palette.open_code_actions(items);
self.mode = Mode::Palette;
self.message = format!("Code actions — {} items", self.code_action_bank.len());
}
pub fn apply_code_action(&mut self, index: usize) {
let Some(action) = self.code_action_bank.get(index).cloned() else {
return;
};
self.code_action_bank.clear();
if !action.edits.is_empty() {
self.apply_file_edits(action.edits);
return;
}
if let Some(cmd) = action.command {
self.lsp
.execute_command(&cmd, action.command_args_json.as_deref());
self.message = format!("Running {cmd}…");
return;
}
self.message = String::from("Code action had no edit/command");
}
pub fn close_current_tab(&mut self) {
self.save_state_to_tab();
if let Some(tab) = self.buffers.get_mut(self.current_buffer) {
if tab.filename.is_some() {
let text = tab.buffer.text();
tab.undo_stack.finish(self.undo_caching, &text);
}
}
if self.buffers.len() <= 1 {
self.lsp.shutdown();
self.buffer = Buffer::new();
self.filename = None;
self.scroll = 0;
self.modified = false;
self.undo_stack = UndoStack::new();
self.undo_stack.push(self.buffer.snapshot());
self.file_mtime = None;
self.buffers[0] = BufferTab {
buffer: self.buffer.clone(),
filename: None,
scroll: 0,
modified: false,
undo_stack: self.undo_stack.clone(),
file_mtime: None,
};
return;
}
self.buffers.remove(self.current_buffer);
if self.current_buffer >= self.buffers.len() {
self.current_buffer = self.buffers.len() - 1;
}
self.restore_state_from_tab();
self.lsp_restart_for_current();
self.refresh_git();
self.message = String::from("Buffer closed");
}
}
pub fn set_cursor_esc(color: ratatui::style::Color) {
use ratatui::style::Color;
if let Color::Rgb(r, g, b) = color {
print!("\x1b]12;rgb:{:02x}{:02x}/{:02x}{:02x}/{:02x}{:02x}\x1b\\", r, r, g, g, b, b);
let _ = std::io::stdout().flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app_with(text: &str) -> App {
let mut app = App::new();
app.buffer = Buffer::from_string(text);
app.viewport = EditorViewport {
x: 0,
y: 0,
width: 80,
height: 24,
text_x: 5,
text_y: 0,
};
app
}
#[test]
fn hscroll_follows_cursor_when_wrap_off() {
let long = "x".repeat(300);
let mut app = app_with(&long);
app.wrap_lines = false;
app.buffer.cursor.col = 200;
app.update_scroll();
assert_eq!(app.hscroll, 200 + 1 - 75);
app.buffer.cursor.col = 10;
app.update_scroll();
assert_eq!(app.hscroll, 10);
app.wrap_lines = true;
app.hscroll = 0;
app.buffer.cursor.col = 250;
app.update_scroll();
assert_eq!(app.hscroll, 0);
}
#[test]
fn split_panes_keep_independent_cursors() {
let text = vec!["word here"; 50].join("\n");
let mut app = app_with(&text);
app.buffer.cursor.row = 10;
app.buffer.cursor.col = 3;
app.split_vertical();
app.focus_other_pane();
app.buffer.cursor.row = 40;
app.buffer.cursor.col = 7;
app.focus_other_pane();
assert_eq!((app.buffer.cursor.row, app.buffer.cursor.col), (10, 3));
app.focus_other_pane();
assert_eq!((app.buffer.cursor.row, app.buffer.cursor.col), (40, 7));
}
#[test]
fn close_split_keeps_the_other_pane() {
let text = vec!["line"; 100].join("\n");
let mut app = app_with(&text);
app.buffer.cursor.row = 8;
app.split_vertical();
app.focus_pane(1);
app.split.panes[0].scroll = 5;
app.close_split();
assert!(!app.split.is_split());
assert_eq!(app.scroll, 5);
}
#[test]
fn search_finds_all_matches_char_safe() {
let mut app = app_with("hello\nhello world\nHELLO");
app.search_pattern = Some("hello".into());
app.collect_matches("hello");
assert_eq!(app.search_matches.len(), 3);
assert_eq!(app.search_matches[0], Position::new(0, 0));
assert_eq!(app.search_matches[1], Position::new(1, 0));
assert_eq!(app.search_matches[2], Position::new(2, 0));
}
#[test]
fn search_case_sensitive_when_pattern_has_upper() {
let mut app = app_with("hello\nHELLO\nHello");
app.collect_matches("Hello");
assert_eq!(app.search_matches.len(), 1);
assert_eq!(app.search_matches[0], Position::new(2, 0));
}
#[test]
fn search_utf8_char_indices() {
let mut app = app_with("안녕 hello 안녕");
app.collect_matches("안녕");
assert_eq!(app.search_matches.len(), 2);
assert_eq!(app.search_matches[0].col, 0);
assert_eq!(app.search_matches[1].col, 9);
}
#[test]
fn enter_search_cancel_restores_cursor() {
let mut app = app_with("abc\ndef\nghi");
app.buffer.cursor = Position::new(1, 1);
app.scroll = 0;
app.enter_search();
app.search_input = "ghi".into();
app.update_search_input();
assert_eq!(app.buffer.cursor.row, 2);
app.cancel_search();
assert_eq!(app.mode, Mode::Normal);
assert_eq!(app.buffer.cursor, Position::new(1, 1));
assert!(app.search_input.is_empty());
}
#[test]
fn commit_search_keeps_pattern_for_n() {
let mut app = app_with("foo bar foo");
app.enter_search();
app.search_input = "foo".into();
app.update_search_input();
app.commit_search();
assert_eq!(app.mode, Mode::Normal);
assert_eq!(app.search_pattern.as_deref(), Some("foo"));
assert_eq!(app.search_matches.len(), 2);
let first = app.buffer.cursor;
app.search_next();
assert_ne!(app.buffer.cursor, first);
}
#[test]
fn search_jumps_to_nearest_from_origin() {
let mut app = app_with("aa\nbb\naa\ncc\naa");
app.buffer.cursor = Position::new(1, 0); app.enter_search();
app.search_input = "aa".into();
app.update_search_input();
assert_eq!(app.buffer.cursor.row, 2);
}
#[test]
fn paste_before_charwise_cursor_on_last_char() {
let mut app = app_with("abc");
app.buffer.cursor = Position::new(0, 1); app.registers.select('z');
app.registers.store("XY".into(), false);
app.registers.select('z');
app.paste_before();
assert_eq!(app.buffer.line(0), "aXYbc");
assert_eq!(app.buffer.cursor, Position::new(0, 2));
}
#[test]
fn paste_after_charwise_cursor_on_last_char() {
let mut app = app_with("ab");
app.buffer.cursor = Position::new(0, 0); app.registers.select('z');
app.registers.store("XY".into(), false);
app.registers.select('z');
app.paste();
assert_eq!(app.buffer.line(0), "aXYb");
assert_eq!(app.buffer.cursor, Position::new(0, 2));
}
#[test]
fn close_tab_keeps_remaining_tab_state() {
let dir = std::env::temp_dir();
let f1 = dir.join("xei_test_close_a.rs");
let f2 = dir.join("xei_test_close_b.rs");
let _ = std::fs::write(&f1, "fn a() {}");
let _ = std::fs::write(&f2, "fn b() {}");
let mut app = App::open_file(f1.to_str().unwrap());
app.open_new_tab(f2.to_str().unwrap());
assert_eq!(app.buffers.len(), 2);
app.close_current_tab();
assert_eq!(app.buffers.len(), 1);
assert_eq!(app.filename.as_deref(), Some(f1.as_path()));
assert_eq!(app.buffer.line(0), "fn a() {}");
let _ = std::fs::remove_file(&f1);
let _ = std::fs::remove_file(&f2);
}
#[test]
fn xlc_wq_is_save_and_quit() {
let dir = std::env::temp_dir().join("xei_test_wq.txt");
let _ = std::fs::write(&dir, "data");
let mut app = App::open_file(dir.to_str().unwrap());
app.buffer.insert_char('!');
app.modified = true;
app.xlc.input = "wq".into();
app.execute_xlc();
assert!(!app.running);
assert!(!app.modified);
let _ = std::fs::remove_file(&dir);
}
}