tenseleyflow/fackr / ac0080d

Browse files

feat: Ctrl+O fortress file browser, fix multi-cursor same-line edits

Authored by espadonne
SHA
ac0080dd33b90aaee8a6ff15d3c611822a1c9a0b
Parents
c958e31
Tree
60121a8

2 changed files

StatusFile+-
M src/editor/state.rs 379 77
M src/render/screen.rs 177 0
src/editor/state.rsmodified
@@ -22,6 +22,17 @@ enum FindReplaceField {
2222
     Replace,
2323
 }
2424
 
25
+/// Entry in the fortress file explorer
26
+#[derive(Debug, Clone, PartialEq)]
27
+struct FortressEntry {
28
+    /// File/directory name
29
+    name: String,
30
+    /// Full path
31
+    path: PathBuf,
32
+    /// Is this a directory?
33
+    is_dir: bool,
34
+}
35
+
2536
 /// Prompt state for quit confirmation
2637
 #[derive(Debug, Clone, PartialEq)]
2738
 enum PromptState {
@@ -61,6 +72,19 @@ enum PromptState {
6172
         /// Regex mode
6273
         regex_mode: bool,
6374
     },
75
+    /// Fortress mode - file explorer modal
76
+    Fortress {
77
+        /// Current directory being browsed
78
+        current_path: PathBuf,
79
+        /// Directory entries (directories first, then files)
80
+        entries: Vec<FortressEntry>,
81
+        /// Currently selected index
82
+        selected_index: usize,
83
+        /// Filter/search query
84
+        filter: String,
85
+        /// Scroll offset for long lists
86
+        scroll_offset: usize,
87
+    },
6488
 }
6589
 
6690
 /// Action to perform when text input is complete
@@ -1236,6 +1260,29 @@ impl Editor {
12361260
                 self.screen.render_references_panel(locations, selected_index, query, &self.workspace.root)?;
12371261
             }
12381262
 
1263
+            // Render fortress modal if active
1264
+            if let PromptState::Fortress {
1265
+                ref current_path,
1266
+                ref entries,
1267
+                selected_index,
1268
+                ref filter,
1269
+                scroll_offset,
1270
+            } = self.prompt {
1271
+                // Convert entries to tuple format for render function
1272
+                let entries_tuples: Vec<(String, PathBuf, bool)> = entries
1273
+                    .iter()
1274
+                    .map(|e| (e.name.clone(), e.path.clone(), e.is_dir))
1275
+                    .collect();
1276
+                self.screen.render_fortress_modal(
1277
+                    current_path,
1278
+                    &entries_tuples,
1279
+                    selected_index,
1280
+                    filter,
1281
+                    scroll_offset,
1282
+                )?;
1283
+                return Ok(()); // Modal handles cursor
1284
+            }
1285
+
12391286
             // Render find/replace bar if active (replaces status bar)
