@@ -22,6 +22,17 @@ enum FindReplaceField { |
| 22 | 22 | Replace, |
| 23 | 23 | } |
| 24 | 24 | |
| 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 | + |
| 25 | 36 | /// Prompt state for quit confirmation |
| 26 | 37 | #[derive(Debug, Clone, PartialEq)] |
| 27 | 38 | enum PromptState { |
@@ -61,6 +72,19 @@ enum PromptState { |
| 61 | 72 | /// Regex mode |
| 62 | 73 | regex_mode: bool, |
| 63 | 74 | }, |
| 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 | + }, |
| 64 | 88 | } |
| 65 | 89 | |
| 66 | 90 | /// Action to perform when text input is complete |
@@ -1236,6 +1260,29 @@ impl Editor { |
| 1236 | 1260 | self.screen.render_references_panel(locations, selected_index, query, &self.workspace.root)?; |
| 1237 | 1261 | } |
| 1238 | 1262 | |
| 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 | + |
| 1239 | 1286 | // Render find/replace bar if active (replaces status bar) |
| 1240 | 1287 | if let PromptState::FindReplace { |
| 1241 | 1288 | ref find_query, |
@@ -1484,6 +1531,10 @@ impl Editor { |
| 1484 | 1531 | // Find previous: Shift+F3 |
| 1485 | 1532 | (Key::F(3), Modifiers { shift: true, .. }) => self.find_prev(), |
| 1486 | 1533 | |
| 1534 | + // === File operations === |
| 1535 | + // Open file browser (Fortress mode): Ctrl+O |
| 1536 | + (Key::Char('o'), Modifiers { ctrl: true, .. }) => self.open_fortress(), |
| 1537 | + |
| 1487 | 1538 | // === Editing === |
| 1488 | 1539 | (Key::Char(c), Modifiers { ctrl: false, alt: false, .. }) => { |
| 1489 | 1540 | self.insert_char(*c); |
@@ -2260,58 +2311,57 @@ impl Editor { |
| 2260 | 2311 | return; |
| 2261 | 2312 | } |
| 2262 | 2313 | |
| 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() |
| 2269 | 2320 | .iter() |
| 2270 | 2321 | .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 | + }) |
| 2272 | 2326 | .collect(); |
| 2273 | 2327 | |
| 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)); |
| 2281 | 2330 | |
| 2282 | 2331 | // Record all cursor positions before the operation |
| 2283 | 2332 | let cursors_before = self.all_cursor_positions(); |
| 2284 | 2333 | self.history_mut().begin_group(); |
| 2285 | 2334 | self.history_mut().set_cursors_before(cursors_before); |
| 2286 | 2335 | |
| 2287 | | - // Count newlines and chars for position updates |
| 2288 | | - let newlines = text.chars().filter(|&c| c == '\n').count(); |
| 2289 | 2336 | 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 | | - |
| 2296 | 2337 | let cursor_before = self.cursor_pos(); |
| 2297 | 2338 | |
| 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) |
| 2304 | 2342 | |
| 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 { |
| 2306 | 2361 | 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; |
| 2315 | 2365 | } |
| 2316 | 2366 | |
| 2317 | 2367 | // Record all cursor positions after the operation |
@@ -2463,20 +2513,21 @@ impl Editor { |
| 2463 | 2513 | |
| 2464 | 2514 | /// Delete backward at all cursor positions (multi-cursor) |
| 2465 | 2515 | 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() |
| 2468 | 2521 | .iter() |
| 2469 | 2522 | .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 | + }) |
| 2471 | 2527 | .collect(); |
| 2472 | 2528 | |
| 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)); |
| 2480 | 2531 | |
| 2481 | 2532 | // Record all cursor positions before the operation |
| 2482 | 2533 | let cursors_before = self.all_cursor_positions(); |
@@ -2484,19 +2535,43 @@ impl Editor { |
| 2484 | 2535 | self.history_mut().set_cursors_before(cursors_before); |
| 2485 | 2536 | |
| 2486 | 2537 | 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); |
| 2493 | 2538 | |
| 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; |
| 2498 | 2566 | } |
| 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; |
| 2500 | 2575 | } |
| 2501 | 2576 | |
| 2502 | 2577 | // Record all cursor positions after the operation |
@@ -2508,20 +2583,23 @@ impl Editor { |
| 2508 | 2583 | |
| 2509 | 2584 | /// Delete forward at all cursor positions (multi-cursor) |
| 2510 | 2585 | 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() |
| 2513 | 2593 | .iter() |
| 2514 | 2594 | .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 | + }) |
| 2516 | 2599 | .collect(); |
| 2517 | 2600 | |
| 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)); |
| 2525 | 2603 | |
| 2526 | 2604 | // Record all cursor positions before the operation |
| 2527 | 2605 | let cursors_before = self.all_cursor_positions(); |
@@ -2529,16 +2607,37 @@ impl Editor { |
| 2529 | 2607 | self.history_mut().set_cursors_before(cursors_before); |
| 2530 | 2608 | |
| 2531 | 2609 | 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 | + } |
| 2540 | 2628 | } |
| 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; |
| 2542 | 2641 | } |
| 2543 | 2642 | |
| 2544 | 2643 | // Record all cursor positions after the operation |
@@ -3753,6 +3852,109 @@ impl Editor { |
| 3753 | 3852 | _ => {} |
| 3754 | 3853 | } |
| 3755 | 3854 | } |
| 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 | + } |
| 3756 | 3958 | PromptState::None => {} |
| 3757 | 3959 | } |
| 3758 | 3960 | Ok(()) |
@@ -4162,6 +4364,106 @@ impl Editor { |
| 4162 | 4364 | self.update_search_matches(); |
| 4163 | 4365 | } |
| 4164 | 4366 | } |
| 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 | + } |
| 4165 | 4467 | } |
| 4166 | 4468 | |
| 4167 | 4469 | impl Drop for Editor { |