Rust · 269428 bytes Raw Blame History
1 use anyhow::Result;
2 use arboard::Clipboard;
3 use crossterm::event::{self, Event, KeyEvent, MouseEvent};
4 use std::path::{Path, PathBuf};
5 use std::time::{Duration, Instant};
6
7 use crate::buffer::Buffer;
8 use crate::input::{Key, Modifiers, Mouse, Button};
9 use crate::lsp::{CompletionItem, Diagnostic, HoverInfo, Location, ServerManagerPanel};
10 use crate::render::{PaneBounds as RenderPaneBounds, PaneInfo, Screen, TabInfo};
11 use crate::terminal::TerminalPanel;
12 use crate::workspace::{PaneDirection, Tab, Workspace};
13
14 use super::{Cursor, Cursors, History, Operation, Position};
15
16 /// How long to wait after last edit before writing idle backup (seconds)
17 const BACKUP_IDLE_SECS: u64 = 30;
18
19 /// Which input field is active in find/replace
20 #[derive(Debug, Clone, Copy, PartialEq)]
21 enum FindReplaceField {
22 Find,
23 Replace,
24 }
25
26 /// Entry in the fortress file explorer
27 #[derive(Debug, Clone, PartialEq)]
28 struct FortressEntry {
29 /// File/directory name
30 name: String,
31 /// Full path
32 path: PathBuf,
33 /// Is this a directory?
34 is_dir: bool,
35 }
36
37 /// A command in the command palette
38 #[derive(Debug, Clone, PartialEq)]
39 struct PaletteCommand {
40 /// Display name (e.g., "Save File")
41 name: &'static str,
42 /// Keyboard shortcut (e.g., "Ctrl+S")
43 shortcut: &'static str,
44 /// Category for grouping (e.g., "File", "Edit")
45 category: &'static str,
46 /// Unique command identifier
47 id: &'static str,
48 /// Fuzzy match score (computed during filtering)
49 score: i32,
50 }
51
52 impl PaletteCommand {
53 const fn new(name: &'static str, shortcut: &'static str, category: &'static str, id: &'static str) -> Self {
54 Self { name, shortcut, category, id, score: 0 }
55 }
56 }
57
58 /// All available commands for the command palette
59 const ALL_COMMANDS: &[PaletteCommand] = &[
60 // File operations
61 PaletteCommand::new("Save File", "Ctrl+S", "File", "save"),
62 PaletteCommand::new("Save All", "", "File", "save-all"),
63 PaletteCommand::new("Open File Browser", "Ctrl+O", "File", "open"),
64 PaletteCommand::new("New Tab", "Alt+T", "File", "new-tab"),
65 PaletteCommand::new("Close Tab", "Alt+Q", "File", "close-tab"),
66 PaletteCommand::new("Next Tab", "Alt+.", "File", "next-tab"),
67 PaletteCommand::new("Previous Tab", "Alt+,", "File", "prev-tab"),
68 PaletteCommand::new("Quit", "Ctrl+Q", "File", "quit"),
69
70 // Edit operations
71 PaletteCommand::new("Undo", "Ctrl+Z", "Edit", "undo"),
72 PaletteCommand::new("Redo", "Ctrl+]", "Edit", "redo"),
73 PaletteCommand::new("Cut", "Ctrl+X", "Edit", "cut"),
74 PaletteCommand::new("Copy", "Ctrl+C", "Edit", "copy"),
75 PaletteCommand::new("Paste", "Ctrl+V", "Edit", "paste"),
76 PaletteCommand::new("Select All", "Ctrl+A", "Edit", "select-all"),
77 PaletteCommand::new("Select Line", "Ctrl+L", "Edit", "select-line"),
78 PaletteCommand::new("Select Word", "Ctrl+D", "Edit", "select-word"),
79 PaletteCommand::new("Toggle Line Comment", "Ctrl+/", "Edit", "toggle-comment"),
80 PaletteCommand::new("Join Lines", "Ctrl+J", "Edit", "join-lines"),
81 PaletteCommand::new("Duplicate Line", "Alt+Shift+Down", "Edit", "duplicate-line"),
82 PaletteCommand::new("Move Line Up", "Alt+Up", "Edit", "move-line-up"),
83 PaletteCommand::new("Move Line Down", "Alt+Down", "Edit", "move-line-down"),
84 PaletteCommand::new("Delete Line", "", "Edit", "delete-line"),
85 PaletteCommand::new("Indent", "Tab", "Edit", "indent"),
86 PaletteCommand::new("Outdent", "Shift+Tab", "Edit", "outdent"),
87 PaletteCommand::new("Transpose Characters", "Ctrl+T", "Edit", "transpose"),
88
89 // Search operations
90 PaletteCommand::new("Find", "Ctrl+F", "Search", "find"),
91 PaletteCommand::new("Find and Replace", "Ctrl+R", "Search", "replace"),
92 PaletteCommand::new("Find Next", "F3", "Search", "find-next"),
93 PaletteCommand::new("Find Previous", "Shift+F3", "Search", "find-prev"),
94 PaletteCommand::new("Search in Files", "F4", "Search", "search-files"),
95
96 // Navigation
97 PaletteCommand::new("Go to Line", "Ctrl+G", "Navigation", "goto-line"),
98 PaletteCommand::new("Go to Beginning of File", "Ctrl+Home", "Navigation", "goto-start"),
99 PaletteCommand::new("Go to End of File", "Ctrl+End", "Navigation", "goto-end"),
100 PaletteCommand::new("Go to Matching Bracket", "Ctrl+M", "Navigation", "goto-bracket"),
101 PaletteCommand::new("Page Up", "PageUp", "Navigation", "page-up"),
102 PaletteCommand::new("Page Down", "PageDown", "Navigation", "page-down"),
103
104 // Selection
105 PaletteCommand::new("Expand Selection to Brackets", "", "Selection", "select-brackets"),
106 PaletteCommand::new("Add Cursor Above", "Ctrl+Alt+Up", "Selection", "cursor-above"),
107 PaletteCommand::new("Add Cursor Below", "Ctrl+Alt+Down", "Selection", "cursor-below"),
108
109 // View / Panes
110 PaletteCommand::new("Split Pane Vertical", "Alt+V", "View", "split-vertical"),
111 PaletteCommand::new("Split Pane Horizontal", "Alt+S", "View", "split-horizontal"),
112 PaletteCommand::new("Close Pane", "Alt+Q", "View", "close-pane"),
113 PaletteCommand::new("Focus Next Pane", "Alt+N", "View", "next-pane"),
114 PaletteCommand::new("Focus Previous Pane", "Alt+P", "View", "prev-pane"),
115 PaletteCommand::new("Toggle File Explorer", "Ctrl+B", "View", "toggle-explorer"),
116
117 // LSP / Code Intelligence
118 PaletteCommand::new("Go to Definition", "F12", "LSP", "goto-definition"),
119 PaletteCommand::new("Find References", "Shift+F12", "LSP", "find-references"),
120 PaletteCommand::new("Rename Symbol", "F2", "LSP", "rename"),
121 PaletteCommand::new("Show Hover Info", "Ctrl+K Ctrl+I", "LSP", "hover"),
122 PaletteCommand::new("Trigger Completion", "Ctrl+Space", "LSP", "completion"),
123 PaletteCommand::new("LSP Server Manager", "Alt+M", "LSP", "server-manager"),
124
125 // Bracket/Quote operations
126 PaletteCommand::new("Jump to Bracket", "Alt+]", "Brackets", "jump-bracket"),
127 PaletteCommand::new("Cycle Bracket Type", "Alt+[", "Brackets", "cycle-brackets"),
128 PaletteCommand::new("Remove Surrounding", "Alt+Backspace", "Brackets", "remove-surrounding"),
129
130 // Help
131 PaletteCommand::new("Command Palette", "Ctrl+P", "Help", "command-palette"),
132 PaletteCommand::new("Help / Keybindings", "Shift+F1", "Help", "help"),
133 ];
134
135 /// A keybinding entry for the help menu
136 #[derive(Debug, Clone, PartialEq)]
137 struct HelpKeybind {
138 /// Keyboard shortcut (e.g., "Ctrl+S")
139 shortcut: &'static str,
140 /// Alternative shortcut (shown when "/" is held)
141 alt_shortcut: &'static str,
142 /// Description of what the keybind does
143 description: &'static str,
144 /// Category for grouping
145 category: &'static str,
146 }
147
148 impl HelpKeybind {
149 const fn new(shortcut: &'static str, description: &'static str, category: &'static str) -> Self {
150 Self { shortcut, alt_shortcut: "", description, category }
151 }
152
153 const fn with_alt(shortcut: &'static str, alt_shortcut: &'static str, description: &'static str, category: &'static str) -> Self {
154 Self { shortcut, alt_shortcut, description, category }
155 }
156 }
157
158 /// All keybindings for the help menu - comprehensive list
159 const ALL_KEYBINDS: &[HelpKeybind] = &[
160 // File Operations
161 HelpKeybind::new("Ctrl+S", "Save file", "File"),
162 HelpKeybind::new("Ctrl+O", "Open file browser (Fortress)", "File"),
163 HelpKeybind::new("Ctrl+Q", "Quit editor", "File"),
164 HelpKeybind::with_alt("Ctrl+B", "F3", "Toggle file explorer", "File"),
165
166 // Tabs
167 HelpKeybind::new("Alt+T", "New tab", "Tabs"),
168 HelpKeybind::new("Alt+Q", "Close tab/pane", "Tabs"),
169 HelpKeybind::new("Alt+.", "Next tab", "Tabs"),
170 HelpKeybind::new("Alt+,", "Previous tab", "Tabs"),
171 HelpKeybind::new("Alt+1-9", "Switch to tab 1-9", "Tabs"),
172
173 // Panes
174 HelpKeybind::new("Alt+V", "Split vertical", "Panes"),
175 HelpKeybind::new("Alt+S", "Split horizontal", "Panes"),
176 HelpKeybind::new("Alt+H/J/K/L", "Navigate panes (vim-style)", "Panes"),
177 HelpKeybind::new("Alt+N", "Next pane", "Panes"),
178 HelpKeybind::new("Alt+P", "Previous pane", "Panes"),
179
180 // Editing
181 HelpKeybind::new("Ctrl+Z", "Undo", "Edit"),
182 HelpKeybind::with_alt("Ctrl+]", "Ctrl+Shift+Z", "Redo", "Edit"),
183 HelpKeybind::new("Ctrl+C", "Copy", "Edit"),
184 HelpKeybind::new("Ctrl+X", "Cut", "Edit"),
185 HelpKeybind::new("Ctrl+V", "Paste", "Edit"),
186 HelpKeybind::new("Ctrl+J", "Join lines", "Edit"),
187 HelpKeybind::new("Ctrl+/", "Toggle line comment", "Edit"),
188 HelpKeybind::new("Ctrl+T", "Transpose characters", "Edit"),
189 HelpKeybind::new("Tab", "Indent", "Edit"),
190 HelpKeybind::new("Shift+Tab", "Outdent", "Edit"),
191 HelpKeybind::new("Backspace", "Delete backward", "Edit"),
192 HelpKeybind::new("Delete", "Delete forward", "Edit"),
193 HelpKeybind::new("Ctrl+W", "Delete word backward", "Edit"),
194 HelpKeybind::new("Alt+D", "Delete word forward", "Edit"),
195 HelpKeybind::new("Alt+Backspace", "Delete word backward", "Edit"),
196 HelpKeybind::new("Ctrl+K", "Kill to end of line", "Edit"),
197 HelpKeybind::new("Ctrl+U", "Kill to start of line", "Edit"),
198 HelpKeybind::new("Ctrl+Y", "Yank (paste from kill ring)", "Edit"),
199 HelpKeybind::new("Alt+Y", "Cycle yank stack", "Edit"),
200
201 // Line Operations
202 HelpKeybind::new("Alt+Up", "Move line up", "Lines"),
203 HelpKeybind::new("Alt+Down", "Move line down", "Lines"),
204 HelpKeybind::new("Alt+Shift+Up", "Duplicate line up", "Lines"),
205 HelpKeybind::new("Alt+Shift+Down", "Duplicate line down", "Lines"),
206
207 // Movement
208 HelpKeybind::new("Arrow keys", "Move cursor", "Movement"),
209 HelpKeybind::with_alt("Home", "Ctrl+A", "Go to line start (smart)", "Movement"),
210 HelpKeybind::with_alt("End", "Ctrl+E", "Go to line end", "Movement"),
211 HelpKeybind::with_alt("Alt+Left", "Alt+B", "Move word left", "Movement"),
212 HelpKeybind::with_alt("Alt+Right", "Alt+F", "Move word right", "Movement"),
213 HelpKeybind::new("PageUp", "Page up", "Movement"),
214 HelpKeybind::new("PageDown", "Page down", "Movement"),
215 HelpKeybind::with_alt("Ctrl+G", "F5", "Go to line", "Movement"),
216
217 // Selection
218 HelpKeybind::new("Shift+Arrow", "Extend selection", "Selection"),
219 HelpKeybind::new("Ctrl+L", "Select line", "Selection"),
220 HelpKeybind::new("Ctrl+D", "Select word / next occurrence", "Selection"),
221 HelpKeybind::new("Escape", "Clear selection / collapse cursors", "Selection"),
222 HelpKeybind::new("Ctrl+Alt+Up", "Add cursor above", "Selection"),
223 HelpKeybind::new("Ctrl+Alt+Down", "Add cursor below", "Selection"),
224
225 // Search
226 HelpKeybind::new("Ctrl+F", "Find", "Search"),
227 HelpKeybind::new("Ctrl+R", "Find and replace", "Search"),
228 HelpKeybind::new("F3", "Find next", "Search"),
229 HelpKeybind::new("Shift+F3", "Find previous", "Search"),
230 HelpKeybind::new("F4", "Search in files", "Search"),
231 HelpKeybind::new("Alt+I", "Toggle case sensitivity (in find)", "Search"),
232 HelpKeybind::new("Alt+X", "Toggle regex mode (in find)", "Search"),
233 HelpKeybind::new("Alt+Enter", "Replace all (in find)", "Search"),
234
235 // Brackets & Quotes
236 HelpKeybind::with_alt("Alt+[", "Alt+]", "Jump to matching bracket", "Brackets"),
237 HelpKeybind::new("Alt+'", "Cycle quote type (\"/'/`)", "Brackets"),
238 HelpKeybind::new("Alt+\"", "Remove surrounding quotes", "Brackets"),
239 HelpKeybind::new("Alt+(", "Cycle bracket type (/{/[)", "Brackets"),
240 HelpKeybind::new("Alt+)", "Remove surrounding brackets", "Brackets"),
241
242 // LSP / Code Intelligence
243 HelpKeybind::new("F1", "Show hover info", "LSP"),
244 HelpKeybind::new("F2", "Rename symbol", "LSP"),
245 HelpKeybind::new("F12", "Go to definition", "LSP"),
246 HelpKeybind::new("Shift+F12", "Find references", "LSP"),
247 HelpKeybind::new("Ctrl+N", "Trigger completion", "LSP"),
248 HelpKeybind::new("Alt+M", "LSP server manager", "LSP"),
249
250 // Help & Commands
251 HelpKeybind::new("Ctrl+P", "Command palette", "Help"),
252 HelpKeybind::new("Shift+F1", "Help / keybindings", "Help"),
253
254 // File Explorer (Fortress/Fuss mode)
255 HelpKeybind::new("Up/Down", "Navigate files", "Explorer"),
256 HelpKeybind::new("Enter", "Open file/directory", "Explorer"),
257 HelpKeybind::new("Right", "Expand directory", "Explorer"),
258 HelpKeybind::new("Left", "Collapse / go to parent", "Explorer"),
259 HelpKeybind::new("Space", "Toggle selection", "Explorer"),
260 HelpKeybind::new("a", "Add file", "Explorer"),
261 HelpKeybind::new("d", "Delete selected", "Explorer"),
262 HelpKeybind::new("m", "Move/rename selected", "Explorer"),
263 HelpKeybind::new("p", "Paste", "Explorer"),
264 HelpKeybind::new("u", "Undo last action", "Explorer"),
265 HelpKeybind::new("f", "Create folder", "Explorer"),
266 HelpKeybind::new("t", "Open in new tab", "Explorer"),
267 HelpKeybind::new("l", "Open in vertical split", "Explorer"),
268 HelpKeybind::new("Alt+G", "Git status", "Explorer"),
269 HelpKeybind::new("Alt+.", "Toggle hidden files", "Explorer"),
270 ];
271
272 /// Prompt state for quit confirmation
273 #[derive(Debug, Clone, PartialEq)]
274 enum PromptState {
275 /// No prompt active
276 None,
277 /// Quit prompt: Save/Discard/Cancel
278 QuitConfirm,
279 /// Close buffer prompt: Save/Discard/Cancel
280 CloseBufferConfirm,
281 /// Restore prompt: Restore/Discard
282 RestoreBackup,
283 /// Text input prompt (label, current input buffer)
284 TextInput { label: String, buffer: String, action: TextInputAction },
285 /// LSP rename modal with original name shown
286 RenameModal {
287 original_name: String,
288 new_name: String,
289 path: String,
290 line: u32,
291 col: u32,
292 },
293 /// LSP references panel
294 ReferencesPanel {
295 locations: Vec<Location>,
296 selected_index: usize,
297 /// Search query being typed (for filtering)
298 query: String,
299 },
300 /// Find/Replace dialog in status bar
301 FindReplace {
302 /// Search query
303 find_query: String,
304 /// Replacement text
305 replace_text: String,
306 /// Which field is active
307 active_field: FindReplaceField,
308 /// Case insensitive search
309 case_insensitive: bool,
310 /// Regex mode
311 regex_mode: bool,
312 },
313 /// Fortress mode - file explorer modal
314 Fortress {
315 /// Current directory being browsed
316 current_path: PathBuf,
317 /// Directory entries (directories first, then files)
318 entries: Vec<FortressEntry>,
319 /// Currently selected index
320 selected_index: usize,
321 /// Filter/search query
322 filter: String,
323 /// Scroll offset for long lists
324 scroll_offset: usize,
325 },
326 /// Multi-file search modal (F4)
327 FileSearch {
328 /// Search query
329 query: String,
330 /// Search results: (file_path, line_number, line_content)
331 results: Vec<FileSearchResult>,
332 /// Currently selected index
333 selected_index: usize,
334 /// Scroll offset for long lists
335 scroll_offset: usize,
336 /// Whether search is in progress
337 searching: bool,
338 },
339 /// Command palette (Ctrl+P)
340 CommandPalette {
341 /// Search/filter query (with > prefix)
342 query: String,
343 /// Filtered commands matching query
344 filtered: Vec<PaletteCommand>,
345 /// Currently selected index
346 selected_index: usize,
347 /// Scroll offset for long lists
348 scroll_offset: usize,
349 },
350 /// Help menu (Shift+F1)
351 HelpMenu {
352 /// Search/filter query
353 query: String,
354 /// Filtered keybinds matching query
355 filtered: Vec<HelpKeybind>,
356 /// Currently selected index
357 selected_index: usize,
358 /// Scroll offset for long lists
359 scroll_offset: usize,
360 /// Show alternative keybindings (toggled with "/")
361 show_alt: bool,
362 },
363 }
364
365 /// Which UI component currently has keyboard focus
366 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
367 pub enum Focus {
368 /// Main editor panes
369 Editor,
370 /// Integrated terminal panel
371 Terminal,
372 /// Fuss mode file tree sidebar
373 FussMode,
374 /// LSP server manager panel
375 ServerManager,
376 /// Active prompt/modal (prompts are exclusive by nature)
377 Prompt,
378 }
379
380 /// Hit test result for determining which region was clicked
381 #[derive(Debug, Clone, Copy)]
382 enum HitRegion {
383 /// Editor pane (with index for split panes)
384 Editor { pane_index: usize },
385 /// Terminal panel
386 Terminal,
387 /// Fuss mode sidebar
388 FussMode,
389 /// Server manager panel
390 ServerManager,
391 /// Prompt/modal area
392 Prompt,
393 /// Outside any interactive region
394 None,
395 }
396
397 /// A single result from multi-file search
398 #[derive(Debug, Clone, PartialEq)]
399 struct FileSearchResult {
400 /// Relative path to file
401 path: PathBuf,
402 /// Line number (1-indexed for display)
403 line_num: usize,
404 /// The matching line content (trimmed)
405 line_content: String,
406 }
407
408 /// Action to perform when text input is complete
409 #[derive(Debug, Clone, PartialEq)]
410 enum TextInputAction {
411 /// Commit with the entered message
412 GitCommit,
413 /// Create a git tag
414 GitTag,
415 /// Go to line (and optionally column)
416 GotoLine,
417 }
418
419 /// LSP UI state
420 #[derive(Debug, Default)]
421 struct LspState {
422 /// Current hover information to display
423 hover: Option<HoverInfo>,
424 /// Whether hover popup is visible
425 hover_visible: bool,
426 /// Original unfiltered completion list from LSP
427 completions_original: Vec<CompletionItem>,
428 /// Current filtered completion list
429 completions: Vec<CompletionItem>,
430 /// Selected completion index
431 completion_index: usize,
432 /// Whether completion popup is visible
433 completion_visible: bool,
434 /// Filter text typed while completion is open
435 completion_filter: String,
436 /// Cursor column when completion was opened (to track popup position)
437 completion_start_col: usize,
438 /// Current diagnostics for the active file
439 diagnostics: Vec<Diagnostic>,
440 /// Go-to-definition results (for multi-result navigation)
441 definition_locations: Vec<Location>,
442 /// Pending request IDs (to match responses)
443 pending_hover: Option<i64>,
444 pending_completion: Option<i64>,
445 pending_definition: Option<i64>,
446 pending_references: Option<i64>,
447 /// Last known buffer hash (to detect changes)
448 last_buffer_hash: Option<u64>,
449 /// Last file path that was synced to LSP
450 last_synced_path: Option<PathBuf>,
451 }
452
453 /// A search match position
454 #[derive(Debug, Clone, PartialEq)]
455 struct SearchMatch {
456 line: usize,
457 start_col: usize,
458 end_col: usize,
459 }
460
461 /// Search state for find/replace
462 #[derive(Debug, Default)]
463 struct SearchState {
464 /// All matches in the current buffer
465 matches: Vec<SearchMatch>,
466 /// Current match index (which one is "active")
467 current_match: usize,
468 /// Last search query (to detect changes)
469 last_query: String,
470 /// Last search settings
471 last_case_insensitive: bool,
472 last_regex: bool,
473 }
474
475 /// Cached bracket match result
476 #[derive(Debug, Default)]
477 struct BracketMatchCache {
478 /// Cursor position when cache was computed (line, col)
479 cursor_pos: Option<(usize, usize)>,
480 /// The cached match result
481 result: Option<(usize, usize)>,
482 /// Whether the cache is valid (invalidated on buffer changes)
483 valid: bool,
484 }
485
486 /// Source of ghost text suggestion
487 #[derive(Debug, Default, Clone, Copy, PartialEq)]
488 enum GhostTextSource {
489 #[default]
490 None,
491 Lsp,
492 CurrentBuffer,
493 OtherBuffer,
494 }
495
496 /// Ghost text inline autocomplete state
497 #[derive(Debug, Default)]
498 struct GhostTextState {
499 /// The ghost text suggestion (suffix to show/insert after cursor)
500 suggestion: Option<String>,
501 /// Position where suggestion applies (line, col)
502 position: Option<(usize, usize)>,
503 /// The prefix that was used to generate this suggestion
504 prefix: String,
505 /// Source of the current suggestion
506 source: GhostTextSource,
507 /// Cached word list from current buffer (content_hash, words)
508 buffer_words_cache: Option<(u64, Vec<String>)>,
509 /// Cached word list from all open buffers
510 all_buffer_words_cache: Vec<String>,
511 }
512
513 /// Main editor state
514 pub struct Editor {
515 /// The workspace (owns tabs, panes, fuss mode, and config)
516 workspace: Workspace,
517 /// Terminal screen
518 screen: Screen,
519 /// Is the editor running?
520 running: bool,
521 /// System clipboard (if available)
522 clipboard: Option<Clipboard>,
523 /// Fallback internal clipboard if system clipboard unavailable
524 internal_clipboard: String,
525 /// Message to display in status bar
526 message: Option<String>,
527 /// Escape key timeout in milliseconds (for Alt key detection)
528 escape_time: u64,
529 /// Current prompt state
530 prompt: PromptState,
531 /// Time of last edit (for idle backup timing), None if no pending backup
532 last_edit_time: Option<Instant>,
533 /// LSP-related UI state
534 lsp_state: LspState,
535 /// LSP server manager panel
536 server_manager: ServerManagerPanel,
537 /// Search state for find/replace
538 search_state: SearchState,
539 /// Cached bracket match for rendering
540 bracket_cache: BracketMatchCache,
541 /// Ghost text inline autocomplete state
542 ghost_text: GhostTextState,
543 /// Yank stack (kill ring) - separate from system clipboard
544 yank_stack: Vec<String>,
545 /// Current index in yank stack when cycling with Alt+Y
546 yank_index: Option<usize>,
547 /// Length of last yank (for replacing when cycling)
548 last_yank_len: usize,
549 /// Integrated terminal panel
550 terminal: TerminalPanel,
551 /// Terminal resize: dragging in progress
552 terminal_resize_dragging: bool,
553 /// Terminal resize: starting Y position of drag
554 terminal_resize_start_y: u16,
555 /// Terminal resize: starting height when drag began
556 terminal_resize_start_height: u16,
557 /// Current keyboard focus target
558 focus: Focus,
559 }
560
561 impl Editor {
562 pub fn new() -> Result<Self> {
563 // Default workspace is current directory
564 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
565 Self::new_with_workspace(workspace_root)
566 }
567
568 pub fn new_with_workspace(workspace_root: PathBuf) -> Result<Self> {
569 let mut screen = Screen::new()?;
570 screen.enter_raw_mode()?;
571 Self::new_with_screen_and_workspace(screen, workspace_root)
572 }
573
574 pub fn new_with_screen_and_workspace(screen: Screen, workspace_root: PathBuf) -> Result<Self> {
575 // Read escape timeout from environment, default to 5ms
576 // Similar to vim's ttimeoutlen or tmux's escape-time
577 let escape_time = std::env::var("FAC_ESCAPE_TIME")
578 .ok()
579 .and_then(|s| s.parse().ok())
580 .unwrap_or(5);
581
582 // Try to initialize system clipboard, fall back to internal if unavailable
583 let clipboard = Clipboard::new().ok();
584
585 let workspace = Workspace::open(workspace_root)?;
586
587 // Check if there are backups to restore
588 let has_backups = workspace.has_backups();
589
590 // Create terminal panel with screen dimensions
591 let terminal = TerminalPanel::new(screen.cols, screen.rows);
592
593 let mut editor = Self {
594 workspace,
595 screen,
596 running: true,
597 clipboard,
598 internal_clipboard: String::new(),
599 message: None,
600 escape_time,
601 prompt: PromptState::None,
602 last_edit_time: None, // No pending backup initially
603 lsp_state: LspState::default(),
604 server_manager: ServerManagerPanel::new(),
605 search_state: SearchState::default(),
606 bracket_cache: BracketMatchCache::default(),
607 ghost_text: GhostTextState::default(),
608 yank_stack: Vec::with_capacity(32),
609 yank_index: None,
610 last_yank_len: 0,
611 terminal,
612 terminal_resize_dragging: false,
613 terminal_resize_start_y: 0,
614 terminal_resize_start_height: 0,
615 focus: Focus::Editor,
616 };
617
618 // If there are backups, show restore prompt
619 if has_backups {
620 editor.prompt = PromptState::RestoreBackup;
621 editor.message = Some("Recovered unsaved changes. [R]estore / [D]iscard / [Esc]".to_string());
622 }
623
624 Ok(editor)
625 }
626
627 pub fn open(&mut self, path: &str) -> Result<()> {
628 let file_path = PathBuf::from(path);
629
630 // If this is the initial open (empty default tab), use workspace detection
631 let is_initial = self.workspace.tabs.len() == 1
632 && !self.workspace.tabs[0].is_modified()
633 && self.workspace.tabs[0].path().is_none();
634
635 if is_initial {
636 // Replace workspace with one detected from the file path
637 // This finds existing .fackr/ in parent dirs or uses file's parent
638 self.workspace = Workspace::open_with_file(&file_path)?;
639 } else {
640 // Just open the file in the current workspace
641 self.workspace.open_file(&file_path)?;
642 }
643
644 Ok(())
645 }
646
647 // ============================================================
648 // ACCESSOR METHODS - These provide access to current tab/pane/buffer
649 // ============================================================
650
651 /// Get the workspace root path
652 pub fn workspace_root(&self) -> PathBuf {
653 self.workspace.root.clone()
654 }
655
656 /// Get the current tab mutably
657 #[inline]
658 fn tab_mut(&mut self) -> &mut Tab {
659 self.workspace.active_tab_mut()
660 }
661
662 /// Get current buffer (read-only)
663 #[inline]
664 fn buffer(&self) -> &Buffer {
665 let tab = self.workspace.active_tab();
666 let pane = &tab.panes[tab.active_pane];
667 &tab.buffers[pane.buffer_idx].buffer
668 }
669
670 /// Get current buffer (mutable)
671 #[inline]
672 fn buffer_mut(&mut self) -> &mut Buffer {
673 let tab = self.workspace.active_tab_mut();
674 let pane_idx = tab.active_pane;
675 let buffer_idx = tab.panes[pane_idx].buffer_idx;
676 &mut tab.buffers[buffer_idx].buffer
677 }
678
679 /// Invalidate syntax highlight cache from a given line onward.
680 /// Call this when buffer content changes at or after the specified line.
681 #[inline]
682 fn invalidate_highlight_cache(&mut self, from_line: usize) {
683 let tab = self.workspace.active_tab_mut();
684 let pane_idx = tab.active_pane;
685 let buffer_idx = tab.panes[pane_idx].buffer_idx;
686 tab.buffers[buffer_idx].highlighter.invalidate_cache(from_line);
687 }
688
689 /// Invalidate the bracket match cache (call on buffer changes)
690 #[inline]
691 fn invalidate_bracket_cache(&mut self) {
692 self.bracket_cache.valid = false;
693 }
694
695 /// Get cached bracket match for current cursor position.
696 /// Computes and caches the result if needed.
697 fn get_bracket_match(&mut self) -> Option<(usize, usize)> {
698 let cursor = self.cursor();
699 let cursor_pos = (cursor.line, cursor.col);
700
701 // Check if cache is valid and for the same position
702 if self.bracket_cache.valid {
703 if let Some(cached_pos) = self.bracket_cache.cursor_pos {
704 if cached_pos == cursor_pos {
705 return self.bracket_cache.result;
706 }
707 }
708 }
709
710 // Cache miss - compute the bracket match
711 let result = self.buffer().find_matching_bracket(cursor_pos.0, cursor_pos.1);
712
713 // Update cache
714 self.bracket_cache.cursor_pos = Some(cursor_pos);
715 self.bracket_cache.result = result;
716 self.bracket_cache.valid = true;
717
718 result
719 }
720
721 /// Get current cursors (read-only)
722 #[inline]
723 fn cursors(&self) -> &Cursors {
724 let tab = self.workspace.active_tab();
725 &tab.panes[tab.active_pane].cursors
726 }
727
728 /// Get current cursors (mutable)
729 #[inline]
730 fn cursors_mut(&mut self) -> &mut Cursors {
731 let tab = self.workspace.active_tab_mut();
732 let pane_idx = tab.active_pane;
733 &mut tab.panes[pane_idx].cursors
734 }
735
736 /// Get current history (mutable)
737 #[inline]
738 fn history_mut(&mut self) -> &mut History {
739 let tab = self.workspace.active_tab_mut();
740 let pane_idx = tab.active_pane;
741 let buffer_idx = tab.panes[pane_idx].buffer_idx;
742 &mut tab.buffers[buffer_idx].history
743 }
744
745 /// Get current buffer entry (immutable)
746 #[inline]
747 fn buffer_entry(&self) -> &crate::workspace::BufferEntry {
748 let tab = self.workspace.active_tab();
749 let pane_idx = tab.active_pane;
750 let buffer_idx = tab.panes[pane_idx].buffer_idx;
751 &tab.buffers[buffer_idx]
752 }
753
754 /// Get current buffer entry (mutable)
755 #[inline]
756 fn buffer_entry_mut(&mut self) -> &mut crate::workspace::BufferEntry {
757 let tab = self.workspace.active_tab_mut();
758 let pane_idx = tab.active_pane;
759 let buffer_idx = tab.panes[pane_idx].buffer_idx;
760 &mut tab.buffers[buffer_idx]
761 }
762
763 /// Get current viewport line
764 #[inline]
765 fn viewport_line(&self) -> usize {
766 let tab = self.workspace.active_tab();
767 tab.panes[tab.active_pane].viewport_line
768 }
769
770 /// Set current viewport line
771 #[inline]
772 fn set_viewport_line(&mut self, line: usize) {
773 let tab = self.workspace.active_tab_mut();
774 let pane_idx = tab.active_pane;
775 tab.panes[pane_idx].viewport_line = line;
776 }
777
778 /// Get current viewport column (horizontal scroll offset)
779 #[inline]
780 fn viewport_col(&self) -> usize {
781 let tab = self.workspace.active_tab();
782 tab.panes[tab.active_pane].viewport_col
783 }
784
785 /// Set current viewport column (horizontal scroll offset)
786 #[inline]
787 fn set_viewport_col(&mut self, col: usize) {
788 let tab = self.workspace.active_tab_mut();
789 let pane_idx = tab.active_pane;
790 tab.panes[pane_idx].viewport_col = col;
791 }
792
793 /// Get current filename
794 #[inline]
795 fn filename(&self) -> Option<PathBuf> {
796 let tab = self.workspace.active_tab();
797 let pane = &tab.panes[tab.active_pane];
798 tab.buffers[pane.buffer_idx].path.clone()
799 }
800
801 pub fn run(&mut self) -> Result<()> {
802 // Initial render
803 self.screen.refresh_size()?;
804 self.render()?;
805
806 while self.running {
807 // Track whether we need to re-render
808 let mut needs_render = false;
809
810 // Poll with a short timeout to allow LSP processing
811 // This balances responsiveness with CPU usage
812 if event::poll(Duration::from_millis(50))? {
813 match event::read()? {
814 Event::Key(key_event) => self.process_key(key_event)?,
815 Event::Mouse(mouse_event) => self.process_mouse(mouse_event)?,
816 Event::Resize(cols, rows) => {
817 self.screen.cols = cols;
818 self.screen.rows = rows;
819 self.terminal.update_screen_size(cols, rows);
820 }
821 _ => {}
822 }
823 needs_render = true;
824
825 // Process any additional queued events before rendering
826 while event::poll(Duration::from_millis(0))? {
827 match event::read()? {
828 Event::Key(key_event) => self.process_key(key_event)?,
829 Event::Mouse(mouse_event) => self.process_mouse(mouse_event)?,
830 Event::Resize(cols, rows) => {
831 self.screen.cols = cols;
832 self.screen.rows = rows;
833 self.terminal.update_screen_size(cols, rows);
834 }
835 _ => {}
836 }
837 }
838 }
839
840 // Poll terminal for output (only render if data received)
841 if self.terminal.visible && self.terminal.poll() {
842 needs_render = true;
843 }
844
845 // Process LSP messages from language servers
846 if self.process_lsp_messages() {
847 needs_render = true;
848 }
849
850 // Poll for completed server installations
851 if self.server_manager.poll_installs() {
852 needs_render = true;
853 }
854
855 // Check if it's time for idle backup
856 self.maybe_idle_backup();
857
858 // Only render if something changed
859 if needs_render {
860 self.screen.refresh_size()?;
861 self.render()?;
862 }
863 }
864
865 // Save workspace state before exiting
866 if let Err(e) = self.workspace.save() {
867 eprintln!("Warning: Failed to save workspace state: {}", e);
868 }
869
870 self.screen.leave_raw_mode()?;
871 Ok(())
872 }
873
874 /// Write idle backups if enough time has passed since last edit
875 fn maybe_idle_backup(&mut self) {
876 if let Some(last_edit) = self.last_edit_time {
877 if last_edit.elapsed() >= Duration::from_secs(BACKUP_IDLE_SECS) {
878 if self.workspace.has_unsaved_changes() {
879 let _ = self.workspace.backup_all_modified();
880 // Mark all modified buffers as backed up
881 for tab in &mut self.workspace.tabs {
882 for buffer_entry in &mut tab.buffers {
883 if buffer_entry.is_modified() {
884 buffer_entry.backed_up = true;
885 }
886 }
887 }
888 }
889 self.last_edit_time = None; // Reset until next edit
890 }
891 }
892 }
893
894 /// Called after key handling - triggers backup if buffer was modified
895 fn on_buffer_edit(&mut self) {
896 // Check buffer state
897 let (is_modified, needs_first_backup) = {
898 let buffer_entry = self.buffer_entry_mut();
899 (buffer_entry.is_modified(), !buffer_entry.backed_up && buffer_entry.is_modified())
900 };
901
902 // Update edit time if buffer has unsaved changes (for idle backup)
903 if is_modified {
904 self.last_edit_time = Some(Instant::now());
905 }
906
907 // First edit since save/load - backup immediately
908 if needs_first_backup {
909 let backup_info: Option<(PathBuf, String)> = {
910 let buffer_entry = self.buffer_entry();
911 buffer_entry.path.as_ref().map(|path| {
912 let full_path = if buffer_entry.is_orphan {
913 path.clone()
914 } else {
915 self.workspace.root.join(path)
916 };
917 let content = buffer_entry.buffer.contents();
918 (full_path, content)
919 })
920 };
921
922 if let Some((full_path, content)) = backup_info {
923 let _ = self.workspace.write_backup(&full_path, &content);
924 self.buffer_entry_mut().backed_up = true;
925 }
926 }
927 }
928
929 /// Process LSP messages. Returns true if any messages were processed.
930 fn process_lsp_messages(&mut self) -> bool {
931 use crate::lsp::LspResponse;
932
933 // Process pending messages from language servers
934 self.workspace.lsp.process_messages();
935
936 let mut had_response = false;
937
938 // Handle any responses that came in
939 while let Some(response) = self.workspace.lsp.poll_response() {
940 had_response = true;
941 match response {
942 LspResponse::Completions(id, items) => {
943 if self.lsp_state.pending_completion == Some(id) {
944 self.lsp_state.completions_original = items.clone();
945 self.lsp_state.completions = items;
946 self.lsp_state.completion_index = 0;
947 self.lsp_state.completion_visible = !self.lsp_state.completions.is_empty();
948 self.lsp_state.completion_filter.clear();
949 self.lsp_state.completion_start_col = self.cursor().col;
950 self.lsp_state.pending_completion = None;
951 }
952 }
953 LspResponse::Hover(id, info) => {
954 if self.lsp_state.pending_hover == Some(id) {
955 self.lsp_state.hover = info;
956 self.lsp_state.hover_visible = self.lsp_state.hover.is_some();
957 self.lsp_state.pending_hover = None;
958 if self.lsp_state.hover.is_none() {
959 self.message = Some("No hover info available".to_string());
960 }
961 }
962 }
963 LspResponse::Definition(id, locations) => {
964 if self.lsp_state.pending_definition == Some(id) {
965 self.lsp_state.definition_locations = locations.clone();
966 self.lsp_state.pending_definition = None;
967 // Jump to first definition
968 if let Some(loc) = locations.first() {
969 self.goto_location(loc);
970 } else {
971 self.message = Some("No definition found".to_string());
972 }
973 }
974 }
975 LspResponse::References(id, locations) => {
976 if self.lsp_state.pending_references == Some(id) {
977 self.lsp_state.pending_references = None;
978 if locations.is_empty() {
979 self.message = Some("No references found".to_string());
980 } else if locations.len() == 1 {
981 // Single reference - just go there
982 self.goto_location(&locations[0]);
983 } else {
984 // Multiple references - show the references panel
985 self.prompt = PromptState::ReferencesPanel {
986 locations,
987 selected_index: 0,
988 query: String::new(),
989 };
990 self.message = None;
991 }
992 }
993 }
994 LspResponse::Symbols(id, symbols) => {
995 // TODO: Show symbols panel
996 let _ = (id, symbols);
997 }
998 LspResponse::Formatting(id, edits) => {
999 // Apply formatting edits
1000 let _ = (id, edits);
1001 // TODO: Apply text edits to buffer
1002 }
1003 LspResponse::Rename(_id, workspace_edit) => {
1004 // Apply rename edits across all affected files
1005 let mut total_edits = 0;
1006 let mut files_changed = 0;
1007
1008 for (uri, edits) in &workspace_edit.changes {
1009 if let Some(path_str) = crate::lsp::uri_to_path(uri) {
1010 // Check if we have this file open
1011 let path = std::path::PathBuf::from(&path_str);
1012 if let Some(tab_idx) = self.workspace.find_tab_by_path(&path) {
1013 // Apply edits to the open buffer (in reverse order to preserve positions)
1014 let mut sorted_edits = edits.clone();
1015 sorted_edits.sort_by(|a, b| {
1016 // Sort by start position, descending
1017 b.range.start.line.cmp(&a.range.start.line)
1018 .then(b.range.start.character.cmp(&a.range.start.character))
1019 });
1020
1021 for edit in sorted_edits {
1022 self.workspace.apply_text_edit(tab_idx, &edit);
1023 total_edits += 1;
1024 }
1025 files_changed += 1;
1026 } else {
1027 // File not open - would need to open, edit, and save
1028 self.message = Some(format!("Note: {} not open, skipped", path_str));
1029 }
1030 }
1031 }
1032
1033 if total_edits > 0 {
1034 self.message = Some(format!("Renamed: {} edits in {} file(s)", total_edits, files_changed));
1035 } else {
1036 self.message = Some("No rename edits to apply".to_string());
1037 }
1038 }
1039 LspResponse::CodeActions(id, actions) => {
1040 // TODO: Show code actions menu
1041 let _ = (id, actions);
1042 }
1043 LspResponse::Error(id, message) => {
1044 // Clear any pending state for this request
1045 if self.lsp_state.pending_completion == Some(id) {
1046 self.lsp_state.pending_completion = None;
1047 }
1048 if self.lsp_state.pending_hover == Some(id) {
1049 self.lsp_state.pending_hover = None;
1050 }
1051 if self.lsp_state.pending_definition == Some(id) {
1052 self.lsp_state.pending_definition = None;
1053 }
1054 if self.lsp_state.pending_references == Some(id) {
1055 self.lsp_state.pending_references = None;
1056 }
1057 // Optionally show error
1058 if !message.is_empty() {
1059 self.message = Some(format!("LSP: {}", message));
1060 }
1061 }
1062 }
1063 }
1064
1065 // Update diagnostics for current file (need full path to match LSP URIs)
1066 if let Some(path) = self.current_file_path() {
1067 let path_str = path.to_string_lossy();
1068 self.lsp_state.diagnostics = self.workspace.lsp.get_diagnostics(&path_str);
1069 }
1070
1071 // Sync document changes to LSP if buffer has changed
1072 self.sync_document_to_lsp();
1073
1074 had_response
1075 }
1076
1077 /// Sync document changes to LSP server
1078 fn sync_document_to_lsp(&mut self) {
1079 let current_path = self.filename();
1080 let current_hash = self.buffer_mut().content_hash();
1081
1082 // Check if we switched files
1083 let file_changed = current_path != self.lsp_state.last_synced_path;
1084
1085 // Check if buffer content changed
1086 let content_changed = self.lsp_state.last_buffer_hash != Some(current_hash);
1087
1088 if file_changed {
1089 // Close the old document if we had one open
1090 if let Some(ref old_path) = self.lsp_state.last_synced_path {
1091 let old_path_str = old_path.to_string_lossy();
1092 let _ = self.workspace.lsp.close_document(&old_path_str);
1093 }
1094
1095 // Open the new document
1096 if let Some(ref path) = current_path {
1097 let tab = self.workspace.active_tab();
1098 let pane = &tab.panes[tab.active_pane];
1099 let buffer_entry = &tab.buffers[pane.buffer_idx];
1100
1101 let full_path = if buffer_entry.is_orphan {
1102 path.clone()
1103 } else {
1104 self.workspace.root.join(path)
1105 };
1106 let path_str = full_path.to_string_lossy();
1107 let content = self.buffer().contents();
1108 let _ = self.workspace.lsp.open_document(&path_str, &content);
1109 }
1110
1111 self.lsp_state.last_synced_path = current_path;
1112 self.lsp_state.last_buffer_hash = Some(current_hash);
1113 } else if content_changed {
1114 // Content changed - send didChange notification
1115 if let Some(ref path) = current_path {
1116 let tab = self.workspace.active_tab();
1117 let pane = &tab.panes[tab.active_pane];
1118 let buffer_entry = &tab.buffers[pane.buffer_idx];
1119
1120 let full_path = if buffer_entry.is_orphan {
1121 path.clone()
1122 } else {
1123 self.workspace.root.join(path)
1124 };
1125 let path_str = full_path.to_string_lossy();
1126 let content = self.buffer().contents();
1127 let _ = self.workspace.lsp.document_changed(&path_str, &content);
1128 }
1129
1130 self.lsp_state.last_buffer_hash = Some(current_hash);
1131 }
1132 }
1133
1134 /// Navigate to an LSP location
1135 fn goto_location(&mut self, location: &Location) {
1136 use crate::lsp::uri_to_path;
1137
1138 if let Some(path) = uri_to_path(&location.uri) {
1139 let path_buf = PathBuf::from(&path);
1140 // Open the file if not already open
1141 if let Err(e) = self.workspace.open_file(&path_buf) {
1142 self.message = Some(format!("Failed to open {}: {}", path, e));
1143 return;
1144 }
1145
1146 // Move cursor to the location
1147 let line = location.range.start.line as usize;
1148 let col = location.range.start.character as usize;
1149
1150 self.cursors_mut().collapse_to_primary();
1151 self.cursor_mut().line = line.min(self.buffer().line_count().saturating_sub(1));
1152 self.cursor_mut().col = col.min(self.buffer().line_len(self.cursor().line));
1153 self.cursor_mut().desired_col = self.cursor().col;
1154 self.cursor_mut().clear_selection();
1155 self.scroll_to_cursor();
1156 }
1157 }
1158
1159 /// Get the full path to the current file
1160 fn current_file_path(&self) -> Option<PathBuf> {
1161 let tab = self.workspace.active_tab();
1162 let pane = &tab.panes[tab.active_pane];
1163 let buffer_entry = &tab.buffers[pane.buffer_idx];
1164
1165 buffer_entry.path.as_ref().map(|p| {
1166 if buffer_entry.is_orphan {
1167 p.clone()
1168 } else {
1169 self.workspace.root.join(p)
1170 }
1171 })
1172 }
1173
1174 /// LSP: Go to definition
1175 fn lsp_goto_definition(&mut self) {
1176 if let Some(path) = self.current_file_path() {
1177 let path_str = path.to_string_lossy().to_string();
1178 let line = self.cursor().line as u32;
1179 let col = self.cursor().col as u32;
1180
1181 match self.workspace.lsp.request_definition(&path_str, line, col) {
1182 Ok(id) => {
1183 self.lsp_state.pending_definition = Some(id);
1184 self.message = Some("Finding definition...".to_string());
1185 }
1186 Err(e) => {
1187 self.message = Some(format!("LSP error: {}", e));
1188 }
1189 }
1190 } else {
1191 self.message = Some("No file open".to_string());
1192 }
1193 }
1194
1195 /// LSP: Find references
1196 fn lsp_find_references(&mut self) {
1197 if let Some(path) = self.current_file_path() {
1198 let path_str = path.to_string_lossy().to_string();
1199 let line = self.cursor().line as u32;
1200 let col = self.cursor().col as u32;
1201
1202 match self.workspace.lsp.request_references(&path_str, line, col, true) {
1203 Ok(id) => {
1204 self.lsp_state.pending_references = Some(id);
1205 self.message = Some("Finding references...".to_string());
1206 }
1207 Err(e) => {
1208 self.message = Some(format!("LSP error: {}", e));
1209 }
1210 }
1211 } else {
1212 self.message = Some("No file open".to_string());
1213 }
1214 }
1215
1216 /// LSP: Show hover information
1217 fn lsp_hover(&mut self) {
1218 if let Some(path) = self.current_file_path() {
1219 let path_str = path.to_string_lossy().to_string();
1220 let line = self.cursor().line as u32;
1221 let col = self.cursor().col as u32;
1222
1223 match self.workspace.lsp.request_hover(&path_str, line, col) {
1224 Ok(id) => {
1225 self.lsp_state.pending_hover = Some(id);
1226 self.message = Some("Loading hover info...".to_string());
1227 }
1228 Err(e) => {
1229 self.message = Some(format!("LSP error: {}", e));
1230 }
1231 }
1232 } else {
1233 self.message = Some("No file open".to_string());
1234 }
1235 }
1236
1237 /// LSP: Trigger completion
1238 fn lsp_complete(&mut self) {
1239 if let Some(path) = self.current_file_path() {
1240 let path_str = path.to_string_lossy().to_string();
1241 let line = self.cursor().line as u32;
1242 let col = self.cursor().col as u32;
1243
1244 match self.workspace.lsp.request_completions(&path_str, line, col) {
1245 Ok(id) => {
1246 self.lsp_state.pending_completion = Some(id);
1247 self.message = Some("Loading completions...".to_string());
1248 }
1249 Err(e) => {
1250 self.message = Some(format!("LSP error: {}", e));
1251 }
1252 }
1253 } else {
1254 self.message = Some("No file open".to_string());
1255 }
1256 }
1257
1258 /// Toggle the LSP server manager panel
1259 fn toggle_server_manager(&mut self) {
1260 if self.server_manager.visible {
1261 self.server_manager.hide();
1262 self.return_focus();
1263 } else {
1264 self.server_manager.show();
1265 self.focus = Focus::ServerManager;
1266 }
1267 }
1268
1269 /// Handle key input when server manager panel is visible
1270 fn handle_server_manager_key(&mut self, key: Key, mods: Modifiers) -> Result<()> {
1271 let max_visible = 10; // Should match screen.rs
1272
1273 // Alt+M toggles the panel closed
1274 if key == Key::Char('m') && mods.alt {
1275 self.server_manager.hide();
1276 self.return_focus();
1277 return Ok(());
1278 }
1279
1280 // Handle confirm mode
1281 if self.server_manager.confirm_mode {
1282 match key {
1283 Key::Char('y') | Key::Char('Y') => {
1284 // Start install in background thread (non-blocking)
1285 self.server_manager.start_install();
1286 }
1287 Key::Char('n') | Key::Char('N') | Key::Escape => {
1288 self.server_manager.cancel_confirm();
1289 }
1290 _ => {}
1291 }
1292 return Ok(());
1293 }
1294
1295 // Handle manual info mode
1296 if self.server_manager.manual_info_mode {
1297 match key {
1298 Key::Char('c') | Key::Char('C') => {
1299 // Copy install instructions to clipboard
1300 if let Some(text) = self.server_manager.get_manual_install_text() {
1301 if let Some(ref mut clip) = self.clipboard {
1302 if clip.set_text(&text).is_ok() {
1303 self.server_manager.mark_copied();
1304 } else {
1305 self.server_manager.status_message = Some("Failed to copy".to_string());
1306 }
1307 } else {
1308 // Fall back to internal clipboard
1309 self.internal_clipboard = text;
1310 self.server_manager.mark_copied();
1311 }
1312 }
1313 }
1314 Key::Escape | Key::Char('q') => {
1315 self.server_manager.cancel_confirm();
1316 }
1317 _ => {}
1318 }
1319 return Ok(());
1320 }
1321
1322 // Normal panel navigation
1323 match key {
1324 Key::Up | Key::Char('k') => {
1325 self.server_manager.move_up();
1326 }
1327 Key::Down | Key::Char('j') => {
1328 self.server_manager.move_down(max_visible);
1329 }
1330 Key::Enter => {
1331 self.server_manager.enter_confirm_mode();
1332 }
1333 Key::Char('r') | Key::Char('R') => {
1334 self.server_manager.refresh();
1335 }
1336 Key::Escape | Key::Char('q') => {
1337 self.server_manager.hide();
1338 }
1339 _ => {}
1340 }
1341
1342 Ok(())
1343 }
1344
1345 /// LSP: Rename symbol - opens prompt for new name
1346 fn lsp_rename(&mut self) {
1347 if let Some(path) = self.current_file_path() {
1348 let path_str = path.to_string_lossy().to_string();
1349 let line = self.cursor().line as u32;
1350 let col = self.cursor().col as u32;
1351
1352 // Get the word under cursor to show in prompt
1353 let buffer = self.buffer();
1354 let cursor = self.cursor();
1355 let current_word = if let Some(line_slice) = buffer.line(cursor.line) {
1356 let line_text: String = line_slice.chars().collect();
1357 let mut start = cursor.col;
1358 let mut end = cursor.col;
1359
1360 // Find word boundaries
1361 while start > 0 {
1362 let ch = line_text.chars().nth(start - 1).unwrap_or(' ');
1363 if ch.is_alphanumeric() || ch == '_' {
1364 start -= 1;
1365 } else {
1366 break;
1367 }
1368 }
1369 while end < line_text.len() {
1370 let ch = line_text.chars().nth(end).unwrap_or(' ');
1371 if ch.is_alphanumeric() || ch == '_' {
1372 end += 1;
1373 } else {
1374 break;
1375 }
1376 }
1377 line_text[start..end].to_string()
1378 } else {
1379 String::new()
1380 };
1381
1382 if current_word.is_empty() {
1383 self.message = Some("No symbol under cursor".to_string());
1384 return;
1385 }
1386
1387 self.prompt = PromptState::RenameModal {
1388 original_name: current_word,
1389 new_name: String::new(),
1390 path: path_str,
1391 line,
1392 col,
1393 };
1394 } else {
1395 self.message = Some("No file open".to_string());
1396 }
1397 }
1398
1399 /// Accept the currently selected completion and insert it
1400 fn accept_completion(&mut self) {
1401 if self.lsp_state.completions.is_empty() {
1402 return;
1403 }
1404
1405 let completion = self.lsp_state.completions[self.lsp_state.completion_index].clone();
1406
1407 // Determine the text to insert
1408 let insert_text = if let Some(ref text_edit) = completion.text_edit {
1409 // Use text edit if provided (includes range to replace)
1410 // For now, just use the new text - proper range replacement would be more complex
1411 text_edit.new_text.clone()
1412 } else if let Some(ref insert) = completion.insert_text {
1413 insert.clone()
1414 } else {
1415 completion.label.clone()
1416 };
1417
1418 // Find the start of the word being completed (walk back from cursor)
1419 let buffer = self.buffer();
1420 let cursor = self.cursor();
1421 let line_idx = cursor.line;
1422 let cursor_col = cursor.col;
1423 let mut word_start = cursor_col;
1424
1425 // Walk back to find word start (alphanumeric or underscore)
1426 if let Some(line_slice) = buffer.line(line_idx) {
1427 let line_text: String = line_slice.chars().collect();
1428 while word_start > 0 {
1429 let prev_char = line_text.chars().nth(word_start - 1).unwrap_or(' ');
1430 if prev_char.is_alphanumeric() || prev_char == '_' {
1431 word_start -= 1;
1432 } else {
1433 break;
1434 }
1435 }
1436 }
1437
1438 // Delete the partial word and insert completion
1439 if word_start < cursor_col {
1440 // Select from word start to cursor
1441 let cursor = self.cursor_mut();
1442 cursor.anchor_line = cursor.line;
1443 cursor.anchor_col = word_start;
1444 cursor.selecting = true;
1445 }
1446
1447 // Insert the completion text (this will replace selection if any)
1448 for ch in insert_text.chars() {
1449 self.insert_char(ch);
1450 }
1451
1452 // Clear completion state
1453 self.dismiss_completion();
1454 }
1455
1456 /// Dismiss the completion popup
1457 fn dismiss_completion(&mut self) {
1458 self.lsp_state.completion_visible = false;
1459 self.lsp_state.completions.clear();
1460 self.lsp_state.completions_original.clear();
1461 self.lsp_state.completion_index = 0;
1462 self.lsp_state.completion_filter.clear();
1463 }
1464
1465 /// Filter completions based on typed text
1466 fn filter_completions(&mut self) {
1467 let filter = self.lsp_state.completion_filter.to_lowercase();
1468 if filter.is_empty() {
1469 self.lsp_state.completions = self.lsp_state.completions_original.clone();
1470 } else {
1471 self.lsp_state.completions = self.lsp_state.completions_original
1472 .iter()
1473 .filter(|item| item.label.to_lowercase().contains(&filter))
1474 .cloned()
1475 .collect();
1476 }
1477 // Reset selection to first item
1478 self.lsp_state.completion_index = 0;
1479 }
1480
1481 // === Ghost Text (Inline Autocomplete) ===
1482
1483 /// Update ghost text suggestion based on current cursor position
1484 fn update_ghost_text(&mut self) {
1485 // Don't show ghost text if completion popup is visible
1486 if self.lsp_state.completion_visible {
1487 self.ghost_text.suggestion = None;
1488 return;
1489 }
1490
1491 let cursor = self.cursor();
1492 let line_idx = cursor.line;
1493 let col = cursor.col;
1494
1495 // Get current line and extract prefix (word before cursor)
1496 let prefix = match self.buffer().line_str(line_idx) {
1497 Some(line) => {
1498 let before_cursor: String = line.chars().take(col).collect();
1499 // Walk back to find word start
1500 before_cursor
1501 .chars()
1502 .rev()
1503 .take_while(|c| c.is_alphanumeric() || *c == '_')
1504 .collect::<String>()
1505 .chars()
1506 .rev()
1507 .collect::<String>()
1508 }
1509 None => return,
1510 };
1511
1512 // Don't suggest for very short prefixes
1513 if prefix.len() < 2 {
1514 self.ghost_text.suggestion = None;
1515 return;
1516 }
1517
1518 // Try to find best suggestion from all sources
1519 if let Some(full_word) = self.find_best_suggestion(&prefix) {
1520 // Ghost text is the suffix (part after prefix)
1521 if full_word.len() > prefix.len() {
1522 let suffix = full_word[prefix.len()..].to_string();
1523 self.ghost_text.suggestion = Some(suffix);
1524 self.ghost_text.position = Some((line_idx, col));
1525 self.ghost_text.prefix = prefix;
1526 return;
1527 }
1528 }
1529
1530 self.ghost_text.suggestion = None;
1531 }
1532
1533 /// Find best matching suggestion from all sources (LSP, current buffer, other buffers)
1534 fn find_best_suggestion(&mut self, prefix: &str) -> Option<String> {
1535 let prefix_lower = prefix.to_lowercase();
1536
1537 // Priority 1: LSP completions (if we have recent ones)
1538 for item in &self.lsp_state.completions_original {
1539 let text = item.insert_text.as_ref().unwrap_or(&item.label);
1540 if text.to_lowercase().starts_with(&prefix_lower) && text.len() > prefix.len() {
1541 self.ghost_text.source = GhostTextSource::Lsp;
1542 return Some(text.clone());
1543 }
1544 }
1545
1546 // Priority 2: Current buffer words
1547 let buffer_hash = self.buffer_mut().content_hash();
1548 let needs_refresh = self
1549 .ghost_text
1550 .buffer_words_cache
1551 .as_ref()
1552 .map(|(h, _)| *h != buffer_hash)
1553 .unwrap_or(true);
1554
1555 if needs_refresh {
1556 let words = self.buffer().extract_words();
1557 self.ghost_text.buffer_words_cache = Some((buffer_hash, words));
1558 }
1559
1560 if let Some((_, ref words)) = self.ghost_text.buffer_words_cache {
1561 for word in words {
1562 if word.to_lowercase().starts_with(&prefix_lower)
1563 && word != prefix
1564 && word.len() > prefix.len()
1565 {
1566 self.ghost_text.source = GhostTextSource::CurrentBuffer;
1567 return Some(word.clone());
1568 }
1569 }
1570 }
1571
1572 // Priority 3: Words from other buffers
1573 for word in &self.ghost_text.all_buffer_words_cache {
1574 if word.to_lowercase().starts_with(&prefix_lower)
1575 && word != prefix
1576 && word.len() > prefix.len()
1577 {
1578 self.ghost_text.source = GhostTextSource::OtherBuffer;
1579 return Some(word.clone());
1580 }
1581 }
1582
1583 None
1584 }
1585
1586 /// Collect words from all open buffers in workspace
1587 fn collect_all_buffer_words(&self) -> Vec<String> {
1588 let mut words = std::collections::HashSet::new();
1589 for tab in &self.workspace.tabs {
1590 for buffer_entry in &tab.buffers {
1591 for word in buffer_entry.buffer.extract_words() {
1592 words.insert(word);
1593 }
1594 }
1595 }
1596 words.into_iter().collect()
1597 }
1598
1599 /// Accept the current ghost text suggestion
1600 fn accept_ghost_text(&mut self) {
1601 if let Some(suffix) = self.ghost_text.suggestion.take() {
1602 self.insert_text(&suffix);
1603 self.ghost_text.position = None;
1604 self.ghost_text.prefix.clear();
1605 }
1606 }
1607
1608 /// Dismiss ghost text (clear suggestion)
1609 fn dismiss_ghost_text(&mut self) {
1610 self.ghost_text.suggestion = None;
1611 self.ghost_text.position = None;
1612 self.ghost_text.prefix.clear();
1613 }
1614
1615 /// Check if ghost text should be dismissed due to cursor movement
1616 fn validate_ghost_text_position(&mut self) {
1617 if let Some((line, col)) = self.ghost_text.position {
1618 let cursor = self.cursor();
1619 // Dismiss if cursor moved to different line or before the prefix start
1620 let prefix_start = col.saturating_sub(self.ghost_text.prefix.len());
1621 if cursor.line != line || cursor.col < prefix_start || cursor.col > col {
1622 self.dismiss_ghost_text();
1623 }
1624 }
1625 }
1626
1627 /// Process a key event, handling ESC as potential Alt prefix
1628 fn process_key(&mut self, key_event: KeyEvent) -> Result<()> {
1629 use crossterm::event::{KeyCode, KeyModifiers};
1630
1631 // Ctrl+` or Ctrl+j toggles terminal (works in both editor and terminal mode)
1632 // Ctrl+j is alternate since Ctrl+` can conflict with tmux
1633 if (key_event.code == KeyCode::Char('`') || key_event.code == KeyCode::Char('j'))
1634 && key_event.modifiers.contains(KeyModifiers::CONTROL)
1635 {
1636 let _ = self.terminal.toggle();
1637 self.terminal_resize_dragging = false;
1638 // Set focus when opening, return focus when closing
1639 if self.terminal.visible {
1640 self.focus = Focus::Terminal;
1641 } else {
1642 self.return_focus();
1643 }
1644 return Ok(());
1645 }
1646
1647 // Focus-based routing for terminal
1648 if self.focus == Focus::Terminal && self.terminal.visible {
1649 // ESC hides terminal and returns focus
1650 if key_event.code == KeyCode::Esc {
1651 self.terminal.hide();
1652 self.terminal_resize_dragging = false;
1653 self.return_focus();
1654 return Ok(());
1655 }
1656
1657 // Terminal tab management keybindings (Alt + key)
1658 if key_event.modifiers.contains(KeyModifiers::ALT) {
1659 match key_event.code {
1660 // Alt+T: New terminal tab
1661 KeyCode::Char('t') => {
1662 let _ = self.terminal.new_session();
1663 return Ok(());
1664 }
1665 // Alt+Q: Close current tab
1666 KeyCode::Char('q') => {
1667 if self.terminal.close_active_session() {
1668 self.terminal.hide();
1669 self.terminal_resize_dragging = false;
1670 self.return_focus();
1671 }
1672 return Ok(());
1673 }
1674 // Alt+.: Next tab
1675 KeyCode::Char('.') => {
1676 self.terminal.next_session();
1677 return Ok(());
1678 }
1679 // Alt+,: Previous tab
1680 KeyCode::Char(',') => {
1681 self.terminal.prev_session();
1682 return Ok(());
1683 }
1684 // Alt+1-9: Switch to specific tab
1685 KeyCode::Char(c @ '1'..='9') => {
1686 let idx = (c as usize) - ('1' as usize);
1687 self.terminal.switch_session(idx);
1688 return Ok(());
1689 }
1690 _ => {}
1691 }
1692 }
1693
1694 // F3 or Ctrl+B toggles fuss mode (works over terminal)
1695 if key_event.code == KeyCode::F(3)
1696 || (key_event.code == KeyCode::Char('b')
1697 && key_event.modifiers.contains(KeyModifiers::CONTROL))
1698 {
1699 self.toggle_fuss_mode();
1700 return Ok(());
1701 }
1702
1703 // Send all other keys to terminal
1704 let _ = self.terminal.send_key(&key_event);
1705 return Ok(());
1706 }
1707
1708 // Check if this is a bare Escape key (potential Alt prefix)
1709 if key_event.code == KeyCode::Esc && key_event.modifiers.is_empty() {
1710 // Check if more data is available within escape_time
1711 // Escape sequences from terminals arrive together, so short timeouts work
1712 let timeout = Duration::from_millis(self.escape_time);
1713
1714 if event::poll(timeout)? {
1715 if let Event::Key(next_event) = event::read()? {
1716 // Check for CSI sequences (ESC [ ...) which are arrow keys etc.
1717 if next_event.code == KeyCode::Char('[') {
1718 // CSI sequence - read the rest
1719 if event::poll(timeout)? {
1720 if let Event::Key(csi_event) = event::read()? {
1721 let mods = Modifiers { alt: true, ..Default::default() };
1722 return match csi_event.code {
1723 KeyCode::Char('A') => self.handle_key_with_mods(Key::Up, mods),
1724 KeyCode::Char('B') => self.handle_key_with_mods(Key::Down, mods),
1725 KeyCode::Char('C') => self.handle_key_with_mods(Key::Right, mods),
1726 KeyCode::Char('D') => self.handle_key_with_mods(Key::Left, mods),
1727 _ => Ok(()), // Unknown CSI sequence
1728 };
1729 }
1730 }
1731 return Ok(()); // Incomplete CSI
1732 }
1733
1734 // Regular Alt+key (ESC followed by a normal key)
1735 let (key, mut mods) = Key::from_crossterm(next_event);
1736 mods.alt = true;
1737 return self.handle_key_with_mods(key, mods);
1738 }
1739 }
1740 // No key followed - it's a real Escape
1741 return self.handle_key_with_mods(Key::Escape, Modifiers::default());
1742 }
1743
1744 // Normal key processing
1745 let (key, mods) = Key::from_crossterm(key_event);
1746 self.handle_key_with_mods(key, mods)
1747 }
1748
1749 /// Process a mouse event
1750 fn process_mouse(&mut self, mouse_event: MouseEvent) -> Result<()> {
1751 if let Some(mouse) = Mouse::from_crossterm(mouse_event) {
1752 self.handle_mouse(mouse)?;
1753 }
1754 Ok(())
1755 }
1756
1757 /// Hit test to determine which UI region contains a screen coordinate
1758 fn hit_test(&self, col: u16, row: u16) -> HitRegion {
1759 // Check prompt/modal first (overlays everything)
1760 // Prompts take up various areas but for click purposes we consider them focused
1761 if self.prompt != PromptState::None {
1762 return HitRegion::Prompt;
1763 }
1764
1765 // Check server manager panel (right side overlay)
1766 if self.server_manager.visible {
1767 let panel_width = 50.min(self.screen.cols / 2);
1768 let panel_start_col = self.screen.cols.saturating_sub(panel_width);
1769 if col >= panel_start_col {
1770 return HitRegion::ServerManager;
1771 }
1772 }
1773
1774 // Check terminal (bottom region)
1775 if self.terminal.visible {
1776 let terminal_start_row = self.terminal.render_start_row(self.screen.rows);
1777 if row >= terminal_start_row {
1778 // Terminal shrinks when fuss mode is active
1779 let fuss_width = if self.workspace.fuss.active {
1780 self.workspace.fuss.width(self.screen.cols)
1781 } else {
1782 0
1783 };
1784 if col >= fuss_width {
1785 return HitRegion::Terminal;
1786 }
1787 }
1788 }
1789
1790 // Check fuss sidebar (left side)
1791 if self.workspace.fuss.active {
1792 let fuss_width = self.workspace.fuss.width(self.screen.cols);
1793 if col < fuss_width {
1794 return HitRegion::FussMode;
1795 }
1796 }
1797
1798 // Otherwise it's the editor - determine which pane
1799 let pane_index = self.workspace.pane_at_position(col, row, self.screen.cols, self.screen.rows);
1800 HitRegion::Editor { pane_index }
1801 }
1802
1803 /// Return focus to a sensible default after closing a component
1804 fn return_focus(&mut self) {
1805 // Return focus to the most recently visible component, defaulting to editor
1806 self.focus = Focus::Editor;
1807 }
1808
1809 /// Handle mouse input
1810 fn handle_mouse(&mut self, mouse: Mouse) -> Result<()> {
1811 // Calculate offsets for fuss mode and tab bar
1812 let left_offset = if self.workspace.fuss.active {
1813 self.workspace.fuss.width(self.screen.cols) as usize
1814 } else {
1815 0
1816 };
1817 // Tab bar is always rendered (takes 1 row)
1818 let top_offset = 1;
1819
1820 // Calculate line number column width (same as in screen.rs)
1821 let line_num_width = {
1822 let line_count = self.buffer().line_count();
1823 let digits = if line_count == 0 { 1 } else { (line_count as f64).log10().floor() as usize + 1 };
1824 digits.max(3)
1825 };
1826 let text_start_col = left_offset + line_num_width + 1;
1827
1828 // Click-to-focus: determine which region was clicked and set focus
1829 if let Mouse::Click { col, row, .. } = mouse {
1830 let region = self.hit_test(col, row);
1831 match region {
1832 HitRegion::Terminal => {
1833 self.focus = Focus::Terminal;
1834 }
1835 HitRegion::FussMode => {
1836 self.focus = Focus::FussMode;
1837 }
1838 HitRegion::Editor { pane_index } => {
1839 self.focus = Focus::Editor;
1840 // Also set the active pane when clicking in editor area
1841 self.workspace.tabs[self.workspace.active_tab].active_pane = pane_index;
1842 }
1843 HitRegion::ServerManager => {
1844 self.focus = Focus::ServerManager;
1845 }
1846 HitRegion::Prompt => {
1847 self.focus = Focus::Prompt;
1848 }
1849 HitRegion::None => {}
1850 }
1851 }
1852
1853 // Handle terminal resize dragging
1854 if self.terminal.visible {
1855 let title_row = self.screen.rows.saturating_sub(self.terminal.height);
1856
1857 match mouse {
1858 Mouse::Click { button: Button::Left, row, .. } if row == title_row => {
1859 // Start dragging on title bar
1860 self.terminal_resize_dragging = true;
1861 self.terminal_resize_start_y = row;
1862 self.terminal_resize_start_height = self.terminal.height;
1863 return Ok(());
1864 }
1865 Mouse::Drag { button: Button::Left, row, .. } if self.terminal_resize_dragging => {
1866 // Resize while dragging
1867 let delta = self.terminal_resize_start_y as i32 - row as i32;
1868 let new_height = (self.terminal_resize_start_height as i32 + delta).max(3) as u16;
1869 self.terminal.resize_height(new_height);
1870 return Ok(());
1871 }
1872 Mouse::Up { button: Button::Left, .. } if self.terminal_resize_dragging => {
1873 // Stop dragging
1874 self.terminal_resize_dragging = false;
1875 return Ok(());
1876 }
1877 _ => {}
1878 }
1879 }
1880
1881 match mouse {
1882 Mouse::Click { button: Button::Left, col, row, modifiers } => {
1883 // Convert screen coordinates to buffer coordinates
1884 let screen_row = row as usize;
1885 let screen_col = col as usize;
1886
1887 // Check if click is in the text area (not line numbers, not status bar, not fuss pane)
1888 let status_row = self.screen.rows.saturating_sub(1) as usize;
1889 if screen_row >= top_offset && screen_row < status_row && screen_col >= text_start_col {
1890 // Calculate buffer position (accounting for top_offset)
1891 let buffer_line = self.viewport_line() + (screen_row - top_offset);
1892 let buffer_col = screen_col - text_start_col;
1893
1894 // Clamp to valid positions
1895 if buffer_line < self.buffer().line_count() {
1896 let line_len = self.buffer().line_len(buffer_line);
1897 let clamped_col = buffer_col.min(line_len);
1898
1899 if modifiers.ctrl {
1900 // Ctrl+click: add or remove cursor at position
1901 self.toggle_cursor_at(buffer_line, clamped_col);
1902 } else {
1903 // Normal click: move cursor to clicked position
1904 self.cursors_mut().collapse_to_primary();
1905 self.cursor_mut().line = buffer_line;
1906 self.cursor_mut().col = clamped_col;
1907 self.cursor_mut().desired_col = clamped_col;
1908 self.cursor_mut().clear_selection();
1909 }
1910 }
1911 }
1912 }
1913 Mouse::Drag { button: Button::Left, col, row, .. } => {
1914 // Extend selection while dragging
1915 let screen_row = row as usize;
1916 let screen_col = col as usize;
1917
1918 let status_row = self.screen.rows.saturating_sub(1) as usize;
1919 if screen_row >= top_offset && screen_row < status_row && screen_col >= text_start_col {
1920 let buffer_line = self.viewport_line() + (screen_row - top_offset);
1921 let buffer_col = screen_col - text_start_col;
1922
1923 if buffer_line < self.buffer().line_count() {
1924 let line_len = self.buffer().line_len(buffer_line);
1925 let clamped_col = buffer_col.min(line_len);
1926
1927 // Start selection if not already selecting
1928 if !self.cursor().selecting {
1929 self.cursor_mut().start_selection();
1930 }
1931
1932 // Move cursor (extends selection)
1933 self.cursor_mut().line = buffer_line;
1934 self.cursor_mut().col = clamped_col;
1935 self.cursor_mut().desired_col = clamped_col;
1936 }
1937 }
1938 }
1939 Mouse::ScrollUp { .. } => {
1940 // Scroll up 3 lines
1941 let new_line = self.viewport_line().saturating_sub(3);
1942 self.set_viewport_line(new_line);
1943 }
1944 Mouse::ScrollDown { .. } => {
1945 // Scroll down 3 lines
1946 // Calculate visible rows (tab bar always rendered, plus gap and status bar)
1947 let top_offset = 1;
1948 let visible_rows = (self.screen.rows as usize).saturating_sub(2 + top_offset);
1949 // Max viewport is when the last line is at the bottom of visible area
1950 let max_viewport = self.buffer().line_count().saturating_sub(visible_rows).max(0);
1951 let new_line = (self.viewport_line() + 3).min(max_viewport);
1952 self.set_viewport_line(new_line);
1953 }
1954 _ => {}
1955 }
1956
1957 Ok(())
1958 }
1959
1960 fn render(&mut self) -> Result<()> {
1961 // Calculate fuss pane width if active
1962 let fuss_width = if self.workspace.fuss.active {
1963 self.workspace.fuss.width(self.screen.cols)
1964 } else {
1965 0
1966 };
1967
1968 // Update fuss mode viewport (actual rendering happens after terminal)
1969 if self.workspace.fuss.active {
1970 let visible_rows = self.screen.rows.saturating_sub(2) as usize;
1971 self.workspace.fuss.update_viewport(visible_rows);
1972 }
1973
1974 // Build tab info for tab bar
1975 let tabs: Vec<TabInfo> = self.workspace.tabs.iter_mut().enumerate().map(|(i, tab)| {
1976 TabInfo {
1977 name: tab.display_name(),
1978 is_active: i == self.workspace.active_tab,
1979 is_modified: tab.is_modified(),
1980 index: i,
1981 }
1982 }).collect();
1983
1984 // Render tab bar (returns height: 1 if multiple tabs, 0 if single tab)
1985 let top_offset = self.screen.render_tab_bar(&tabs, fuss_width)?;
1986
1987 // Get pane count and filename before potentially getting mutable reference
1988 let pane_count = {
1989 let tab = self.workspace.active_tab();
1990 tab.panes.len()
1991 };
1992 let filename = {
1993 let tab = self.workspace.active_tab();
1994 let pane = &tab.panes[tab.active_pane];
1995 tab.buffers[pane.buffer_idx].path.as_ref().and_then(|p| p.to_str()).map(|s| s.to_string())
1996 };
1997 let filename_ref = filename.as_deref();
1998
1999 // Use multi-pane rendering if we have more than one pane
2000 if pane_count > 1 {
2001 // Pre-compute is_modified for each buffer (needs mutable access)
2002 let buffer_modified: Vec<bool> = {
2003 let tab = self.workspace.active_tab_mut();
2004 tab.buffers.iter_mut().map(|be| be.is_modified()).collect()
2005 };
2006
2007 let tab = self.workspace.active_tab();
2008 // Build PaneInfo for each pane
2009 let pane_infos: Vec<PaneInfo> = tab.panes.iter().enumerate().map(|(i, pane)| {
2010 let buffer_entry = &tab.buffers[pane.buffer_idx];
2011 let buffer = &buffer_entry.buffer;
2012 let cursor = pane.cursors.primary();
2013 let bracket_match = buffer.find_matching_bracket(cursor.line, cursor.col);
2014
2015 PaneInfo {
2016 buffer,
2017 cursors: &pane.cursors,
2018 viewport_line: pane.viewport_line,
2019 bounds: RenderPaneBounds {
2020 x_start: pane.bounds.x_start,
2021 y_start: pane.bounds.y_start,
2022 x_end: pane.bounds.x_end,
2023 y_end: pane.bounds.y_end,
2024 },
2025 is_active: i == tab.active_pane,
2026 bracket_match,
2027 is_modified: buffer_modified[pane.buffer_idx],
2028 }
2029 }).collect();
2030
2031 self.screen.render_panes(
2032 &pane_infos,
2033 filename_ref,
2034 self.message.as_deref(),
2035 fuss_width,
2036 top_offset,
2037 )
2038 } else {
2039 // Single pane - use simpler render path with syntax highlighting
2040 // Get cached bracket match (this may compute it if not cached)
2041 let bracket_match = self.get_bracket_match();
2042
2043 // Get is_modified first (needs mutable access for hash caching)
2044 let is_modified = {
2045 let tab = self.workspace.active_tab_mut();
2046 let pane = &tab.panes[tab.active_pane];
2047 tab.buffers[pane.buffer_idx].is_modified()
2048 };
2049
2050 // Get values we need before mutable borrow for highlighter
2051 let (viewport_line, viewport_col, cursors, line_count) = {
2052 let tab = self.workspace.active_tab();
2053 let pane = &tab.panes[tab.active_pane];
2054 let buffer_entry = &tab.buffers[pane.buffer_idx];
2055 let buffer = &buffer_entry.buffer;
2056 let cursors = pane.cursors.clone();
2057 (pane.viewport_line, pane.viewport_col, cursors, buffer.line_count())
2058 };
2059
2060 // Now get mutable access to highlighter and buffer for rendering
2061 {
2062 let tab = self.workspace.active_tab_mut();
2063 let buffer_idx = tab.panes[tab.active_pane].buffer_idx;
2064 let buffer_entry = &mut tab.buffers[buffer_idx];
2065 let buffer = &buffer_entry.buffer;
2066
2067 self.screen.render_with_syntax(
2068 buffer,
2069 &cursors,
2070 viewport_line,
2071 viewport_col,
2072 filename_ref,
2073 self.message.as_deref(),
2074 bracket_match,
2075 fuss_width,
2076 top_offset,
2077 is_modified,
2078 &mut buffer_entry.highlighter,
2079 self.ghost_text.suggestion.as_deref(),
2080 )?;
2081 }
2082
2083 // Render diagnostics markers in gutter
2084 if !self.lsp_state.diagnostics.is_empty() {
2085 self.screen.render_diagnostics_gutter(
2086 &self.lsp_state.diagnostics,
2087 viewport_line,
2088 fuss_width,
2089 top_offset,
2090 )?;
2091 }
2092
2093 // Render completion popup if visible
2094 if self.lsp_state.completion_visible && !self.lsp_state.completions.is_empty() {
2095 let cursor = cursors.primary();
2096 // Calculate cursor screen position
2097 let cursor_row = (cursor.line.saturating_sub(viewport_line)) as u16 + top_offset;
2098 let line_num_width = self.screen.line_number_width(line_count) as u16;
2099 let cursor_col = cursor.col as u16 + line_num_width + 1;
2100
2101 self.screen.render_completion_popup(
2102 &self.lsp_state.completions,
2103 self.lsp_state.completion_index,
2104 cursor_row,
2105 cursor_col,
2106 fuss_width,
2107 )?;
2108 }
2109
2110 // Render hover popup if visible
2111 if self.lsp_state.hover_visible {
2112 if let Some(ref hover) = self.lsp_state.hover {
2113 let cursor = cursors.primary();
2114 let cursor_row = (cursor.line.saturating_sub(viewport_line)) as u16 + top_offset;
2115 let line_num_width = self.screen.line_number_width(line_count) as u16;
2116 let cursor_col = cursor.col as u16 + line_num_width + 1;
2117
2118 self.screen.render_hover_popup(
2119 hover,
2120 cursor_row,
2121 cursor_col,
2122 fuss_width,
2123 )?;
2124 }
2125 }
2126
2127 // Render server manager panel if visible (on top of everything)
2128 if self.server_manager.visible {
2129 self.screen.render_server_manager_panel(&self.server_manager)?;
2130 }
2131
2132 // Render terminal panel if visible (overlays editor content)
2133 if self.terminal.visible {
2134 self.screen.render_terminal(&self.terminal, fuss_width)?;
2135 }
2136
2137 // Render fuss mode sidebar if active (after terminal so it paints on top)
2138 if self.workspace.fuss.active {
2139 if let Some(ref tree) = self.workspace.fuss.tree {
2140 let repo_name = self.workspace.repo_name();
2141 let branch = self.workspace.git_branch();
2142 self.screen.render_fuss(
2143 tree.visible_items(),
2144 self.workspace.fuss.selected,
2145 self.workspace.fuss.scroll,
2146 fuss_width,
2147 self.workspace.fuss.hints_expanded,
2148 &repo_name,
2149 branch.as_deref(),
2150 self.workspace.fuss.git_mode,
2151 )?;
2152 }
2153 }
2154
2155 // Render rename modal if active
2156 if let PromptState::RenameModal { ref original_name, ref new_name, .. } = self.prompt {
2157 self.screen.render_rename_modal(original_name, new_name)?;
2158 }
2159
2160 // Render references panel if active
2161 if let PromptState::ReferencesPanel { ref locations, selected_index, ref query } = self.prompt {
2162 self.screen.render_references_panel(locations, selected_index, query, &self.workspace.root)?;
2163 }
2164
2165 // Render fortress modal if active
2166 if let PromptState::Fortress {
2167 ref current_path,
2168 ref entries,
2169 selected_index,
2170 ref filter,
2171 scroll_offset,
2172 } = self.prompt {
2173 // Convert entries to tuple format for render function
2174 let entries_tuples: Vec<(String, PathBuf, bool)> = entries
2175 .iter()
2176 .map(|e| (e.name.clone(), e.path.clone(), e.is_dir))
2177 .collect();
2178 self.screen.render_fortress_modal(
2179 current_path,
2180 &entries_tuples,
2181 selected_index,
2182 filter,
2183 scroll_offset,
2184 )?;
2185 return Ok(()); // Modal handles cursor
2186 }
2187
2188 // Render file search modal if active
2189 if let PromptState::FileSearch {
2190 ref query,
2191 ref results,
2192 selected_index,
2193 scroll_offset,
2194 searching,
2195 } = self.prompt {
2196 // Convert results to tuple format for render function
2197 let results_tuples: Vec<(PathBuf, usize, String)> = results
2198 .iter()
2199 .map(|r| (r.path.clone(), r.line_num, r.line_content.clone()))
2200 .collect();
2201 self.screen.render_file_search_modal(
2202 query,
2203 &results_tuples,
2204 selected_index,
2205 scroll_offset,
2206 searching,
2207 )?;
2208 return Ok(()); // Modal handles cursor
2209 }
2210
2211 // Render command palette if active
2212 if let PromptState::CommandPalette {
2213 ref query,
2214 ref filtered,
2215 selected_index,
2216 scroll_offset,
2217 } = self.prompt {
2218 // Convert commands to tuple format for render function
2219 let commands_tuples: Vec<(String, String, String, String)> = filtered
2220 .iter()
2221 .map(|c| (c.name.to_string(), c.shortcut.to_string(), c.category.to_string(), c.id.to_string()))
2222 .collect();
2223 self.screen.render_command_palette(
2224 query,
2225 &commands_tuples,
2226 selected_index,
2227 scroll_offset,
2228 )?;
2229 return Ok(()); // Modal handles cursor
2230 }
2231
2232 // Render help menu if active
2233 if let PromptState::HelpMenu {
2234 ref query,
2235 ref filtered,
2236 selected_index,
2237 scroll_offset,
2238 show_alt,
2239 } = self.prompt {
2240 // Convert keybinds to tuple format for render function
2241 // Use alt_shortcut when show_alt is true (for entries that have one)
2242 let keybinds_tuples: Vec<(String, String, String)> = filtered
2243 .iter()
2244 .map(|kb| {
2245 let shortcut = if show_alt && !kb.alt_shortcut.is_empty() {
2246 kb.alt_shortcut.to_string()
2247 } else {
2248 kb.shortcut.to_string()
2249 };
2250 (shortcut, kb.description.to_string(), kb.category.to_string())
2251 })
2252 .collect();
2253 self.screen.render_help_menu(
2254 query,
2255 &keybinds_tuples,
2256 selected_index,
2257 scroll_offset,
2258 show_alt,
2259 )?;
2260 return Ok(()); // Modal handles cursor
2261 }
2262
2263 // Render find/replace bar if active (replaces status bar)
2264 if let PromptState::FindReplace {
2265 ref find_query,
2266 ref replace_text,
2267 active_field,
2268 case_insensitive,
2269 regex_mode,
2270 } = self.prompt {
2271 let is_find_active = active_field == FindReplaceField::Find;
2272 self.screen.render_find_replace_bar(
2273 find_query,
2274 replace_text,
2275 is_find_active,
2276 case_insensitive,
2277 regex_mode,
2278 self.search_state.matches.len(),
2279 self.search_state.current_match,
2280 fuss_width,
2281 )?;
2282 return Ok(()); // Skip cursor repositioning, bar handles it
2283 }
2284
2285 // After all overlays are rendered, reposition cursor to the correct location
2286 // (overlays may have moved the terminal cursor position)
2287 let cursor = cursors.primary();
2288 let cursor_row = (cursor.line.saturating_sub(viewport_line)) as u16 + top_offset;
2289 let line_num_width = self.screen.line_number_width(line_count) as u16;
2290 // Account for horizontal scroll offset
2291 let cursor_screen_col = fuss_width + line_num_width + 1 + (cursor.col.saturating_sub(viewport_col)) as u16;
2292 self.screen.show_cursor_at(cursor_screen_col, cursor_row)?;
2293
2294 Ok(())
2295 }
2296 }
2297
2298 fn handle_key_with_mods(&mut self, key: Key, mods: Modifiers) -> Result<()> {
2299 // Handle Ctrl+F/Ctrl+R specially - they can toggle/switch even when in FindReplace prompt
2300 if let PromptState::FindReplace { .. } = &self.prompt {
2301 match (&key, &mods) {
2302 (Key::Char('f'), Modifiers { ctrl: true, .. }) => {
2303 self.open_find();
2304 return Ok(());
2305 }
2306 (Key::Char('r'), Modifiers { ctrl: true, .. }) => {
2307 self.open_replace();
2308 return Ok(());
2309 }
2310 // Alt+I: toggle case insensitivity
2311 (Key::Char('i'), Modifiers { alt: true, .. }) => {
2312 self.toggle_case_sensitivity();
2313 return Ok(());
2314 }
2315 // Alt+X: toggle regex mode
2316 (Key::Char('x'), Modifiers { alt: true, .. }) => {
2317 self.toggle_regex_mode();
2318 return Ok(());
2319 }
2320 // Alt+Enter or Ctrl+Shift+Enter: replace all
2321 (Key::Enter, Modifiers { alt: true, .. }) |
2322 (Key::Enter, Modifiers { ctrl: true, shift: true, .. }) => {
2323 self.replace_all();
2324 return Ok(());
2325 }
2326 _ => {}
2327 }
2328 }
2329
2330 // Handle active prompts first
2331 if self.prompt != PromptState::None {
2332 return self.handle_prompt_key(key);
2333 }
2334
2335 // Focus-based routing for server manager
2336 if self.focus == Focus::ServerManager && self.server_manager.visible {
2337 return self.handle_server_manager_key(key, mods);
2338 }
2339
2340 // Clear message on any key
2341 self.message = None;
2342
2343 // Toggle fuss mode: Ctrl+B or F3 (global shortcut that sets focus)
2344 if matches!((&key, &mods), (Key::Char('b'), Modifiers { ctrl: true, .. }) | (Key::F(3), _)) {
2345 self.toggle_fuss_mode();
2346 return Ok(());
2347 }
2348
2349 // Focus-based routing for fuss mode
2350 if self.focus == Focus::FussMode && self.workspace.fuss.active {
2351 return self.handle_fuss_key(key, mods);
2352 }
2353
2354 // Handle completion popup navigation when visible
2355 if self.lsp_state.completion_visible {
2356 match (&key, &mods) {
2357 // Navigate up in completion list
2358 (Key::Up, _) => {
2359 if self.lsp_state.completion_index > 0 {
2360 self.lsp_state.completion_index -= 1;
2361 } else {
2362 // Wrap to bottom
2363 self.lsp_state.completion_index = self.lsp_state.completions.len().saturating_sub(1);
2364 }
2365 return Ok(());
2366 }
2367 // Navigate down in completion list
2368 (Key::Down, _) => {
2369 if self.lsp_state.completion_index < self.lsp_state.completions.len().saturating_sub(1) {
2370 self.lsp_state.completion_index += 1;
2371 } else {
2372 // Wrap to top
2373 self.lsp_state.completion_index = 0;
2374 }
2375 return Ok(());
2376 }
2377 // Select completion with Enter or Tab
2378 (Key::Enter, _) | (Key::Tab, _) => {
2379 self.accept_completion();
2380 return Ok(());
2381 }
2382 // Dismiss completion popup with Escape
2383 (Key::Escape, _) => {
2384 self.dismiss_completion();
2385 return Ok(());
2386 }
2387 // Backspace: remove from filter (if any) and continue
2388 (Key::Backspace, _) => {
2389 if !self.lsp_state.completion_filter.is_empty() {
2390 self.lsp_state.completion_filter.pop();
2391 self.filter_completions();
2392 } else {
2393 // No filter left, dismiss popup
2394 self.dismiss_completion();
2395 }
2396 // Continue to normal backspace handling
2397 }
2398 // Character input: add to filter and continue typing
2399 (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => {
2400 self.lsp_state.completion_filter.push(*c);
2401 self.filter_completions();
2402 // If no matches left, dismiss
2403 if self.lsp_state.completions.is_empty() {
2404 self.dismiss_completion();
2405 }
2406 // Continue to normal character handling
2407 }
2408 // Any other key dismisses popup
2409 _ => {
2410 self.dismiss_completion();
2411 }
2412 }
2413 }
2414
2415 // Dismiss hover popup on any key press
2416 if self.lsp_state.hover_visible {
2417 self.lsp_state.hover_visible = false;
2418 self.lsp_state.hover = None;
2419 // Let Escape just dismiss the popup without doing anything else
2420 if matches!(key, Key::Escape) {
2421 return Ok(());
2422 }
2423 }
2424
2425 // Break undo group on any non-character key (movement, commands, etc.)
2426 // This ensures each "typing session" is its own undo unit
2427 let is_typing = matches!(
2428 (&key, &mods),
2429 (Key::Char(_), Modifiers { ctrl: false, alt: false, .. })
2430 );
2431 if !is_typing {
2432 self.history_mut().maybe_break_group();
2433 }
2434
2435 match (&key, &mods) {
2436 // === System ===
2437 // Quit: Ctrl+Q
2438 (Key::Char('q'), Modifiers { ctrl: true, .. }) => {
2439 self.try_quit();
2440 }
2441 // Save: Ctrl+S
2442 (Key::Char('s'), Modifiers { ctrl: true, .. }) => {
2443 self.save()?;
2444 }
2445 // Escape: clear selection and collapse to single cursor
2446 (Key::Escape, _) => {
2447 if self.cursors().len() > 1 {
2448 self.cursors_mut().collapse_to_primary();
2449 } else {
2450 self.cursors_mut().primary_mut().clear_selection();
2451 }
2452 self.dismiss_ghost_text();
2453 }
2454
2455 // === Undo/Redo ===
2456 (Key::Char('z'), Modifiers { ctrl: true, shift: false, .. }) => {
2457 self.undo();
2458 }
2459 (Key::Char('z'), Modifiers { ctrl: true, shift: true, .. })
2460 | (Key::Char(']'), Modifiers { ctrl: true, .. })
2461 | (Key::Char('5'), Modifiers { ctrl: true, .. }) // Ctrl+] reports as Ctrl+5 on some terminals
2462 | (Key::Char('\x1d'), _) => { // 0x1D = Ctrl+] as raw control character
2463 self.redo();
2464 }
2465
2466 // === Clipboard ===
2467 (Key::Char('c'), Modifiers { ctrl: true, .. }) => {
2468 self.copy();
2469 }
2470 (Key::Char('x'), Modifiers { ctrl: true, .. }) => {
2471 self.cut();
2472 }
2473 (Key::Char('v'), Modifiers { ctrl: true, .. }) => {
2474 self.paste();
2475 }
2476
2477 // === Multi-cursor operations (must come before other movement to capture Ctrl+Alt) ===
2478 // Add cursor above: Ctrl+Alt+Up
2479 (Key::Up, Modifiers { ctrl: true, alt: true, .. }) => self.add_cursor_above(),
2480 // Add cursor below: Ctrl+Alt+Down
2481 (Key::Down, Modifiers { ctrl: true, alt: true, .. }) => self.add_cursor_below(),
2482
2483 // === Line operations (must come before movement to capture Alt+arrows) ===
2484 // Move line up/down: Alt+Up/Down
2485 (Key::Up, Modifiers { alt: true, shift: false, .. }) => self.move_line_up(),
2486 (Key::Down, Modifiers { alt: true, shift: false, .. }) => self.move_line_down(),
2487 // Duplicate line: Alt+Shift+Up/Down
2488 (Key::Up, Modifiers { alt: true, shift: true, .. }) => self.duplicate_line_up(),
2489 (Key::Down, Modifiers { alt: true, shift: true, .. }) => self.duplicate_line_down(),
2490
2491 // Word movement: Alt+Left/Right
2492 (Key::Left, Modifiers { alt: true, shift, .. }) => self.move_word_left(*shift),
2493 (Key::Right, Modifiers { alt: true, shift, .. }) => self.move_word_right(*shift),
2494 // Unix-style word movement: Alt+B (back), Alt+F (forward)
2495 (Key::Char('b'), Modifiers { alt: true, .. }) => self.move_word_left(false),
2496 (Key::Char('f'), Modifiers { alt: true, .. }) => self.move_word_right(false),
2497
2498 // === Movement with selection ===
2499 (Key::Up, Modifiers { shift, .. }) => {
2500 self.move_up(*shift);
2501 self.validate_ghost_text_position();
2502 }
2503 (Key::Down, Modifiers { shift, .. }) => {
2504 self.move_down(*shift);
2505 self.validate_ghost_text_position();
2506 }
2507 (Key::Left, Modifiers { shift, .. }) => {
2508 self.move_left(*shift);
2509 self.validate_ghost_text_position();
2510 }
2511 (Key::Right, Modifiers { shift, .. }) => {
2512 self.move_right(*shift);
2513 self.validate_ghost_text_position();
2514 }
2515
2516 // Home/End
2517 (Key::Home, Modifiers { shift, .. }) => {
2518 self.move_home(*shift);
2519 self.validate_ghost_text_position();
2520 }
2521 (Key::End, Modifiers { shift, .. }) => {
2522 self.move_end(*shift);
2523 self.validate_ghost_text_position();
2524 }
2525 (Key::Char('a'), Modifiers { ctrl: true, shift, .. }) => self.smart_home(*shift),
2526 (Key::Char('e'), Modifiers { ctrl: true, shift, .. }) => self.move_end(*shift),
2527
2528 // Page movement
2529 (Key::PageUp, Modifiers { shift, .. }) => {
2530 self.page_up(*shift);
2531 self.validate_ghost_text_position();
2532 }
2533 (Key::PageDown, Modifiers { shift, .. }) => {
2534 self.page_down(*shift);
2535 self.validate_ghost_text_position();
2536 }
2537
2538 // Join lines: Ctrl+J
2539 (Key::Char('j'), Modifiers { ctrl: true, .. }) => self.join_lines(),
2540
2541 // Toggle line comment: Ctrl+/
2542 // Different terminals send: Ctrl+/, Ctrl+_, \x1f (ASCII 31), or Ctrl+7
2543 (Key::Char('/'), Modifiers { ctrl: true, .. })
2544 | (Key::Char('_'), Modifiers { ctrl: true, .. })
2545 | (Key::Char('\x1f'), _)
2546 | (Key::Char('7'), Modifiers { ctrl: true, .. }) => self.toggle_line_comment(),
2547
2548 // Select line: Ctrl+L
2549 (Key::Char('l'), Modifiers { ctrl: true, .. }) => self.select_line(),
2550 // Select word: Ctrl+D (select word at cursor, or next occurrence if already selected)
2551 (Key::Char('d'), Modifiers { ctrl: true, .. }) => self.select_word(),
2552
2553 // === Find/Replace ===
2554 // Find: Ctrl+F
2555 (Key::Char('f'), Modifiers { ctrl: true, .. }) => self.open_find(),
2556 // Replace: Ctrl+R (or Ctrl+H for compatibility)
2557 (Key::Char('r'), Modifiers { ctrl: true, .. }) |
2558 (Key::Char('h'), Modifiers { ctrl: true, alt: true, .. }) => self.open_replace(),
2559 // Find next: F3
2560 (Key::F(3), Modifiers { shift: false, .. }) => self.find_next(),
2561 // Find previous: Shift+F3
2562 (Key::F(3), Modifiers { shift: true, .. }) => self.find_prev(),
2563
2564 // === File operations ===
2565 // Open file browser (Fortress mode): Ctrl+O
2566 (Key::Char('o'), Modifiers { ctrl: true, .. }) => self.open_fortress(),
2567 // Go to line: Ctrl+G or F5
2568 (Key::Char('g'), Modifiers { ctrl: true, .. }) |
2569 (Key::F(5), _) => self.open_goto_line(),
2570 // Multi-file search: F4
2571 (Key::F(4), _) => self.open_file_search(),
2572 // Command palette: Ctrl+P
2573 (Key::Char('p'), Modifiers { ctrl: true, .. }) => self.open_command_palette(),
2574
2575 // === Editing ===
2576 (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => {
2577 self.insert_char(*c);
2578 }
2579 (Key::Enter, _) => {
2580 self.insert_newline();
2581 self.dismiss_ghost_text();
2582 }
2583 (Key::Backspace, Modifiers { alt: true, .. }) => {
2584 self.delete_word_backward();
2585 self.update_ghost_text();
2586 }
2587 (Key::Backspace, _) | (Key::Char('h'), Modifiers { ctrl: true, .. }) => {
2588 self.delete_backward();
2589 self.update_ghost_text();
2590 }
2591 (Key::Delete, _) => {
2592 self.delete_forward();
2593 self.dismiss_ghost_text();
2594 }
2595 (Key::Tab, _) => {
2596 // Accept ghost text if visible and no selection
2597 if self.ghost_text.suggestion.is_some() && !self.cursor().has_selection() {
2598 self.accept_ghost_text();
2599 } else {
2600 self.insert_tab();
2601 }
2602 }
2603 (Key::BackTab, _) => self.dedent(),
2604
2605 // Delete word backward: Ctrl+W
2606 (Key::Char('w'), Modifiers { ctrl: true, .. }) => self.delete_word_backward(),
2607 // Delete word forward: Alt+D
2608 (Key::Char('d'), Modifiers { alt: true, .. }) => self.delete_word_forward(),
2609
2610 // Unix-style kill commands
2611 // Kill to end of line: Ctrl+K
2612 (Key::Char('k'), Modifiers { ctrl: true, .. }) => self.kill_to_end_of_line(),
2613 // Kill to start of line: Ctrl+U
2614 (Key::Char('u'), Modifiers { ctrl: true, .. }) => self.kill_to_start_of_line(),
2615 // Yank from kill ring: Ctrl+Y
2616 (Key::Char('y'), Modifiers { ctrl: true, .. }) => self.yank(),
2617 // Cycle yank stack: Alt+Y
2618 (Key::Char('y'), Modifiers { alt: true, .. }) => self.yank_cycle(),
2619
2620 // Character transpose: Ctrl+T
2621 (Key::Char('t'), Modifiers { ctrl: true, .. }) => self.transpose_chars(),
2622
2623 // === Bracket/Quote operations ===
2624 // Jump to matching bracket: Alt+[ or Alt+]
2625 (Key::Char('['), Modifiers { alt: true, .. }) |
2626 (Key::Char(']'), Modifiers { alt: true, .. }) => self.jump_to_matching_bracket(),
2627 // Cycle quotes: Alt+' (cycles " -> ' -> ` -> ")
2628 (Key::Char('\''), Modifiers { alt: true, shift: false, .. }) => self.cycle_quotes(),
2629 // Remove surrounding quotes/brackets: Alt+Shift+' (Alt+")
2630 (Key::Char('"'), Modifiers { alt: true, .. }) => self.remove_surrounding(),
2631 // Cycle bracket type: Alt+Shift+9 (cycles ( -> { -> [ -> ()
2632 (Key::Char('('), Modifiers { alt: true, .. }) => self.cycle_brackets(),
2633 // Remove surrounding brackets: Alt+Shift+0
2634 (Key::Char(')'), Modifiers { alt: true, .. }) => self.remove_surrounding_brackets(),
2635
2636 // === Pane operations ===
2637 // Split vertical: Alt+V
2638 (Key::Char('v'), Modifiers { alt: true, .. }) => {
2639 self.split_vertical();
2640 }
2641 // Split horizontal: Alt+S
2642 (Key::Char('s'), Modifiers { alt: true, .. }) => {
2643 self.split_horizontal();
2644 }
2645 // Close pane/tab: Alt+Q
2646 (Key::Char('q'), Modifiers { alt: true, .. }) => {
2647 self.close_pane();
2648 }
2649 // Navigate panes: Alt+H/J/K/L (vim-style)
2650 (Key::Char('h'), Modifiers { alt: true, .. }) => {
2651 self.navigate_pane_left();
2652 }
2653 (Key::Char('j'), Modifiers { alt: true, .. }) => {
2654 self.navigate_pane_down();
2655 }
2656 (Key::Char('k'), Modifiers { alt: true, .. }) => {
2657 self.navigate_pane_up();
2658 }
2659 (Key::Char('l'), Modifiers { alt: true, .. }) => {
2660 self.navigate_pane_right();
2661 }
2662 // Next/Prev pane: Alt+N / Alt+P
2663 (Key::Char('n'), Modifiers { alt: true, .. }) => {
2664 self.next_pane();
2665 }
2666 (Key::Char('p'), Modifiers { alt: true, .. }) => {
2667 self.prev_pane();
2668 }
2669
2670 // === Tab operations ===
2671 // Switch to tab by number: Alt+1-9
2672 (Key::Char('1'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(0),
2673 (Key::Char('2'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(1),
2674 (Key::Char('3'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(2),
2675 (Key::Char('4'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(3),
2676 (Key::Char('5'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(4),
2677 (Key::Char('6'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(5),
2678 (Key::Char('7'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(6),
2679 (Key::Char('8'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(7),
2680 (Key::Char('9'), Modifiers { alt: true, .. }) => self.workspace.switch_to_tab(8),
2681 // Next/Prev tab: Alt+. / Alt+,
2682 (Key::Char('.'), Modifiers { alt: true, .. }) => self.workspace.next_tab(),
2683 (Key::Char(','), Modifiers { alt: true, .. }) => self.workspace.prev_tab(),
2684 // New tab: Alt+T
2685 (Key::Char('t'), Modifiers { alt: true, .. }) => self.workspace.new_tab(),
2686
2687 // === LSP operations ===
2688 // Go to definition: F12
2689 (Key::F(12), Modifiers { shift: false, .. }) => self.lsp_goto_definition(),
2690 // Find references: Shift+F12
2691 (Key::F(12), Modifiers { shift: true, .. }) => self.lsp_find_references(),
2692 // Hover info: F1
2693 (Key::F(1), Modifiers { shift: false, .. }) => self.lsp_hover(),
2694 // Code completion: Ctrl+N (vim-style)
2695 (Key::Char('n'), Modifiers { ctrl: true, .. }) => self.lsp_complete(),
2696 // Rename: F2
2697 (Key::F(2), _) => self.lsp_rename(),
2698 // Server manager: Alt+M
2699 (Key::Char('m'), Modifiers { alt: true, .. }) => self.toggle_server_manager(),
2700
2701 // === Help ===
2702 // Help / keybindings: Shift+F1
2703 (Key::F(1), Modifiers { shift: true, .. }) => self.open_help_menu(),
2704
2705 _ => {}
2706 }
2707
2708 // Check if buffer was edited and needs backup
2709 self.on_buffer_edit();
2710
2711 self.scroll_to_cursor();
2712 Ok(())
2713 }
2714
2715 // === Cursor helpers ===
2716
2717 /// Get reference to primary cursor
2718 fn cursor(&self) -> &Cursor {
2719 self.cursors().primary()
2720 }
2721
2722 /// Get mutable reference to primary cursor
2723 fn cursor_mut(&mut self) -> &mut Cursor {
2724 self.cursors_mut().primary_mut()
2725 }
2726
2727 // === Multi-cursor operations ===
2728
2729 /// Add a cursor on the line above the topmost cursor
2730 fn add_cursor_above(&mut self) {
2731 // Find the topmost cursor
2732 let topmost = self.cursors().all().iter().map(|c| c.line).min().unwrap_or(0);
2733 let col = self.cursors().primary().col;
2734
2735 if topmost > 0 {
2736 let new_line = topmost - 1;
2737 let line_len = self.buffer().line_len(new_line);
2738 let new_col = col.min(line_len);
2739 self.cursors_mut().add(new_line, new_col);
2740 }
2741 }
2742
2743 /// Add a cursor on the line below the bottommost cursor
2744 fn add_cursor_below(&mut self) {
2745 // Find the bottommost cursor
2746 let bottommost = self.cursors().all().iter().map(|c| c.line).max().unwrap_or(0);
2747 let col = self.cursors().primary().col;
2748 let line_count = self.buffer().line_count();
2749
2750 if bottommost + 1 < line_count {
2751 let new_line = bottommost + 1;
2752 let line_len = self.buffer().line_len(new_line);
2753 let new_col = col.min(line_len);
2754 self.cursors_mut().add(new_line, new_col);
2755 }
2756 }
2757
2758 /// Toggle cursor at position (for Ctrl+click)
2759 /// Returns true if cursor was added, false if removed
2760 fn toggle_cursor_at(&mut self, line: usize, col: usize) -> bool {
2761 self.cursors_mut().toggle_at(line, col)
2762 }
2763
2764 // === Movement ===
2765
2766 fn move_up(&mut self, extend_selection: bool) {
2767 // Get line lengths we need before borrowing cursors mutably
2768 let line_count = self.buffer().line_count();
2769 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2770
2771 // Apply to all cursors
2772 for cursor in self.cursors_mut().all_mut() {
2773 if cursor.line > 0 {
2774 let new_line = cursor.line - 1;
2775 let line_len = line_lens.get(new_line).copied().unwrap_or(0);
2776 let new_col = cursor.desired_col.min(line_len);
2777 cursor.move_to(new_line, new_col, extend_selection);
2778 } else {
2779 // On first line, move to start of line
2780 cursor.move_to(0, 0, extend_selection);
2781 }
2782 }
2783 self.cursors_mut().merge_overlapping();
2784 }
2785
2786 fn move_down(&mut self, extend_selection: bool) {
2787 let line_count = self.buffer().line_count();
2788 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2789
2790 for cursor in self.cursors_mut().all_mut() {
2791 if cursor.line + 1 < line_count {
2792 let new_line = cursor.line + 1;
2793 let line_len = line_lens.get(new_line).copied().unwrap_or(0);
2794 let new_col = cursor.desired_col.min(line_len);
2795 cursor.move_to(new_line, new_col, extend_selection);
2796 } else {
2797 // On last line, move to end of line
2798 let line_len = line_lens.get(cursor.line).copied().unwrap_or(0);
2799 cursor.move_to(cursor.line, line_len, extend_selection);
2800 }
2801 }
2802 self.cursors_mut().merge_overlapping();
2803 }
2804
2805 fn move_left(&mut self, extend_selection: bool) {
2806 let line_count = self.buffer().line_count();
2807 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2808
2809 for cursor in self.cursors_mut().all_mut() {
2810 if cursor.col > 0 {
2811 cursor.move_to(cursor.line, cursor.col - 1, extend_selection);
2812 cursor.desired_col = cursor.col;
2813 } else if cursor.line > 0 {
2814 let new_line = cursor.line - 1;
2815 let new_col = line_lens.get(new_line).copied().unwrap_or(0);
2816 cursor.move_to(new_line, new_col, extend_selection);
2817 cursor.desired_col = cursor.col;
2818 }
2819 }
2820 self.cursors_mut().merge_overlapping();
2821 }
2822
2823 fn move_right(&mut self, extend_selection: bool) {
2824 let line_count = self.buffer().line_count();
2825 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2826
2827 for cursor in self.cursors_mut().all_mut() {
2828 let line_len = line_lens.get(cursor.line).copied().unwrap_or(0);
2829 if cursor.col < line_len {
2830 cursor.move_to(cursor.line, cursor.col + 1, extend_selection);
2831 cursor.desired_col = cursor.col;
2832 } else if cursor.line + 1 < line_count {
2833 cursor.move_to(cursor.line + 1, 0, extend_selection);
2834 cursor.desired_col = 0;
2835 }
2836 }
2837 self.cursors_mut().merge_overlapping();
2838 }
2839
2840 fn move_word_left(&mut self, extend_selection: bool) {
2841 // Collect line data before borrowing cursors mutably
2842 let line_count = self.buffer().line_count();
2843 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2844 let line_strs: Vec<String> = (0..line_count)
2845 .map(|l| self.buffer().line_str(l).unwrap_or_default())
2846 .collect();
2847
2848 for cursor in self.cursors_mut().all_mut() {
2849 let (mut line, mut col) = (cursor.line, cursor.col);
2850
2851 // If at start of line, go to end of previous line
2852 if col == 0 && line > 0 {
2853 line -= 1;
2854 col = line_lens.get(line).copied().unwrap_or(0);
2855 }
2856
2857 if let Some(line_str) = line_strs.get(line) {
2858 let chars: Vec<char> = line_str.chars().collect();
2859 if col > 0 {
2860 col = col.min(chars.len());
2861 // Skip whitespace
2862 while col > 0 && chars.get(col - 1).map_or(false, |c| c.is_whitespace()) {
2863 col -= 1;
2864 }
2865 // Determine what kind of characters to skip based on char before cursor
2866 if col > 0 {
2867 let prev_char = chars[col - 1];
2868 if is_word_char(prev_char) {
2869 // Skip word characters
2870 while col > 0 && chars.get(col - 1).map_or(false, |c| is_word_char(*c)) {
2871 col -= 1;
2872 }
2873 } else {
2874 // Skip punctuation/symbols
2875 while col > 0 && chars.get(col - 1).map_or(false, |c| !is_word_char(*c) && !c.is_whitespace()) {
2876 col -= 1;
2877 }
2878 }
2879 }
2880 }
2881 }
2882
2883 cursor.move_to(line, col, extend_selection);
2884 cursor.desired_col = col;
2885 }
2886 self.cursors_mut().merge_overlapping();
2887 }
2888
2889 fn move_word_right(&mut self, extend_selection: bool) {
2890 let line_count = self.buffer().line_count();
2891 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2892 let line_strs: Vec<String> = (0..line_count)
2893 .map(|l| self.buffer().line_str(l).unwrap_or_default())
2894 .collect();
2895
2896 for cursor in self.cursors_mut().all_mut() {
2897 let (mut line, mut col) = (cursor.line, cursor.col);
2898 let line_len = line_lens.get(line).copied().unwrap_or(0);
2899
2900 // If at end of line, go to start of next line
2901 if col >= line_len && line + 1 < line_count {
2902 line += 1;
2903 col = 0;
2904 }
2905
2906 if let Some(line_str) = line_strs.get(line) {
2907 let chars: Vec<char> = line_str.chars().collect();
2908 if col < chars.len() {
2909 let curr_char = chars[col];
2910 if is_word_char(curr_char) {
2911 // Skip word characters
2912 while col < chars.len() && chars.get(col).map_or(false, |c| is_word_char(*c)) {
2913 col += 1;
2914 }
2915 } else if !curr_char.is_whitespace() {
2916 // Skip punctuation/symbols
2917 while col < chars.len() && chars.get(col).map_or(false, |c| !is_word_char(*c) && !c.is_whitespace()) {
2918 col += 1;
2919 }
2920 }
2921 }
2922 // Skip whitespace
2923 while col < chars.len() && chars.get(col).map_or(false, |c| c.is_whitespace()) {
2924 col += 1;
2925 }
2926 }
2927
2928 cursor.move_to(line, col, extend_selection);
2929 cursor.desired_col = col;
2930 }
2931 self.cursors_mut().merge_overlapping();
2932 }
2933
2934 fn move_home(&mut self, extend_selection: bool) {
2935 for cursor in self.cursors_mut().all_mut() {
2936 let line = cursor.line;
2937 cursor.move_to(line, 0, extend_selection);
2938 cursor.desired_col = 0;
2939 }
2940 self.cursors_mut().merge_overlapping();
2941 }
2942
2943 fn smart_home(&mut self, extend_selection: bool) {
2944 // Toggle between column 0 and first non-whitespace
2945 let line_count = self.buffer().line_count();
2946 let line_strs: Vec<String> = (0..line_count)
2947 .map(|l| self.buffer().line_str(l).unwrap_or_default())
2948 .collect();
2949
2950 for cursor in self.cursors_mut().all_mut() {
2951 let line = cursor.line;
2952 let col = cursor.col;
2953 if let Some(line_str) = line_strs.get(line) {
2954 let first_non_ws = line_str.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
2955 let new_col = if col == first_non_ws || col == 0 {
2956 if col == 0 { first_non_ws } else { 0 }
2957 } else {
2958 first_non_ws
2959 };
2960 cursor.move_to(line, new_col, extend_selection);
2961 cursor.desired_col = new_col;
2962 }
2963 }
2964 self.cursors_mut().merge_overlapping();
2965 }
2966
2967 fn move_end(&mut self, extend_selection: bool) {
2968 let line_count = self.buffer().line_count();
2969 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2970
2971 for cursor in self.cursors_mut().all_mut() {
2972 let line = cursor.line;
2973 let line_len = line_lens.get(line).copied().unwrap_or(0);
2974 cursor.move_to(line, line_len, extend_selection);
2975 cursor.desired_col = line_len;
2976 }
2977 self.cursors_mut().merge_overlapping();
2978 }
2979
2980 fn page_up(&mut self, extend_selection: bool) {
2981 let page = self.screen.rows.saturating_sub(2) as usize;
2982 let line_count = self.buffer().line_count();
2983 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2984
2985 for cursor in self.cursors_mut().all_mut() {
2986 let new_line = cursor.line.saturating_sub(page);
2987 let line_len = line_lens.get(new_line).copied().unwrap_or(0);
2988 let new_col = cursor.desired_col.min(line_len);
2989 cursor.move_to(new_line, new_col, extend_selection);
2990 }
2991 self.cursors_mut().merge_overlapping();
2992 }
2993
2994 fn page_down(&mut self, extend_selection: bool) {
2995 let page = self.screen.rows.saturating_sub(2) as usize;
2996 let line_count = self.buffer().line_count();
2997 let max_line = line_count.saturating_sub(1);
2998 let line_lens: Vec<usize> = (0..line_count).map(|l| self.buffer().line_len(l)).collect();
2999
3000 for cursor in self.cursors_mut().all_mut() {
3001 let new_line = (cursor.line + page).min(max_line);
3002 let line_len = line_lens.get(new_line).copied().unwrap_or(0);
3003 let new_col = cursor.desired_col.min(line_len);
3004 cursor.move_to(new_line, new_col, extend_selection);
3005 }
3006 self.cursors_mut().merge_overlapping();
3007 }
3008
3009 // === Selection ===
3010
3011 fn select_line(&mut self) {
3012 // Select the entire current line (including newline if not last line)
3013 let line_len = self.buffer().line_len(self.cursor().line);
3014 self.cursor_mut().anchor_line = self.cursor().line;
3015 self.cursor_mut().anchor_col = 0;
3016 self.cursor_mut().col = line_len;
3017 self.cursor_mut().desired_col = line_len;
3018 self.cursor_mut().selecting = true;
3019 }
3020
3021 fn select_word(&mut self) {
3022 // If primary cursor has a selection, find next occurrence and add cursor there
3023 if self.cursor().has_selection() {
3024 self.select_next_occurrence();
3025 return;
3026 }
3027
3028 // No selection - select word at cursor
3029 if let Some(line_str) = self.buffer().line_str(self.cursor().line) {
3030 let chars: Vec<char> = line_str.chars().collect();
3031 let col = self.cursor().col.min(chars.len());
3032
3033 // Find word boundaries
3034 let mut start = col;
3035 let mut end = col;
3036
3037 // If cursor is on a word char, expand to word boundaries
3038 if col < chars.len() && is_word_char(chars[col]) {
3039 // Expand left
3040 while start > 0 && is_word_char(chars[start - 1]) {
3041 start -= 1;
3042 }
3043 // Expand right
3044 while end < chars.len() && is_word_char(chars[end]) {
3045 end += 1;
3046 }
3047 } else if col > 0 && is_word_char(chars[col - 1]) {
3048 // Cursor is just after a word
3049 end = col;
3050 start = col - 1;
3051 while start > 0 && is_word_char(chars[start - 1]) {
3052 start -= 1;
3053 }
3054 }
3055
3056 if start < end {
3057 self.cursor_mut().anchor_line = self.cursor().line;
3058 self.cursor_mut().anchor_col = start;
3059 self.cursor_mut().col = end;
3060 self.cursor_mut().desired_col = end;
3061 self.cursor_mut().selecting = true;
3062 }
3063 }
3064 }
3065
3066 /// Find the next occurrence of the selected text and add a cursor there
3067 fn select_next_occurrence(&mut self) {
3068 // Get the selected text from primary cursor
3069 let selected_text = {
3070 let cursor = self.cursor();
3071 if !cursor.has_selection() {
3072 return;
3073 }
3074 let (start, end) = cursor.selection().ordered();
3075 let buffer = self.buffer();
3076
3077 // Extract selected text
3078 let mut text = String::new();
3079 for line_idx in start.line..=end.line {
3080 if let Some(line) = buffer.line_str(line_idx) {
3081 let line_start = if line_idx == start.line { start.col } else { 0 };
3082 let line_end = if line_idx == end.line { end.col } else { line.len() };
3083 if line_start < line_end && line_end <= line.len() {
3084 text.push_str(&line[line_start..line_end]);
3085 }
3086 if line_idx < end.line {
3087 text.push('\n');
3088 }
3089 }
3090 }
3091 text
3092 };
3093
3094 if selected_text.is_empty() {
3095 return;
3096 }
3097
3098 // Find the position to start searching from (after the last cursor with this selection)
3099 let search_start = {
3100 let cursors = self.cursors();
3101 let mut max_pos = (0usize, 0usize);
3102 for cursor in cursors.all() {
3103 if cursor.has_selection() {
3104 let (_, end) = cursor.selection().ordered();
3105 if (end.line, end.col) > max_pos {
3106 max_pos = (end.line, end.col);
3107 }
3108 }
3109 }
3110 max_pos
3111 };
3112
3113 // Search for next occurrence
3114 let buffer = self.buffer();
3115 let line_count = buffer.line_count();
3116 let search_text = &selected_text;
3117
3118 // Start searching from the line after the last selection end
3119 for line_idx in search_start.0..line_count {
3120 if let Some(line) = buffer.line_str(line_idx) {
3121 let start_col = if line_idx == search_start.0 { search_start.1 } else { 0 };
3122
3123 // Search for the text in this line (only works for single-line selections for now)
3124 if !search_text.contains('\n') {
3125 if let Some(found_col) = line[start_col..].find(search_text) {
3126 let match_start = start_col + found_col;
3127 let match_end = match_start + search_text.len();
3128
3129 // Add a new cursor with selection at this location
3130 self.cursors_mut().add_with_selection(
3131 line_idx,
3132 match_end,
3133 line_idx,
3134 match_start,
3135 );
3136 return;
3137 }
3138 }
3139 }
3140 }
3141
3142 // Wrap around to beginning if not found
3143 for line_idx in 0..=search_start.0 {
3144 if let Some(line) = buffer.line_str(line_idx) {
3145 let end_col = if line_idx == search_start.0 {
3146 // Don't search past where we started
3147 search_start.1.saturating_sub(search_text.len())
3148 } else {
3149 line.len()
3150 };
3151
3152 if !search_text.contains('\n') {
3153 if let Some(found_col) = line[..end_col].find(search_text) {
3154 let match_start = found_col;
3155 let match_end = match_start + search_text.len();
3156
3157 // Check if this position already has a cursor
3158 let already_has_cursor = self.cursors().all().iter().any(|c| {
3159 c.line == line_idx && c.col == match_end
3160 });
3161
3162 if !already_has_cursor {
3163 self.cursors_mut().add_with_selection(
3164 line_idx,
3165 match_end,
3166 line_idx,
3167 match_start,
3168 );
3169 return;
3170 }
3171 }
3172 }
3173 }
3174 }
3175
3176 // No more occurrences found
3177 self.message = Some("No more occurrences".to_string());
3178 }
3179
3180 // === Bracket/Quote Operations ===
3181
3182 fn jump_to_matching_bracket(&mut self) {
3183 // First check if cursor is on a bracket
3184 if let Some((line, col)) = self.buffer().find_matching_bracket(self.cursor().line, self.cursor().col) {
3185 self.cursor_mut().clear_selection();
3186 self.cursor_mut().line = line;
3187 self.cursor_mut().col = col;
3188 self.cursor_mut().desired_col = col;
3189 return;
3190 }
3191
3192 // If not on a bracket, find surrounding brackets and jump to opening
3193 if let Some((open_idx, close_idx, _, _)) = self.buffer().find_surrounding_brackets(self.cursor().line, self.cursor().col) {
3194 let cursor_idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3195 // Jump to whichever bracket we're not at
3196 let (target_line, target_col) = if cursor_idx == open_idx + 1 {
3197 self.buffer().char_to_line_col(close_idx)
3198 } else {
3199 self.buffer().char_to_line_col(open_idx)
3200 };
3201 self.cursor_mut().clear_selection();
3202 self.cursor_mut().line = target_line;
3203 self.cursor_mut().col = target_col;
3204 self.cursor_mut().desired_col = target_col;
3205 }
3206 }
3207
3208 fn cycle_quotes(&mut self) {
3209 // Find surrounding quotes (across lines) and cycle: " -> ' -> ` -> "
3210 if let Some((open_idx, close_idx, quote_char)) = self.buffer().find_surrounding_quotes(self.cursor().line, self.cursor().col) {
3211 let new_quote = match quote_char {
3212 '"' => '\'',
3213 '\'' => '`',
3214 '`' => '"',
3215 _ => return,
3216 };
3217
3218 let cursor_before = self.cursor_pos();
3219 self.history_mut().begin_group();
3220
3221 // Replace closing quote first (to maintain positions)
3222 self.buffer_mut().delete(close_idx, close_idx + 1);
3223 self.buffer_mut().insert(close_idx, &new_quote.to_string());
3224 self.history_mut().record_delete(close_idx, quote_char.to_string(), cursor_before, cursor_before);
3225 self.history_mut().record_insert(close_idx, new_quote.to_string(), cursor_before, cursor_before);
3226
3227 // Replace opening quote
3228 self.buffer_mut().delete(open_idx, open_idx + 1);
3229 self.buffer_mut().insert(open_idx, &new_quote.to_string());
3230 self.history_mut().record_delete(open_idx, quote_char.to_string(), cursor_before, cursor_before);
3231 self.history_mut().record_insert(open_idx, new_quote.to_string(), cursor_before, cursor_before);
3232
3233 self.history_mut().end_group();
3234 }
3235 }
3236
3237 fn cycle_brackets(&mut self) {
3238 // Find surrounding brackets (across lines) and cycle: ( -> { -> [ -> (
3239 if let Some((open_idx, close_idx, open, close)) = self.buffer().find_surrounding_brackets(self.cursor().line, self.cursor().col) {
3240 let (new_open, new_close) = match open {
3241 '(' => ('{', '}'),
3242 '{' => ('[', ']'),
3243 '[' => ('(', ')'),
3244 _ => return,
3245 };
3246
3247 let cursor_before = self.cursor_pos();
3248 self.history_mut().begin_group();
3249
3250 // Replace closing bracket first
3251 self.buffer_mut().delete(close_idx, close_idx + 1);
3252 self.buffer_mut().insert(close_idx, &new_close.to_string());
3253 self.history_mut().record_delete(close_idx, close.to_string(), cursor_before, cursor_before);
3254 self.history_mut().record_insert(close_idx, new_close.to_string(), cursor_before, cursor_before);
3255
3256 // Replace opening bracket
3257 self.buffer_mut().delete(open_idx, open_idx + 1);
3258 self.buffer_mut().insert(open_idx, &new_open.to_string());
3259 self.history_mut().record_delete(open_idx, open.to_string(), cursor_before, cursor_before);
3260 self.history_mut().record_insert(open_idx, new_open.to_string(), cursor_before, cursor_before);
3261
3262 self.history_mut().end_group();
3263 }
3264 }
3265
3266 fn remove_surrounding(&mut self) {
3267 // Remove surrounding quotes OR brackets (whichever is innermost/closest)
3268 let cursor_idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3269
3270 // Find both surrounding quotes and brackets
3271 let quotes = self.buffer().find_surrounding_quotes(self.cursor().line, self.cursor().col);
3272 let brackets = self.buffer().find_surrounding_brackets(self.cursor().line, self.cursor().col);
3273
3274 // Pick whichever has the closer opening (innermost)
3275 let (open_idx, close_idx, open_char, close_char) = match (quotes, brackets) {
3276 (Some((qo, qc, qch)), Some((bo, bc, bop, bcl))) => {
3277 if qo > bo { (qo, qc, qch, qch) } else { (bo, bc, bop, bcl) }
3278 }
3279 (Some((qo, qc, qch)), None) => (qo, qc, qch, qch),
3280 (None, Some((bo, bc, bop, bcl))) => (bo, bc, bop, bcl),
3281 (None, None) => return,
3282 };
3283
3284 let cursor_before = self.cursor_pos();
3285 self.history_mut().begin_group();
3286
3287 // Delete closing first (to maintain open position)
3288 self.buffer_mut().delete(close_idx, close_idx + 1);
3289 self.history_mut().record_delete(close_idx, close_char.to_string(), cursor_before, cursor_before);
3290
3291 // Delete opening
3292 self.buffer_mut().delete(open_idx, open_idx + 1);
3293 self.history_mut().record_delete(open_idx, open_char.to_string(), cursor_before, cursor_before);
3294
3295 // Adjust cursor position
3296 if cursor_idx > open_idx {
3297 self.cursor_mut().col = self.cursor().col.saturating_sub(1);
3298 }
3299 // Recalculate line/col after deletions
3300 let new_cursor_idx = if cursor_idx > close_idx {
3301 cursor_idx - 2
3302 } else if cursor_idx > open_idx {
3303 cursor_idx - 1
3304 } else {
3305 cursor_idx
3306 };
3307 let (new_line, new_col) = self.buffer().char_to_line_col(new_cursor_idx.min(self.buffer().len_chars().saturating_sub(1)));
3308 self.cursor_mut().line = new_line;
3309 self.cursor_mut().col = new_col;
3310 self.cursor_mut().desired_col = new_col;
3311
3312 self.history_mut().end_group();
3313 }
3314
3315 fn remove_surrounding_brackets(&mut self) {
3316 // Remove only surrounding brackets (not quotes)
3317 if let Some((open_idx, close_idx, open, close)) = self.buffer().find_surrounding_brackets(self.cursor().line, self.cursor().col) {
3318 let cursor_idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3319 let cursor_before = self.cursor_pos();
3320 self.history_mut().begin_group();
3321
3322 // Delete closing first
3323 self.buffer_mut().delete(close_idx, close_idx + 1);
3324 self.history_mut().record_delete(close_idx, close.to_string(), cursor_before, cursor_before);
3325
3326 // Delete opening
3327 self.buffer_mut().delete(open_idx, open_idx + 1);
3328 self.history_mut().record_delete(open_idx, open.to_string(), cursor_before, cursor_before);
3329
3330 // Recalculate cursor position after deletions
3331 let new_cursor_idx = if cursor_idx > close_idx {
3332 cursor_idx - 2
3333 } else if cursor_idx > open_idx {
3334 cursor_idx - 1
3335 } else {
3336 cursor_idx
3337 };
3338 let (new_line, new_col) = self.buffer().char_to_line_col(new_cursor_idx.min(self.buffer().len_chars().saturating_sub(1)));
3339 self.cursor_mut().line = new_line;
3340 self.cursor_mut().col = new_col;
3341 self.cursor_mut().desired_col = new_col;
3342
3343 self.history_mut().end_group();
3344 }
3345 }
3346
3347 // === Editing ===
3348
3349 fn cursor_pos(&self) -> Position {
3350 Position::new(self.cursor().line, self.cursor().col)
3351 }
3352
3353 /// Get all cursor positions (for multi-cursor undo/redo)
3354 fn all_cursor_positions(&self) -> Vec<Position> {
3355 self.cursors().all().iter().map(|c| Position::new(c.line, c.col)).collect()
3356 }
3357
3358 fn delete_selection(&mut self) -> bool {
3359 if let Some((start, end)) = self.cursor().selection_bounds() {
3360 let start_idx = self.buffer().line_col_to_char(start.line, start.col);
3361 let end_idx = self.buffer().line_col_to_char(end.line, end.col);
3362
3363 // Record for undo
3364 let deleted_text: String = self.buffer().slice(start_idx, end_idx).chars().collect();
3365 let cursor_before = self.cursor_pos();
3366
3367 // Invalidate caches
3368 self.invalidate_highlight_cache(start.line);
3369 self.invalidate_bracket_cache();
3370
3371 self.buffer_mut().delete(start_idx, end_idx);
3372
3373 self.cursor_mut().line = start.line;
3374 self.cursor_mut().col = start.col;
3375 self.cursor_mut().desired_col = start.col;
3376 self.cursor_mut().clear_selection();
3377
3378 let cursor_after = self.cursor_pos();
3379 self.history_mut().record_delete(start_idx, deleted_text, cursor_before, cursor_after);
3380 self.history_mut().maybe_break_group();
3381
3382 true
3383 } else {
3384 false
3385 }
3386 }
3387
3388 /// Insert text at all cursor positions (for multi-cursor support)
3389 fn insert_text_multi(&mut self, text: &str) {
3390 if self.cursors().len() == 1 {
3391 // Single cursor - use simple path
3392 self.insert_text_single(text);
3393 return;
3394 }
3395
3396 // Multi-cursor: compute absolute character indices FIRST from a frozen view of the buffer.
3397 // Then sort by ASCENDING char index, apply edits from start to end,
3398 // and track cumulative offset to adjust subsequent positions.
3399
3400 // Step 1: Compute char indices for all cursors from current buffer state
3401 let mut cursor_char_indices: Vec<(usize, usize)> = self.cursors().all()
3402 .iter()
3403 .enumerate()
3404 .map(|(i, c)| {
3405 let char_idx = self.buffer().line_col_to_char(c.line, c.col);
3406 (i, char_idx)
3407 })
3408 .collect();
3409
3410 // Step 2: Sort by ASCENDING char index (process from start of document)
3411 cursor_char_indices.sort_by(|a, b| a.1.cmp(&b.1));
3412
3413 // Invalidate caches from the earliest cursor's line
3414 if let Some(&(first_cursor_idx, _)) = cursor_char_indices.first() {
3415 let first_line = self.cursors().all()[first_cursor_idx].line;
3416 self.invalidate_highlight_cache(first_line);
3417 }
3418 self.invalidate_bracket_cache();
3419
3420 // Record all cursor positions before the operation
3421 let cursors_before = self.all_cursor_positions();
3422 self.history_mut().begin_group();
3423 self.history_mut().set_cursors_before(cursors_before);
3424
3425 let text_char_count = text.chars().count();
3426 let cursor_before = self.cursor_pos();
3427
3428 // Step 3: Apply inserts from start to end, tracking cumulative offset
3429 let mut cumulative_offset: usize = 0;
3430 let mut new_positions: Vec<(usize, usize, usize)> = Vec::new(); // (cursor_idx, line, col)
3431
3432 for (cursor_idx, original_char_idx) in cursor_char_indices {
3433 // Adjust position by cumulative offset from previous inserts
3434 let adjusted_char_idx = original_char_idx + cumulative_offset;
3435
3436 self.buffer_mut().insert(adjusted_char_idx, text);
3437 self.history_mut().record_insert(adjusted_char_idx, text.to_string(), cursor_before, cursor_before);
3438
3439 // New cursor position is right after the inserted text
3440 let new_char_idx = adjusted_char_idx + text_char_count;
3441 let (new_line, new_col) = self.buffer().char_to_line_col(new_char_idx);
3442 new_positions.push((cursor_idx, new_line, new_col));
3443
3444 // Update cumulative offset for next cursor
3445 cumulative_offset += text_char_count;
3446 }
3447
3448 // Step 4: Update all cursor positions at once
3449 for (cursor_idx, new_line, new_col) in new_positions {
3450 let cursor = &mut self.cursors_mut().all_mut()[cursor_idx];
3451 cursor.line = new_line;
3452 cursor.col = new_col;
3453 cursor.desired_col = new_col;
3454 }
3455
3456 // Record all cursor positions after the operation
3457 let cursors_after = self.all_cursor_positions();
3458 self.history_mut().set_cursors_after(cursors_after);
3459 self.history_mut().end_group();
3460 self.cursors_mut().merge_overlapping();
3461 }
3462
3463 /// Insert text at single (primary) cursor position
3464 fn insert_text_single(&mut self, text: &str) {
3465 self.delete_selection();
3466
3467 let cursor_before = self.cursor_pos();
3468 let edit_line = self.cursor().line;
3469 let idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3470
3471 self.buffer_mut().insert(idx, text);
3472 self.invalidate_highlight_cache(edit_line);
3473 self.invalidate_bracket_cache();
3474 self.history_mut().record_insert(idx, text.to_string(), cursor_before, Position::new(0, 0));
3475
3476 // Update cursor position
3477 for c in text.chars() {
3478 if c == '\n' {
3479 self.cursor_mut().line += 1;
3480 self.cursor_mut().col = 0;
3481 } else {
3482 self.cursor_mut().col += 1;
3483 }
3484 }
3485 self.cursor_mut().desired_col = self.cursor().col;
3486
3487 // Update the cursor_after in history
3488 let cursor_after = self.cursor_pos();
3489 if let Some(op) = self.history_mut().undo_stack_last_mut() {
3490 if let Operation::Insert { cursor_after: ref mut ca, .. } = op {
3491 *ca = cursor_after;
3492 }
3493 }
3494 }
3495
3496 fn insert_text(&mut self, text: &str) {
3497 self.insert_text_multi(text);
3498 }
3499
3500 fn insert_char(&mut self, c: char) {
3501 // For multi-cursor, use simple insert (skip auto-pair complexity for now)
3502 if self.cursors().len() > 1 {
3503 self.insert_text_multi(&c.to_string());
3504 self.dismiss_ghost_text();
3505 return;
3506 }
3507
3508 // Single cursor: handle auto-pair
3509 // Check for auto-pair closing: if typing a closing bracket/quote
3510 // and the next char is the same, just move cursor right
3511 if let Some(next_char) = self.char_at_cursor() {
3512 if c == next_char && (c == ')' || c == ']' || c == '}' || c == '"' || c == '\'' || c == '`') {
3513 self.cursor_mut().col += 1;
3514 self.cursor_mut().desired_col = self.cursor().col;
3515 self.dismiss_ghost_text();
3516 return;
3517 }
3518 }
3519
3520 // Check for auto-pair opening: insert pair and place cursor between
3521 let pair = match c {
3522 '(' => Some(')'),
3523 '[' => Some(']'),
3524 '{' => Some('}'),
3525 '"' => Some('"'),
3526 '\'' => Some('\''),
3527 '`' => Some('`'),
3528 _ => None,
3529 };
3530
3531 if let Some(close) = pair {
3532 // For quotes, only auto-pair if not inside a word
3533 let should_pair = if c == '"' || c == '\'' || c == '`' {
3534 // Don't auto-pair if previous char is alphanumeric (e.g., typing apostrophe in "don't")
3535 let prev_char = if self.cursor().col > 0 {
3536 let idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3537 self.buffer().char_at(idx.saturating_sub(1))
3538 } else {
3539 None
3540 };
3541 !prev_char.map_or(false, |ch| ch.is_alphanumeric())
3542 } else {
3543 true
3544 };
3545
3546 if should_pair {
3547 self.delete_selection();
3548 let cursor_before = self.cursor_pos();
3549 let idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3550 let pair_str = format!("{}{}", c, close);
3551
3552 self.buffer_mut().insert(idx, &pair_str);
3553 self.cursor_mut().col += 1; // Position cursor between the pair
3554 self.cursor_mut().desired_col = self.cursor().col;
3555
3556 let cursor_after = self.cursor_pos();
3557 self.history_mut().record_insert(idx, pair_str, cursor_before, cursor_after);
3558 self.dismiss_ghost_text();
3559 return;
3560 }
3561 }
3562
3563 self.insert_text(&c.to_string());
3564
3565 // Update ghost text after alphanumeric input
3566 if c.is_alphanumeric() || c == '_' {
3567 self.update_ghost_text();
3568 } else {
3569 self.dismiss_ghost_text();
3570 }
3571 }
3572
3573 /// Get character at cursor position (if any)
3574 fn char_at_cursor(&self) -> Option<char> {
3575 let idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3576 self.buffer().char_at(idx)
3577 }
3578
3579 fn insert_newline(&mut self) {
3580 self.history_mut().maybe_break_group();
3581 self.insert_text("\n");
3582 self.history_mut().maybe_break_group();
3583 }
3584
3585 fn insert_tab(&mut self) {
3586 if self.cursor().has_selection() {
3587 self.indent_selection();
3588 } else {
3589 self.insert_text(" ");
3590 }
3591 }
3592
3593 /// Indent all lines in selection
3594 fn indent_selection(&mut self) {
3595 if let Some((start, end)) = self.cursor().selection_bounds() {
3596 let cursor_before = self.cursor_pos();
3597 self.history_mut().begin_group();
3598
3599 // Indent each line from start to end (inclusive)
3600 for line_idx in start.line..=end.line {
3601 let line_start = self.buffer().line_col_to_char(line_idx, 0);
3602 let indent = " ";
3603 self.buffer_mut().insert(line_start, indent);
3604 self.history_mut().record_insert(line_start, indent.to_string(), cursor_before, cursor_before);
3605 }
3606
3607 // Adjust selection to cover the indented text
3608 self.cursor_mut().anchor_col += 4;
3609 self.cursor_mut().col += 4;
3610 self.cursor_mut().desired_col = self.cursor().col;
3611
3612 self.history_mut().end_group();
3613 }
3614 }
3615
3616 /// Delete backward at all cursor positions (multi-cursor)
3617 fn delete_backward_multi(&mut self) {
3618 // Multi-cursor: compute absolute character indices FIRST from a frozen view of the buffer.
3619 // Sort by ASCENDING, process start to end, track cumulative offset.
3620
3621 // Step 1: Compute char indices for all cursors from current buffer state
3622 let mut cursor_char_indices: Vec<(usize, usize)> = self.cursors().all()
3623 .iter()
3624 .enumerate()
3625 .map(|(i, c)| {
3626 let char_idx = self.buffer().line_col_to_char(c.line, c.col);
3627 (i, char_idx)
3628 })
3629 .collect();
3630
3631 // Step 2: Sort by ASCENDING char index (process from start of document)
3632 cursor_char_indices.sort_by(|a, b| a.1.cmp(&b.1));
3633
3634 // Record all cursor positions before the operation
3635 let cursors_before = self.all_cursor_positions();
3636 self.history_mut().begin_group();
3637 self.history_mut().set_cursors_before(cursors_before);
3638
3639 let cursor_before = self.cursor_pos();
3640
3641 // Step 3: Apply deletes from start to end, tracking cumulative offset
3642 let mut cumulative_offset: isize = 0;
3643 let mut new_positions: Vec<(usize, usize, usize)> = Vec::new();
3644
3645 for (cursor_idx, original_char_idx) in cursor_char_indices {
3646 if original_char_idx == 0 {
3647 // Can't delete backward from position 0, keep cursor where it is
3648 let cursor = &self.cursors().all()[cursor_idx];
3649 new_positions.push((cursor_idx, cursor.line, cursor.col));
3650 continue;
3651 }
3652
3653 // Adjust position by cumulative offset from previous deletes
3654 let adjusted_char_idx = (original_char_idx as isize + cumulative_offset) as usize;
3655
3656 if adjusted_char_idx > 0 {
3657 let deleted = self.buffer().char_at(adjusted_char_idx - 1).map(|c| c.to_string()).unwrap_or_default();
3658 self.buffer_mut().delete(adjusted_char_idx - 1, adjusted_char_idx);
3659 self.history_mut().record_delete(adjusted_char_idx - 1, deleted, cursor_before, cursor_before);
3660
3661 // New cursor position is at the delete point
3662 let new_char_idx = adjusted_char_idx - 1;
3663 let (new_line, new_col) = self.buffer().char_to_line_col(new_char_idx);
3664 new_positions.push((cursor_idx, new_line, new_col));
3665
3666 // Update cumulative offset (we deleted 1 char, so offset decreases by 1)
3667 cumulative_offset -= 1;
3668 }
3669 }
3670
3671 // Step 4: Update all cursor positions at once
3672 for (cursor_idx, new_line, new_col) in new_positions {
3673 let cursor = &mut self.cursors_mut().all_mut()[cursor_idx];
3674 cursor.line = new_line;
3675 cursor.col = new_col;
3676 cursor.desired_col = new_col;
3677 }
3678
3679 // Record all cursor positions after the operation
3680 let cursors_after = self.all_cursor_positions();
3681 self.history_mut().set_cursors_after(cursors_after);
3682 self.history_mut().end_group();
3683 self.cursors_mut().merge_overlapping();
3684 }
3685
3686 /// Delete forward at all cursor positions (multi-cursor)
3687 fn delete_forward_multi(&mut self) {
3688 // Multi-cursor: compute absolute character indices FIRST from a frozen view of the buffer.
3689 // Sort by ASCENDING, process start to end, track cumulative offset.
3690
3691 let total_chars = self.buffer().char_count();
3692
3693 // Step 1: Compute char indices for all cursors from current buffer state
3694 let mut cursor_char_indices: Vec<(usize, usize)> = self.cursors().all()
3695 .iter()
3696 .enumerate()
3697 .map(|(i, c)| {
3698 let char_idx = self.buffer().line_col_to_char(c.line, c.col);
3699 (i, char_idx)
3700 })
3701 .collect();
3702
3703 // Step 2: Sort by ASCENDING char index (process from start of document)
3704 cursor_char_indices.sort_by(|a, b| a.1.cmp(&b.1));
3705
3706 // Record all cursor positions before the operation
3707 let cursors_before = self.all_cursor_positions();
3708 self.history_mut().begin_group();
3709 self.history_mut().set_cursors_before(cursors_before);
3710
3711 let cursor_before = self.cursor_pos();
3712
3713 // Step 3: Apply deletes from start to end, tracking cumulative offset
3714 let mut cumulative_offset: isize = 0;
3715 let mut new_positions: Vec<(usize, usize, usize)> = Vec::new();
3716
3717 for (cursor_idx, original_char_idx) in cursor_char_indices {
3718 // Adjust position by cumulative offset from previous deletes
3719 let adjusted_char_idx = (original_char_idx as isize + cumulative_offset) as usize;
3720 let current_total = (total_chars as isize + cumulative_offset) as usize;
3721
3722 if adjusted_char_idx < current_total {
3723 let deleted = self.buffer().char_at(adjusted_char_idx).map(|c| c.to_string()).unwrap_or_default();
3724 // Don't delete newlines in multi-cursor mode for simplicity
3725 if deleted != "\n" {
3726 self.buffer_mut().delete(adjusted_char_idx, adjusted_char_idx + 1);
3727 self.history_mut().record_delete(adjusted_char_idx, deleted, cursor_before, cursor_before);
3728 cumulative_offset -= 1;
3729 }
3730 }
3731
3732 // Cursor position: convert from adjusted char index (cursor doesn't move for delete forward)
3733 let (new_line, new_col) = self.buffer().char_to_line_col(adjusted_char_idx.min(self.buffer().char_count()));
3734 new_positions.push((cursor_idx, new_line, new_col));
3735 }
3736
3737 // Step 4: Update all cursor positions at once
3738 for (cursor_idx, new_line, new_col) in new_positions {
3739 let cursor = &mut self.cursors_mut().all_mut()[cursor_idx];
3740 cursor.line = new_line;
3741 cursor.col = new_col;
3742 cursor.desired_col = new_col;
3743 }
3744
3745 // Record all cursor positions after the operation
3746 let cursors_after = self.all_cursor_positions();
3747 self.history_mut().set_cursors_after(cursors_after);
3748 self.history_mut().end_group();
3749 self.cursors_mut().merge_overlapping();
3750 }
3751
3752 fn delete_backward(&mut self) {
3753 // For multi-cursor, use simplified delete
3754 if self.cursors().len() > 1 {
3755 self.delete_backward_multi();
3756 return;
3757 }
3758
3759 if self.delete_selection() {
3760 return;
3761 }
3762
3763 // Invalidate caches from cursor line (or previous line if merging)
3764 let invalidate_line = if self.cursor().col == 0 && self.cursor().line > 0 {
3765 self.cursor().line - 1
3766 } else {
3767 self.cursor().line
3768 };
3769 self.invalidate_highlight_cache(invalidate_line);
3770 self.invalidate_bracket_cache();
3771
3772 if self.cursor().col > 0 {
3773 let cursor_before = self.cursor_pos();
3774 let idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3775 let prev_char = self.buffer().char_at(idx - 1);
3776 let next_char = self.buffer().char_at(idx);
3777
3778 // Check for auto-pair deletion: if deleting opening bracket/quote
3779 // and next char is the matching close, delete both
3780 let is_pair = match (prev_char, next_char) {
3781 (Some('('), Some(')')) => true,
3782 (Some('['), Some(']')) => true,
3783 (Some('{'), Some('}')) => true,
3784 (Some('"'), Some('"')) => true,
3785 (Some('\''), Some('\'')) => true,
3786 (Some('`'), Some('`')) => true,
3787 _ => false,
3788 };
3789
3790 if is_pair {
3791 // Delete both characters
3792 let deleted = format!("{}{}", prev_char.unwrap(), next_char.unwrap());
3793 self.buffer_mut().delete(idx - 1, idx + 1);
3794 self.cursor_mut().col -= 1;
3795 self.cursor_mut().desired_col = self.cursor().col;
3796
3797 let cursor_after = self.cursor_pos();
3798 self.history_mut().record_delete(idx - 1, deleted, cursor_before, cursor_after);
3799 } else {
3800 let deleted = prev_char.map(|c| c.to_string()).unwrap_or_default();
3801
3802 self.buffer_mut().delete(idx - 1, idx);
3803 self.cursor_mut().col -= 1;
3804 self.cursor_mut().desired_col = self.cursor().col;
3805
3806 let cursor_after = self.cursor_pos();
3807 self.history_mut().record_delete(idx - 1, deleted, cursor_before, cursor_after);
3808 }
3809 } else if self.cursor().line > 0 {
3810 let cursor_before = self.cursor_pos();
3811 let prev_line_len = self.buffer().line_len(self.cursor().line - 1);
3812 let idx = self.buffer().line_col_to_char(self.cursor().line, 0);
3813
3814 self.buffer_mut().delete(idx - 1, idx);
3815 self.cursor_mut().line -= 1;
3816 self.cursor_mut().col = prev_line_len;
3817 self.cursor_mut().desired_col = self.cursor().col;
3818
3819 let cursor_after = self.cursor_pos();
3820 self.history_mut().record_delete(idx - 1, "\n".to_string(), cursor_before, cursor_after);
3821 self.history_mut().maybe_break_group();
3822 }
3823 }
3824
3825 fn delete_forward(&mut self) {
3826 // For multi-cursor, use simplified delete
3827 if self.cursors().len() > 1 {
3828 self.delete_forward_multi();
3829 return;
3830 }
3831
3832 if self.delete_selection() {
3833 return;
3834 }
3835
3836 // Invalidate caches from cursor line
3837 self.invalidate_highlight_cache(self.cursor().line);
3838 self.invalidate_bracket_cache();
3839
3840 let line_len = self.buffer().line_len(self.cursor().line);
3841 let idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3842
3843 if self.cursor().col < line_len {
3844 let cursor_before = self.cursor_pos();
3845 let deleted = self.buffer().char_at(idx).map(|c| c.to_string()).unwrap_or_default();
3846 self.buffer_mut().delete(idx, idx + 1);
3847 let cursor_after = self.cursor_pos();
3848 self.history_mut().record_delete(idx, deleted, cursor_before, cursor_after);
3849 } else if self.cursor().line + 1 < self.buffer().line_count() {
3850 let cursor_before = self.cursor_pos();
3851 self.buffer_mut().delete(idx, idx + 1);
3852 let cursor_after = self.cursor_pos();
3853 self.history_mut().record_delete(idx, "\n".to_string(), cursor_before, cursor_after);
3854 self.history_mut().maybe_break_group();
3855 }
3856 }
3857
3858 fn delete_word_backward(&mut self) {
3859 // For multi-cursor, use multi version
3860 if self.cursors().len() > 1 {
3861 self.delete_word_backward_multi();
3862 return;
3863 }
3864
3865 if self.delete_selection() {
3866 return;
3867 }
3868
3869 let start_col = self.cursor().col;
3870 self.move_word_left(false);
3871
3872 if self.cursor_mut().line == self.cursor().line && self.cursor().col < start_col {
3873 let cursor_before = Position::new(self.cursor().line, start_col);
3874 let start_idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3875 let end_idx = self.buffer().line_col_to_char(self.cursor().line, start_col);
3876 let deleted: String = self.buffer().slice(start_idx, end_idx).chars().collect();
3877
3878 self.buffer_mut().delete(start_idx, end_idx);
3879 self.yank_push(deleted.clone());
3880 let cursor_after = self.cursor_pos();
3881 self.history_mut().record_delete(start_idx, deleted, cursor_before, cursor_after);
3882 self.history_mut().maybe_break_group();
3883 }
3884 }
3885
3886 fn delete_word_backward_multi(&mut self) {
3887 // Collect cursor positions, process from bottom to top
3888 let mut cursor_data: Vec<(usize, usize, usize)> = self.cursors().all()
3889 .iter()
3890 .enumerate()
3891 .map(|(i, c)| (i, c.line, c.col))
3892 .collect();
3893
3894 // Sort by position, bottom-right first
3895 cursor_data.sort_by(|a, b| {
3896 match b.1.cmp(&a.1) {
3897 std::cmp::Ordering::Equal => b.2.cmp(&a.2),
3898 ord => ord,
3899 }
3900 });
3901
3902 // Record all cursor positions before the operation
3903 let cursors_before = self.all_cursor_positions();
3904 self.history_mut().begin_group();
3905 self.history_mut().set_cursors_before(cursors_before);
3906
3907 for (cursor_idx, line, col) in cursor_data {
3908 if col == 0 {
3909 continue; // Can't delete word at start of line in multi-cursor mode
3910 }
3911
3912 // Find word start (same logic as move_word_left)
3913 let line_str = self.buffer().line_str(line).unwrap_or_default();
3914 let chars: Vec<char> = line_str.chars().collect();
3915 let mut new_col = col;
3916
3917 // Skip whitespace backward
3918 while new_col > 0 && chars.get(new_col - 1).map(|c| c.is_whitespace()).unwrap_or(false) {
3919 new_col -= 1;
3920 }
3921
3922 // Skip word characters backward
3923 if new_col > 0 {
3924 let is_word = chars.get(new_col - 1).map(|c| is_word_char(*c)).unwrap_or(false);
3925 if is_word {
3926 while new_col > 0 && chars.get(new_col - 1).map(|c| is_word_char(*c)).unwrap_or(false) {
3927 new_col -= 1;
3928 }
3929 } else {
3930 // Skip punctuation
3931 while new_col > 0 && chars.get(new_col - 1).map(|c| !c.is_whitespace() && !is_word_char(*c)).unwrap_or(false) {
3932 new_col -= 1;
3933 }
3934 }
3935 }
3936
3937 if new_col < col {
3938 let cursor_before = Position::new(line, col);
3939 let start_idx = self.buffer().line_col_to_char(line, new_col);
3940 let end_idx = self.buffer().line_col_to_char(line, col);
3941 let deleted: String = self.buffer().slice(start_idx, end_idx).chars().collect();
3942
3943 self.buffer_mut().delete(start_idx, end_idx);
3944 self.history_mut().record_delete(start_idx, deleted, cursor_before, cursor_before);
3945
3946 // Update cursor position
3947 let cursor = &mut self.cursors_mut().all_mut()[cursor_idx];
3948 cursor.col = new_col;
3949 cursor.desired_col = new_col;
3950 }
3951 }
3952
3953 // Record all cursor positions after the operation
3954 let cursors_after = self.all_cursor_positions();
3955 self.history_mut().set_cursors_after(cursors_after);
3956 self.history_mut().end_group();
3957 self.cursors_mut().merge_overlapping();
3958 }
3959
3960 fn delete_word_forward(&mut self) {
3961 if self.delete_selection() {
3962 return;
3963 }
3964
3965 let start_line = self.cursor().line;
3966 let start_col = self.cursor().col;
3967 self.move_word_right(false);
3968
3969 let cursor_before = Position::new(start_line, start_col);
3970 let start_idx = self.buffer().line_col_to_char(start_line, start_col);
3971 let end_idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
3972
3973 if end_idx > start_idx {
3974 let deleted: String = self.buffer().slice(start_idx, end_idx).chars().collect();
3975 self.buffer_mut().delete(start_idx, end_idx);
3976 self.yank_push(deleted.clone());
3977 self.cursor_mut().line = start_line;
3978 self.cursor_mut().col = start_col;
3979 let cursor_after = self.cursor_pos();
3980 self.history_mut().record_delete(start_idx, deleted, cursor_before, cursor_after);
3981 self.history_mut().maybe_break_group();
3982 }
3983 }
3984
3985 /// Push text onto the yank stack (kill ring)
3986 fn yank_push(&mut self, text: String) {
3987 if text.is_empty() {
3988 return;
3989 }
3990 // Limit stack size to 32 entries
3991 const MAX_YANK_STACK: usize = 32;
3992 if self.yank_stack.len() >= MAX_YANK_STACK {
3993 self.yank_stack.remove(0);
3994 }
3995 self.yank_stack.push(text);
3996 // Reset cycling state
3997 self.yank_index = None;
3998 }
3999
4000 /// Yank (paste) from yank stack (Ctrl+Y)
4001 fn yank(&mut self) {
4002 if self.yank_stack.is_empty() {
4003 self.message = Some("Yank stack empty".to_string());
4004 return;
4005 }
4006
4007 // Delete selection first if any
4008 self.delete_selection();
4009
4010 // Get the most recent entry
4011 let text = self.yank_stack.last().unwrap().clone();
4012 let cursor_before = self.cursor_pos();
4013
4014 // Insert the text
4015 let idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
4016 self.buffer_mut().insert(idx, &text);
4017
4018 // Move cursor to end of inserted text
4019 let text_len = text.chars().count();
4020 self.last_yank_len = text_len;
4021
4022 // Update cursor position
4023 for ch in text.chars() {
4024 if ch == '\n' {
4025 self.cursor_mut().line += 1;
4026 self.cursor_mut().col = 0;
4027 } else {
4028 self.cursor_mut().col += 1;
4029 }
4030 }
4031 self.cursor_mut().desired_col = self.cursor().col;
4032
4033 let cursor_after = self.cursor_pos();
4034 self.history_mut().record_insert(idx, text, cursor_before, cursor_after);
4035
4036 // Set yank index for cycling
4037 self.yank_index = Some(self.yank_stack.len() - 1);
4038 }
4039
4040 /// Cycle through yank stack (Alt+Y) - must be used after Ctrl+Y
4041 fn yank_cycle(&mut self) {
4042 // Only works if we just yanked
4043 let current_idx = match self.yank_index {
4044 Some(idx) => idx,
4045 None => {
4046 self.message = Some("No active yank to cycle".to_string());
4047 return;
4048 }
4049 };
4050
4051 if self.yank_stack.len() <= 1 {
4052 self.message = Some("Only one item in yank stack".to_string());
4053 return;
4054 }
4055
4056 // Calculate previous index (cycle backwards through stack)
4057 let new_idx = if current_idx == 0 {
4058 self.yank_stack.len() - 1
4059 } else {
4060 current_idx - 1
4061 };
4062
4063 // Delete the previously yanked text
4064 let cursor_before = self.cursor_pos();
4065 let end_idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
4066 let start_idx = end_idx.saturating_sub(self.last_yank_len);
4067
4068 if start_idx < end_idx {
4069 let deleted: String = self.buffer().slice(start_idx, end_idx).chars().collect();
4070 self.buffer_mut().delete(start_idx, end_idx);
4071
4072 // Move cursor back
4073 let (line, col) = self.buffer().char_to_line_col(start_idx);
4074 self.cursor_mut().line = line;
4075 self.cursor_mut().col = col;
4076 self.cursor_mut().desired_col = self.cursor().col;
4077 }
4078
4079 // Insert the new text from yank stack
4080 let text = self.yank_stack[new_idx].clone();
4081 let idx = self.buffer().line_col_to_char(self.cursor().line, self.cursor().col);
4082 self.buffer_mut().insert(idx, &text);
4083
4084 // Move cursor to end of inserted text
4085 let text_len = text.chars().count();
4086 self.last_yank_len = text_len;
4087
4088 for ch in text.chars() {
4089 if ch == '\n' {
4090 self.cursor_mut().line += 1;
4091 self.cursor_mut().col = 0;
4092 } else {
4093 self.cursor_mut().col += 1;
4094 }
4095 }
4096 self.cursor_mut().desired_col = self.cursor().col;
4097
4098 let cursor_after = self.cursor_pos();
4099 self.history_mut().record_insert(idx, text, cursor_before, cursor_after);
4100
4101 // Update yank index
4102 self.yank_index = Some(new_idx);
4103 }
4104
4105 /// Kill (delete) from cursor to end of line (Ctrl+K)
4106 fn kill_to_end_of_line(&mut self) {
4107 if self.delete_selection() {
4108 return;
4109 }
4110
4111 let line = self.cursor().line;
4112 let col = self.cursor().col;
4113 let line_len = self.buffer().line_len(line);
4114
4115 if col >= line_len {
4116 // At end of line - delete the newline to join with next line
4117 if line < self.buffer().line_count().saturating_sub(1) {
4118 let cursor_before = self.cursor_pos();
4119 let idx = self.buffer().line_col_to_char(line, col);
4120 self.buffer_mut().delete(idx, idx + 1);
4121 self.yank_push("\n".to_string());
4122 let cursor_after = self.cursor_pos();
4123 self.history_mut().record_delete(idx, "\n".to_string(), cursor_before, cursor_after);
4124 }
4125 } else {
4126 // Delete from cursor to end of line
4127 let cursor_before = self.cursor_pos();
4128 let start_idx = self.buffer().line_col_to_char(line, col);
4129 let end_idx = self.buffer().line_col_to_char(line, line_len);
4130 let deleted: String = self.buffer().slice(start_idx, end_idx).chars().collect();
4131 self.buffer_mut().delete(start_idx, end_idx);
4132 self.yank_push(deleted.clone());
4133 let cursor_after = self.cursor_pos();
4134 self.history_mut().record_delete(start_idx, deleted, cursor_before, cursor_after);
4135 }
4136 self.history_mut().maybe_break_group();
4137 }
4138
4139 /// Kill (delete) from cursor to start of line (Ctrl+U)
4140 fn kill_to_start_of_line(&mut self) {
4141 if self.delete_selection() {
4142 return;
4143 }
4144
4145 let line = self.cursor().line;
4146 let col = self.cursor().col;
4147
4148 if col == 0 {
4149 // At start of line - delete the newline before to join with previous line
4150 if line > 0 {
4151 let cursor_before = self.cursor_pos();
4152 let idx = self.buffer().line_col_to_char(line, 0);
4153 self.buffer_mut().delete(idx - 1, idx);
4154 self.yank_push("\n".to_string());
4155 // Move cursor to end of previous line
4156 self.cursor_mut().line = line - 1;
4157 self.cursor_mut().col = self.buffer().line_len(line - 1);
4158 self.cursor_mut().desired_col = self.cursor().col;
4159 let cursor_after = self.cursor_pos();
4160 self.history_mut().record_delete(idx - 1, "\n".to_string(), cursor_before, cursor_after);
4161 }
4162 } else {
4163 // Delete from start of line to cursor
4164 let cursor_before = self.cursor_pos();
4165 let start_idx = self.buffer().line_col_to_char(line, 0);
4166 let end_idx = self.buffer().line_col_to_char(line, col);
4167 let deleted: String = self.buffer().slice(start_idx, end_idx).chars().collect();
4168 self.buffer_mut().delete(start_idx, end_idx);
4169 self.yank_push(deleted.clone());
4170 self.cursor_mut().col = 0;
4171 self.cursor_mut().desired_col = 0;
4172 let cursor_after = self.cursor_pos();
4173 self.history_mut().record_delete(start_idx, deleted, cursor_before, cursor_after);
4174 }
4175 self.history_mut().maybe_break_group();
4176 }
4177
4178 fn transpose_chars(&mut self) {
4179 // Transpose the two characters around the cursor
4180 // If at end of line, swap the two chars before cursor
4181 // If at start of line, do nothing
4182 let line_len = self.buffer().line_len(self.cursor().line);
4183 if line_len < 2 {
4184 return;
4185 }
4186
4187 let (swap_pos, move_cursor) = if self.cursor_mut().col == 0 {
4188 // At start of line - nothing to transpose
4189 return;
4190 } else if self.cursor().col >= line_len {
4191 // At or past end of line - swap last two chars
4192 (self.cursor().col - 2, false)
4193 } else {
4194 // In middle - swap char before cursor with char at cursor
4195 (self.cursor().col - 1, true)
4196 };
4197
4198 let idx = self.buffer().line_col_to_char(self.cursor().line, swap_pos);
4199 let char1 = self.buffer().char_at(idx);
4200 let char2 = self.buffer().char_at(idx + 1);
4201
4202 if let (Some(c1), Some(c2)) = (char1, char2) {
4203 let cursor_before = self.cursor_pos();
4204 self.history_mut().begin_group();
4205
4206 // Delete both chars
4207 let deleted = format!("{}{}", c1, c2);
4208 self.buffer_mut().delete(idx, idx + 2);
4209 self.history_mut().record_delete(idx, deleted, cursor_before, cursor_before);
4210
4211 // Insert in swapped order
4212 let swapped = format!("{}{}", c2, c1);
4213 self.buffer_mut().insert(idx, &swapped);
4214
4215 if move_cursor {
4216 self.cursor_mut().col += 1;
4217 self.cursor_mut().desired_col = self.cursor().col;
4218 }
4219
4220 let cursor_after = self.cursor_pos();
4221 self.history_mut().record_insert(idx, swapped, cursor_before, cursor_after);
4222 self.history_mut().end_group();
4223 }
4224 }
4225
4226 fn dedent(&mut self) {
4227 if self.cursor().has_selection() {
4228 self.dedent_selection();
4229 } else {
4230 self.dedent_line(self.cursor().line);
4231 self.history_mut().maybe_break_group();
4232 }
4233 }
4234
4235 /// Dedent a single line, returns number of spaces removed
4236 fn dedent_line(&mut self, line_idx: usize) -> usize {
4237 if let Some(line_str) = self.buffer().line_str(line_idx) {
4238 let spaces_to_remove = line_str.chars().take(4).take_while(|c| *c == ' ').count();
4239 if spaces_to_remove > 0 {
4240 let cursor_before = self.cursor_pos();
4241 let line_start = self.buffer().line_col_to_char(line_idx, 0);
4242 let deleted: String = " ".repeat(spaces_to_remove);
4243
4244 self.buffer_mut().delete(line_start, line_start + spaces_to_remove);
4245
4246 // Only adjust cursor if this is the cursor's line
4247 if line_idx == self.cursor().line {
4248 self.cursor_mut().col = self.cursor().col.saturating_sub(spaces_to_remove);
4249 self.cursor_mut().desired_col = self.cursor().col;
4250 }
4251
4252 let cursor_after = self.cursor_pos();
4253 self.history_mut().record_delete(line_start, deleted, cursor_before, cursor_after);
4254 return spaces_to_remove;
4255 }
4256 }
4257 0
4258 }
4259
4260 /// Dedent all lines in selection
4261 fn dedent_selection(&mut self) {
4262 if let Some((start, end)) = self.cursor().selection_bounds() {
4263 self.history_mut().begin_group();
4264
4265 let mut total_removed_anchor_line = 0;
4266 let mut total_removed_cursor_line = 0;
4267
4268 // Dedent each line from start to end (inclusive)
4269 // We need to track adjustments carefully since positions shift
4270 for line_idx in start.line..=end.line {
4271 let removed = self.dedent_line(line_idx);
4272 if line_idx == self.cursor().anchor_line {
4273 total_removed_anchor_line = removed;
4274 }
4275 if line_idx == self.cursor().line {
4276 total_removed_cursor_line = removed;
4277 }
4278 }
4279
4280 // Adjust selection columns
4281 self.cursor_mut().anchor_col = self.cursor().anchor_col.saturating_sub(total_removed_anchor_line);
4282 self.cursor_mut().col = self.cursor().col.saturating_sub(total_removed_cursor_line);
4283 self.cursor_mut().desired_col = self.cursor().col;
4284
4285 self.history_mut().end_group();
4286 }
4287 }
4288
4289 // === Line operations ===
4290
4291 fn move_line_up(&mut self) {
4292 if self.cursor().line > 0 {
4293 let cursor_before = self.cursor_pos();
4294 self.history_mut().begin_group();
4295
4296 let curr_line = self.cursor().line;
4297 let prev_line = curr_line - 1;
4298
4299 let curr_content = self.buffer().line_str(curr_line).unwrap_or_default();
4300
4301 // Delete current line (including its newline)
4302 let curr_start = self.buffer().line_col_to_char(curr_line, 0);
4303 let delete_start = curr_start.saturating_sub(1); // Include newline before
4304 let delete_end = curr_start + curr_content.len();
4305 let deleted: String = self.buffer().slice(delete_start, delete_end).chars().collect();
4306 self.buffer_mut().delete(delete_start, delete_end);
4307 self.history_mut().record_delete(delete_start, deleted, cursor_before, cursor_before);
4308
4309 // Insert current line before previous line
4310 let prev_start = self.buffer().line_col_to_char(prev_line, 0);
4311 let insert_text = format!("{}\n", curr_content);
4312 let cursor_col = self.cursor().col;
4313 self.buffer_mut().insert(prev_start, &insert_text);
4314 self.history_mut().record_insert(prev_start, insert_text, cursor_before, Position::new(prev_line, cursor_col));
4315
4316 self.cursor_mut().line = prev_line;
4317 self.history_mut().end_group();
4318 }
4319 }
4320
4321 fn move_line_down(&mut self) {
4322 if self.cursor().line + 1 < self.buffer().line_count() {
4323 let cursor_before = self.cursor_pos();
4324 self.history_mut().begin_group();
4325
4326 let curr_line = self.cursor().line;
4327 let next_line = curr_line + 1;
4328
4329 let curr_content = self.buffer().line_str(curr_line).unwrap_or_default();
4330
4331 // Delete current line (including newline after)
4332 let curr_start = self.buffer().line_col_to_char(curr_line, 0);
4333 let next_start = self.buffer().line_col_to_char(next_line, 0);
4334 let deleted: String = self.buffer().slice(curr_start, next_start).chars().collect();
4335 self.buffer_mut().delete(curr_start, next_start);
4336 self.history_mut().record_delete(curr_start, deleted, cursor_before, cursor_before);
4337
4338 // Insert current line after what was the next line (now at curr_line)
4339 let new_line_end = self.buffer().line_col_to_char(curr_line, self.buffer().line_len(curr_line));
4340 let insert_text = format!("\n{}", curr_content);
4341 let cursor_col = self.cursor().col;
4342 self.buffer_mut().insert(new_line_end, &insert_text);
4343 self.history_mut().record_insert(new_line_end, insert_text, cursor_before, Position::new(next_line, cursor_col));
4344
4345 self.cursor_mut().line = next_line;
4346 self.history_mut().end_group();
4347 }
4348 }
4349
4350 fn duplicate_line_up(&mut self) {
4351 let cursor_before = self.cursor_pos();
4352 self.history_mut().begin_group();
4353 let content = self.buffer().line_str(self.cursor().line).unwrap_or_default();
4354 let line_start = self.buffer().line_col_to_char(self.cursor().line, 0);
4355 let insert_text = format!("{}\n", content);
4356 self.buffer_mut().insert(line_start, &insert_text);
4357 // Cursor stays on same logical line (now shifted down by 1)
4358 self.cursor_mut().line += 1;
4359 let cursor_after = self.cursor_pos();
4360 self.history_mut().record_insert(line_start, insert_text, cursor_before, cursor_after);
4361 self.history_mut().end_group();
4362 }
4363
4364 fn duplicate_line_down(&mut self) {
4365 let cursor_before = self.cursor_pos();
4366 self.history_mut().begin_group();
4367 let content = self.buffer().line_str(self.cursor().line).unwrap_or_default();
4368 let line_end = self.buffer().line_col_to_char(self.cursor().line, self.buffer().line_len(self.cursor().line));
4369 let insert_text = format!("\n{}", content);
4370 self.buffer_mut().insert(line_end, &insert_text);
4371 self.cursor_mut().line += 1;
4372 let cursor_after = self.cursor_pos();
4373 self.history_mut().record_insert(line_end, insert_text, cursor_before, cursor_after);
4374 self.history_mut().end_group();
4375 }
4376
4377 fn join_lines(&mut self) {
4378 if self.cursor().line + 1 < self.buffer().line_count() {
4379 let cursor_before = self.cursor_pos();
4380 self.history_mut().begin_group();
4381
4382 let line_len = self.buffer().line_len(self.cursor().line);
4383 let idx = self.buffer().line_col_to_char(self.cursor().line, line_len);
4384
4385 // Delete newline
4386 self.buffer_mut().delete(idx, idx + 1);
4387
4388 // Move cursor to join point
4389 self.cursor_mut().col = line_len;
4390 self.cursor_mut().desired_col = self.cursor().col;
4391
4392 let cursor_after = self.cursor_pos();
4393 self.history_mut().record_delete(idx, "\n".to_string(), cursor_before, cursor_after);
4394 self.history_mut().end_group();
4395 }
4396 }
4397
4398 /// Toggle line comment on current line or all lines in selection
4399 /// Works like VSCode: if all lines are commented, uncomment them; otherwise comment them all
4400 fn toggle_line_comment(&mut self) {
4401 // Get the comment prefix for current language
4402 let comment_prefix = match self.buffer_entry().highlighter.line_comment() {
4403 Some(prefix) => prefix,
4404 None => {
4405 self.message = Some("No line comment syntax for this file type".to_string());
4406 return;
4407 }
4408 };
4409
4410 // Determine line range to operate on
4411 let (start_line, end_line) = if let Some((start, end)) = self.cursor().selection_bounds() {
4412 // Selection: operate on all lines in selection
4413 (start.line, end.line)
4414 } else {
4415 // No selection: operate on current line only
4416 let line = self.cursor().line;
4417 (line, line)
4418 };
4419
4420 // Check if all lines in range are commented
4421 let all_commented = (start_line..=end_line).all(|line_idx| {
4422 if let Some(line) = self.buffer().line_str(line_idx) {
4423 let trimmed = line.trim_start();
4424 trimmed.starts_with(comment_prefix)
4425 } else {
4426 false
4427 }
4428 });
4429
4430 self.history_mut().begin_group();
4431
4432 if all_commented {
4433 // Uncomment all lines
4434 for line_idx in start_line..=end_line {
4435 self.uncomment_line(line_idx, comment_prefix);
4436 }
4437 } else {
4438 // Comment all lines - find minimum indentation first for alignment
4439 let min_indent = (start_line..=end_line)
4440 .filter_map(|line_idx| {
4441 self.buffer().line_str(line_idx).map(|line| {
4442 if line.trim().is_empty() {
4443 usize::MAX // Don't count empty lines
4444 } else {
4445 line.chars().take_while(|c| c.is_whitespace()).count()
4446 }
4447 })
4448 })
4449 .min()
4450 .unwrap_or(0);
4451
4452 for line_idx in start_line..=end_line {
4453 self.comment_line(line_idx, comment_prefix, min_indent);
4454 }
4455 }
4456
4457 self.history_mut().end_group();
4458
4459 // Invalidate highlight cache for affected lines
4460 self.invalidate_highlight_cache(start_line);
4461 }
4462
4463 /// Add a comment prefix to a line at the specified indentation level
4464 fn comment_line(&mut self, line_idx: usize, prefix: &str, indent: usize) {
4465 let Some(line) = self.buffer().line_str(line_idx) else {
4466 return;
4467 };
4468
4469 // Skip completely empty lines
4470 if line.is_empty() {
4471 return;
4472 }
4473
4474 let cursor_before = self.cursor_pos();
4475
4476 // Insert comment prefix after the minimum indentation
4477 let insert_pos = self.buffer().line_col_to_char(line_idx, indent);
4478 let insert_text = format!("{} ", prefix);
4479 self.buffer_mut().insert(insert_pos, &insert_text);
4480
4481 let cursor_after = self.cursor_pos();
4482 self.history_mut().record_insert(insert_pos, insert_text, cursor_before, cursor_after);
4483
4484 // Adjust cursor if on this line and after the insert point
4485 if self.cursor().line == line_idx && self.cursor().col >= indent {
4486 let prefix_len = prefix.len() + 1; // +1 for space
4487 self.cursor_mut().col += prefix_len;
4488 self.cursor_mut().desired_col = self.cursor().col;
4489 }
4490 }
4491
4492 /// Remove a comment prefix from a line
4493 fn uncomment_line(&mut self, line_idx: usize, prefix: &str) {
4494 let Some(line) = self.buffer().line_str(line_idx) else {
4495 return;
4496 };
4497
4498 // Find where the comment prefix starts
4499 let trimmed = line.trim_start();
4500 if !trimmed.starts_with(prefix) {
4501 return;
4502 }
4503
4504 let cursor_before = self.cursor_pos();
4505
4506 // Calculate the position of the comment prefix
4507 let leading_spaces = line.len() - trimmed.len();
4508 let delete_start = self.buffer().line_col_to_char(line_idx, leading_spaces);
4509
4510 // Calculate how much to delete (prefix + optional space after)
4511 let delete_len = if trimmed.len() > prefix.len() && trimmed.chars().nth(prefix.len()) == Some(' ') {
4512 prefix.len() + 1
4513 } else {
4514 prefix.len()
4515 };
4516
4517 let delete_end = delete_start + delete_len;
4518 let deleted_text: String = self.buffer().slice(delete_start, delete_end).chars().collect();
4519 self.buffer_mut().delete(delete_start, delete_end);
4520
4521 let cursor_after = self.cursor_pos();
4522 self.history_mut().record_delete(delete_start, deleted_text, cursor_before, cursor_after);
4523
4524 // Adjust cursor if on this line and after the deleted region
4525 if self.cursor().line == line_idx && self.cursor().col > leading_spaces {
4526 let new_col = if self.cursor().col >= leading_spaces + delete_len {
4527 self.cursor().col - delete_len
4528 } else {
4529 leading_spaces
4530 };
4531 self.cursor_mut().col = new_col;
4532 self.cursor_mut().desired_col = self.cursor().col;
4533 }
4534 }
4535
4536 // === Clipboard ===
4537
4538 fn get_selection_text(&self) -> Option<String> {
4539 self.cursor().selection_bounds().map(|(start, end)| {
4540 let start_idx = self.buffer().line_col_to_char(start.line, start.col);
4541 let end_idx = self.buffer().line_col_to_char(end.line, end.col);
4542 self.buffer().slice(start_idx, end_idx).chars().collect()
4543 })
4544 }
4545
4546 /// Set clipboard text (system if available, internal fallback)
4547 fn set_clipboard(&mut self, text: String) {
4548 if let Some(ref mut cb) = self.clipboard {
4549 let _ = cb.set_text(&text);
4550 }
4551 self.internal_clipboard = text;
4552 }
4553
4554 /// Get clipboard text (system if available, internal fallback)
4555 fn get_clipboard(&mut self) -> String {
4556 if let Some(ref mut cb) = self.clipboard {
4557 if let Ok(text) = cb.get_text() {
4558 return text;
4559 }
4560 }
4561 self.internal_clipboard.clone()
4562 }
4563
4564 fn copy(&mut self) {
4565 if let Some(text) = self.get_selection_text() {
4566 self.set_clipboard(text);
4567 self.message = Some("Copied".to_string());
4568 } else {
4569 // Copy current line
4570 if let Some(line) = self.buffer().line_str(self.cursor().line) {
4571 self.set_clipboard(format!("{}\n", line));
4572 self.message = Some("Copied line".to_string());
4573 }
4574 }
4575 }
4576
4577 fn cut(&mut self) {
4578 if let Some(text) = self.get_selection_text() {
4579 self.set_clipboard(text);
4580 self.delete_selection();
4581 self.message = Some("Cut".to_string());
4582 } else {
4583 // Cut current line
4584 if let Some(line) = self.buffer().line_str(self.cursor().line) {
4585 self.set_clipboard(format!("{}\n", line));
4586 let cursor_before = self.cursor_pos();
4587
4588 let line_start = self.buffer().line_col_to_char(self.cursor().line, 0);
4589
4590 if self.cursor().line + 1 < self.buffer().line_count() {
4591 // Not the last line - delete line including its newline
4592 let line_end = line_start + line.len() + 1;
4593 let deleted: String = self.buffer().slice(line_start, line_end).chars().collect();
4594 self.buffer_mut().delete(line_start, line_end);
4595 self.cursor_mut().col = 0;
4596 self.cursor_mut().desired_col = 0;
4597 let cursor_after = self.cursor_pos();
4598 self.history_mut().record_delete(line_start, deleted, cursor_before, cursor_after);
4599 } else if self.cursor().line > 0 {
4600 // Last line with content - delete newline before it and the line
4601 let delete_start = line_start.saturating_sub(1);
4602 let delete_end = line_start + line.len();
4603 let deleted: String = self.buffer().slice(delete_start, delete_end).chars().collect();
4604 self.buffer_mut().delete(delete_start, delete_end);
4605 self.cursor_mut().line -= 1;
4606 self.cursor_mut().col = 0;
4607 self.cursor_mut().desired_col = 0;
4608 let cursor_after = self.cursor_pos();
4609 self.history_mut().record_delete(delete_start, deleted, cursor_before, cursor_after);
4610 } else {
4611 // Only line - just clear it
4612 if !line.is_empty() {
4613 self.buffer_mut().delete(line_start, line_start + line.len());
4614 self.cursor_mut().col = 0;
4615 self.cursor_mut().desired_col = 0;
4616 let cursor_after = self.cursor_pos();
4617 self.history_mut().record_delete(line_start, line.clone(), cursor_before, cursor_after);
4618 }
4619 }
4620
4621 self.message = Some("Cut line".to_string());
4622 }
4623 }
4624 self.history_mut().maybe_break_group();
4625 }
4626
4627 fn paste(&mut self) {
4628 let text = self.get_clipboard();
4629 if !text.is_empty() {
4630 self.insert_text(&text);
4631 self.message = Some("Pasted".to_string());
4632 self.history_mut().maybe_break_group();
4633 }
4634 }
4635
4636 // === Undo/Redo ===
4637
4638 fn undo(&mut self) {
4639 if let Some((ops, cursor_positions)) = self.history_mut().undo() {
4640 // Apply operations in reverse
4641 for op in ops.into_iter().rev() {
4642 match op {
4643 Operation::Insert { pos, text, .. } => {
4644 self.buffer_mut().delete(pos, pos + text.chars().count());
4645 }
4646 Operation::Delete { pos, text, .. } => {
4647 self.buffer_mut().insert(pos, &text);
4648 }
4649 }
4650 }
4651 // Restore cursor positions from before the operation
4652 self.cursors_mut().set_from_positions(&cursor_positions);
4653 self.cursors_mut().clear_selections();
4654 self.message = Some("Undo".to_string());
4655 }
4656 }
4657
4658 fn redo(&mut self) {
4659 if let Some((ops, cursor_positions)) = self.history_mut().redo() {
4660 // Apply operations forward
4661 for op in ops {
4662 match op {
4663 Operation::Insert { pos, text, .. } => {
4664 self.buffer_mut().insert(pos, &text);
4665 }
4666 Operation::Delete { pos, text, .. } => {
4667 self.buffer_mut().delete(pos, pos + text.chars().count());
4668 }
4669 }
4670 }
4671 // Restore cursor positions from after the operation
4672 self.cursors_mut().set_from_positions(&cursor_positions);
4673 self.cursors_mut().clear_selections();
4674 self.message = Some("Redo".to_string());
4675 }
4676 }
4677
4678 // === Viewport ===
4679
4680 fn scroll_to_cursor(&mut self) {
4681 // Tab bar is always rendered (takes 1 row)
4682 let top_offset = 1;
4683 // Vertical scrolling (2 rows reserved: gap + status bar, plus top_offset for tab bar)
4684 let visible_rows = (self.screen.rows as usize).saturating_sub(2 + top_offset);
4685
4686 // In multi-cursor mode, scroll to the LAST cursor (most recently added)
4687 // This ensures Ctrl+D shows the newly found occurrence
4688 let cursors = self.cursors();
4689 let target_cursor = if cursors.len() > 1 {
4690 cursors.all().last().unwrap()
4691 } else {
4692 cursors.primary()
4693 };
4694 let cursor_line = target_cursor.line;
4695 let cursor_col = target_cursor.col;
4696
4697 let viewport_line = self.viewport_line();
4698
4699 if cursor_line < viewport_line {
4700 self.set_viewport_line(cursor_line);
4701 }
4702
4703 if cursor_line >= viewport_line + visible_rows {
4704 self.set_viewport_line(cursor_line - visible_rows + 1);
4705 }
4706
4707 // Horizontal scrolling
4708 let line_num_width = self.screen.line_number_width(self.buffer().line_count());
4709 let fuss_width = if self.workspace.fuss.active {
4710 self.workspace.fuss.width(self.screen.cols)
4711 } else {
4712 0
4713 };
4714 // Available text columns = screen width - fuss sidebar - line numbers - 1 (separator)
4715 let visible_cols = (self.screen.cols as usize)
4716 .saturating_sub(fuss_width as usize)
4717 .saturating_sub(line_num_width + 1);
4718
4719 let viewport_col = self.viewport_col();
4720
4721 // Keep some margin (3 chars) so cursor isn't right at the edge
4722 let margin = 3;
4723
4724 if cursor_col < viewport_col {
4725 // Cursor is left of viewport - scroll left
4726 self.set_viewport_col(cursor_col.saturating_sub(margin));
4727 }
4728
4729 if cursor_col >= viewport_col + visible_cols.saturating_sub(margin) {
4730 // Cursor is right of viewport - scroll right
4731 self.set_viewport_col(cursor_col.saturating_sub(visible_cols.saturating_sub(margin + 1)));
4732 }
4733 }
4734
4735 // === File operations ===
4736
4737 fn save(&mut self) -> Result<()> {
4738 let path = self.filename();
4739 if let Some(ref p) = path {
4740 // Construct full path: orphan files use absolute path, workspace files need root prefix
4741 let full_path = if self.buffer_entry().is_orphan {
4742 p.clone()
4743 } else {
4744 self.workspace.root.join(p)
4745 };
4746 self.buffer_mut().save(&full_path)?;
4747 self.buffer_entry_mut().mark_saved();
4748 let _ = self.workspace.delete_backup(&full_path);
4749 self.message = Some("Saved".to_string());
4750 }
4751 Ok(())
4752 }
4753
4754 // === Pane operations ===
4755
4756 fn split_vertical(&mut self) {
4757 self.tab_mut().split_vertical();
4758 self.message = Some("Split vertical".to_string());
4759 }
4760
4761 fn split_horizontal(&mut self) {
4762 self.tab_mut().split_horizontal();
4763 self.message = Some("Split horizontal".to_string());
4764 }
4765
4766 fn close_pane(&mut self) {
4767 // Check if current buffer has unsaved changes
4768 if self.buffer_entry_mut().is_modified() {
4769 self.prompt = PromptState::CloseBufferConfirm;
4770 self.message = Some("Unsaved changes. [S]ave / [D]iscard / [C]ancel".to_string());
4771 return;
4772 }
4773 self.close_pane_force();
4774 }
4775
4776 /// Close pane without checking for unsaved changes (used after save/discard)
4777 fn close_pane_force(&mut self) {
4778 if self.workspace.active_tab_mut().close_active_pane() {
4779 // Last pane was closed - close the tab
4780 if self.workspace.close_active_tab() {
4781 // Last tab - quit the editor
4782 self.running = false;
4783 } else {
4784 self.message = Some("Tab closed".to_string());
4785 }
4786 } else {
4787 self.message = Some("Pane closed".to_string());
4788 }
4789 }
4790
4791 fn next_pane(&mut self) {
4792 self.tab_mut().next_pane();
4793 }
4794
4795 fn prev_pane(&mut self) {
4796 self.tab_mut().prev_pane();
4797 }
4798
4799 fn navigate_pane_left(&mut self) {
4800 self.tab_mut().navigate_pane(PaneDirection::Left);
4801 }
4802
4803 fn navigate_pane_right(&mut self) {
4804 self.tab_mut().navigate_pane(PaneDirection::Right);
4805 }
4806
4807 fn navigate_pane_up(&mut self) {
4808 self.tab_mut().navigate_pane(PaneDirection::Up);
4809 }
4810
4811 fn navigate_pane_down(&mut self) {
4812 self.tab_mut().navigate_pane(PaneDirection::Down);
4813 }
4814
4815 // === Fuss mode (file tree) ===
4816
4817 fn toggle_fuss_mode(&mut self) {
4818 if !self.workspace.fuss.active {
4819 self.workspace.fuss.activate(&self.workspace.root);
4820 self.focus = Focus::FussMode;
4821 } else {
4822 self.workspace.fuss.deactivate();
4823 self.return_focus();
4824 }
4825 }
4826
4827 fn handle_fuss_key(&mut self, key: Key, mods: Modifiers) -> Result<()> {
4828 // Handle git mode separately
4829 if self.workspace.fuss.git_mode {
4830 return self.handle_fuss_git_key(key, mods);
4831 }
4832
4833 match (&key, &mods) {
4834 // Quit: Ctrl+Q (still works in fuss mode)
4835 (Key::Char('q'), Modifiers { ctrl: true, .. }) => {
4836 self.try_quit();
4837 }
4838
4839 // Exit fuss mode (Escape or F3)
4840 (Key::Escape, _) | (Key::F(3), _) => {
4841 self.workspace.fuss.filter_clear();
4842 self.workspace.fuss.deactivate();
4843 self.return_focus();
4844 }
4845
4846 // Navigation
4847 (Key::Up, _) => {
4848 self.workspace.fuss.filter_clear();
4849 self.workspace.fuss.move_up();
4850 }
4851 (Key::Down, _) => {
4852 self.workspace.fuss.filter_clear();
4853 self.workspace.fuss.move_down();
4854 }
4855
4856 // Toggle expand/collapse directory, or collapse parent if on a file/collapsed dir
4857 (Key::Char(' '), _) => {
4858 self.workspace.fuss.filter_clear();
4859 if self.workspace.fuss.is_dir_selected() {
4860 // If on a directory, toggle its expand state
4861 self.workspace.fuss.toggle_expand();
4862 } else {
4863 // If on a file, collapse parent directory
4864 self.workspace.fuss.collapse_parent();
4865 }
4866 }
4867
4868 // Expand directory (right arrow)
4869 (Key::Right, _) => {
4870 self.workspace.fuss.filter_clear();
4871 if self.workspace.fuss.is_dir_selected() {
4872 // Only expand if not already expanded
4873 if let Some(ref tree) = self.workspace.fuss.tree {
4874 let items = tree.visible_items();
4875 if let Some(item) = items.get(self.workspace.fuss.selected) {
4876 if item.is_dir && !item.expanded {
4877 self.workspace.fuss.toggle_expand();
4878 }
4879 }
4880 }
4881 }
4882 }
4883
4884 // Collapse directory or go to parent (left arrow)
4885 (Key::Left, _) => {
4886 self.workspace.fuss.filter_clear();
4887 let mut collapsed = false;
4888 if self.workspace.fuss.is_dir_selected() {
4889 // If on an expanded directory, collapse it
4890 if let Some(ref tree) = self.workspace.fuss.tree {
4891 let items = tree.visible_items();
4892 if let Some(item) = items.get(self.workspace.fuss.selected) {
4893 if item.is_dir && item.expanded {
4894 self.workspace.fuss.toggle_expand();
4895 collapsed = true;
4896 }
4897 }
4898 }
4899 }
4900 // If not collapsed (either a file or already-collapsed dir), go to parent
4901 if !collapsed {
4902 self.workspace.fuss.collapse_parent();
4903 }
4904 }
4905
4906 // Open file or toggle directory
4907 (Key::Enter, _) => {
4908 self.workspace.fuss.filter_clear();
4909 if self.workspace.fuss.is_dir_selected() {
4910 self.workspace.fuss.toggle_expand();
4911 } else if let Some(path) = self.workspace.fuss.selected_file() {
4912 self.open_file(&path)?;
4913 self.workspace.fuss.deactivate();
4914 }
4915 }
4916
4917 // Toggle hidden files: Alt+.
4918 (Key::Char('.'), Modifiers { alt: true, .. }) => {
4919 self.workspace.fuss.toggle_hidden();
4920 }
4921
4922 // Toggle hints (Ctrl+/ may send different codes depending on terminal)
4923 // Different terminals send: Ctrl+/, Ctrl+_, \x1f (ASCII 31), or Ctrl+7
4924 (Key::Char('/'), Modifiers { ctrl: true, .. })
4925 | (Key::Char('_'), Modifiers { ctrl: true, .. })
4926 | (Key::Char('\x1f'), _) // ASCII 31 = Ctrl+/
4927 | (Key::Char('7'), Modifiers { ctrl: true, .. }) => {
4928 self.workspace.fuss.toggle_hints();
4929 }
4930
4931 // Open file in vertical split: Ctrl+V
4932 (Key::Char('v'), Modifiers { ctrl: true, .. }) => {
4933 if !self.workspace.fuss.is_dir_selected() {
4934 if let Some(path) = self.workspace.fuss.selected_file() {
4935 self.open_file_in_vsplit(&path)?;
4936 self.workspace.fuss.deactivate();
4937 }
4938 }
4939 }
4940
4941 // Open file in horizontal split: Ctrl+S
4942 (Key::Char('s'), Modifiers { ctrl: true, .. }) => {
4943 if !self.workspace.fuss.is_dir_selected() {
4944 if let Some(path) = self.workspace.fuss.selected_file() {
4945 self.open_file_in_hsplit(&path)?;
4946 self.workspace.fuss.deactivate();
4947 }
4948 }
4949 }
4950
4951 // Enter git mode: Alt+G
4952 (Key::Char('g'), Modifiers { alt: true, .. }) => {
4953 self.workspace.fuss.enter_git_mode();
4954 self.message = Some("Git: [a]dd [u]nstage [d]iff [m]sg [p]ush pu[l]l [f]etch [t]ag".to_string());
4955 }
4956
4957 // Backspace: remove last filter character
4958 (Key::Backspace, _) => {
4959 self.workspace.fuss.filter_pop();
4960 }
4961
4962 // Regular characters: add to filter for fuzzy jump
4963 (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => {
4964 self.workspace.fuss.filter_push(*c);
4965 }
4966
4967 _ => {}
4968 }
4969 Ok(())
4970 }
4971
4972 /// Handle keys when in git sub-mode within fuss
4973 fn handle_fuss_git_key(&mut self, key: Key, mods: Modifiers) -> Result<()> {
4974 // Any key exits git mode (after potentially doing an action)
4975 self.workspace.fuss.exit_git_mode();
4976 self.message = None;
4977
4978 match (&key, &mods) {
4979 // Git: Stage file (a)
4980 (Key::Char('a'), _) => {
4981 if self.workspace.fuss.stage_selected() {
4982 self.message = Some("Staged".to_string());
4983 } else {
4984 self.message = Some("Failed to stage".to_string());
4985 }
4986 }
4987
4988 // Git: Unstage file (u)
4989 (Key::Char('u'), _) => {
4990 if self.workspace.fuss.unstage_selected() {
4991 self.message = Some("Unstaged".to_string());
4992 } else {
4993 self.message = Some("Failed to unstage".to_string());
4994 }
4995 }
4996
4997 // Git: Show diff (d)
4998 (Key::Char('d'), _) => {
4999 if let Some((filename, diff)) = self.workspace.fuss.get_diff_for_selected() {
5000 let display_name = format!("[diff] {}", filename);
5001 self.workspace.open_content_tab(&diff, &display_name);
5002 self.workspace.fuss.deactivate();
5003 } else {
5004 self.message = Some("No diff available".to_string());
5005 }
5006 }
5007
5008 // Git: Commit (m) - opens prompt for commit message
5009 (Key::Char('m'), _) => {
5010 self.prompt = PromptState::TextInput {
5011 label: "Commit message: ".to_string(),
5012 buffer: String::new(),
5013 action: TextInputAction::GitCommit,
5014 };
5015 self.message = Some("Enter commit message (Enter to commit, Esc to cancel)".to_string());
5016 }
5017
5018 // Git: Push (p)
5019 (Key::Char('p'), _) => {
5020 let (_, msg) = self.workspace.fuss.git_push();
5021 self.message = Some(msg);
5022 }
5023
5024 // Git: Pull (l)
5025 (Key::Char('l'), _) => {
5026 let (_, msg) = self.workspace.fuss.git_pull();
5027 self.message = Some(msg);
5028 }
5029
5030 // Git: Fetch (f)
5031 (Key::Char('f'), _) => {
5032 let (_, msg) = self.workspace.fuss.git_fetch();
5033 self.message = Some(msg);
5034 }
5035
5036 // Git: Tag (t) - opens prompt for tag name
5037 (Key::Char('t'), _) => {
5038 self.prompt = PromptState::TextInput {
5039 label: "Tag name: ".to_string(),
5040 buffer: String::new(),
5041 action: TextInputAction::GitTag,
5042 };
5043 self.message = Some("Enter tag name (Enter to create, Esc to cancel)".to_string());
5044 }
5045
5046 // Escape or any other key just cancels git mode
5047 _ => {}
5048 }
5049 Ok(())
5050 }
5051
5052 fn open_file(&mut self, path: &Path) -> Result<()> {
5053 self.workspace.open_file(path)
5054 }
5055
5056 fn open_file_in_vsplit(&mut self, path: &Path) -> Result<()> {
5057 self.workspace.open_file_in_vsplit(path)?;
5058 self.message = Some("Opened in vertical split".to_string());
5059 Ok(())
5060 }
5061
5062 fn open_file_in_hsplit(&mut self, path: &Path) -> Result<()> {
5063 self.workspace.open_file_in_hsplit(path)?;
5064 self.message = Some("Opened in horizontal split".to_string());
5065 Ok(())
5066 }
5067
5068 // === Quit and prompt handling ===
5069
5070 fn try_quit(&mut self) {
5071 if self.workspace.has_unsaved_changes() {
5072 // Show quit confirmation prompt
5073 self.prompt = PromptState::QuitConfirm;
5074 self.message = Some("Unsaved changes. [S]ave all / [D]iscard / [C]ancel".to_string());
5075 } else {
5076 // No unsaved changes, quit immediately
5077 self.running = false;
5078 }
5079 }
5080
5081 fn handle_prompt_key(&mut self, key: Key) -> Result<()> {
5082 match self.prompt {
5083 PromptState::QuitConfirm => {
5084 match key {
5085 Key::Char('s') | Key::Char('S') => {
5086 // Save all and quit
5087 if let Err(e) = self.workspace.save_all() {
5088 self.message = Some(format!("Save failed: {}", e));
5089 } else {
5090 self.running = false;
5091 }
5092 self.prompt = PromptState::None;
5093 }
5094 Key::Char('d') | Key::Char('D') => {
5095 // Discard changes and quit - delete backups
5096 let _ = self.workspace.delete_all_backups();
5097 self.running = false;
5098 self.prompt = PromptState::None;
5099 }
5100 Key::Char('c') | Key::Char('C') | Key::Escape => {
5101 // Cancel - return to editing
5102 self.prompt = PromptState::None;
5103 self.message = None;
5104 }
5105 _ => {
5106 // Repeat the prompt
5107 self.message = Some("Unsaved changes. [S]ave all / [D]iscard / [C]ancel".to_string());
5108 }
5109 }
5110 }
5111 PromptState::CloseBufferConfirm => {
5112 match key {
5113 Key::Char('s') | Key::Char('S') => {
5114 // Save and close
5115 if let Err(e) = self.save() {
5116 self.message = Some(format!("Save failed: {}", e));
5117 } else {
5118 self.prompt = PromptState::None;
5119 self.close_pane_force();
5120 }
5121 }
5122 Key::Char('d') | Key::Char('D') => {
5123 // Discard changes - delete backup for this buffer and close
5124 if let Some(path) = self.buffer_entry().path.clone() {
5125 let full_path = if self.buffer_entry().is_orphan {
5126 path.clone()
5127 } else {
5128 self.workspace.root.join(&path)
5129 };
5130 let _ = self.workspace.delete_backup(&full_path);
5131 }
5132 self.prompt = PromptState::None;
5133 self.close_pane_force();
5134 }
5135 Key::Char('c') | Key::Char('C') | Key::Escape => {
5136 // Cancel - return to editing
5137 self.prompt = PromptState::None;
5138 self.message = None;
5139 }
5140 _ => {
5141 // Repeat the prompt
5142 self.message = Some("Unsaved changes. [S]ave / [D]iscard / [C]ancel".to_string());
5143 }
5144 }
5145 }
5146 PromptState::RestoreBackup => {
5147 match key {
5148 Key::Char('r') | Key::Char('R') => {
5149 // Restore backups
5150 if let Err(e) = self.restore_backups() {
5151 self.message = Some(format!("Restore failed: {}", e));
5152 } else {
5153 self.message = Some("Restored unsaved changes".to_string());
5154 }
5155 self.prompt = PromptState::None;
5156 }
5157 Key::Char('d') | Key::Char('D') | Key::Escape => {
5158 // Discard backups (Escape = discard)
5159 let _ = self.workspace.delete_all_backups();
5160 self.message = Some("Discarded recovered changes".to_string());
5161 self.prompt = PromptState::None;
5162 }
5163 _ => {
5164 // Repeat the prompt
5165 self.message = Some("Recovered unsaved changes. [R]estore / [D]iscard / [Esc]".to_string());
5166 }
5167 }
5168 }
5169 PromptState::TextInput { ref label, ref mut buffer, ref action } => {
5170 match key {
5171 Key::Enter => {
5172 // Execute the action
5173 let action = action.clone();
5174 let buffer = buffer.clone();
5175 self.prompt = PromptState::None;
5176 self.execute_text_input_action(action, &buffer);
5177 }
5178 Key::Escape => {
5179 // Cancel
5180 self.prompt = PromptState::None;
5181 self.message = Some("Cancelled".to_string());
5182 }
5183 Key::Backspace => {
5184 // Delete last character
5185 buffer.pop();
5186 self.message = Some(format!("{}{}", label, buffer));
5187 }
5188 Key::Char(c) => {
5189 // Add character to buffer
5190 buffer.push(c);
5191 self.message = Some(format!("{}{}", label, buffer));
5192 }
5193 _ => {
5194 // Update display
5195 self.message = Some(format!("{}{}", label, buffer));
5196 }
5197 }
5198 }
5199 PromptState::RenameModal { ref original_name, ref mut new_name, ref path, line, col } => {
5200 match key {
5201 Key::Enter => {
5202 // Clone values before modifying self.prompt
5203 let original = original_name.clone();
5204 let new = new_name.clone();
5205 let path = path.clone();
5206
5207 // Execute rename
5208 if new.is_empty() {
5209 self.prompt = PromptState::None;
5210 self.message = Some("Rename cancelled: empty name".to_string());
5211 } else if new == original {
5212 self.prompt = PromptState::None;
5213 self.message = Some("Rename cancelled: name unchanged".to_string());
5214 } else {
5215 self.prompt = PromptState::None;
5216 match self.workspace.lsp.request_rename(&path, line, col, &new) {
5217 Ok(_id) => {
5218 self.message = Some(format!("Renaming '{}' to '{}'...", original, new));
5219 }
5220 Err(e) => {
5221 self.message = Some(format!("Rename failed: {}", e));
5222 }
5223 }
5224 }
5225 }
5226 Key::Escape => {
5227 self.prompt = PromptState::None;
5228 self.message = Some("Rename cancelled".to_string());
5229 }
5230 Key::Backspace => {
5231 new_name.pop();
5232 }
5233 Key::Char(c) => {
5234 new_name.push(c);
5235 }
5236 _ => {}
5237 }
5238 }
5239 PromptState::ReferencesPanel { ref locations, ref mut selected_index, ref mut query } => {
5240 // Filter locations based on query
5241 let filtered: Vec<(usize, &Location)> = if query.is_empty() {
5242 locations.iter().enumerate().collect()
5243 } else {
5244 let q = query.to_lowercase();
5245 locations.iter().enumerate()
5246 .filter(|(_, loc)| {
5247 loc.uri.to_lowercase().contains(&q)
5248 })
5249 .collect()
5250 };
5251
5252 match key {
5253 Key::Enter => {
5254 // Jump to selected reference
5255 if let Some((orig_idx, _)) = filtered.get(*selected_index) {
5256 let loc = locations[*orig_idx].clone();
5257 self.prompt = PromptState::None;
5258 self.goto_location(&loc);
5259 }
5260 }
5261 Key::Escape => {
5262 self.prompt = PromptState::None;
5263 self.message = None;
5264 }
5265 Key::Up => {
5266 if *selected_index > 0 {
5267 *selected_index -= 1;
5268 }
5269 }
5270 Key::Down => {
5271 if *selected_index + 1 < filtered.len() {
5272 *selected_index += 1;
5273 }
5274 }
5275 Key::PageUp => {
5276 *selected_index = selected_index.saturating_sub(10);
5277 }
5278 Key::PageDown => {
5279 *selected_index = (*selected_index + 10).min(filtered.len().saturating_sub(1));
5280 }
5281 Key::Home => {
5282 *selected_index = 0;
5283 }
5284 Key::End => {
5285 if !filtered.is_empty() {
5286 *selected_index = filtered.len() - 1;
5287 }
5288 }
5289 Key::Backspace => {
5290 query.pop();
5291 // Reset selection when filter changes
5292 *selected_index = 0;
5293 }
5294 Key::Char(c) => {
5295 query.push(c);
5296 // Reset selection when filter changes
5297 *selected_index = 0;
5298 }
5299 _ => {}
5300 }
5301 }
5302 PromptState::FindReplace {
5303 ref mut find_query,
5304 ref mut replace_text,
5305 ref mut active_field,
5306 case_insensitive: _,
5307 regex_mode: _,
5308 } => {
5309 match key {
5310 Key::Escape => {
5311 self.prompt = PromptState::None;
5312 self.search_state.matches.clear();
5313 self.message = None;
5314 }
5315 Key::Enter => {
5316 if *active_field == FindReplaceField::Find {
5317 // Find next
5318 self.find_next();
5319 } else {
5320 // Replace current and find next
5321 self.replace_current();
5322 }
5323 }
5324 Key::Tab => {
5325 // Switch between find and replace fields
5326 *active_field = if *active_field == FindReplaceField::Find {
5327 FindReplaceField::Replace
5328 } else {
5329 FindReplaceField::Find
5330 };
5331 }
5332 Key::BackTab => {
5333 // Switch in reverse
5334 *active_field = if *active_field == FindReplaceField::Replace {
5335 FindReplaceField::Find
5336 } else {
5337 FindReplaceField::Replace
5338 };
5339 }
5340 Key::Up => {
5341 // Find previous
5342 self.find_prev();
5343 }
5344 Key::Down => {
5345 // Find next
5346 self.find_next();
5347 }
5348 Key::Backspace => {
5349 if *active_field == FindReplaceField::Find {
5350 find_query.pop();
5351 self.search_state.last_query.clear(); // Force re-search
5352 self.update_search_matches();
5353 } else {
5354 replace_text.pop();
5355 }
5356 }
5357 Key::Char(c) => {
5358 if *active_field == FindReplaceField::Find {
5359 find_query.push(c);
5360 self.search_state.last_query.clear(); // Force re-search
5361 self.update_search_matches();
5362 } else {
5363 replace_text.push(c);
5364 }
5365 }
5366 _ => {}
5367 }
5368 }
5369 PromptState::Fortress {
5370 ref current_path,
5371 ref entries,
5372 ref mut selected_index,
5373 ref mut filter,
5374 ref mut scroll_offset,
5375 } => {
5376 // Filter entries based on query
5377 let filtered: Vec<(usize, &FortressEntry)> = if filter.is_empty() {
5378 entries.iter().enumerate().collect()
5379 } else {
5380 let f = filter.to_lowercase();
5381 entries.iter().enumerate()
5382 .filter(|(_, e)| e.name.to_lowercase().contains(&f))
5383 .collect()
5384 };
5385
5386 match key {
5387 Key::Enter => {
5388 // Open selected entry
5389 if let Some((orig_idx, _entry)) = filtered.get(*selected_index) {
5390 let entry = entries[*orig_idx].clone();
5391 if entry.is_dir {
5392 // Navigate into directory
5393 self.fortress_navigate_to(&entry.path);
5394 } else {
5395 // Open the file
5396 self.prompt = PromptState::None;
5397 self.fortress_open_file(&entry.path);
5398 }
5399 }
5400 }
5401 Key::Escape => {
5402 self.prompt = PromptState::None;
5403 self.message = None;
5404 }
5405 Key::Backspace if filter.is_empty() => {
5406 // Go up one directory when filter is empty
5407 if let Some(parent) = current_path.parent() {
5408 let parent = parent.to_path_buf();
5409 self.fortress_navigate_to(&parent);
5410 }
5411 }
5412 Key::Backspace => {
5413 filter.pop();
5414 *selected_index = 0;
5415 *scroll_offset = 0;
5416 }
5417 Key::Up => {
5418 if *selected_index > 0 {
5419 *selected_index -= 1;
5420 // Adjust scroll
5421 if *selected_index < *scroll_offset {
5422 *scroll_offset = *selected_index;
5423 }
5424 }
5425 }
5426 Key::Down => {
5427 if *selected_index + 1 < filtered.len() {
5428 *selected_index += 1;
5429 }
5430 }
5431 Key::Left => {
5432 // Go up one directory
5433 if let Some(parent) = current_path.parent() {
5434 let parent = parent.to_path_buf();
5435 self.fortress_navigate_to(&parent);
5436 }
5437 }
5438 Key::Right => {
5439 // Enter selected directory (same as Enter for dirs)
5440 if let Some((orig_idx, _)) = filtered.get(*selected_index) {
5441 let entry = entries[*orig_idx].clone();
5442 if entry.is_dir {
5443 self.fortress_navigate_to(&entry.path);
5444 }
5445 }
5446 }
5447 Key::PageUp => {
5448 *selected_index = selected_index.saturating_sub(10);
5449 *scroll_offset = scroll_offset.saturating_sub(10);
5450 }
5451 Key::PageDown => {
5452 let max = filtered.len().saturating_sub(1);
5453 *selected_index = (*selected_index + 10).min(max);
5454 }
5455 Key::Home => {
5456 *selected_index = 0;
5457 *scroll_offset = 0;
5458 }
5459 Key::End => {
5460 if !filtered.is_empty() {
5461 *selected_index = filtered.len() - 1;
5462 }
5463 }
5464 Key::Char(c) => {
5465 filter.push(c);
5466 *selected_index = 0;
5467 *scroll_offset = 0;
5468 }
5469 _ => {}
5470 }
5471 }
5472 PromptState::FileSearch {
5473 ref mut query,
5474 ref mut results,
5475 ref mut selected_index,
5476 ref mut scroll_offset,
5477 searching: _,
5478 } => {
5479 match key {
5480 Key::Enter => {
5481 if !query.is_empty() && results.is_empty() {
5482 // Trigger search - clone query first to avoid borrow conflict
5483 let query_str = query.clone();
5484 let new_results = self.search_files(&query_str);
5485 // Re-borrow after search
5486 if let PromptState::FileSearch { results, selected_index, scroll_offset, .. } = &mut self.prompt {
5487 *results = new_results;
5488 *selected_index = 0;
5489 *scroll_offset = 0;
5490 }
5491 } else if !results.is_empty() {
5492 // Open selected result
5493 let result = results[*selected_index].clone();
5494 self.prompt = PromptState::None;
5495 self.file_search_open_result(&result);
5496 }
5497 }
5498 Key::Escape => {
5499 self.prompt = PromptState::None;
5500 self.message = None;
5501 }
5502 Key::Backspace => {
5503 if !query.is_empty() {
5504 query.pop();
5505 // Clear results when query changes
5506 results.clear();
5507 *selected_index = 0;
5508 *scroll_offset = 0;
5509 }
5510 }
5511 Key::Up => {
5512 if *selected_index > 0 {
5513 *selected_index -= 1;
5514 if *selected_index < *scroll_offset {
5515 *scroll_offset = *selected_index;
5516 }
5517 }
5518 }
5519 Key::Down => {
5520 if *selected_index + 1 < results.len() {
5521 *selected_index += 1;
5522 }
5523 }
5524 Key::PageUp => {
5525 *selected_index = selected_index.saturating_sub(10);
5526 *scroll_offset = scroll_offset.saturating_sub(10);
5527 }
5528 Key::PageDown => {
5529 let max = results.len().saturating_sub(1);
5530 *selected_index = (*selected_index + 10).min(max);
5531 }
5532 Key::Home => {
5533 *selected_index = 0;
5534 *scroll_offset = 0;
5535 }
5536 Key::End => {
5537 if !results.is_empty() {
5538 *selected_index = results.len() - 1;
5539 }
5540 }
5541 Key::Char(c) => {
5542 query.push(c);
5543 // Clear results when query changes
5544 results.clear();
5545 *selected_index = 0;
5546 *scroll_offset = 0;
5547 }
5548 _ => {}
5549 }
5550 }
5551 PromptState::CommandPalette {
5552 ref mut query,
5553 ref mut filtered,
5554 ref mut selected_index,
5555 ref mut scroll_offset,
5556 } => {
5557 match key {
5558 Key::Escape => {
5559 self.prompt = PromptState::None;
5560 }
5561 Key::Enter => {
5562 // Execute selected command
5563 if let Some(cmd) = filtered.get(*selected_index) {
5564 let cmd_id = cmd.id.to_string();
5565 self.prompt = PromptState::None;
5566 self.execute_command(&cmd_id);
5567 self.scroll_to_cursor(); // Ensure viewport follows cursor after command
5568 } else {
5569 self.prompt = PromptState::None;
5570 }
5571 }
5572 Key::Up => {
5573 if *selected_index > 0 {
5574 *selected_index -= 1;
5575 if *selected_index < *scroll_offset {
5576 *scroll_offset = *selected_index;
5577 }
5578 }
5579 }
5580 Key::Down => {
5581 if *selected_index + 1 < filtered.len() {
5582 *selected_index += 1;
5583 // Keep selected item visible (assume ~15 visible rows)
5584 let visible_rows = 15;
5585 if *selected_index >= *scroll_offset + visible_rows {
5586 *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
5587 }
5588 }
5589 }
5590 Key::PageUp => {
5591 *selected_index = selected_index.saturating_sub(10);
5592 if *selected_index < *scroll_offset {
5593 *scroll_offset = *selected_index;
5594 }
5595 }
5596 Key::PageDown => {
5597 *selected_index = (*selected_index + 10).min(filtered.len().saturating_sub(1));
5598 let visible_rows = 15;
5599 if *selected_index >= *scroll_offset + visible_rows {
5600 *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
5601 }
5602 }
5603 Key::Backspace => {
5604 if !query.is_empty() {
5605 query.pop();
5606 *filtered = filter_commands(query);
5607 *selected_index = 0;
5608 *scroll_offset = 0;
5609 }
5610 }
5611 Key::Char(c) => {
5612 query.push(c);
5613 *filtered = filter_commands(query);
5614 *selected_index = 0;
5615 *scroll_offset = 0;
5616 }
5617 _ => {}
5618 }
5619 }
5620 PromptState::HelpMenu {
5621 ref mut query,
5622 ref mut filtered,
5623 ref mut selected_index,
5624 ref mut scroll_offset,
5625 ref mut show_alt,
5626 } => {
5627 match key {
5628 Key::Escape | Key::Enter => {
5629 self.prompt = PromptState::None;
5630 }
5631 Key::Up => {
5632 if *selected_index > 0 {
5633 *selected_index -= 1;
5634 if *selected_index < *scroll_offset {
5635 *scroll_offset = *selected_index;
5636 }
5637 }
5638 }
5639 Key::Down => {
5640 if *selected_index + 1 < filtered.len() {
5641 *selected_index += 1;
5642 let visible_rows = 18;
5643 if *selected_index >= *scroll_offset + visible_rows {
5644 *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
5645 }
5646 }
5647 }
5648 Key::PageUp => {
5649 *selected_index = selected_index.saturating_sub(10);
5650 if *selected_index < *scroll_offset {
5651 *scroll_offset = *selected_index;
5652 }
5653 }
5654 Key::PageDown => {
5655 *selected_index = (*selected_index + 10).min(filtered.len().saturating_sub(1));
5656 let visible_rows = 18;
5657 if *selected_index >= *scroll_offset + visible_rows {
5658 *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
5659 }
5660 }
5661 Key::Home => {
5662 *selected_index = 0;
5663 *scroll_offset = 0;
5664 }
5665 Key::End => {
5666 *selected_index = filtered.len().saturating_sub(1);
5667 let visible_rows = 18;
5668 *scroll_offset = selected_index.saturating_sub(visible_rows - 1);
5669 }
5670 Key::Backspace => {
5671 if !query.is_empty() {
5672 query.pop();
5673 *filtered = filter_keybinds(query);
5674 *selected_index = 0;
5675 *scroll_offset = 0;
5676 }
5677 }
5678 // Toggle alternate keybindings view
5679 Key::Char('/') => {
5680 *show_alt = !*show_alt;
5681 }
5682 Key::Char(c) => {
5683 query.push(c);
5684 *filtered = filter_keybinds(query);
5685 *selected_index = 0;
5686 *scroll_offset = 0;
5687 }
5688 _ => {}
5689 }
5690 }
5691 PromptState::None => {}
5692 }
5693 Ok(())
5694 }
5695
5696 fn execute_text_input_action(&mut self, action: TextInputAction, buffer: &str) {
5697 match action {
5698 TextInputAction::GitCommit => {
5699 let (_, msg) = self.workspace.fuss.git_commit(buffer);
5700 self.message = Some(msg);
5701 }
5702 TextInputAction::GitTag => {
5703 let (_, msg) = self.workspace.fuss.git_tag(buffer);
5704 self.message = Some(msg);
5705 }
5706 TextInputAction::GotoLine => {
5707 self.goto_line_col(buffer);
5708 }
5709 }
5710 }
5711
5712 /// Open the goto line prompt
5713 fn open_goto_line(&mut self) {
5714 self.prompt = PromptState::TextInput {
5715 label: "Go to line: ".to_string(),
5716 buffer: String::new(),
5717 action: TextInputAction::GotoLine,
5718 };
5719 self.message = Some("Go to line: ".to_string());
5720 }
5721
5722 /// Parse line:col input and jump to position
5723 fn goto_line_col(&mut self, input: &str) {
5724 let input = input.trim();
5725 if input.is_empty() {
5726 return;
5727 }
5728
5729 // Parse formats: "line", "line:", "line:col"
5730 let (line_str, col_str) = if let Some(colon_pos) = input.find(':') {
5731 (&input[..colon_pos], &input[colon_pos + 1..])
5732 } else {
5733 (input, "")
5734 };
5735
5736 let line: usize = match line_str.parse::<usize>() {
5737 Ok(n) if n > 0 => n - 1, // Convert to 0-indexed
5738 Ok(_) => {
5739 self.message = Some("Invalid line number".to_string());
5740 return;
5741 }
5742 Err(_) => {
5743 self.message = Some("Invalid line number".to_string());
5744 return;
5745 }
5746 };
5747
5748 let col: usize = if col_str.is_empty() {
5749 0
5750 } else {
5751 match col_str.parse::<usize>() {
5752 Ok(n) if n > 0 => n - 1, // Convert to 0-indexed
5753 Ok(_) => 0,
5754 Err(_) => 0,
5755 }
5756 };
5757
5758 // Clamp to buffer bounds
5759 let line_count = self.buffer().line_count();
5760 let line = line.min(line_count.saturating_sub(1));
5761 let line_len = self.buffer().line_len(line);
5762 let col = col.min(line_len);
5763
5764 // Move cursor
5765 self.cursor_mut().line = line;
5766 self.cursor_mut().col = col;
5767 self.cursor_mut().desired_col = col;
5768 self.cursor_mut().clear_selection();
5769
5770 // Center the view on the target line
5771 self.scroll_to_cursor();
5772
5773 self.message = Some(format!("Line {}, Column {}", line + 1, col + 1));
5774 }
5775
5776 fn restore_backups(&mut self) -> Result<()> {
5777 let backups = self.workspace.list_backups();
5778
5779 for (original_path, backup_path) in backups {
5780 let (_, content) = self.workspace.read_backup(&backup_path)?;
5781
5782 // Try to find an open buffer with this path
5783 let mut found = false;
5784 for tab in &mut self.workspace.tabs {
5785 for buffer_entry in &mut tab.buffers {
5786 if let Some(ref buf_path) = buffer_entry.path {
5787 let full_path = if buffer_entry.is_orphan {
5788 buf_path.clone()
5789 } else {
5790 self.workspace.root.join(buf_path)
5791 };
5792 if full_path == original_path {
5793 buffer_entry.buffer.set_contents(&content);
5794 found = true;
5795 break;
5796 }
5797 }
5798 }
5799 if found {
5800 break;
5801 }
5802 }
5803
5804 // If not found as open buffer, open the file first then restore
5805 if !found {
5806 // Open the file
5807 self.workspace.open_file(&original_path)?;
5808 // Now restore content to the newly opened buffer
5809 if let Some(tab) = self.workspace.tabs.last_mut() {
5810 if let Some(buffer_entry) = tab.buffers.last_mut() {
5811 buffer_entry.buffer.set_contents(&content);
5812 }
5813 }
5814 }
5815
5816 // Delete the backup after successful restore
5817 std::fs::remove_file(&backup_path)?;
5818 }
5819
5820 Ok(())
5821 }
5822
5823 // ========== Find/Replace Methods ==========
5824
5825 /// Open the find dialog (or toggle if already open on find field)
5826 fn open_find(&mut self) {
5827 match &self.prompt {
5828 PromptState::FindReplace { active_field: FindReplaceField::Find, .. } => {
5829 // Already in find mode with find field active - close
5830 self.prompt = PromptState::None;
5831 self.search_state.matches.clear();
5832 }
5833 PromptState::FindReplace { find_query, replace_text, case_insensitive, regex_mode, .. } => {
5834 // In find/replace but on replace field - switch to find
5835 self.prompt = PromptState::FindReplace {
5836 find_query: find_query.clone(),
5837 replace_text: replace_text.clone(),
5838 active_field: FindReplaceField::Find,
5839 case_insensitive: *case_insensitive,
5840 regex_mode: *regex_mode,
5841 };
5842 }
5843 _ => {
5844 // Open fresh find dialog, possibly with selected text
5845 let initial_query = self.get_selection_text().unwrap_or_default();
5846 self.prompt = PromptState::FindReplace {
5847 find_query: initial_query,
5848 replace_text: String::new(),
5849 active_field: FindReplaceField::Find,
5850 case_insensitive: false,
5851 regex_mode: false,
5852 };
5853 self.update_search_matches();
5854 }
5855 }
5856 }
5857
5858 /// Open the replace dialog (or switch to replace field, or close if already on replace)
5859 fn open_replace(&mut self) {
5860 match &self.prompt {
5861 PromptState::FindReplace { active_field: FindReplaceField::Replace, .. } => {
5862 // Already in replace mode with replace field active - close
5863 self.prompt = PromptState::None;
5864 self.search_state.matches.clear();
5865 }
5866 PromptState::FindReplace { find_query, replace_text, case_insensitive, regex_mode, .. } => {
5867 // In find/replace but on find field - switch to replace
5868 self.prompt = PromptState::FindReplace {
5869 find_query: find_query.clone(),
5870 replace_text: replace_text.clone(),
5871 active_field: FindReplaceField::Replace,
5872 case_insensitive: *case_insensitive,
5873 regex_mode: *regex_mode,
5874 };
5875 }
5876 _ => {
5877 // Open find/replace with replace field active
5878 let initial_query = self.get_selection_text().unwrap_or_default();
5879 self.prompt = PromptState::FindReplace {
5880 find_query: initial_query,
5881 replace_text: String::new(),
5882 active_field: FindReplaceField::Replace,
5883 case_insensitive: false,
5884 regex_mode: false,
5885 };
5886 self.update_search_matches();
5887 }
5888 }
5889 }
5890
5891 /// Update search matches based on current query
5892 fn update_search_matches(&mut self) {
5893 let (query, case_insensitive, regex_mode) = match &self.prompt {
5894 PromptState::FindReplace { find_query, case_insensitive, regex_mode, .. } => {
5895 (find_query.clone(), *case_insensitive, *regex_mode)
5896 }
5897 _ => return,
5898 };
5899
5900 // Check if we need to update (query or settings changed)
5901 if query == self.search_state.last_query
5902 && case_insensitive == self.search_state.last_case_insensitive
5903 && regex_mode == self.search_state.last_regex
5904 {
5905 return;
5906 }
5907
5908 self.search_state.last_query = query.clone();
5909 self.search_state.last_case_insensitive = case_insensitive;
5910 self.search_state.last_regex = regex_mode;
5911 self.search_state.matches.clear();
5912 self.search_state.current_match = 0;
5913
5914 if query.is_empty() {
5915 return;
5916 }
5917
5918 // Collect all lines from buffer first to avoid borrow issues
5919 let buffer = self.buffer();
5920 let line_count = buffer.line_count();
5921 let lines: Vec<String> = (0..line_count)
5922 .filter_map(|i| buffer.line_str(i))
5923 .collect();
5924
5925 // Now search through the collected lines
5926 let mut matches = Vec::new();
5927
5928 if regex_mode {
5929 // Regex search
5930 let pattern = if case_insensitive {
5931 format!("(?i){}", query)
5932 } else {
5933 query.clone()
5934 };
5935
5936 if let Ok(re) = regex::Regex::new(&pattern) {
5937 for (line_idx, line) in lines.iter().enumerate() {
5938 for mat in re.find_iter(line) {
5939 // Convert byte positions to char positions for proper cursor placement
5940 let start_col = line[..mat.start()].chars().count();
5941 let match_char_len = line[mat.start()..mat.end()].chars().count();
5942 matches.push(SearchMatch {
5943 line: line_idx,
5944 start_col,
5945 end_col: start_col + match_char_len,
5946 });
5947 }
5948 }
5949 }
5950 } else {
5951 // Plain text search - optimized version using str::find()
5952 let search_query = if case_insensitive {
5953 query.to_lowercase()
5954 } else {
5955 query.clone()
5956 };
5957 let query_char_len = query.chars().count();
5958
5959 // Reusable buffer for case-insensitive search to avoid allocations
5960 let mut lowered_line = String::new();
5961
5962 for (line_idx, line) in lines.iter().enumerate() {
5963 // Get the search line (reuse buffer for case-insensitive)
5964 let search_line: &str = if case_insensitive {
5965 lowered_line.clear();
5966 for c in line.chars() {
5967 for lc in c.to_lowercase() {
5968 lowered_line.push(lc);
5969 }
5970 }
5971 &lowered_line
5972 } else {
5973 line
5974 };
5975
5976 // Use str::find() which is SIMD-optimized for byte search
5977 // Then convert byte positions to char positions
5978 let mut byte_offset = 0;
5979 while let Some(byte_pos) = search_line[byte_offset..].find(&search_query) {
5980 let abs_byte_pos = byte_offset + byte_pos;
5981
5982 // Convert byte position to char position
5983 let start_col = search_line[..abs_byte_pos].chars().count();
5984
5985 matches.push(SearchMatch {
5986 line: line_idx,
5987 start_col,
5988 end_col: start_col + query_char_len,
5989 });
5990
5991 // Move past this match (by at least one byte, or query length)
5992 byte_offset = abs_byte_pos + search_query.len().max(1);
5993 if byte_offset >= search_line.len() {
5994 break;
5995 }
5996 }
5997 }
5998 }
5999
6000 self.search_state.matches = matches;
6001
6002 // Find the match closest to current cursor position
6003 if !self.search_state.matches.is_empty() {
6004 let cursor = self.cursors().primary();
6005 let cursor_pos = (cursor.line, cursor.col);
6006
6007 // Find first match at or after cursor
6008 let mut best_idx = 0;
6009 for (i, m) in self.search_state.matches.iter().enumerate() {
6010 if (m.line, m.start_col) >= cursor_pos {
6011 best_idx = i;
6012 break;
6013 }
6014 best_idx = i;
6015 }
6016 self.search_state.current_match = best_idx;
6017 }
6018 }
6019
6020 /// Find and jump to next match
6021 fn find_next(&mut self) {
6022 self.update_search_matches();
6023
6024 if self.search_state.matches.is_empty() {
6025 self.message = Some("No matches found".to_string());
6026 return;
6027 }
6028
6029 // Move to next match (wrap around)
6030 self.search_state.current_match =
6031 (self.search_state.current_match + 1) % self.search_state.matches.len();
6032
6033 self.jump_to_current_match();
6034 }
6035
6036 /// Find and jump to previous match
6037 fn find_prev(&mut self) {
6038 self.update_search_matches();
6039
6040 if self.search_state.matches.is_empty() {
6041 self.message = Some("No matches found".to_string());
6042 return;
6043 }
6044
6045 // Move to previous match (wrap around)
6046 if self.search_state.current_match == 0 {
6047 self.search_state.current_match = self.search_state.matches.len() - 1;
6048 } else {
6049 self.search_state.current_match -= 1;
6050 }
6051
6052 self.jump_to_current_match();
6053 }
6054
6055 /// Jump cursor to the current match and select it
6056 fn jump_to_current_match(&mut self) {
6057 if let Some(m) = self.search_state.matches.get(self.search_state.current_match).cloned() {
6058 // Collapse to primary cursor and move it to the match
6059 self.cursors_mut().collapse_to_primary();
6060 let cursor = self.cursors_mut().primary_mut();
6061
6062 // Move cursor to start of match with selection extending to end
6063 cursor.line = m.start_col.min(m.end_col); // Cursor at start
6064 cursor.col = m.start_col;
6065 cursor.line = m.line;
6066
6067 // Set up selection from end to start (so cursor is at start, anchor at end)
6068 cursor.anchor_line = m.line;
6069 cursor.anchor_col = m.end_col;
6070 cursor.selecting = true;
6071
6072 // Scroll to make match visible
6073 self.scroll_to_cursor();
6074
6075 // Update message with match count
6076 let total = self.search_state.matches.len();
6077 let current = self.search_state.current_match + 1;
6078 self.message = Some(format!("{}/{} matches", current, total));
6079 }
6080 }
6081
6082 /// Replace current match and find next
6083 fn replace_current(&mut self) {
6084 let replace_text = match &self.prompt {
6085 PromptState::FindReplace { replace_text, .. } => replace_text.clone(),
6086 _ => return,
6087 };
6088
6089 if self.search_state.matches.is_empty() {
6090 self.message = Some("No matches to replace".to_string());
6091 return;
6092 }
6093
6094 // Get current match
6095 let current_idx = self.search_state.current_match;
6096 if let Some(m) = self.search_state.matches.get(current_idx).cloned() {
6097 // Delete the matched text and insert replacement
6098 let buffer = self.buffer_mut();
6099 let start_char = buffer.line_col_to_char(m.line, m.start_col);
6100 let end_char = buffer.line_col_to_char(m.line, m.end_col);
6101 buffer.delete(start_char, end_char);
6102 buffer.insert(start_char, &replace_text);
6103
6104 // Re-run search to update matches
6105 self.search_state.last_query.clear(); // Force re-search
6106 self.update_search_matches();
6107
6108 // Jump to next (or stay at same index if there are still matches)
6109 if !self.search_state.matches.is_empty() {
6110 // Keep index in bounds
6111 if self.search_state.current_match >= self.search_state.matches.len() {
6112 self.search_state.current_match = 0;
6113 }
6114 self.jump_to_current_match();
6115 } else {
6116 self.message = Some("All matches replaced".to_string());
6117 }
6118 }
6119 }
6120
6121 /// Replace all matches
6122 fn replace_all(&mut self) {
6123 let replace_text = match &self.prompt {
6124 PromptState::FindReplace { replace_text, .. } => replace_text.clone(),
6125 _ => return,
6126 };
6127
6128 self.update_search_matches();
6129
6130 if self.search_state.matches.is_empty() {
6131 self.message = Some("No matches to replace".to_string());
6132 return;
6133 }
6134
6135 let count = self.search_state.matches.len();
6136
6137 // Replace from end to start to preserve positions
6138 let matches: Vec<_> = self.search_state.matches.iter().cloned().collect();
6139 for m in matches.into_iter().rev() {
6140 let buffer = self.buffer_mut();
6141 let start_char = buffer.line_col_to_char(m.line, m.start_col);
6142 let end_char = buffer.line_col_to_char(m.line, m.end_col);
6143 buffer.delete(start_char, end_char);
6144 buffer.insert(start_char, &replace_text);
6145 }
6146
6147 self.search_state.matches.clear();
6148 self.search_state.last_query.clear();
6149 self.message = Some(format!("Replaced {} occurrences", count));
6150 }
6151
6152 /// Toggle case sensitivity
6153 fn toggle_case_sensitivity(&mut self) {
6154 if let PromptState::FindReplace { find_query, replace_text, active_field, case_insensitive, regex_mode } = &self.prompt {
6155 self.prompt = PromptState::FindReplace {
6156 find_query: find_query.clone(),
6157 replace_text: replace_text.clone(),
6158 active_field: *active_field,
6159 case_insensitive: !*case_insensitive,
6160 regex_mode: *regex_mode,
6161 };
6162 self.search_state.last_query.clear(); // Force re-search
6163 self.update_search_matches();
6164 }
6165 }
6166
6167 /// Toggle regex mode
6168 fn toggle_regex_mode(&mut self) {
6169 if let PromptState::FindReplace { find_query, replace_text, active_field, case_insensitive, regex_mode } = &self.prompt {
6170 self.prompt = PromptState::FindReplace {
6171 find_query: find_query.clone(),
6172 replace_text: replace_text.clone(),
6173 active_field: *active_field,
6174 case_insensitive: *case_insensitive,
6175 regex_mode: !*regex_mode,
6176 };
6177 self.search_state.last_query.clear(); // Force re-search
6178 self.update_search_matches();
6179 }
6180 }
6181
6182 // === Fortress mode (file browser) ===
6183
6184 /// Open fortress mode file browser
6185 fn open_fortress(&mut self) {
6186 // Start at current file's directory, or workspace root
6187 let start_path = if let Some(path) = self.current_file_path() {
6188 if let Some(parent) = path.parent() {
6189 parent.to_path_buf()
6190 } else {
6191 self.workspace.root.clone()
6192 }
6193 } else {
6194 self.workspace.root.clone()
6195 };
6196
6197 let entries = self.read_directory(&start_path);
6198 self.prompt = PromptState::Fortress {
6199 current_path: start_path,
6200 entries,
6201 selected_index: 0,
6202 filter: String::new(),
6203 scroll_offset: 0,
6204 };
6205 }
6206
6207 /// Read directory contents and return sorted entries (dirs first, then files)
6208 fn read_directory(&self, path: &Path) -> Vec<FortressEntry> {
6209 let mut entries = Vec::new();
6210
6211 // Add parent directory entry if not at root
6212 if path.parent().is_some() {
6213 entries.push(FortressEntry {
6214 name: "..".to_string(),
6215 path: path.parent().unwrap().to_path_buf(),
6216 is_dir: true,
6217 });
6218 }
6219
6220 if let Ok(read_dir) = std::fs::read_dir(path) {
6221 let mut dirs = Vec::new();
6222 let mut files = Vec::new();
6223
6224 for entry in read_dir.flatten() {
6225 let entry_path = entry.path();
6226 let name = entry.file_name().to_string_lossy().to_string();
6227
6228 // Skip hidden files (starting with .)
6229 if name.starts_with('.') {
6230 continue;
6231 }
6232
6233 let is_dir = entry_path.is_dir();
6234 let fortress_entry = FortressEntry {
6235 name,
6236 path: entry_path,
6237 is_dir,
6238 };
6239
6240 if is_dir {
6241 dirs.push(fortress_entry);
6242 } else {
6243 files.push(fortress_entry);
6244 }
6245 }
6246
6247 // Sort alphabetically (case-insensitive)
6248 dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
6249 files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
6250
6251 // Directories first, then files
6252 entries.extend(dirs);
6253 entries.extend(files);
6254 }
6255
6256 entries
6257 }
6258
6259 /// Navigate to a new directory in fortress mode
6260 fn fortress_navigate_to(&mut self, path: &Path) {
6261 let entries = self.read_directory(path);
6262 self.prompt = PromptState::Fortress {
6263 current_path: path.to_path_buf(),
6264 entries,
6265 selected_index: 0,
6266 filter: String::new(),
6267 scroll_offset: 0,
6268 };
6269 }
6270
6271 /// Open a file from fortress mode
6272 fn fortress_open_file(&mut self, path: &Path) {
6273 // Open file in current pane by reusing workspace method
6274 if let Err(e) = self.workspace.open_file(path) {
6275 self.message = Some(format!("Failed to open file: {}", e));
6276 } else {
6277 // Sync with LSP
6278 self.sync_document_to_lsp();
6279 }
6280 }
6281
6282 /// Open multi-file search modal (F4)
6283 fn open_file_search(&mut self) {
6284 self.prompt = PromptState::FileSearch {
6285 query: String::new(),
6286 results: Vec::new(),
6287 selected_index: 0,
6288 scroll_offset: 0,
6289 searching: false,
6290 };
6291 }
6292
6293 /// Search files in workspace for query string (grep-like)
6294 /// Uses streaming file reading to avoid loading entire files into memory
6295 fn search_files(&self, query: &str) -> Vec<FileSearchResult> {
6296 use std::io::{BufRead, BufReader};
6297 use std::fs::File;
6298
6299 if query.is_empty() {
6300 return Vec::new();
6301 }
6302
6303 let mut results = Vec::new();
6304 let root = &self.workspace.root;
6305 let query_lower = query.to_lowercase();
6306
6307 // Walk directory tree
6308 fn walk_dir(
6309 dir: &Path,
6310 query_lower: &str,
6311 results: &mut Vec<FileSearchResult>,
6312 root: &Path,
6313 ) {
6314 let Ok(entries) = std::fs::read_dir(dir) else {
6315 return;
6316 };
6317
6318 for entry in entries.flatten() {
6319 // Check result limit early to avoid unnecessary work
6320 if results.len() >= 500 {
6321 return;
6322 }
6323
6324 let path = entry.path();
6325 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
6326
6327 // Skip hidden files/dirs
6328 if name.starts_with('.') {
6329 continue;
6330 }
6331
6332 // Skip common non-text directories
6333 if path.is_dir() {
6334 if matches!(name, "target" | "node_modules" | "build" | "dist" | "__pycache__") {
6335 continue;
6336 }
6337 walk_dir(&path, query_lower, results, root);
6338 } else if path.is_file() {
6339 // Skip binary/large files by extension
6340 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
6341 if matches!(ext, "png" | "jpg" | "jpeg" | "gif" | "ico" | "woff" | "woff2" | "ttf" | "eot" | "pdf" | "zip" | "tar" | "gz" | "exe" | "dll" | "so" | "dylib" | "o" | "a" | "rlib") {
6342 continue;
6343 }
6344
6345 // Stream file line-by-line instead of loading entire file
6346 let Ok(file) = File::open(&path) else {
6347 continue;
6348 };
6349 let reader = BufReader::new(file);
6350 let rel_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
6351
6352 // Reusable buffer for lowercasing
6353 let mut line_lower = String::new();
6354
6355 for (line_idx, line_result) in reader.lines().enumerate() {
6356 // Check result limit
6357 if results.len() >= 500 {
6358 return;
6359 }
6360
6361 let Ok(line) = line_result else {
6362 // Non-UTF8 content - likely binary, skip file
6363 break;
6364 };
6365
6366 // Reuse buffer for lowercase conversion
6367 line_lower.clear();
6368 for c in line.chars() {
6369 for lc in c.to_lowercase() {
6370 line_lower.push(lc);
6371 }
6372 }
6373
6374 if line_lower.contains(query_lower) {
6375 results.push(FileSearchResult {
6376 path: rel_path.clone(),
6377 line_num: line_idx + 1,
6378 line_content: line.trim().to_string(),
6379 });
6380 }
6381 }
6382 }
6383 }
6384 }
6385
6386 walk_dir(root, &query_lower, &mut results, root);
6387 results
6388 }
6389
6390 /// Open file at the location from a file search result
6391 fn file_search_open_result(&mut self, result: &FileSearchResult) {
6392 let full_path = self.workspace.root.join(&result.path);
6393
6394 if let Err(e) = self.workspace.open_file(&full_path) {
6395 self.message = Some(format!("Failed to open file: {}", e));
6396 return;
6397 }
6398
6399 // Sync with LSP
6400 self.sync_document_to_lsp();
6401
6402 // Go to line
6403 let line = result.line_num.saturating_sub(1); // Convert to 0-indexed
6404 let tab = self.workspace.active_tab_mut();
6405 let max_line = tab.active_buffer().buffer.line_count().saturating_sub(1);
6406 let target_line = line.min(max_line);
6407
6408 let pane = tab.active_pane_mut();
6409 pane.cursors.primary_mut().line = target_line;
6410 pane.cursors.primary_mut().col = 0;
6411
6412 // Center the line in viewport
6413 let viewport_height = self.screen.rows.saturating_sub(2) as usize;
6414 pane.viewport_line = target_line.saturating_sub(viewport_height / 2);
6415 }
6416
6417 // === Command Palette ===
6418
6419 /// Open the command palette
6420 fn open_command_palette(&mut self) {
6421 let filtered = filter_commands("");
6422 self.prompt = PromptState::CommandPalette {
6423 query: String::new(),
6424 filtered,
6425 selected_index: 0,
6426 scroll_offset: 0,
6427 };
6428 }
6429
6430 /// Execute a command by its ID
6431 fn execute_command(&mut self, command_id: &str) {
6432 match command_id {
6433 // File operations
6434 "save" => { let _ = self.save(); }
6435 "save-all" => { let _ = self.workspace.save_all(); }
6436 "open" => self.open_fortress(),
6437 "new-tab" => self.workspace.new_tab(),
6438 "close-tab" => self.close_pane(), // Close current pane/tab
6439 "next-tab" => self.workspace.next_tab(),
6440 "prev-tab" => self.workspace.prev_tab(),
6441 "quit" => self.try_quit(),
6442
6443 // Edit operations
6444 "undo" => self.undo(),
6445 "redo" => self.redo(),
6446 "cut" => self.cut(),
6447 "copy" => self.copy(),
6448 "paste" => self.paste(),
6449 "select-all" => {
6450 // Select all text in current buffer
6451 let line_count = self.buffer().line_count();
6452 let last_line = line_count.saturating_sub(1);
6453 let last_col = self.buffer().line_len(last_line);
6454 self.cursor_mut().anchor_line = 0;
6455 self.cursor_mut().anchor_col = 0;
6456 self.cursor_mut().line = last_line;
6457 self.cursor_mut().col = last_col;
6458 self.cursor_mut().selecting = true;
6459 }
6460 "select-line" => self.select_line(),
6461 "select-word" => self.select_word(),
6462 "toggle-comment" => self.toggle_line_comment(),
6463 "join-lines" => self.join_lines(),
6464 "duplicate-line" => self.duplicate_line_down(),
6465 "move-line-up" => self.move_line_up(),
6466 "move-line-down" => self.move_line_down(),
6467 "delete-line" => {
6468 // Delete the current line
6469 let line = self.cursor().line;
6470 let line_count = self.buffer().line_count();
6471 let line_start = self.buffer().line_col_to_char(line, 0);
6472 let line_end = if line + 1 < line_count {
6473 self.buffer().line_col_to_char(line + 1, 0)
6474 } else {
6475 self.buffer().len_chars()
6476 };
6477 if line_start < line_end {
6478 self.buffer_mut().delete(line_start, line_end);
6479 self.cursor_mut().col = 0;
6480 self.cursor_mut().desired_col = 0;
6481 // Clamp line if we deleted the last line
6482 let new_line_count = self.buffer().line_count();
6483 if self.cursor().line >= new_line_count {
6484 self.cursor_mut().line = new_line_count.saturating_sub(1);
6485 }
6486 }
6487 }
6488 "indent" => self.insert_tab(),
6489 "outdent" => self.dedent(),
6490 "transpose" => self.transpose_chars(),
6491
6492 // Search operations
6493 "find" => self.open_find(),
6494 "replace" => self.open_replace(),
6495 "find-next" => self.find_next(),
6496 "find-prev" => self.find_prev(),
6497 "search-files" => self.open_file_search(),
6498
6499 // Navigation
6500 "goto-line" => self.open_goto_line(),
6501 "goto-start" => {
6502 self.cursor_mut().line = 0;
6503 self.cursor_mut().col = 0;
6504 self.cursor_mut().desired_col = 0;
6505 self.cursor_mut().clear_selection();
6506 }
6507 "goto-end" => {
6508 let last_line = self.buffer().line_count().saturating_sub(1);
6509 let last_col = self.buffer().line_len(last_line);
6510 self.cursor_mut().line = last_line;
6511 self.cursor_mut().col = last_col;
6512 self.cursor_mut().desired_col = last_col;
6513 self.cursor_mut().clear_selection();
6514 }
6515 "goto-bracket" => self.jump_to_matching_bracket(),
6516 "page-up" => self.page_up(false),
6517 "page-down" => self.page_down(false),
6518
6519 // Selection
6520 "select-brackets" => self.jump_to_matching_bracket(), // TODO: implement select inside brackets
6521 "cursor-above" => self.add_cursor_above(),
6522 "cursor-below" => self.add_cursor_below(),
6523
6524 // View / Panes
6525 "split-vertical" => self.split_vertical(),
6526 "split-horizontal" => self.split_horizontal(),
6527 "close-pane" => self.close_pane(),
6528 "next-pane" => self.tab_mut().navigate_pane(PaneDirection::Right),
6529 "prev-pane" => self.tab_mut().navigate_pane(PaneDirection::Left),
6530 "toggle-explorer" => self.workspace.fuss.toggle(),
6531
6532 // LSP operations
6533 "goto-definition" => self.lsp_goto_definition(),
6534 "find-references" => self.lsp_find_references(),
6535 "rename" => self.lsp_rename(),
6536 "hover" => self.lsp_hover(),
6537 "completion" => self.filter_completions(),
6538 "server-manager" => self.toggle_server_manager(),
6539
6540 // Bracket/Quote operations
6541 "jump-bracket" => self.jump_to_matching_bracket(),
6542 "cycle-brackets" => self.cycle_brackets(),
6543 "remove-surrounding" => self.remove_surrounding(),
6544
6545 // Help
6546 "command-palette" => {} // Already open
6547 "help" => self.open_help_menu(),
6548
6549 _ => {
6550 self.message = Some(format!("Unknown command: {}", command_id));
6551 }
6552 }
6553 }
6554
6555 // === Help Menu ===
6556
6557 /// Open the help menu with keybindings
6558 fn open_help_menu(&mut self) {
6559 let filtered = filter_keybinds("");
6560 self.prompt = PromptState::HelpMenu {
6561 query: String::new(),
6562 filtered,
6563 selected_index: 0,
6564 scroll_offset: 0,
6565 show_alt: false,
6566 };
6567 }
6568 }
6569
6570 /// Fuzzy match scoring for command palette
6571 fn fuzzy_match_score(text: &str, pattern: &str) -> i32 {
6572 if pattern.is_empty() {
6573 return 100; // Empty pattern matches everything with base score
6574 }
6575
6576 let text_lower = text.to_lowercase();
6577 let pattern_lower = pattern.to_lowercase();
6578
6579 let mut score = 0i32;
6580 let mut pattern_idx = 0;
6581 let mut consecutive = 0;
6582 let pattern_chars: Vec<char> = pattern_lower.chars().collect();
6583 let text_chars: Vec<char> = text_lower.chars().collect();
6584
6585 if pattern_chars.is_empty() {
6586 return 100;
6587 }
6588
6589 for (i, &tc) in text_chars.iter().enumerate() {
6590 if pattern_idx >= pattern_chars.len() {
6591 break;
6592 }
6593
6594 if tc == pattern_chars[pattern_idx] {
6595 score += 10;
6596 consecutive += 1;
6597
6598 // Bonus for consecutive matches
6599 if consecutive > 1 {
6600 score += 5;
6601 }
6602
6603 // Bonus for matching at start or after space/separator
6604 if i == 0 || matches!(text_chars.get(i.wrapping_sub(1)), Some(' ' | ':' | '-' | '_' | '/')) {
6605 score += 15;
6606 }
6607
6608 pattern_idx += 1;
6609 } else {
6610 consecutive = 0;
6611 }
6612 }
6613
6614 // Only return positive score if all pattern characters matched
6615 if pattern_idx == pattern_chars.len() {
6616 score
6617 } else {
6618 0
6619 }
6620 }
6621
6622 /// Filter and sort commands by fuzzy match score
6623 fn filter_commands(query: &str) -> Vec<PaletteCommand> {
6624 let mut filtered: Vec<PaletteCommand> = ALL_COMMANDS
6625 .iter()
6626 .filter_map(|cmd| {
6627 // Match against name, category, or command ID
6628 let name_score = fuzzy_match_score(cmd.name, query);
6629 let category_score = fuzzy_match_score(cmd.category, query) / 2; // Category match worth less
6630 let id_score = fuzzy_match_score(cmd.id, query) / 2;
6631
6632 let score = name_score.max(category_score).max(id_score);
6633 if score > 0 {
6634 let mut cmd = cmd.clone();
6635 cmd.score = score;
6636 Some(cmd)
6637 } else {
6638 None
6639 }
6640 })
6641 .collect();
6642
6643 // Sort by score descending
6644 filtered.sort_by(|a, b| b.score.cmp(&a.score));
6645 filtered
6646 }
6647
6648 /// Filter keybinds by fuzzy match (for help menu)
6649 fn filter_keybinds(query: &str) -> Vec<HelpKeybind> {
6650 if query.is_empty() {
6651 // Return all keybinds in original order (grouped by category)
6652 return ALL_KEYBINDS.to_vec();
6653 }
6654
6655 let mut filtered: Vec<(HelpKeybind, i32)> = ALL_KEYBINDS
6656 .iter()
6657 .filter_map(|kb| {
6658 // Match against shortcut, description, or category
6659 let shortcut_score = fuzzy_match_score(kb.shortcut, query);
6660 let desc_score = fuzzy_match_score(kb.description, query);
6661 let category_score = fuzzy_match_score(kb.category, query) / 2;
6662
6663 let score = shortcut_score.max(desc_score).max(category_score);
6664 if score > 0 {
6665 Some((kb.clone(), score))
6666 } else {
6667 None
6668 }
6669 })
6670 .collect();
6671
6672 // Sort by score descending
6673 filtered.sort_by(|a, b| b.1.cmp(&a.1));
6674 filtered.into_iter().map(|(kb, _)| kb).collect()
6675 }
6676
6677 impl Drop for Editor {
6678 fn drop(&mut self) {
6679 let _ = self.screen.leave_raw_mode();
6680 }
6681 }
6682
6683 /// Check if a character is a "word" character (alphanumeric or underscore)
6684 fn is_word_char(c: char) -> bool {
6685 c.is_alphanumeric() || c == '_'
6686 }
6687