12401287
             if let PromptState::FindReplace {
12411288
                 ref find_query,
@@ -1484,6 +1531,10 @@ impl Editor {
14841531
             // Find previous: Shift+F3
14851532
             (Key::F(3), Modifiers { shift: true, .. }) => self.find_prev(),
14861533
 
1534
+            // === File operations ===
1535
+            // Open file browser (Fortress mode): Ctrl+O
1536
+            (Key::Char('o'), Modifiers { ctrl: true, .. }) => self.open_fortress(),
1537
+
14871538
             // === Editing ===
14881539
             (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => {
14891540
                 self.insert_char(*c);
@@ -2260,58 +2311,57 @@ impl Editor {
22602311
             return;
22612312
         }
22622313
 
2263
-        // Multi-cursor: process from bottom-right to top-left to maintain correct positions.
2264
-        // This ordering ensures that when we insert text, we don't affect the character indices
2265
-        // of cursors we haven't processed yet (they're all earlier in the document).
2266
-        //
2267
-        // Collect original cursor positions with indices
2268
-        let mut positions: Vec<(usize, usize, usize)> = self.cursors().all()
2314
+        // Multi-cursor: compute absolute character indices FIRST from a frozen view of the buffer.
2315
+        // Then sort by ASCENDING char index, apply edits from start to end,
2316
+        // and track cumulative offset to adjust subsequent positions.
2317
+
2318
+        // Step 1: Compute char indices for all cursors from current buffer state
2319
+        let mut cursor_char_indices: Vec<(usize, usize)> = self.cursors().all()
22692320
             .iter()
22702321
             .enumerate()
2271
-            .map(|(i, c)| (i, c.line, c.col))
2322
+            .map(|(i, c)| {
2323
+                let char_idx = self.buffer().line_col_to_char(c.line, c.col);
2324
+                (i, char_idx)
2325
+            })
22722326
             .collect();
22732327
 
2274
-        // Sort by position, bottom-right first (highest line, then highest col)
2275
-        positions.sort_by(|a, b| {
2276
-            match b.1.cmp(&a.1) {
2277
-                std::cmp::Ordering::Equal => b.2.cmp(&a.2),
2278
-                ord => ord,
2279
-            }
2280
-        });
2328
+        // Step 2: Sort by ASCENDING char index (process from start of document)
2329
+        cursor_char_indices.sort_by(|a, b| a.1.cmp(&b.1));
22812330
 
22822331
         // Record all cursor positions before the operation
22832332
         let cursors_before = self.all_cursor_positions();
22842333
         self.history_mut().begin_group();
22852334
         self.history_mut().set_cursors_before(cursors_before);
22862335
 
2287
-        // Count newlines and chars for position updates
2288
-        let newlines = text.chars().filter(|&c| c == '\n').count();
22892336
         let text_char_count = text.chars().count();
2290
-        let chars_after_last_newline = if let Some(pos) = text.rfind('\n') {
2291
-            text[pos + 1..].chars().count()
2292
-        } else {
2293
-            text_char_count
2294
-        };
2295
-
22962337
         let cursor_before = self.cursor_pos();
22972338
 
2298
-        // Process each cursor using the ORIGINAL positions we captured.
2299
-        // Since we go bottom-right to top-left, insertions don't affect positions we'll use later.
2300
-        for (cursor_idx, orig_line, orig_col) in positions.iter().copied() {
2301
-            let idx = self.buffer().line_col_to_char(orig_line, orig_col);
2302
-            self.buffer_mut().insert(idx, text);
2303
-            self.history_mut().record_insert(idx, text.to_string(), cursor_before, cursor_before);
2339
+        // Step 3: Apply inserts from start to end, tracking cumulative offset
2340
+        let mut cumulative_offset: usize = 0;
2341
+        let mut new_positions: Vec<(usize, usize, usize)> = Vec::new(); // (cursor_idx, line, col)
23042342
 
2305
-            // Update this cursor's final position
2343
+        for (cursor_idx, original_char_idx) in cursor_char_indices {
2344
+            // Adjust position by cumulative offset from previous inserts
2345
+            let adjusted_char_idx = original_char_idx + cumulative_offset;
2346
+
2347
+            self.buffer_mut().insert(adjusted_char_idx, text);
2348
+            self.history_mut().record_insert(adjusted_char_idx, text.to_string(), cursor_before, cursor_before);
2349
+
2350
+            // New cursor position is right after the inserted text
2351
+            let new_char_idx = adjusted_char_idx + text_char_count;
2352
+            let (new_line, new_col) = self.buffer().char_to_line_col(new_char_idx);
2353
+            new_positions.push((cursor_idx, new_line, new_col));
2354
+
2355
+            // Update cumulative offset for next cursor
2356
+            cumulative_offset += text_char_count;
2357
+        }
2358
+
2359
+        // Step 4: Update all cursor positions at once
2360
+        for (cursor_idx, new_line, new_col) in new_positions {
23062361
             let cursor = &mut self.cursors_mut().all_mut()[cursor_idx];
2307
-            if newlines > 0 {
2308
-                cursor.line = orig_line + newlines;
2309
-                cursor.col = chars_after_last_newline;
2310
-            } else {
2311
-                cursor.line = orig_line;
2312
-                cursor.col = orig_col + text_char_count;
2313
-            }
2314
-            cursor.desired_col = cursor.col;
2362
+            cursor.line = new_line;
2363
+            cursor.col = new_col;
2364
+            cursor.desired_col = new_col;
23152365
         }
23162366
 
23172367
         // Record all cursor positions after the operation
@@ -2463,20 +2513,21 @@ impl Editor {
24632513
 
24642514
     /// Delete backward at all cursor positions (multi-cursor)
24652515
     fn delete_backward_multi(&mut self) {
2466
-        // Collect cursor positions, process from bottom to top
2467
-        let mut positions: Vec<(usize, usize, usize)> = self.cursors().all()
2516
+        // Multi-cursor: compute absolute character indices FIRST from a frozen view of the buffer.
2517
+        // Sort by ASCENDING, process start to end, track cumulative offset.
2518
+
2519
+        // Step 1: Compute char indices for all cursors from current buffer state
2520
+        let mut cursor_char_indices: Vec<(usize, usize)> = self.cursors().all()
24682521
             .iter()
24692522
             .enumerate()
2470
-            .map(|(i, c)| (i, c.line, c.col))
2523
+            .map(|(i, c)| {
2524
+                let char_idx = self.buffer().line_col_to_char(c.line, c.col);
2525
+                (i, char_idx)
2526
+            })
24712527
             .collect();
24722528
 
2473
-        // Sort by position, bottom-right first
2474
-        positions.sort_by(|a, b| {
2475
-            match b.1.cmp(&a.1) {
2476
-                std::cmp::Ordering::Equal => b.2.cmp(&a.2),
2477
-                ord => ord,
2478
-            }
2479
-        });
2529
+        // Step 2: Sort by ASCENDING char index (process from start of document)
2530
+        cursor_char_indices.sort_by(|a, b| a.1.cmp(&b.1));
24802531
 
24812532
         // Record all cursor positions before the operation
24822533
         let cursors_before = self.all_cursor_positions();
@@ -2484,19 +2535,43 @@ impl Editor {
24842535
         self.history_mut().set_cursors_before(cursors_before);
24852536
 
24862537
         let cursor_before = self.cursor_pos();
2487
-        for (cursor_idx, line, col) in positions {
2488
-            if col > 0 {
2489
-                let idx = self.buffer().line_col_to_char(line, col);
2490
-                let deleted = self.buffer().char_at(idx - 1).map(|c| c.to_string()).unwrap_or_default();
2491
-                self.buffer_mut().delete(idx - 1, idx);
2492
-                self.history_mut().record_delete(idx - 1, deleted, cursor_before, cursor_before);
24932538
 
2494
-                // Update cursor position
2495
-                let cursor = &mut self.cursors_mut().all_mut()[cursor_idx];
2496
-                cursor.col -= 1;
2497
-                cursor.desired_col = cursor.col;
2539
+        // Step 3: Apply deletes from start to end, tracking cumulative offset
2540
+        let mut cumulative_offset: isize = 0;
2541
+        let mut new_positions: Vec<(usize, usize, usize)> = Vec::new();
2542
+
2543
+        for (cursor_idx, original_char_idx) in cursor_char_indices {
2544
+            if original_char_idx == 0 {
2545
+                // Can't delete backward from position 0, keep cursor where it is
2546
+                let cursor = &self.cursors().all()[cursor_idx];
2547
+                new_positions.push((cursor_idx, cursor.line, cursor.col));
2548
+                continue;
2549
+            }
2550
+
2551
+            // Adjust position by cumulative offset from previous deletes
2552
+            let adjusted_char_idx = (original_char_idx as isize + cumulative_offset) as usize;
2553
+
2554
+            if adjusted_char_idx > 0 {
2555
+                let deleted = self.buffer().char_at(adjusted_char_idx - 1).map(|c| c.to_string()).unwrap_or_default();
2556
+                self.buffer_mut().delete(adjusted_char_idx - 1, adjusted_char_idx);
2557
+                self.history_mut().record_delete(adjusted_char_idx - 1, deleted, cursor_before, cursor_before);
2558
+
2559
+                // New cursor position is at the delete point
2560
+                let new_char_idx = adjusted_char_idx - 1;
2561
+                let (new_line, new_col) = self.buffer().char_to_line_col(new_char_idx);
2562
+                new_positions.push((cursor_idx, new_line, new_col));
2563
+
2564
+                // Update cumulative offset (we deleted 1 char, so offset decreases by 1)
2565
+                cumulative_offset -= 1;
24982566
             }
2499
-            // Note: For simplicity, we don't handle joining lines in multi-cursor mode
2567
+        }
2568
+
2569
+        // Step 4: Update all cursor positions at once
2570
+        for (cursor_idx, new_line, new_col) in new_positions {
2571
+            let cursor = &mut self.cursors_mut().all_mut()[cursor_idx];
2572
+            cursor.line = new_line;
2573
+            cursor.col = new_col;
2574
+            cursor.desired_col = new_col;
25002575
         }
25012576
 
25022577
         // Record all cursor positions after the operation
@@ -2508,20 +2583,23 @@ impl Editor {
25082583
 
25092584
     /// Delete forward at all cursor positions (multi-cursor)
25102585
     fn delete_forward_multi(&mut self) {
2511
-        // Collect cursor positions, process from bottom to top
2512
-        let mut positions: Vec<(usize, usize, usize)> = self.cursors().all()
2586
+        // Multi-cursor: compute absolute character indices FIRST from a frozen view of the buffer.
2587
+        // Sort by ASCENDING, process start to end, track cumulative offset.
2588
+
2589
+        let total_chars = self.buffer().char_count();
2590
+
2591
+        // Step 1: Compute char indices for all cursors from current buffer state
2592
+        let mut cursor_char_indices: Vec<(usize, usize)> = self.cursors().all()
25132593
             .iter()
25142594
             .enumerate()
2515
-            .map(|(i, c)| (i, c.line, c.col))
2595
+            .map(|(i, c)| {
2596
+                let char_idx = self.buffer().line_col_to_char(c.line, c.col);
2597
+                (i, char_idx)
2598
+            })
25162599
             .collect();
25172600
 
2518
-        // Sort by position, bottom-right first
2519
-        positions.sort_by(|a, b| {
2520
-            match b.1.cmp(&a.1) {
2521
-                std::cmp::Ordering::Equal => b.2.cmp(&a.2),
2522
-                ord => ord,
2523
-            }
2524
-        });
2601
+        // Step 2: Sort by ASCENDING char index (process from start of document)
2602
+        cursor_char_indices.sort_by(|a, b| a.1.cmp(&b.1));
25252603
 
25262604
         // Record all cursor positions before the operation
25272605
         let cursors_before = self.all_cursor_positions();
@@ -2529,16 +2607,37 @@ impl Editor {
25292607
         self.history_mut().set_cursors_before(cursors_before);
25302608
 
25312609
         let cursor_before = self.cursor_pos();
2532
-        for (_cursor_idx, line, col) in positions {
2533
-            let line_len = self.buffer().line_len(line);
2534
-            if col < line_len {
2535
-                let idx = self.buffer().line_col_to_char(line, col);
2536
-                let deleted = self.buffer().char_at(idx).map(|c| c.to_string()).unwrap_or_default();
2537
-                self.buffer_mut().delete(idx, idx + 1);
2538
-                self.history_mut().record_delete(idx, deleted, cursor_before, cursor_before);
2539
-                // Cursor position doesn't change for delete forward
2610
+
2611
+        // Step 3: Apply deletes from start to end, tracking cumulative offset
2612
+        let mut cumulative_offset: isize = 0;
2613
+        let mut new_positions: Vec<(usize, usize, usize)> = Vec::new();
2614
+
2615
+        for (cursor_idx, original_char_idx) in cursor_char_indices {
2616
+            // Adjust position by cumulative offset from previous deletes
2617
+            let adjusted_char_idx = (original_char_idx as isize + cumulative_offset) as usize;
2618
+            let current_total = (total_chars as isize + cumulative_offset) as usize;
2619
+
2620
+            if adjusted_char_idx < current_total {
2621
+                let deleted = self.buffer().char_at(adjusted_char_idx).map(|c| c.to_string()).unwrap_or_default();
2622
+                // Don't delete newlines in multi-cursor mode for simplicity
2623
+                if deleted != "\n" {
2624
+                    self.buffer_mut().delete(adjusted_char_idx, adjusted_char_idx + 1);
2625
+                    self.history_mut().record_delete(adjusted_char_idx, deleted, cursor_before, cursor_before);
2626
+                    cumulative_offset -= 1;
2627
+                }
25402628
             }
2541
-            // Note: For simplicity, we don't handle joining lines in multi-cursor mode
2629
+
2630
+            // Cursor position: convert from adjusted char index (cursor doesn't move for delete forward)
2631
+            let (new_line, new_col) = self.buffer().char_to_line_col(adjusted_char_idx.min(self.buffer().char_count()));
2632
+            new_positions.push((cursor_idx, new_line, new_col));
2633
+        }
2634
+
2635
+        // Step 4: Update all cursor positions at once
2636
+        for (cursor_idx, new_line, new_col) in new_positions {
2637
+            let cursor = &mut self.cursors_mut().all_mut()[cursor_idx];
2638
+            cursor.line = new_line;
2639
+            cursor.col = new_col;
2640
+            cursor.desired_col = new_col;
25422641
         }
25432642
 
25442643
         // Record all cursor positions after the operation
@@ -3753,6 +3852,109 @@ impl Editor {
37533852
                     _ => {}
37543853
                 }
37553854
             }
3855
+            PromptState::Fortress {
3856
+                ref current_path,
3857
+                ref entries,
3858
+                ref mut selected_index,
3859
+                ref mut filter,
3860
+                ref mut scroll_offset,
3861
+            } => {
3862
+                // Filter entries based on query
3863
+                let filtered: Vec<(usize, &FortressEntry)> = if filter.is_empty() {
3864
+                    entries.iter().enumerate().collect()
3865
+                } else {
3866
+                    let f = filter.to_lowercase();
3867
+                    entries.iter().enumerate()
3868
+                        .filter(|(_, e)| e.name.to_lowercase().contains(&f))
3869
+                        .collect()
3870
+                };
3871
+
3872
+                match key {
3873
+                    Key::Enter => {
3874
+                        // Open selected entry
3875
+                        if let Some((orig_idx, _entry)) = filtered.get(*selected_index) {
3876
+                            let entry = entries[*orig_idx].clone();
3877
+                            if entry.is_dir {
3878
+                                // Navigate into directory
3879
+                                self.fortress_navigate_to(&entry.path);
3880
+                            } else {
3881
+                                // Open the file
3882
+                                self.prompt = PromptState::None;
3883
+                                self.fortress_open_file(&entry.path);
3884
+                            }
3885
+                        }
3886
+                    }
3887
+                    Key::Escape => {
3888
+                        self.prompt = PromptState::None;
3889
+                        self.message = None;
3890
+                    }
3891
+                    Key::Backspace if filter.is_empty() => {
3892
+                        // Go up one directory when filter is empty
3893
+                        if let Some(parent) = current_path.parent() {
3894
+                            let parent = parent.to_path_buf();
3895
+                            self.fortress_navigate_to(&parent);
3896
+                        }
3897
+                    }
3898
+                    Key::Backspace => {
3899
+                        filter.pop();
3900
+                        *selected_index = 0;
3901
+                        *scroll_offset = 0;
3902
+                    }
3903
+                    Key::Up => {
3904
+                        if *selected_index > 0 {
3905
+                            *selected_index -= 1;
3906
+                            // Adjust scroll
3907
+                            if *selected_index < *scroll_offset {
3908
+                                *scroll_offset = *selected_index;
3909
+                            }
3910
+                        }
3911
+                    }
3912
+                    Key::Down => {
3913
+                        if *selected_index + 1 < filtered.len() {
3914
+                            *selected_index += 1;
3915
+                        }
3916
+                    }
3917
+                    Key::Left => {
3918
+                        // Go up one directory
3919
+                        if let Some(parent) = current_path.parent() {
3920
+                            let parent = parent.to_path_buf();
3921
+                            self.fortress_navigate_to(&parent);
3922
+                        }
3923
+                    }
3924
+                    Key::Right => {
3925
+                        // Enter selected directory (same as Enter for dirs)
3926
+                        if let Some((orig_idx, _)) = filtered.get(*selected_index) {
3927
+                            let entry = entries[*orig_idx].clone();
3928
+                            if entry.is_dir {
3929
+                                self.fortress_navigate_to(&entry.path);
3930
+                            }
3931
+                        }
3932
+                    }
3933
+                    Key::PageUp => {
3934
+                        *selected_index = selected_index.saturating_sub(10);
3935
+                        *scroll_offset = scroll_offset.saturating_sub(10);
3936
+                    }
3937
+                    Key::PageDown => {
3938
+                        let max = filtered.len().saturating_sub(1);
3939
+                        *selected_index = (*selected_index + 10).min(max);
3940
+                    }
3941
+                    Key::Home => {
3942
+                        *selected_index = 0;
3943
+                        *scroll_offset = 0;
3944
+                    }
3945
+                    Key::End => {
3946
+                        if !filtered.is_empty() {
3947
+                            *selected_index = filtered.len() - 1;
3948
+                        }
3949
+                    }
3950
+                    Key::Char(c) => {
3951
+                        filter.push(c);
3952
+                        *selected_index = 0;
3953
+                        *scroll_offset = 0;
3954
+                    }
3955
+                    _ => {}
3956
+                }
3957
+            }
37563958
             PromptState::None => {}
37573959
         }
37583960
         Ok(())
@@ -4162,6 +4364,106 @@ impl Editor {
41624364
             self.update_search_matches();
41634365
         }
41644366
     }
4367
+
4368
+    // === Fortress mode (file browser) ===
4369
+
4370
+    /// Open fortress mode file browser
4371
+    fn open_fortress(&mut self) {
4372
+        // Start at current file's directory, or workspace root
4373
+        let start_path = if let Some(path) = self.current_file_path() {
4374
+            if let Some(parent) = path.parent() {
4375
+                parent.to_path_buf()
4376
+            } else {
4377
+                self.workspace.root.clone()
4378
+            }
4379
+        } else {
4380
+            self.workspace.root.clone()
4381
+        };
4382
+
4383
+        let entries = self.read_directory(&start_path);
4384
+        self.prompt = PromptState::Fortress {
4385
+            current_path: start_path,
4386
+            entries,
4387
+            selected_index: 0,
4388
+            filter: String::new(),
4389
+            scroll_offset: 0,
4390
+        };
4391
+    }
4392
+
4393
+    /// Read directory contents and return sorted entries (dirs first, then files)
4394
+    fn read_directory(&self, path: &Path) -> Vec<FortressEntry> {
4395
+        let mut entries = Vec::new();
4396
+
4397
+        // Add parent directory entry if not at root
4398
+        if path.parent().is_some() {
4399
+            entries.push(FortressEntry {
4400
+                name: "..".to_string(),
4401
+                path: path.parent().unwrap().to_path_buf(),
4402
+                is_dir: true,
4403
+            });
4404
+        }
4405
+
4406
+        if let Ok(read_dir) = std::fs::read_dir(path) {
4407
+            let mut dirs = Vec::new();
4408
+            let mut files = Vec::new();
4409
+
4410
+            for entry in read_dir.flatten() {
4411
+                let entry_path = entry.path();
4412
+                let name = entry.file_name().to_string_lossy().to_string();
4413
+
4414
+                // Skip hidden files (starting with .)
4415
+                if name.starts_with('.') {
4416
+                    continue;
4417
+                }
4418
+
4419
+                let is_dir = entry_path.is_dir();
4420
+                let fortress_entry = FortressEntry {
4421
+                    name,
4422
+                    path: entry_path,
4423
+                    is_dir,
4424
+                };
4425
+
4426
+                if is_dir {
4427
+                    dirs.push(fortress_entry);
4428
+                } else {
4429
+                    files.push(fortress_entry);
4430
+                }
4431
+            }
4432
+
4433
+            // Sort alphabetically (case-insensitive)
4434
+            dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
4435
+            files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
4436
+
4437
+            // Directories first, then files
4438
+            entries.extend(dirs);
4439
+            entries.extend(files);
4440
+        }
4441
+
4442
+        entries
4443
+    }
4444
+
4445
+    /// Navigate to a new directory in fortress mode
4446
+    fn fortress_navigate_to(&mut self, path: &Path) {
4447
+        let entries = self.read_directory(path);
4448
+        self.prompt = PromptState::Fortress {
4449
+            current_path: path.to_path_buf(),
4450
+            entries,
4451
+            selected_index: 0,
4452
+            filter: String::new(),
4453
+            scroll_offset: 0,
4454
+        };
4455
+    }
4456
+
4457
+    /// Open a file from fortress mode
4458
+    fn fortress_open_file(&mut self, path: &Path) {
4459
+        // Open file in current pane by reusing workspace method
4460
+        if let Err(e) = self.workspace.open_file(path) {
4461
+            self.message = Some(format!("Failed to open file: {}", e));
4462
+        } else {
4463
+            // Sync with LSP
4464
+            self.sync_document_to_lsp();
4465
+        }
4466
+    }
41654467
 }
41664468
 
41674469
 impl Drop for Editor {
src/render/screen.rsmodified
@@ -2101,6 +2101,183 @@ impl Screen {
21012101
         Ok(())
21022102
     }
21032103
 
2104
+    /// Render the Fortress file browser modal
2105
+    pub fn render_fortress_modal(
2106
+        &mut self,
2107
+        current_path: &std::path::Path,
2108
+        entries: &[(String, std::path::PathBuf, bool)], // (name, path, is_dir)
2109
+        selected_index: usize,
2110
+        filter: &str,
2111
+        scroll_offset: usize,
2112
+    ) -> Result<()> {
2113
+        let (width, height) = (self.cols as usize, self.rows as usize);
2114
+
2115
+        // Modal dimensions - centered
2116
+        let modal_width = 60.min(width - 4);
2117
+        let modal_height = 20.min(height - 4);
2118
+        let start_col = (width.saturating_sub(modal_width)) / 2;
2119
+        let start_row = (height.saturating_sub(modal_height)) / 2;
2120
+
2121
+        // Filter entries based on query
2122
+        let filtered: Vec<(usize, &(String, std::path::PathBuf, bool))> = if filter.is_empty() {
2123
+            entries.iter().enumerate().collect()
2124
+        } else {
2125
+            let f = filter.to_lowercase();
2126
+            entries.iter().enumerate()
2127
+                .filter(|(_, (name, _, _))| name.to_lowercase().contains(&f))
2128
+                .collect()
2129
+        };
2130
+
2131
+        // Colors
2132
+        let bg = Color::AnsiValue(235);
2133
+        let border_color = Color::AnsiValue(244);
2134
+        let header_color = Color::Cyan;
2135
+        let dir_color = Color::Blue;
2136
+        let file_color = Color::AnsiValue(252);
2137
+        let selected_bg = Color::AnsiValue(240);
2138
+        let input_bg = Color::AnsiValue(238);
2139
+
2140
+        // Draw top border with title
2141
+        let path_str = current_path.to_string_lossy();
2142
+        let max_path_len = modal_width - 6;
2143
+        let display_path = if path_str.len() > max_path_len {
2144
+            format!("...{}", &path_str[path_str.len().saturating_sub(max_path_len - 3)..])
2145
+        } else {
2146
+            path_str.to_string()
2147
+        };
2148
+        let title = format!(" {} ", display_path);
2149
+        execute!(
2150
+            self.stdout,
2151
+            MoveTo(start_col as u16, start_row as u16),
2152
+            SetBackgroundColor(bg),
2153
+            SetForegroundColor(border_color),
2154
+            Print("┌"),
2155
+            SetForegroundColor(header_color),
2156
+            Print(&title),
2157
+            SetForegroundColor(border_color),
2158
+            Print(format!("{:─<width$}┐", "", width = modal_width.saturating_sub(title.len() + 2))),
2159
+            ResetColor,
2160
+        )?;
2161
+
2162
+        // Draw filter input row
2163
+        execute!(
2164
+            self.stdout,
2165
+            MoveTo(start_col as u16, (start_row + 1) as u16),
2166
+            SetBackgroundColor(bg),
2167
+            SetForegroundColor(border_color),
2168
+            Print("│ "),
2169
+            SetForegroundColor(Color::AnsiValue(248)),
2170
+            Print("Filter: "),
2171
+            SetBackgroundColor(input_bg),
2172
+            SetForegroundColor(Color::White),
2173
+            Print(format!("{:<width$}", filter, width = modal_width.saturating_sub(12))),
2174
+            SetBackgroundColor(bg),
2175
+            SetForegroundColor(border_color),
2176
+            Print("│"),
2177
+            ResetColor,
2178
+        )?;
2179
+
2180
+        // Draw separator
2181
+        execute!(
2182
+            self.stdout,
2183
+            MoveTo(start_col as u16, (start_row + 2) as u16),
2184
+            SetBackgroundColor(bg),
2185
+            SetForegroundColor(border_color),
2186
+            Print(format!("├{:─<width$}┤", "", width = modal_width.saturating_sub(2))),
2187
+            ResetColor,
2188
+        )?;
2189
+
2190
+        // Calculate visible range
2191
+        let visible_rows = modal_height.saturating_sub(5); // Account for borders, title, filter, help
2192
+
2193
+        // Adjust scroll offset so selected item is visible
2194
+        let scroll = if selected_index < scroll_offset {
2195
+            selected_index
2196
+        } else if selected_index >= scroll_offset + visible_rows {
2197
+            selected_index - visible_rows + 1
2198
+        } else {
2199
+            scroll_offset
2200
+        };
2201
+
2202
+        // Draw file/directory entries
2203
+        for (display_idx, (_orig_idx, (name, _, is_dir))) in filtered.iter().enumerate().skip(scroll).take(visible_rows) {
2204
+            let row = (start_row + 3 + display_idx - scroll) as u16;
2205
+            let is_selected = display_idx == selected_index;
2206
+
2207
+            let item_bg = if is_selected { selected_bg } else { bg };
2208
+            let name_color = if *is_dir { dir_color } else { file_color };
2209
+            let icon = if *is_dir { "[d] " } else { "    " };
2210
+
2211
+            // Truncate name if needed
2212
+            let max_name_len = modal_width.saturating_sub(6);
2213
+            let display_name = if name.len() > max_name_len {
2214
+                format!("{}...", &name[..max_name_len - 3])
2215
+            } else {
2216
+                name.clone()
2217
+            };
2218
+
2219
+            execute!(
2220
+                self.stdout,
2221
+                MoveTo(start_col as u16, row),
2222
+                SetBackgroundColor(item_bg),
2223
+                SetForegroundColor(border_color),
2224
+                Print("│ "),
2225
+                Print(icon),
2226
+                SetForegroundColor(name_color),
2227
+                Print(format!("{:<width$}", display_name, width = modal_width.saturating_sub(6))),
2228
+                SetForegroundColor(border_color),
2229
+                Print("│"),
2230
+                ResetColor,
2231
+            )?;
2232
+        }
2233
+
2234
+        // Fill remaining rows with empty space
2235
+        let items_drawn = filtered.len().saturating_sub(scroll).min(visible_rows);
2236
+        for i in items_drawn..visible_rows {
2237
+            let row = (start_row + 3 + i) as u16;
2238
+            execute!(
2239
+                self.stdout,
2240
+                MoveTo(start_col as u16, row),
2241
+                SetBackgroundColor(bg),
2242
+                SetForegroundColor(border_color),
2243
+                Print(format!("│{:width$}│", "", width = modal_width.saturating_sub(2))),
2244
+                ResetColor,
2245
+            )?;
2246
+        }
2247
+
2248
+        // Draw help text row
2249
+        let help_row = (start_row + 3 + visible_rows) as u16;
2250
+        let help_text = "←:up  →/Enter:open  ↑↓:nav  Esc:close";
2251
+        execute!(
2252
+            self.stdout,
2253
+            MoveTo(start_col as u16, help_row),
2254
+            SetBackgroundColor(bg),
2255
+            SetForegroundColor(border_color),
2256
+            Print("├"),
2257
+            SetForegroundColor(Color::AnsiValue(243)),
2258
+            Print(format!(" {:<width$}", help_text, width = modal_width.saturating_sub(3))),
2259
+            SetForegroundColor(border_color),
2260
+            Print("┤"),
2261
+            ResetColor,
2262
+        )?;
2263
+
2264
+        // Draw bottom border
2265
+        execute!(
2266
+            self.stdout,
2267
+            MoveTo(start_col as u16, help_row + 1),
2268
+            SetBackgroundColor(bg),
2269
+            SetForegroundColor(border_color),
2270
+            Print(format!("└{:─<width$}┘", "", width = modal_width.saturating_sub(2))),
2271
+            ResetColor,
2272
+        )?;
2273
+
2274
+        // Hide cursor when in fortress modal
2275
+        execute!(self.stdout, Hide)?;
2276
+
2277
+        self.stdout.flush()?;
2278
+        Ok(())
2279
+    }
2280
+
21042281
     /// Render the LSP references panel (sidebar style)
21052282
     pub fn render_references_panel(
21062283
         &mut self,