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