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