Rust · 142582 bytes Raw Blame History
1 //! Application state and event loop.
2
3 use crate::PickerConfig;
4 use garfield::core::{
5 Clipboard, ClipboardOperation, DragTarget, FileOperation, FileDragController, ImagePreviewLoader, PdfPreviewLoader, PreviewLoader, RecentsManager, UndoStack,
6 copy_files, move_files, delete_files, create_directory,
7 trash_files, restore_from_trash, matches_any_filter,
8 };
9 use garfield::ui::pane::SplitDirection;
10 use garfield::ui::{AddressBar, AppPickerDialog, AppPickerResult, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, ContextMenu, ContextMenuAction, ContextType, DialogResult, HelpModal, IconSize, InputDialog, InputResult, Pane, PaneToolbarClick, PickerToolbar, PickerToolbarClick, ProgressDialog, Sidebar, SidebarClick, StatusBar, TabBar, TabBarClickResult, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT, PICKER_TOOLBAR_HEIGHT};
11 use anyhow::Result;
12 use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme};
13 use gartk_render::{Renderer, TextStyle};
14 use gartk_x11::{ClipboardManager, Connection, EventLoop, EventLoopConfig, Window, WindowConfig};
15 use std::path::PathBuf;
16 use std::time::Instant;
17 use x11rb::protocol::xproto::{ConnectionExt, ImageFormat};
18
19 /// Height of the breadcrumb bar.
20 const BREADCRUMB_HEIGHT: u32 = 40;
21
22 /// Width of the sidebar.
23 const SIDEBAR_WIDTH: u32 = 180;
24
25 /// Height of the status bar.
26 const STATUS_BAR_HEIGHT: u32 = 24;
27
28 /// Application state.
29 pub struct App {
30 /// X11 window.
31 window: Window,
32 /// Renderer.
33 renderer: Renderer,
34 /// Graphics context for blitting.
35 gc: u32,
36 /// Toolbar with action buttons.
37 toolbar: Toolbar,
38 /// Breadcrumb path bar.
39 breadcrumb: Breadcrumb,
40 /// Address bar for path editing.
41 address_bar: AddressBar,
42 /// Places sidebar.
43 sidebar: Sidebar,
44 /// Tab bar component.
45 tab_bar: TabBar,
46 /// Root pane (contains all tabs/splits).
47 root_pane: Pane,
48 /// Focused pane ID.
49 focused_pane_id: u32,
50 /// Next pane ID to assign.
51 next_pane_id: u32,
52 /// Status bar component.
53 status_bar: StatusBar,
54 /// Help modal overlay.
55 help_modal: HelpModal,
56 /// Whether the app should quit.
57 should_quit: bool,
58 /// Pane divider resize in progress (split pane pointer path).
59 pane_resize_path: Option<Vec<bool>>,
60 /// Sidebar resize in progress.
61 sidebar_resizing: bool,
62 /// Last click time for double-click detection.
63 last_click_time: Option<Instant>,
64 /// Last click position for double-click detection.
65 last_click_pos: Option<Point>,
66 /// Path being dragged for bookmark drop (directory only).
67 drag_source_path: Option<PathBuf>,
68 /// Name of the item being dragged (for visual feedback).
69 drag_label: Option<String>,
70 /// Starting position of potential drag.
71 drag_start_pos: Option<Point>,
72 /// Current mouse position during drag (for visual feedback).
73 drag_current_pos: Option<Point>,
74 /// Whether drag is actively in progress (moved past threshold).
75 drag_active: bool,
76 /// File drag controller for drag-to-move operations.
77 file_drag: FileDragController,
78 /// Clipboard for file operations.
79 clipboard: Clipboard,
80 /// Confirmation dialog.
81 confirm_dialog: ConfirmDialog,
82 /// Conflict resolution dialog.
83 conflict_dialog: ConflictDialog,
84 /// Progress dialog for long operations.
85 progress_dialog: ProgressDialog,
86 /// Context menu for right-click actions.
87 context_menu: ContextMenu,
88 /// Input dialog for text entry.
89 input_dialog: InputDialog,
90 /// Application picker dialog.
91 app_picker: AppPickerDialog,
92 /// Path pending "Open With" custom application.
93 pending_open_with_path: Option<PathBuf>,
94 /// Paths pending delete confirmation.
95 pending_delete_paths: Vec<PathBuf>,
96 /// Undo/redo stack for file operations.
97 undo_stack: UndoStack,
98 /// Pending paste operation with conflicts.
99 pending_paste: Option<PendingPaste>,
100 /// Async preview loader for column view.
101 preview_loader: PreviewLoader,
102 /// Async image preview loader.
103 image_preview_loader: ImagePreviewLoader,
104 /// Async PDF preview loader.
105 pdf_preview_loader: PdfPreviewLoader,
106 /// X11 clipboard manager for system clipboard integration.
107 x11_clipboard: ClipboardManager,
108 /// Picker mode configuration (None for normal browser).
109 picker_config: PickerConfig,
110 /// Picker toolbar (only used in picker mode).
111 picker_toolbar: Option<PickerToolbar>,
112 /// Recently used files manager.
113 recents: RecentsManager,
114 }
115
116 /// State for a paste operation with conflicts.
117 struct PendingPaste {
118 /// Files to paste.
119 files: Vec<PathBuf>,
120 /// Clipboard operation type.
121 operation: ClipboardOperation,
122 /// Destination directory.
123 dest_dir: PathBuf,
124 /// Files that conflict (exist in destination).
125 conflicts: Vec<PathBuf>,
126 }
127
128 impl App {
129 /// Create a new application.
130 pub fn new(start_dir: Option<PathBuf>, picker_config: PickerConfig) -> Result<Self> {
131 // Connect to X11
132 let conn = Connection::connect(None)?;
133
134 // Get primary monitor for window sizing
135 let monitor = gartk_x11::primary_monitor(&conn)?;
136
137 // Calculate window size (70% of screen, smaller for picker mode)
138 let scale = if picker_config.is_picker() { 0.5 } else { 0.7 };
139 let width = (monitor.rect.width as f64 * scale) as u32;
140 let height = (monitor.rect.height as f64 * scale) as u32;
141 let x = monitor.rect.x + (monitor.rect.width as i32 - width as i32) / 2;
142 let y = monitor.rect.y + (monitor.rect.height as i32 - height as i32) / 2;
143
144 tracing::debug!(
145 "Window size: monitor={}x{}, requested={}x{} at ({}, {})",
146 monitor.rect.width, monitor.rect.height, width, height, x, y
147 );
148
149 // Window title depends on mode
150 let title = if picker_config.is_picker() {
151 picker_config.title.clone().unwrap_or_else(|| {
152 if picker_config.mode.is_directory_mode() {
153 "Select Folder".to_string()
154 } else {
155 "Open File".to_string()
156 }
157 })
158 } else {
159 "garfield".to_string()
160 };
161
162 // Create window - use Dialog type and different class for picker mode
163 let window_class = if picker_config.is_picker() {
164 "garfield-picker"
165 } else {
166 "garfield"
167 };
168
169 let mut window_config = WindowConfig::default()
170 .title(&title)
171 .class(window_class)
172 .position(x, y)
173 .size(width, height)
174 .transparent(false);
175
176 // Use Dialog window type for picker mode (better focus handling)
177 if picker_config.is_picker() {
178 window_config = window_config.window_type(gartk_x11::WindowType::Dialog);
179
180 // Set transient-for if parent window specified (dialog belongs to parent)
181 if let Some(parent) = picker_config.parent_window {
182 window_config = window_config.parent_window(parent).modal(true);
183 }
184 }
185
186 let window = Window::create(conn.clone(), window_config)?;
187 conn.flush()?;
188
189 // Create X11 clipboard manager for system clipboard integration
190 let x11_clipboard = ClipboardManager::new(conn.clone(), window.id())?;
191
192 // Create graphics context for blitting
193 let gc = conn.generate_id()?;
194 conn.inner().create_gc(gc, window.id(), &Default::default())?;
195 conn.flush()?;
196
197 // Create renderer with dark theme
198 let theme = Theme::dark();
199 let renderer = Renderer::with_theme(width, height, theme)?;
200
201 // Determine starting directory
202 let current_dir = start_dir
203 .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
204
205 let sidebar_w = SIDEBAR_WIDTH;
206 let header_height = TAB_BAR_HEIGHT + TOOLBAR_HEIGHT + BREADCRUMB_HEIGHT;
207
208 // Create toolbar (below tab bar)
209 let toolbar_bounds = Rect::new(
210 sidebar_w as i32,
211 TAB_BAR_HEIGHT as i32,
212 width - sidebar_w,
213 TOOLBAR_HEIGHT,
214 );
215 let toolbar = Toolbar::new(toolbar_bounds);
216
217 // Create picker toolbar at BOTTOM of window (only if in picker mode)
218 let picker_toolbar = if picker_config.is_picker() {
219 let picker_toolbar_bounds = Rect::new(
220 sidebar_w as i32,
221 (height - PICKER_TOOLBAR_HEIGHT) as i32,
222 width - sidebar_w,
223 PICKER_TOOLBAR_HEIGHT,
224 );
225 let pt = if picker_config.mode.is_save_mode() {
226 // Save mode - create with filename textbox
227 let suggested = picker_config.mode.suggested_filename()
228 .unwrap_or("untitled")
229 .to_string();
230 PickerToolbar::new_save_mode(picker_toolbar_bounds, picker_config.accept_label.clone(), suggested)
231 } else {
232 // Open mode - create with filters
233 let mut pt = PickerToolbar::new(picker_toolbar_bounds, picker_config.accept_label.clone());
234 // Set filter description if we have filters
235 let filters = picker_config.mode.filters();
236 if !filters.is_empty() {
237 let desc = format!("Filter: {}", filters.join(", "));
238 pt.set_filter_description(Some(desc));
239 }
240 pt
241 };
242 Some(pt)
243 } else {
244 None
245 };
246
247 // Create breadcrumb (below toolbar)
248 let breadcrumb_bounds = Rect::new(
249 sidebar_w as i32,
250 (TAB_BAR_HEIGHT + TOOLBAR_HEIGHT) as i32,
251 width - sidebar_w,
252 BREADCRUMB_HEIGHT,
253 );
254 let mut breadcrumb = Breadcrumb::new(breadcrumb_bounds);
255 breadcrumb.set_path(&current_dir);
256
257 // Create address bar (same bounds as breadcrumb)
258 let address_bar = AddressBar::new(breadcrumb_bounds);
259
260 // Create sidebar
261 let sidebar_bounds = Rect::new(0, 0, SIDEBAR_WIDTH, height);
262 let sidebar = Sidebar::new(sidebar_bounds);
263
264 // Create tab bar
265 let tab_bar_bounds = Rect::new(sidebar_w as i32, 0, width - sidebar_w, TAB_BAR_HEIGHT);
266 let mut tab_bar = TabBar::new(tab_bar_bounds);
267
268 // Create status bar
269 let status_bar_bounds = Rect::new(
270 sidebar_w as i32,
271 (height - STATUS_BAR_HEIGHT) as i32,
272 width - sidebar_w,
273 STATUS_BAR_HEIGHT,
274 );
275 let mut status_bar = StatusBar::new(status_bar_bounds);
276 status_bar.set_view_mode("List");
277
278 // Create help modal (full window bounds)
279 let help_modal = HelpModal::new(Rect::new(0, 0, width, height));
280
281 // Create confirm dialog (full window bounds)
282 let confirm_dialog = ConfirmDialog::new(Rect::new(0, 0, width, height));
283
284 // Create conflict dialog (full window bounds)
285 let conflict_dialog = ConflictDialog::new(Rect::new(0, 0, width, height));
286
287 // Create progress dialog (full window bounds)
288 let progress_dialog = ProgressDialog::new(Rect::new(0, 0, width, height));
289
290 // Create context menu (full window bounds for positioning)
291 let context_menu = ContextMenu::new(Rect::new(0, 0, width, height));
292
293 // Create input dialog (full window bounds)
294 let input_dialog = InputDialog::new(Rect::new(0, 0, width, height));
295
296 // Create app picker dialog (full window bounds)
297 let app_picker = AppPickerDialog::new(Rect::new(0, 0, width, height));
298
299 // Content area bounds (for panes)
300 // In picker mode, picker toolbar replaces status bar at bottom
301 let footer_height = if picker_config.is_picker() {
302 PICKER_TOOLBAR_HEIGHT
303 } else {
304 STATUS_BAR_HEIGHT
305 };
306 let content_bounds = Rect::new(
307 sidebar_w as i32,
308 header_height as i32,
309 width - sidebar_w,
310 height - header_height - footer_height,
311 );
312
313 // Create root pane with initial tab
314 let root_pane = Pane::new_leaf(current_dir, content_bounds, 1);
315 let focused_pane_id = 1;
316 let next_pane_id = 2;
317
318 // Initialize tab bar with first tab
319 let tabs = vec![TabInfo {
320 title: root_pane.active_tab().map(|t| t.title()).unwrap_or_default(),
321 active: true,
322 }];
323 tab_bar.set_tabs(tabs, 0);
324
325 let mut app = Self {
326 window,
327 renderer,
328 gc,
329 toolbar,
330 breadcrumb,
331 address_bar,
332 sidebar,
333 tab_bar,
334 root_pane,
335 focused_pane_id,
336 next_pane_id,
337 status_bar,
338 help_modal,
339 should_quit: false,
340 pane_resize_path: None,
341 sidebar_resizing: false,
342 last_click_time: None,
343 last_click_pos: None,
344 drag_source_path: None,
345 drag_label: None,
346 drag_start_pos: None,
347 drag_current_pos: None,
348 drag_active: false,
349 file_drag: FileDragController::new(),
350 clipboard: Clipboard::new(),
351 confirm_dialog,
352 conflict_dialog,
353 progress_dialog,
354 context_menu,
355 input_dialog,
356 app_picker,
357 pending_open_with_path: None,
358 pending_delete_paths: Vec::new(),
359 undo_stack: UndoStack::new(),
360 pending_paste: None,
361 preview_loader: PreviewLoader::new(),
362 image_preview_loader: ImagePreviewLoader::new(),
363 pdf_preview_loader: PdfPreviewLoader::new(),
364 x11_clipboard,
365 picker_config,
366 picker_toolbar,
367 recents: {
368 let mut recents = RecentsManager::new();
369 if let Err(e) = recents.load() {
370 tracing::warn!("Failed to load recents: {}", e);
371 }
372 recents
373 },
374 };
375
376 app.update_status_bar();
377
378 Ok(app)
379 }
380
381 /// Get the focused pane.
382 fn focused_pane(&self) -> Option<&Pane> {
383 self.root_pane.leaf_by_id(self.focused_pane_id)
384 }
385
386 /// Get the focused pane (mutable).
387 fn focused_pane_mut(&mut self) -> Option<&mut Pane> {
388 self.root_pane.leaf_by_id_mut(self.focused_pane_id)
389 }
390
391 /// Check if the current selection is valid for picker mode.
392 fn has_valid_picker_selection(&self) -> bool {
393 let Some(pane) = self.focused_pane() else {
394 return false;
395 };
396 let Some(tab) = pane.active_tab() else {
397 return false;
398 };
399
400 let selected = tab.selected_entries();
401 let filters = self.picker_config.mode.filters();
402
403 // In directory mode, we can always accept (use current directory if nothing selected)
404 if self.picker_config.mode.is_directory_mode() {
405 // If nothing selected, the current directory is the selection
406 if selected.is_empty() {
407 return true;
408 }
409 // Otherwise, at least one directory must be selected
410 return selected.iter().any(|e| e.is_dir());
411 }
412
413 // In file mode, we need at least one file selected that matches filters
414 // If multiple is disabled, we need exactly one matching file
415 let matching_file_count = selected.iter()
416 .filter(|e| !e.is_dir() && matches_any_filter(e, filters))
417 .count();
418
419 if matching_file_count == 0 {
420 return false;
421 }
422
423 if !self.picker_config.mode.allows_multiple() && matching_file_count > 1 {
424 return false;
425 }
426
427 true
428 }
429
430 /// Get the selected paths for picker mode.
431 fn get_picker_selection(&self) -> Vec<PathBuf> {
432 let Some(pane) = self.focused_pane() else {
433 return Vec::new();
434 };
435 let Some(tab) = pane.active_tab() else {
436 return Vec::new();
437 };
438
439 let filters = self.picker_config.mode.filters();
440
441 if self.picker_config.mode.is_directory_mode() {
442 // In directory mode, return selected directories or current directory
443 let dirs: Vec<_> = tab.selected_entries().iter()
444 .filter(|e| e.is_dir())
445 .map(|e| e.path.clone())
446 .collect();
447
448 if dirs.is_empty() {
449 // Return current directory
450 vec![tab.current_path().to_path_buf()]
451 } else {
452 dirs
453 }
454 } else {
455 // In file mode, return selected files that match filters
456 tab.selected_entries().iter()
457 .filter(|e| !e.is_dir() && matches_any_filter(e, filters))
458 .map(|e| e.path.clone())
459 .collect()
460 }
461 }
462
463 /// Output picker selection and exit.
464 fn accept_picker_selection(&mut self) {
465 // Check if we're in save mode
466 if self.picker_config.mode.is_save_mode() {
467 // Get filename from picker toolbar
468 let filename = self.picker_toolbar
469 .as_ref()
470 .map(|pt| pt.filename().to_string())
471 .unwrap_or_else(|| "untitled".to_string());
472
473 if filename.is_empty() {
474 // Don't accept with empty filename
475 return;
476 }
477
478 // Get current directory from focused pane
479 if let Some(pane) = self.focused_pane() {
480 if let Some(tab) = pane.active_tab() {
481 let current_dir = tab.current_path();
482 let full_path = current_dir.join(&filename);
483 println!("{}", full_path.display());
484 }
485 }
486 } else {
487 // Open mode - output selected paths
488 let paths = self.get_picker_selection();
489 for path in &paths {
490 println!("{}", path.display());
491 }
492 }
493
494 self.should_quit = true;
495 }
496
497 /// Cancel picker and exit with no output.
498 fn cancel_picker(&mut self) {
499 // Exit with code 1 to indicate cancellation
500 self.should_quit = true;
501 }
502
503 /// Run the application event loop.
504 pub fn run(&mut self) -> Result<()> {
505 let mut event_loop = EventLoop::new(&self.window, EventLoopConfig::default())?;
506
507 // Don't render before event loop - wait for ConfigureNotify/Expose
508 // to get correct window dimensions from WM before first render
509
510 event_loop.run(|ev, event| {
511 match event {
512 InputEvent::Key(key_event) if key_event.pressed => {
513 self.handle_key(&key_event.key, &key_event.modifiers);
514 ev.request_redraw();
515 }
516 InputEvent::MousePress(mouse_event) => {
517 let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
518 self.handle_mouse_press(pos, &mouse_event.modifiers, mouse_event.button);
519 ev.request_redraw();
520 }
521 InputEvent::MouseRelease(mouse_event) => {
522 let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
523 self.handle_mouse_release(pos);
524 ev.request_redraw();
525 }
526 InputEvent::MouseMove(mouse_event) => {
527 let pos = Point::new(mouse_event.position.x, mouse_event.position.y);
528 if self.handle_mouse_move(pos) {
529 ev.request_redraw();
530 }
531 }
532 InputEvent::MouseLeave => {
533 self.toolbar.clear_hover();
534 self.breadcrumb.clear_hover();
535 self.sidebar.clear_hover();
536 self.tab_bar.clear_hover();
537 if let Some(pane) = self.focused_pane_mut() {
538 if let Some(tab) = pane.active_tab_mut() {
539 tab.clear_hover();
540 }
541 }
542 ev.request_redraw();
543 }
544 InputEvent::Resize { width, height } => {
545 tracing::debug!("ConfigureNotify resize: {}x{}", width, height);
546 if let Err(e) = self.renderer.resize(width, height) {
547 tracing::error!("Failed to resize renderer: {}", e);
548 }
549 // Update internal rect tracking (don't send X11 request - WM already sized us)
550 self.window.set_size(width, height);
551 self.update_layout(width, height);
552 ev.request_redraw();
553 }
554 InputEvent::Expose => {
555 ev.request_redraw();
556 }
557 InputEvent::CloseRequested => {
558 self.should_quit = true;
559 }
560 InputEvent::Scroll(scroll_event) => {
561 let pos = Point::new(scroll_event.position.x, scroll_event.position.y);
562 if self.handle_scroll(pos, scroll_event.delta_x, scroll_event.delta_y) {
563 ev.request_redraw();
564 }
565 }
566 InputEvent::SelectionRequest(req) => {
567 // Another application is requesting our clipboard data
568 tracing::debug!("Received SelectionRequest event");
569 if let Err(e) = self.x11_clipboard.handle_selection_request(&req) {
570 tracing::warn!("Failed to handle selection request: {}", e);
571 }
572 }
573 InputEvent::SelectionClear => {
574 // We lost clipboard ownership to another application
575 tracing::debug!("Received SelectionClear event");
576 self.x11_clipboard.handle_selection_clear();
577 }
578 _ => {}
579 }
580
581 // Poll for completed async preview loads (directories)
582 if let Some(result) = self.preview_loader.poll() {
583 if let Some(entries) = result.entries {
584 if let Some(pane) = self.focused_pane_mut() {
585 if let Some(tab) = pane.active_tab_mut() {
586 tab.set_preview_entries(&result.path, entries);
587 }
588 }
589 }
590 ev.request_redraw();
591 }
592
593 // Poll for completed async image preview loads
594 if let Some(result) = self.image_preview_loader.poll() {
595 if let Some(pane) = self.focused_pane_mut() {
596 if let Some(tab) = pane.active_tab_mut() {
597 tab.set_image_preview(&result.path, result.image);
598 }
599 }
600 ev.request_redraw();
601 }
602
603 // Poll for completed async PDF preview loads
604 if let Some(result) = self.pdf_preview_loader.poll() {
605 if let Some(pane) = self.focused_pane_mut() {
606 if let Some(tab) = pane.active_tab_mut() {
607 tab.set_pdf_preview(&result.path, result.image);
608 }
609 }
610 ev.request_redraw();
611 }
612
613 // Poll for completed grid view thumbnails
614 if let Some(pane) = self.focused_pane_mut() {
615 if let Some(tab) = pane.active_tab_mut() {
616 if tab.poll_thumbnails() {
617 ev.request_redraw();
618 }
619 }
620 }
621
622 // Update file drag flash animation
623 if self.file_drag.is_hovering() {
624 let flash_complete = self.file_drag.update_flash();
625 ev.request_redraw();
626
627 if flash_complete {
628 // Flash sequence complete - trigger auto-enter
629 if let Some(target) = self.file_drag.get_auto_enter_target() {
630 self.handle_file_drag_auto_enter(target);
631 }
632 }
633 }
634
635 // Check for pending preview requests and submit them
636 self.process_pending_previews();
637 self.process_pending_image_previews();
638 self.process_pending_pdf_previews();
639
640 // Request thumbnails for visible grid items
641 if let Some(pane) = self.focused_pane_mut() {
642 if let Some(tab) = pane.active_tab_mut() {
643 tab.request_visible_thumbnails();
644 }
645 }
646
647 if ev.needs_redraw() {
648 let _ = self.render();
649 ev.redraw_done();
650 }
651
652 Ok(!self.should_quit)
653 })?;
654
655 Ok(())
656 }
657
658 /// Handle mouse press.
659 fn handle_mouse_press(&mut self, pos: Point, modifiers: &gartk_core::Modifiers, button: Option<MouseButton>) {
660 // Check progress dialog first (blocks all other input)
661 if self.progress_dialog.is_visible() {
662 self.progress_dialog.on_click(pos);
663 return;
664 }
665
666 // Check confirm dialog first
667 if self.confirm_dialog.is_visible() {
668 if let Some(result) = self.confirm_dialog.on_click(pos) {
669 self.handle_dialog_result(result);
670 }
671 return;
672 }
673
674 // Check conflict dialog
675 if self.conflict_dialog.is_visible() {
676 if let Some(action) = self.conflict_dialog.on_click(pos) {
677 self.handle_conflict_action(action);
678 }
679 return;
680 }
681
682 // Check input dialog
683 if self.input_dialog.is_visible() {
684 if let Some(result) = self.input_dialog.on_click(pos) {
685 self.handle_input_result(result);
686 }
687 return;
688 }
689
690 // Check app picker
691 if self.app_picker.is_visible() {
692 if let Some(result) = self.app_picker.on_click(pos) {
693 self.handle_app_picker_result(result);
694 }
695 return;
696 }
697
698 // Check help modal (clicking outside closes it)
699 if self.help_modal.on_click(pos) {
700 return;
701 }
702
703 // Check context menu
704 if self.context_menu.is_visible() {
705 if let Some(action) = self.context_menu.on_click(pos) {
706 self.handle_context_menu_action(action);
707 }
708 return;
709 }
710
711 // Right-click shows context menu
712 if button == Some(MouseButton::Right) {
713 self.show_context_menu(pos);
714 return;
715 }
716
717 // Handle middle-click on tab bar to close tab
718 if button == Some(MouseButton::Middle) {
719 if let Some(index) = self.tab_bar.tab_at_point(pos) {
720 self.close_tab(index);
721 return;
722 }
723 }
724
725 // Check tab bar clicks - try to start tab drag first (left click only)
726 if button == Some(MouseButton::Left) || button.is_none() {
727 if self.tab_bar.start_drag(pos) {
728 // Started potential tab drag, don't switch yet
729 return;
730 }
731
732 // Handle tab bar clicks (non-drag)
733 match self.tab_bar.on_click(pos) {
734 TabBarClickResult::Tab(tab_index, is_close) => {
735 if is_close {
736 self.close_tab(tab_index);
737 }
738 // Tab selection happens on mouse release if not dragged
739 return;
740 }
741 TabBarClickResult::NewTab => {
742 self.new_tab();
743 return;
744 }
745 TabBarClickResult::None => {}
746 }
747 }
748
749 // Check picker toolbar clicks (if in picker mode)
750 if let Some(picker_toolbar) = &mut self.picker_toolbar {
751 match picker_toolbar.on_click(pos) {
752 PickerToolbarClick::Accept => {
753 self.accept_picker_selection();
754 return;
755 }
756 PickerToolbarClick::Cancel => {
757 self.cancel_picker();
758 return;
759 }
760 PickerToolbarClick::None => {}
761 }
762 }
763
764 // Check normal toolbar clicks (always, not just when picker_toolbar is None)
765 if let Some(action) = self.toolbar.on_click(pos) {
766 self.handle_toolbar_action(action);
767 return;
768 }
769
770 // Check pane toolbar clicks (view mode buttons)
771 if let PaneToolbarClick::ViewMode(_mode) = self.root_pane.on_toolbar_click(pos) {
772 self.sync_toolbar_view();
773 self.update_status_bar();
774 return;
775 }
776
777 // Check sidebar clicks - try to start bookmark drag first
778 if self.sidebar.start_bookmark_drag(pos) {
779 // Started potential bookmark drag, don't navigate yet
780 return;
781 }
782
783 // Check sidebar clicks (for non-bookmark items)
784 if let Some(click) = self.sidebar.on_click(pos) {
785 match click {
786 SidebarClick::Path(path) => self.navigate_to(path),
787 SidebarClick::Recents => self.show_recents(),
788 }
789 return;
790 }
791
792 // Check breadcrumb back button
793 if self.breadcrumb.back_button_bounds().contains_point(pos) {
794 self.go_back();
795 return;
796 }
797
798 // Check breadcrumb forward button
799 if self.breadcrumb.forward_button_bounds().contains_point(pos) {
800 self.go_forward();
801 return;
802 }
803
804 // Check breadcrumb segments
805 if let Some(path) = self.breadcrumb.on_click(pos) {
806 self.navigate_to(path);
807 return;
808 }
809
810 // Check for sidebar resize handle
811 if self.sidebar.is_resize_handle(pos) {
812 self.sidebar_resizing = true;
813 return;
814 }
815
816 // Check for pane split divider - double-click to equalize, single-click to resize
817 if let Some(path) = self.root_pane.split_divider_at(pos) {
818 let now = Instant::now();
819 let is_double_click = if let (Some(last_time), Some(last_pos)) = (self.last_click_time, self.last_click_pos) {
820 let elapsed = now.duration_since(last_time);
821 let distance = ((pos.x - last_pos.x).pow(2) + (pos.y - last_pos.y).pow(2)) as f64;
822 elapsed.as_millis() < 400 && distance.sqrt() < 10.0
823 } else {
824 false
825 };
826
827 if is_double_click {
828 // Double-click: equalize the split
829 self.root_pane.equalize_split_at(&path);
830 self.last_click_time = None;
831 self.last_click_pos = None;
832 } else {
833 // Single click: start resize
834 self.pane_resize_path = Some(path);
835 self.last_click_time = Some(now);
836 self.last_click_pos = Some(pos);
837 }
838 return;
839 }
840
841 // Check for column resize start in list view
842 if let Some(pane) = self.focused_pane_mut() {
843 if let Some(divider) = pane.column_divider_at(pos) {
844 pane.start_resize(divider);
845 return;
846 }
847 }
848
849 // Handle pane content clicks (also check for pane focus switch)
850 if let Some(leaf) = self.root_pane.leaf_at(pos) {
851 if let Some(id) = leaf.id() {
852 if id != self.focused_pane_id {
853 self.focused_pane_id = id;
854 self.sync_tab_bar();
855 self.sync_breadcrumb();
856 self.update_status_bar();
857 }
858 }
859 }
860
861 // Check for double-click to enter directory
862 let now = Instant::now();
863 let is_double_click = if let (Some(last_time), Some(last_pos)) = (self.last_click_time, self.last_click_pos) {
864 let elapsed = now.duration_since(last_time);
865 let distance = ((pos.x - last_pos.x).pow(2) + (pos.y - last_pos.y).pow(2)) as f64;
866 elapsed.as_millis() < 400 && distance.sqrt() < 5.0
867 } else {
868 false
869 };
870
871 // Update click tracking
872 self.last_click_time = Some(now);
873 self.last_click_pos = Some(pos);
874
875 if is_double_click {
876 // Double-click: enter the selected item
877 self.enter_selected();
878 // Clear click tracking to prevent triple-click
879 self.last_click_time = None;
880 self.last_click_pos = None;
881 } else {
882 // Check if clicking on an already-selected item - start pending file drag
883 let start_file_drag = self.focused_pane()
884 .and_then(|pane| pane.active_tab())
885 .and_then(|tab| {
886 tab.entry_at_point(pos).map(|entry| {
887 let is_selected = tab.is_path_selected(&entry.path);
888 (is_selected, tab.selected_paths())
889 })
890 });
891
892 if let Some((true, selected_paths)) = start_file_drag {
893 // Clicking on an already-selected item - start pending file drag
894 if !selected_paths.is_empty() {
895 self.file_drag.start_pending(pos, selected_paths);
896 self.update_status_bar();
897 return;
898 }
899 }
900
901 // Single click: handle selection
902 if let Some(pane) = self.focused_pane_mut() {
903 if let Some(tab) = pane.active_tab_mut() {
904 tab.on_click(pos, modifiers);
905 }
906 }
907 // Update status bar with new selection
908 self.update_status_bar();
909
910 // Capture drag source from entry at click position (for bookmark drag)
911 let entry_at_click = self.focused_pane()
912 .and_then(|pane| pane.active_tab())
913 .and_then(|tab| tab.entry_at_point(pos))
914 .filter(|e| e.is_dir())
915 .map(|e| (e.path.clone(), e.name.clone()));
916
917 if let Some((path, name)) = entry_at_click {
918 self.drag_source_path = Some(path);
919 self.drag_label = Some(name);
920 self.drag_start_pos = Some(pos);
921 self.drag_current_pos = Some(pos);
922 self.drag_active = false;
923 }
924 }
925 }
926
927 /// Handle mouse release.
928 fn handle_mouse_release(&mut self, pos: Point) {
929 // Handle tab reorder drag completion
930 if self.tab_bar.is_dragging() {
931 if let Some((from, to)) = self.tab_bar.complete_drag() {
932 self.reorder_tab(from, to);
933 }
934 } else if self.tab_bar.dragging_tab().is_some() {
935 // Clicked on tab but didn't drag - select it
936 if let Some(index) = self.tab_bar.dragging_tab() {
937 self.switch_tab(index);
938 }
939 self.tab_bar.cancel_drag();
940 }
941
942 // Handle bookmark reorder drag completion
943 if self.sidebar.is_bookmark_dragging() {
944 self.sidebar.complete_bookmark_drag();
945 } else if self.sidebar.bookmark_drag_index().is_some() {
946 // Clicked on bookmark but didn't drag - navigate to it
947 if let Some(path) = self.sidebar.bookmark_path_at_index() {
948 self.navigate_to(path);
949 }
950 self.sidebar.cancel_bookmark_drag();
951 }
952
953 // Handle bookmark drag drop (dragging from file view to sidebar)
954 if self.drag_active {
955 if let Some(path) = self.drag_source_path.take() {
956 if self.sidebar.is_bookmark_drop_zone(pos) {
957 self.sidebar.add_bookmark(&path);
958 }
959 }
960 }
961
962 // Handle file drag drop completion
963 if self.file_drag.is_dragging() {
964 // Get paths BEFORE complete() since it calls cancel() internally
965 let dragged_paths = self.file_drag.dragged_paths().cloned();
966
967 if let Some((paths, target)) = self.file_drag.complete() {
968 // Dropped on a specific target (hovering state)
969 let dest_dir = target.path().clone();
970 self.move_files_to_directory(paths, dest_dir);
971 } else if let Some(paths) = dragged_paths {
972 // Dropped while dragging (not hovering on target) - move to current directory
973 // This happens after auto-entering directories via hover
974 if let Some(dest_dir) = self.focused_pane()
975 .and_then(|p| p.active_tab())
976 .map(|t| t.current_path().clone())
977 {
978 self.move_files_to_directory(paths, dest_dir);
979 }
980 }
981 } else {
982 // Cancel file drag if it was pending but didn't activate
983 self.file_drag.cancel();
984 }
985
986 // Clear drag state
987 self.drag_source_path = None;
988 self.drag_label = None;
989 self.drag_start_pos = None;
990 self.drag_current_pos = None;
991 self.drag_active = false;
992 self.sidebar.set_drop_highlight(false);
993
994 // Clear resize states
995 self.pane_resize_path = None;
996 self.sidebar_resizing = false;
997
998 let was_dragging = self.focused_pane().map(|p| p.is_dragging()).unwrap_or(false);
999
1000 if let Some(pane) = self.focused_pane_mut() {
1001 if pane.is_resizing() {
1002 pane.stop_resize();
1003 }
1004 if pane.is_dragging() {
1005 pane.stop_drag();
1006 }
1007 }
1008
1009 // Update status bar after any mouse release (ensures rubber band selection is reflected)
1010 if was_dragging {
1011 self.update_status_bar();
1012 }
1013 }
1014
1015 /// Handle mouse move. Returns true if a redraw is needed.
1016 fn handle_mouse_move(&mut self, pos: Point) -> bool {
1017 // Handle progress dialog hover
1018 if self.progress_dialog.is_visible() {
1019 self.progress_dialog.on_mouse_move(pos);
1020 return true; // Dialogs always redraw for responsiveness
1021 }
1022
1023 // Handle confirm dialog hover
1024 if self.confirm_dialog.is_visible() {
1025 self.confirm_dialog.on_mouse_move(pos);
1026 return true;
1027 }
1028
1029 // Handle conflict dialog hover
1030 if self.conflict_dialog.is_visible() {
1031 self.conflict_dialog.on_mouse_move(pos);
1032 return true;
1033 }
1034
1035 // Handle input dialog hover
1036 if self.input_dialog.is_visible() {
1037 self.input_dialog.on_mouse_move(pos);
1038 return true;
1039 }
1040
1041 // Handle app picker hover
1042 if self.app_picker.is_visible() {
1043 return self.app_picker.on_mouse_move(pos);
1044 }
1045
1046 // Handle context menu hover
1047 if self.context_menu.is_visible() {
1048 return self.context_menu.on_mouse_move(pos);
1049 }
1050
1051 // Handle sidebar resize in progress
1052 if self.sidebar_resizing {
1053 let new_width = (pos.x - self.sidebar.bounds().x).max(0) as u32;
1054 self.sidebar.set_width(new_width);
1055 let size = self.renderer.size();
1056 self.update_layout(size.width, size.height);
1057 return true;
1058 }
1059
1060 // Handle pane divider resize in progress
1061 if let Some(path) = &self.pane_resize_path {
1062 let path_clone = path.clone();
1063 self.root_pane.adjust_split_at(&path_clone, pos);
1064 return true;
1065 }
1066
1067 // Handle column resize/drag in progress
1068 if let Some(pane) = self.focused_pane_mut() {
1069 if pane.is_resizing() || pane.is_dragging() {
1070 if let Some(tab) = pane.active_tab_mut() {
1071 tab.on_mouse_move(pos);
1072 }
1073 return true;
1074 }
1075 }
1076
1077 let mut needs_redraw = false;
1078
1079 // Handle file drag in progress
1080 if self.file_drag.is_active() {
1081 self.file_drag.update_position(pos);
1082
1083 // If actively dragging (past threshold), detect hover targets and always redraw
1084 if self.file_drag.is_dragging() {
1085 needs_redraw = true; // Always redraw when dragging for smooth cursor tracking
1086 let target = self.detect_file_drag_target(pos);
1087 self.file_drag.set_hover_target(target);
1088 }
1089 }
1090
1091 // Handle tab reorder drag in progress
1092 if self.tab_bar.dragging_tab().is_some() {
1093 self.tab_bar.update_drag(pos);
1094 needs_redraw = true;
1095 }
1096
1097 // Handle bookmark reorder drag in progress
1098 if self.sidebar.bookmark_drag_index().is_some() {
1099 self.sidebar.update_bookmark_drag(pos);
1100 needs_redraw = true;
1101 }
1102
1103 // Handle bookmark drag in progress (dragging from file view)
1104 if self.drag_source_path.is_some() {
1105 // Update current drag position for visual feedback
1106 self.drag_current_pos = Some(pos);
1107
1108 // Check if we've moved past the drag threshold (5px)
1109 if let Some(start_pos) = self.drag_start_pos {
1110 let distance = ((pos.x - start_pos.x).pow(2) + (pos.y - start_pos.y).pow(2)) as f64;
1111 if distance.sqrt() > 5.0 {
1112 self.drag_active = true;
1113 }
1114 }
1115
1116 // Update sidebar drop highlight if drag is active
1117 if self.drag_active {
1118 let is_over_drop_zone = self.sidebar.is_bookmark_drop_zone(pos);
1119 self.sidebar.set_drop_highlight(is_over_drop_zone);
1120 }
1121 needs_redraw = true;
1122 }
1123
1124 // Check hover states - only redraw if any changed
1125 if let Some(picker_toolbar) = &mut self.picker_toolbar {
1126 needs_redraw |= picker_toolbar.on_mouse_move(pos);
1127 } else {
1128 needs_redraw |= self.toolbar.on_mouse_move(pos);
1129 }
1130 needs_redraw |= self.breadcrumb.on_mouse_move(pos);
1131 needs_redraw |= self.sidebar.on_mouse_move(pos);
1132 needs_redraw |= self.tab_bar.on_mouse_move(pos);
1133 needs_redraw |= self.root_pane.on_toolbar_mouse_move(pos);
1134
1135 let mut is_dragging = false;
1136 let mut selection_count = 0;
1137 if let Some(pane) = self.focused_pane_mut() {
1138 if let Some(tab) = pane.active_tab_mut() {
1139 needs_redraw |= tab.on_mouse_move(pos);
1140 is_dragging = tab.is_dragging();
1141 if is_dragging {
1142 selection_count = tab.selection_count();
1143 }
1144 }
1145 }
1146
1147 // Update status bar selection count if rubber band is active (lightweight update)
1148 if is_dragging {
1149 self.status_bar.update_selection_count(selection_count);
1150 }
1151
1152 needs_redraw
1153 }
1154
1155 /// Handle mouse scroll. Returns true if a redraw is needed.
1156 fn handle_scroll(&mut self, pos: Point, _delta_x: i32, delta_y: i32) -> bool {
1157 // Help modal captures all scroll events when visible
1158 if self.help_modal.is_visible() {
1159 self.help_modal.on_scroll(delta_y);
1160 return true;
1161 }
1162
1163 // Check if scroll is over the content area (not sidebar, toolbar, etc.)
1164 if let Some(pane) = self.focused_pane_mut() {
1165 if let Some(tab) = pane.active_tab_mut() {
1166 if tab.bounds().contains_point(pos) {
1167 return tab.on_scroll(delta_y);
1168 }
1169 }
1170 }
1171
1172 // Check if scroll is over sidebar
1173 if self.sidebar.bounds().contains_point(pos) {
1174 return self.sidebar.on_scroll(delta_y);
1175 }
1176
1177 false
1178 }
1179
1180 /// Handle a key press.
1181 fn handle_key(&mut self, key: &Key, modifiers: &gartk_core::Modifiers) {
1182 // Handle progress dialog when visible (blocks all other input)
1183 if self.progress_dialog.is_visible() {
1184 self.progress_dialog.handle_key(key);
1185 return;
1186 }
1187
1188 // Handle confirm dialog when visible
1189 if self.confirm_dialog.is_visible() {
1190 if let Some(result) = self.confirm_dialog.handle_key(key) {
1191 self.handle_dialog_result(result);
1192 }
1193 return;
1194 }
1195
1196 // Handle conflict dialog when visible
1197 if self.conflict_dialog.is_visible() {
1198 if let Some(action) = self.conflict_dialog.handle_key(key) {
1199 self.handle_conflict_action(action);
1200 }
1201 return;
1202 }
1203
1204 // Handle input dialog when visible
1205 if self.input_dialog.is_visible() {
1206 if let Some(result) = self.input_dialog.handle_key(key) {
1207 self.handle_input_result(result);
1208 }
1209 return;
1210 }
1211
1212 // Handle app picker when visible
1213 if self.app_picker.is_visible() {
1214 if let Some(result) = self.app_picker.handle_key(key) {
1215 self.handle_app_picker_result(result);
1216 }
1217 return;
1218 }
1219
1220 // Handle help modal when visible
1221 if self.help_modal.is_visible() {
1222 match key {
1223 Key::Escape | Key::F1 => self.help_modal.hide(),
1224 Key::Up | Key::Char('k') => self.help_modal.scroll_up(),
1225 Key::Down | Key::Char('j') => self.help_modal.scroll_down(),
1226 _ => {}
1227 }
1228 return;
1229 }
1230
1231 // Handle context menu when visible
1232 if self.context_menu.is_visible() {
1233 if let Some(action) = self.context_menu.handle_key(key) {
1234 self.handle_context_menu_action(action);
1235 }
1236 return;
1237 }
1238
1239 // Cancel file drag on Escape
1240 if *key == Key::Escape && self.file_drag.is_active() {
1241 self.file_drag.cancel();
1242 return;
1243 }
1244
1245 // Handle Escape in picker mode (cancel)
1246 if *key == Key::Escape && self.picker_config.is_picker() {
1247 self.cancel_picker();
1248 return;
1249 }
1250
1251 // F1 toggles help
1252 if *key == Key::F1 {
1253 self.help_modal.show();
1254 return;
1255 }
1256
1257 // Handle address bar input first
1258 if self.address_bar.is_active() {
1259 if *key == Key::Return {
1260 if let Some(path) = self.address_bar.confirm() {
1261 self.navigate_to(path);
1262 }
1263 return;
1264 }
1265 if self.address_bar.handle_key(key) {
1266 return;
1267 }
1268 }
1269
1270 // Handle picker filename textbox input (save mode)
1271 if let Some(picker_toolbar) = &mut self.picker_toolbar {
1272 if picker_toolbar.is_editing_filename() {
1273 if *key == Key::Return {
1274 // Enter accepts the save
1275 self.accept_picker_selection();
1276 return;
1277 }
1278 if *key == Key::Tab {
1279 // Tab cycles focus
1280 picker_toolbar.cycle_focus();
1281 return;
1282 }
1283 if picker_toolbar.handle_key(key) {
1284 return;
1285 }
1286 }
1287 }
1288
1289 // Handle rename input
1290 if self.is_renaming() {
1291 match key {
1292 Key::Escape => {
1293 self.cancel_rename();
1294 return;
1295 }
1296 Key::Return => {
1297 self.confirm_rename();
1298 return;
1299 }
1300 _ => {
1301 // Route other keys to rename handler
1302 if let Some(pane) = self.focused_pane_mut() {
1303 if let Some(tab) = pane.active_tab_mut() {
1304 if tab.handle_rename_key(key) {
1305 return;
1306 }
1307 }
1308 }
1309 }
1310 }
1311 return;
1312 }
1313
1314 // Alt+Arrow for history navigation
1315 if modifiers.alt {
1316 match key {
1317 Key::Left => {
1318 self.go_back();
1319 return;
1320 }
1321 Key::Right => {
1322 self.go_forward();
1323 return;
1324 }
1325 // Alt+1-9 to jump to tab N
1326 Key::Char('1') => { self.switch_tab(0); return; }
1327 Key::Char('2') => { self.switch_tab(1); return; }
1328 Key::Char('3') => { self.switch_tab(2); return; }
1329 Key::Char('4') => { self.switch_tab(3); return; }
1330 Key::Char('5') => { self.switch_tab(4); return; }
1331 Key::Char('6') => { self.switch_tab(5); return; }
1332 Key::Char('7') => { self.switch_tab(6); return; }
1333 Key::Char('8') => { self.switch_tab(7); return; }
1334 Key::Char('9') => { self.switch_tab(8); return; }
1335 _ => {}
1336 }
1337 }
1338
1339 // Ctrl+Shift keybinds (splits, new folder, new file, duplicate)
1340 if modifiers.ctrl && modifiers.shift {
1341 match key {
1342 Key::Char('n') | Key::Char('N') => {
1343 self.create_new_folder();
1344 return;
1345 }
1346 Key::Char('f') | Key::Char('F') => {
1347 self.create_new_file();
1348 return;
1349 }
1350 Key::Char('d') | Key::Char('D') => {
1351 self.duplicate_selected();
1352 return;
1353 }
1354 Key::Char('h') | Key::Char('H') => {
1355 self.split_horizontal();
1356 return;
1357 }
1358 Key::Char('v') | Key::Char('V') => {
1359 self.split_vertical();
1360 return;
1361 }
1362 Key::Char('w') | Key::Char('W') => {
1363 self.close_pane();
1364 return;
1365 }
1366 Key::Left => {
1367 self.focus_pane_left();
1368 return;
1369 }
1370 Key::Right => {
1371 self.focus_pane_right();
1372 return;
1373 }
1374 Key::Up => {
1375 self.focus_pane_up();
1376 return;
1377 }
1378 Key::Down => {
1379 self.focus_pane_down();
1380 return;
1381 }
1382 _ => {}
1383 }
1384 }
1385
1386 // Ctrl keybinds
1387 if modifiers.ctrl {
1388 match key {
1389 Key::Char('1') => {
1390 self.set_view_mode(ViewMode::List);
1391 return;
1392 }
1393 Key::Char('2') => {
1394 self.set_view_mode(ViewMode::Grid);
1395 return;
1396 }
1397 Key::Char('3') => {
1398 self.set_view_mode(ViewMode::Columns);
1399 return;
1400 }
1401 Key::Char('+') | Key::Char('=') => {
1402 // Increase icon size in grid view
1403 if let Some(pane) = self.focused_pane_mut() {
1404 if let Some(tab) = pane.active_tab_mut() {
1405 tab.increase_icon_size();
1406 }
1407 }
1408 self.sync_toolbar_icon_size();
1409 return;
1410 }
1411 Key::Char('-') | Key::Char('_') => {
1412 // Decrease icon size in grid view
1413 if let Some(pane) = self.focused_pane_mut() {
1414 if let Some(tab) = pane.active_tab_mut() {
1415 tab.decrease_icon_size();
1416 }
1417 }
1418 self.sync_toolbar_icon_size();
1419 return;
1420 }
1421 Key::Char('t') | Key::Char('T') => {
1422 self.new_tab();
1423 return;
1424 }
1425 Key::Char('w') | Key::Char('W') => {
1426 self.close_active_tab();
1427 return;
1428 }
1429 Key::Tab => {
1430 self.next_tab();
1431 return;
1432 }
1433 Key::Char('a') | Key::Char('A') => {
1434 if let Some(pane) = self.focused_pane_mut() {
1435 if let Some(tab) = pane.active_tab_mut() {
1436 tab.select_all();
1437 }
1438 }
1439 return;
1440 }
1441 Key::Char('b') | Key::Char('B') => {
1442 self.sidebar.toggle();
1443 let size = self.renderer.size();
1444 self.update_layout(size.width, size.height);
1445 return;
1446 }
1447 Key::Char('d') | Key::Char('D') => {
1448 // Bookmark the selected item (must be a directory)
1449 let bookmark_path = self.focused_pane()
1450 .and_then(|pane| pane.active_tab())
1451 .and_then(|tab| tab.selected_entry())
1452 .filter(|e| e.is_dir())
1453 .map(|e| e.path.clone());
1454
1455 if let Some(path) = bookmark_path {
1456 self.sidebar.toggle_bookmark(&path);
1457 }
1458 return;
1459 }
1460 Key::Char('l') | Key::Char('L') => {
1461 if let Some(pane) = self.focused_pane() {
1462 if let Some(tab) = pane.active_tab() {
1463 let current = tab.current_path().clone();
1464 self.address_bar.activate(&current);
1465 }
1466 }
1467 return;
1468 }
1469 Key::Char('c') | Key::Char('C') => {
1470 self.copy_selected();
1471 return;
1472 }
1473 Key::Char('x') | Key::Char('X') => {
1474 self.cut_selected();
1475 return;
1476 }
1477 Key::Char('v') | Key::Char('V') => {
1478 self.paste();
1479 return;
1480 }
1481 Key::Char('z') | Key::Char('Z') => {
1482 self.undo();
1483 return;
1484 }
1485 Key::Char('y') | Key::Char('Y') => {
1486 self.redo();
1487 return;
1488 }
1489 _ => {}
1490 }
1491 }
1492
1493 // Shift+Delete for permanent delete
1494 if modifiers.shift && *key == Key::Delete {
1495 self.delete_selected_permanently();
1496 return;
1497 }
1498
1499 match key {
1500 Key::Delete => {
1501 self.trash_selected();
1502 }
1503 Key::F2 => {
1504 self.start_rename();
1505 }
1506 Key::Escape => {
1507 // Cancel drag first if active
1508 if self.drag_active || self.drag_source_path.is_some() {
1509 self.drag_source_path = None;
1510 self.drag_label = None;
1511 self.drag_start_pos = None;
1512 self.drag_current_pos = None;
1513 self.drag_active = false;
1514 self.sidebar.set_drop_highlight(false);
1515 } else if self.address_bar.is_active() {
1516 self.address_bar.cancel();
1517 } else {
1518 self.should_quit = true;
1519 }
1520 }
1521 Key::Char('q') => {
1522 self.should_quit = true;
1523 }
1524 Key::Up | Key::Char('k') => {
1525 if let Some(pane) = self.focused_pane_mut() {
1526 if let Some(tab) = pane.active_tab_mut() {
1527 tab.select_prev();
1528 }
1529 }
1530 }
1531 Key::Down | Key::Char('j') => {
1532 if let Some(pane) = self.focused_pane_mut() {
1533 if let Some(tab) = pane.active_tab_mut() {
1534 tab.select_next();
1535 }
1536 }
1537 }
1538 Key::Home | Key::Char('g') => {
1539 if let Some(pane) = self.focused_pane_mut() {
1540 if let Some(tab) = pane.active_tab_mut() {
1541 tab.select_first();
1542 }
1543 }
1544 }
1545 Key::End | Key::Char('G') => {
1546 if let Some(pane) = self.focused_pane_mut() {
1547 if let Some(tab) = pane.active_tab_mut() {
1548 tab.select_last();
1549 }
1550 }
1551 }
1552 Key::PageUp => {
1553 if let Some(pane) = self.focused_pane_mut() {
1554 if let Some(tab) = pane.active_tab_mut() {
1555 tab.page_up();
1556 }
1557 }
1558 }
1559 Key::PageDown => {
1560 if let Some(pane) = self.focused_pane_mut() {
1561 if let Some(tab) = pane.active_tab_mut() {
1562 tab.page_down();
1563 }
1564 }
1565 }
1566 Key::Return => {
1567 self.enter_selected();
1568 }
1569 Key::Right | Key::Char('l') => {
1570 if let Some(pane) = self.focused_pane_mut() {
1571 if let Some(tab) = pane.active_tab_mut() {
1572 if tab.view_mode() == ViewMode::Grid {
1573 tab.select_right();
1574 } else {
1575 self.enter_selected();
1576 return;
1577 }
1578 }
1579 }
1580 }
1581 Key::Backspace | Key::Left | Key::Char('h') => {
1582 if let Some(pane) = self.focused_pane_mut() {
1583 if let Some(tab) = pane.active_tab_mut() {
1584 if tab.view_mode() == ViewMode::Grid && *key == Key::Left {
1585 tab.select_left();
1586 } else {
1587 self.go_up();
1588 return;
1589 }
1590 }
1591 }
1592 }
1593 Key::Char('H') => {
1594 if let Some(pane) = self.focused_pane_mut() {
1595 if let Some(tab) = pane.active_tab_mut() {
1596 tab.toggle_hidden();
1597 }
1598 }
1599 }
1600 Key::Char('~') => {
1601 self.navigate_to(dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
1602 }
1603 Key::Char('/') => {
1604 self.navigate_to(PathBuf::from("/"));
1605 }
1606 Key::Char('r') => {
1607 self.refresh();
1608 }
1609 _ => {}
1610 }
1611 }
1612
1613 // === Tab operations ===
1614
1615 /// Create a new tab in the focused pane.
1616 fn new_tab(&mut self) {
1617 let path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
1618
1619 if let Some(pane) = self.focused_pane_mut() {
1620 pane.add_tab(path);
1621 }
1622
1623 self.sync_tab_bar();
1624 self.sync_breadcrumb();
1625 self.update_status_bar();
1626 }
1627
1628 /// Close the active tab in the focused pane.
1629 fn close_active_tab(&mut self) {
1630 if let Some(pane) = self.focused_pane_mut() {
1631 let should_remove = pane.close_active_tab();
1632 if should_remove {
1633 // For now, just quit if last tab is closed
1634 // TODO: Handle pane removal properly
1635 self.should_quit = true;
1636 return;
1637 }
1638 }
1639
1640 self.sync_tab_bar();
1641 self.sync_breadcrumb();
1642 self.update_status_bar();
1643 }
1644
1645 /// Close tab at index.
1646 fn close_tab(&mut self, index: usize) {
1647 if let Some(pane) = self.focused_pane_mut() {
1648 let should_remove = pane.close_tab(index);
1649 if should_remove {
1650 self.should_quit = true;
1651 return;
1652 }
1653 }
1654
1655 self.sync_tab_bar();
1656 self.sync_breadcrumb();
1657 self.update_status_bar();
1658 }
1659
1660 /// Switch to tab at index.
1661 fn switch_tab(&mut self, index: usize) {
1662 if let Some(pane) = self.focused_pane_mut() {
1663 pane.set_active_tab(index);
1664 }
1665
1666 self.sync_tab_bar();
1667 self.sync_breadcrumb();
1668 self.sync_toolbar_view();
1669 self.sync_toolbar_icon_size();
1670 self.update_status_bar();
1671 }
1672
1673 /// Reorder a tab from one position to another.
1674 fn reorder_tab(&mut self, from: usize, to: usize) {
1675 if let Some(pane) = self.focused_pane_mut() {
1676 pane.reorder_tab(from, to);
1677 }
1678
1679 self.sync_tab_bar();
1680 self.sync_breadcrumb();
1681 self.update_status_bar();
1682 }
1683
1684 /// Cycle to next tab.
1685 fn next_tab(&mut self) {
1686 if let Some(pane) = self.focused_pane_mut() {
1687 pane.next_tab();
1688 }
1689
1690 self.sync_tab_bar();
1691 self.sync_breadcrumb();
1692 self.update_status_bar();
1693 }
1694
1695 // === Split operations ===
1696
1697 /// Split the focused pane horizontally.
1698 fn split_horizontal(&mut self) {
1699 let (path, view_mode) = if let Some(pane) = self.focused_pane() {
1700 if let Some(tab) = pane.active_tab() {
1701 (Some(tab.current_path().clone()), Some(tab.view_mode()))
1702 } else {
1703 (None, None)
1704 }
1705 } else {
1706 (None, None)
1707 };
1708
1709 let path = path.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
1710 let new_id = self.next_pane_id;
1711
1712 if let Some(pane) = self.focused_pane_mut() {
1713 if pane.split(SplitDirection::Horizontal, path, new_id, view_mode).is_some() {
1714 self.next_pane_id += 1;
1715 self.focused_pane_id = new_id;
1716 }
1717 }
1718
1719 self.sync_tab_bar();
1720 self.sync_breadcrumb();
1721 self.update_status_bar();
1722 }
1723
1724 /// Split the focused pane vertically.
1725 fn split_vertical(&mut self) {
1726 let (path, view_mode) = if let Some(pane) = self.focused_pane() {
1727 if let Some(tab) = pane.active_tab() {
1728 (Some(tab.current_path().clone()), Some(tab.view_mode()))
1729 } else {
1730 (None, None)
1731 }
1732 } else {
1733 (None, None)
1734 };
1735
1736 let path = path.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
1737 let new_id = self.next_pane_id;
1738
1739 if let Some(pane) = self.focused_pane_mut() {
1740 if pane.split(SplitDirection::Vertical, path, new_id, view_mode).is_some() {
1741 self.next_pane_id += 1;
1742 self.focused_pane_id = new_id;
1743 }
1744 }
1745
1746 self.sync_tab_bar();
1747 self.sync_breadcrumb();
1748 self.update_status_bar();
1749 }
1750
1751 /// Close the focused pane.
1752 fn close_pane(&mut self) {
1753 // Can't close if only one pane
1754 if self.root_pane.leaf_ids().len() <= 1 {
1755 return;
1756 }
1757
1758 // Remove the focused pane and get sibling to focus
1759 if let Some(new_focus_id) = self.root_pane.remove_pane(self.focused_pane_id) {
1760 self.focused_pane_id = new_focus_id;
1761 self.sync_tab_bar();
1762 self.sync_breadcrumb();
1763 self.sync_toolbar_view();
1764 self.sync_toolbar_icon_size();
1765 self.update_status_bar();
1766 }
1767 }
1768
1769 /// Focus the pane to the left.
1770 fn focus_pane_left(&mut self) {
1771 if let Some(new_id) = self.root_pane.pane_left_of(self.focused_pane_id) {
1772 self.focused_pane_id = new_id;
1773 self.sync_tab_bar();
1774 self.sync_breadcrumb();
1775 self.sync_toolbar_view();
1776 self.sync_toolbar_icon_size();
1777 self.update_status_bar();
1778 }
1779 }
1780
1781 /// Focus the pane to the right.
1782 fn focus_pane_right(&mut self) {
1783 if let Some(new_id) = self.root_pane.pane_right_of(self.focused_pane_id) {
1784 self.focused_pane_id = new_id;
1785 self.sync_tab_bar();
1786 self.sync_breadcrumb();
1787 self.sync_toolbar_view();
1788 self.sync_toolbar_icon_size();
1789 self.update_status_bar();
1790 }
1791 }
1792
1793 /// Focus the pane above.
1794 fn focus_pane_up(&mut self) {
1795 if let Some(new_id) = self.root_pane.pane_above(self.focused_pane_id) {
1796 self.focused_pane_id = new_id;
1797 self.sync_tab_bar();
1798 self.sync_breadcrumb();
1799 self.sync_toolbar_view();
1800 self.sync_toolbar_icon_size();
1801 self.update_status_bar();
1802 }
1803 }
1804
1805 /// Focus the pane below.
1806 fn focus_pane_down(&mut self) {
1807 if let Some(new_id) = self.root_pane.pane_below(self.focused_pane_id) {
1808 self.focused_pane_id = new_id;
1809 self.sync_tab_bar();
1810 self.sync_breadcrumb();
1811 self.sync_toolbar_view();
1812 self.sync_toolbar_icon_size();
1813 self.update_status_bar();
1814 }
1815 }
1816
1817 // === Navigation ===
1818
1819 /// Enter the selected entry.
1820 fn enter_selected(&mut self) {
1821 self.status_bar.clear_status_message();
1822
1823 // In picker mode, check if we should accept the selection instead of navigating
1824 if self.picker_config.is_picker() {
1825 if let Some(pane) = self.focused_pane() {
1826 if let Some(tab) = pane.active_tab() {
1827 let selected = tab.selected_entries();
1828 if !selected.is_empty() {
1829 // If it's a directory in non-directory mode, navigate into it
1830 if !self.picker_config.mode.is_directory_mode() {
1831 let first = &selected[0];
1832 if first.is_dir() {
1833 // Fall through to normal enter behavior
1834 } else {
1835 // File selected - accept it
1836 self.accept_picker_selection();
1837 return;
1838 }
1839 }
1840 }
1841 }
1842 }
1843 }
1844
1845 // In recents mode, open files with default app and navigate into directories
1846 let showing_recents = self.focused_pane()
1847 .and_then(|pane| pane.active_tab())
1848 .is_some_and(|tab| tab.is_showing_recents());
1849
1850 if showing_recents {
1851 if let Some(entry) = self.focused_pane()
1852 .and_then(|pane| pane.active_tab())
1853 .and_then(|tab| tab.selected_entry())
1854 .cloned()
1855 {
1856 if entry.is_dir() {
1857 // Navigate to directory (this exits recents mode)
1858 self.navigate_to(entry.path);
1859 } else {
1860 // Open file with default application
1861 self.open_file_with_default(&entry.path);
1862 }
1863 }
1864 self.sync_breadcrumb();
1865 self.update_status_bar();
1866 return;
1867 }
1868
1869 if let Some(pane) = self.focused_pane_mut() {
1870 if let Some(tab) = pane.active_tab_mut() {
1871 tab.enter_selected();
1872 }
1873 }
1874 self.sync_breadcrumb();
1875 self.update_status_bar();
1876 }
1877
1878 /// Navigate to a directory.
1879 fn navigate_to(&mut self, path: PathBuf) {
1880 self.status_bar.clear_status_message();
1881 if let Some(pane) = self.focused_pane_mut() {
1882 if let Some(tab) = pane.active_tab_mut() {
1883 tab.navigate_to(path);
1884 }
1885 }
1886 self.sync_tab_bar();
1887 self.sync_breadcrumb();
1888 self.update_status_bar();
1889 }
1890
1891 /// Show the recents view.
1892 fn show_recents(&mut self) {
1893 self.status_bar.clear_status_message();
1894 // Clone recents entries to avoid borrow conflict
1895 let recents_entries: Vec<_> = self.recents.entries().to_vec();
1896 if let Some(pane) = self.focused_pane_mut() {
1897 if let Some(tab) = pane.active_tab_mut() {
1898 tab.show_recents_entries(&recents_entries);
1899 }
1900 }
1901 self.sync_tab_bar();
1902 self.sync_breadcrumb();
1903 self.update_status_bar();
1904 }
1905
1906 /// Go back in history.
1907 fn go_back(&mut self) {
1908 self.status_bar.clear_status_message();
1909 if let Some(pane) = self.focused_pane_mut() {
1910 if let Some(tab) = pane.active_tab_mut() {
1911 tab.go_back();
1912 }
1913 }
1914 self.sync_tab_bar();
1915 self.sync_breadcrumb();
1916 self.update_status_bar();
1917 }
1918
1919 /// Go forward in history.
1920 fn go_forward(&mut self) {
1921 self.status_bar.clear_status_message();
1922 if let Some(pane) = self.focused_pane_mut() {
1923 if let Some(tab) = pane.active_tab_mut() {
1924 tab.go_forward();
1925 }
1926 }
1927 self.sync_tab_bar();
1928 self.sync_breadcrumb();
1929 self.update_status_bar();
1930 }
1931
1932 /// Go up to parent directory.
1933 fn go_up(&mut self) {
1934 self.status_bar.clear_status_message();
1935 if let Some(pane) = self.focused_pane_mut() {
1936 if let Some(tab) = pane.active_tab_mut() {
1937 tab.go_up();
1938 }
1939 }
1940 self.sync_breadcrumb();
1941 self.update_status_bar();
1942 }
1943
1944 /// Refresh the current directory.
1945 fn refresh(&mut self) {
1946 if let Some(pane) = self.focused_pane_mut() {
1947 if let Some(tab) = pane.active_tab_mut() {
1948 tab.refresh();
1949 }
1950 }
1951 self.update_status_bar();
1952 }
1953
1954 // === File Operations ===
1955
1956 /// Copy selected files to clipboard.
1957 fn copy_selected(&mut self) {
1958 let paths = self.get_selected_paths();
1959 if !paths.is_empty() {
1960 let count = paths.len();
1961
1962 // Update internal clipboard
1963 self.clipboard.copy(paths.clone());
1964
1965 // Update X11 system clipboard so other apps can paste
1966 if let Err(e) = self.x11_clipboard.set_files(&paths, false) {
1967 tracing::warn!("Failed to set X11 clipboard: {}", e);
1968 }
1969
1970 let msg = if count == 1 { "1 item copied".to_string() } else { format!("{} items copied", count) };
1971 self.status_bar.set_status_message(msg);
1972 self.update_status_bar();
1973 }
1974 }
1975
1976 /// Cut selected files to clipboard.
1977 fn cut_selected(&mut self) {
1978 let paths = self.get_selected_paths();
1979 if !paths.is_empty() {
1980 let count = paths.len();
1981
1982 // Update internal clipboard
1983 self.clipboard.cut(paths.clone());
1984
1985 // Update X11 system clipboard with cut flag so other apps know to move
1986 if let Err(e) = self.x11_clipboard.set_files(&paths, true) {
1987 tracing::warn!("Failed to set X11 clipboard: {}", e);
1988 }
1989
1990 let msg = if count == 1 { "1 item cut".to_string() } else { format!("{} items cut", count) };
1991 self.status_bar.set_status_message(msg);
1992 self.update_status_bar();
1993 }
1994 }
1995
1996 /// Paste files from clipboard to current directory.
1997 fn paste(&mut self) {
1998 let dest_dir = self.focused_pane()
1999 .and_then(|p| p.active_tab())
2000 .map(|t| t.current_path().clone());
2001
2002 let dest_dir = match dest_dir {
2003 Some(d) => d,
2004 None => return,
2005 };
2006
2007 if let Some((files, op)) = self.clipboard.take() {
2008 // Check for conflicts first
2009 let conflicts: Vec<PathBuf> = files.iter()
2010 .filter_map(|f| {
2011 f.file_name().and_then(|name| {
2012 let dest = dest_dir.join(name);
2013 if dest.exists() { Some(f.clone()) } else { None }
2014 })
2015 })
2016 .collect();
2017
2018 if !conflicts.is_empty() {
2019 // Ring bell to alert user
2020 self.bell();
2021
2022 // Show conflict dialog for the first conflict
2023 let first_conflict_name = conflicts[0]
2024 .file_name()
2025 .map(|n| n.to_string_lossy().to_string())
2026 .unwrap_or_default();
2027
2028 self.conflict_dialog.show(&first_conflict_name);
2029
2030 // Store pending paste state
2031 self.pending_paste = Some(PendingPaste {
2032 files,
2033 operation: op,
2034 dest_dir,
2035 conflicts,
2036 });
2037 return;
2038 }
2039
2040 // No conflicts - proceed with paste
2041 let count = files.len();
2042 let sources = files.clone();
2043 let result = match op {
2044 ClipboardOperation::Copy => copy_files(&files, &dest_dir),
2045 ClipboardOperation::Cut => move_files(&files, &dest_dir),
2046 };
2047
2048 // Show result in status bar and record for undo
2049 if result.success && !result.processed.is_empty() {
2050 let action = if op == ClipboardOperation::Copy { "copied" } else { "moved" };
2051 let msg = if count == 1 { format!("1 item {}", action) } else { format!("{} items {}", count, action) };
2052 self.status_bar.set_status_message(msg);
2053
2054 // Record for undo
2055 let undo_op = match op {
2056 ClipboardOperation::Copy => FileOperation::Copy {
2057 sources,
2058 destinations: result.processed.clone(),
2059 },
2060 ClipboardOperation::Cut => FileOperation::Move {
2061 sources,
2062 destinations: result.processed.clone(),
2063 },
2064 };
2065 self.undo_stack.push(undo_op);
2066 } else if !result.success {
2067 let msg = format!("Operation failed: {}", result.error.as_deref().unwrap_or("unknown error"));
2068 self.status_bar.set_status_message(msg);
2069 }
2070
2071 self.refresh();
2072 }
2073 }
2074
2075 /// Move files to a directory (used by drag-and-drop).
2076 fn move_files_to_directory(&mut self, files: Vec<PathBuf>, dest_dir: PathBuf) {
2077 // Filter out files that are already in the destination directory (same-directory drop = cancel)
2078 let files: Vec<PathBuf> = files.into_iter()
2079 .filter(|f| f.parent() != Some(dest_dir.as_path()))
2080 .collect();
2081
2082 // If no files need moving, silently cancel
2083 if files.is_empty() {
2084 return;
2085 }
2086
2087 // Check for conflicts first
2088 let conflicts: Vec<PathBuf> = files.iter()
2089 .filter_map(|f| {
2090 f.file_name().and_then(|name| {
2091 let dest = dest_dir.join(name);
2092 if dest.exists() { Some(f.clone()) } else { None }
2093 })
2094 })
2095 .collect();
2096
2097 if !conflicts.is_empty() {
2098 // Ring bell to alert user
2099 self.bell();
2100
2101 // Show conflict dialog for the first conflict
2102 let first_conflict_name = conflicts[0]
2103 .file_name()
2104 .map(|n| n.to_string_lossy().to_string())
2105 .unwrap_or_default();
2106
2107 self.conflict_dialog.show(&first_conflict_name);
2108
2109 // Store pending paste state (reuse for drag-drop moves)
2110 self.pending_paste = Some(PendingPaste {
2111 files,
2112 operation: ClipboardOperation::Cut, // Move operation
2113 dest_dir,
2114 conflicts,
2115 });
2116 return;
2117 }
2118
2119 // No conflicts - proceed with move
2120 let count = files.len();
2121 let sources = files.clone();
2122 let result = move_files(&files, &dest_dir);
2123
2124 // Show result in status bar and record for undo
2125 if result.success && !result.processed.is_empty() {
2126 let msg = if count == 1 { "1 item moved".to_string() } else { format!("{} items moved", count) };
2127 self.status_bar.set_status_message(msg);
2128
2129 // Record for undo
2130 let undo_op = FileOperation::Move {
2131 sources,
2132 destinations: result.processed.clone(),
2133 };
2134 self.undo_stack.push(undo_op);
2135 } else if !result.success {
2136 let msg = format!("Move failed: {}", result.error.as_deref().unwrap_or("unknown error"));
2137 self.status_bar.set_status_message(msg);
2138 }
2139
2140 self.refresh();
2141 }
2142
2143 /// Move selected files to trash.
2144 fn trash_selected(&mut self) {
2145 let paths = self.get_selected_paths();
2146 if paths.is_empty() {
2147 return;
2148 }
2149
2150 let count = paths.len();
2151 let results = trash_files(&paths);
2152 let success_count = results.iter().filter(|r| r.is_ok()).count();
2153 let failed_count = results.iter().filter(|r| r.is_err()).count();
2154
2155 // Collect successful trash operations for undo
2156 let mut trashed_originals = Vec::new();
2157 let mut trash_names = Vec::new();
2158 for (i, result) in results.iter().enumerate() {
2159 if let Ok(trash_path) = result {
2160 trashed_originals.push(paths[i].clone());
2161 // Extract the trash entry name (filename in trash/files/)
2162 if let Some(name) = trash_path.file_name() {
2163 trash_names.push(name.to_string_lossy().to_string());
2164 }
2165 }
2166 }
2167
2168 if failed_count > 0 {
2169 let msg = format!("Moved {} to trash, {} failed", success_count, failed_count);
2170 self.status_bar.set_status_message(msg);
2171 } else {
2172 let msg = if count == 1 { "1 item moved to trash".to_string() } else { format!("{} items moved to trash", count) };
2173 self.status_bar.set_status_message(msg);
2174 }
2175
2176 // Record for undo if any succeeded
2177 if !trashed_originals.is_empty() {
2178 self.undo_stack.push(FileOperation::Trash {
2179 originals: trashed_originals,
2180 trash_names,
2181 });
2182 }
2183
2184 self.refresh();
2185 }
2186
2187 /// Delete selected files permanently (shows confirmation dialog).
2188 fn delete_selected_permanently(&mut self) {
2189 let paths = self.get_selected_paths();
2190 if paths.is_empty() {
2191 return;
2192 }
2193
2194 // Store paths and show confirmation dialog
2195 self.pending_delete_paths = paths.clone();
2196 self.confirm_dialog.show_delete_confirm(paths.len());
2197 }
2198
2199 /// Handle confirmation dialog result.
2200 fn handle_dialog_result(&mut self, result: DialogResult) {
2201 match result {
2202 DialogResult::Confirmed => {
2203 // Perform the pending delete
2204 if !self.pending_delete_paths.is_empty() {
2205 let paths = std::mem::take(&mut self.pending_delete_paths);
2206 let count = paths.len();
2207 let result = delete_files(&paths);
2208
2209 if result.success {
2210 let msg = if count == 1 { "1 item deleted".to_string() } else { format!("{} items deleted", count) };
2211 self.status_bar.set_status_message(msg);
2212 } else {
2213 let msg = format!("Delete failed: {}", result.error.as_deref().unwrap_or("unknown error"));
2214 self.status_bar.set_status_message(msg);
2215 }
2216
2217 self.refresh();
2218 }
2219 }
2220 DialogResult::Cancelled => {
2221 // Clear pending paths
2222 self.pending_delete_paths.clear();
2223 }
2224 }
2225 }
2226
2227 /// Handle input dialog result.
2228 fn handle_input_result(&mut self, result: InputResult) {
2229 match result {
2230 InputResult::Submitted(value) => {
2231 // Currently only used for "Open With" custom application
2232 self.open_with_custom(&value);
2233 }
2234 InputResult::Cancelled => {
2235 self.pending_open_with_path = None;
2236 }
2237 }
2238 }
2239
2240 /// Handle app picker result.
2241 fn handle_app_picker_result(&mut self, result: AppPickerResult) {
2242 match result {
2243 AppPickerResult::Selected(exec) => {
2244 // Open the pending file with the selected application
2245 self.open_with_custom(&exec);
2246 }
2247 AppPickerResult::Cancelled => {
2248 self.pending_open_with_path = None;
2249 }
2250 }
2251 }
2252
2253 /// Handle conflict dialog result.
2254 fn handle_conflict_action(&mut self, action: ConflictAction) {
2255 let pending = match self.pending_paste.take() {
2256 Some(p) => p,
2257 None => return,
2258 };
2259
2260 let apply_to_all = self.conflict_dialog.apply_to_all();
2261
2262 match action {
2263 ConflictAction::Cancel => {
2264 // Cancel the entire operation
2265 self.status_bar.set_status_message("Paste cancelled");
2266 }
2267 ConflictAction::Skip => {
2268 // Skip conflicting files, paste the rest
2269 self.complete_paste_with_skip(pending, apply_to_all);
2270 }
2271 ConflictAction::Replace => {
2272 // Replace conflicting files
2273 self.complete_paste_with_replace(pending, apply_to_all);
2274 }
2275 ConflictAction::KeepBoth => {
2276 // Auto-rename and paste all
2277 self.complete_paste_with_rename(pending, apply_to_all);
2278 }
2279 }
2280 }
2281
2282 /// Complete paste, skipping conflicts.
2283 fn complete_paste_with_skip(&mut self, pending: PendingPaste, _apply_to_all: bool) {
2284 let non_conflicting: Vec<_> = pending.files.iter()
2285 .filter(|f| !pending.conflicts.contains(f))
2286 .cloned()
2287 .collect();
2288
2289 if non_conflicting.is_empty() {
2290 self.status_bar.set_status_message("All files skipped (conflicts)");
2291 return;
2292 }
2293
2294 let result = match pending.operation {
2295 ClipboardOperation::Copy => copy_files(&non_conflicting, &pending.dest_dir),
2296 ClipboardOperation::Cut => move_files(&non_conflicting, &pending.dest_dir),
2297 };
2298
2299 let skipped = pending.conflicts.len();
2300 if result.success {
2301 let action = if pending.operation == ClipboardOperation::Copy { "copied" } else { "moved" };
2302 self.status_bar.set_status_message(format!("{} {} (skipped {})", non_conflicting.len(), action, skipped));
2303 }
2304 self.refresh();
2305 }
2306
2307 /// Complete paste, replacing conflicts.
2308 fn complete_paste_with_replace(&mut self, pending: PendingPaste, _apply_to_all: bool) {
2309 // Delete conflicting files first
2310 for conflict in &pending.conflicts {
2311 if let Some(name) = conflict.file_name() {
2312 let dest = pending.dest_dir.join(name);
2313 let _ = std::fs::remove_file(&dest).or_else(|_| std::fs::remove_dir_all(&dest));
2314 }
2315 }
2316
2317 let result = match pending.operation {
2318 ClipboardOperation::Copy => copy_files(&pending.files, &pending.dest_dir),
2319 ClipboardOperation::Cut => move_files(&pending.files, &pending.dest_dir),
2320 };
2321
2322 if result.success {
2323 let action = if pending.operation == ClipboardOperation::Copy { "copied" } else { "moved" };
2324 self.status_bar.set_status_message(format!("{} {} (replaced {})", pending.files.len(), action, pending.conflicts.len()));
2325 }
2326 self.refresh();
2327 }
2328
2329 /// Complete paste, auto-renaming conflicts with inline rename prompt.
2330 fn complete_paste_with_rename(&mut self, mut pending: PendingPaste, apply_to_all: bool) {
2331 use garfield::core::make_unique_name;
2332
2333 // First, paste all non-conflicting files
2334 let non_conflicting: Vec<_> = pending.files.iter()
2335 .filter(|f| !pending.conflicts.contains(f))
2336 .cloned()
2337 .collect();
2338
2339 let mut sources = Vec::new();
2340 let mut destinations = Vec::new();
2341
2342 for file in &non_conflicting {
2343 if let Some(name) = file.file_name() {
2344 let dest = pending.dest_dir.join(name);
2345 let result = match pending.operation {
2346 ClipboardOperation::Copy => {
2347 if file.is_dir() {
2348 garfield::core::copy_path(file, &pending.dest_dir)
2349 } else {
2350 std::fs::copy(file, &dest).map(|_| dest.clone())
2351 }
2352 }
2353 ClipboardOperation::Cut => {
2354 std::fs::rename(file, &dest).map(|_| dest.clone())
2355 }
2356 };
2357 if let Ok(dest_path) = result {
2358 sources.push(file.clone());
2359 destinations.push(dest_path);
2360 }
2361 }
2362 }
2363
2364 // Now handle the first conflict - paste with suggested name and start rename
2365 if let Some(conflict_file) = pending.conflicts.first().cloned() {
2366 if let Some(name) = conflict_file.file_name() {
2367 let name_str = name.to_string_lossy();
2368 let unique_name = make_unique_name(&pending.dest_dir, &name_str);
2369 let final_dest = pending.dest_dir.join(&unique_name);
2370
2371 // Perform the copy/move with the unique name
2372 let result = match pending.operation {
2373 ClipboardOperation::Copy => {
2374 // Copy directly to the unique destination path
2375 garfield::core::copy_to_path(&conflict_file, &final_dest)
2376 }
2377 ClipboardOperation::Cut => {
2378 std::fs::rename(&conflict_file, &final_dest).map(|_| final_dest.clone())
2379 }
2380 };
2381
2382 if let Ok(dest_path) = result {
2383 sources.push(conflict_file.clone());
2384 destinations.push(dest_path.clone());
2385
2386 // Record partial undo
2387 if !destinations.is_empty() {
2388 let undo_op = match pending.operation {
2389 ClipboardOperation::Copy => FileOperation::Copy {
2390 sources: sources.clone(),
2391 destinations: destinations.clone(),
2392 },
2393 ClipboardOperation::Cut => FileOperation::Move {
2394 sources: sources.clone(),
2395 destinations: destinations.clone(),
2396 },
2397 };
2398 self.undo_stack.push(undo_op);
2399 }
2400
2401 // Refresh to show the new file
2402 self.refresh();
2403
2404 // Select the newly pasted file and start rename
2405 let found = if let Some(pane) = self.focused_pane_mut() {
2406 if let Some(tab) = pane.active_tab_mut() {
2407 // Find and select the file by name
2408 if tab.select_by_name(&unique_name) {
2409 // Start rename with the suggested name pre-populated
2410 tab.start_rename_with_text(&unique_name);
2411 true
2412 } else {
2413 false
2414 }
2415 } else {
2416 false
2417 }
2418 } else {
2419 false
2420 };
2421
2422 if !found {
2423 self.status_bar.set_status_message(format!("Pasted as '{}' - press F2 to rename", unique_name));
2424 }
2425
2426 // Store remaining conflicts if not apply_to_all
2427 if !apply_to_all && pending.conflicts.len() > 1 {
2428 pending.conflicts.remove(0);
2429 pending.files.retain(|f| pending.conflicts.contains(f));
2430 self.pending_paste = Some(pending);
2431 self.status_bar.set_status_message("Rename file, then Ctrl+V to continue");
2432 } else if apply_to_all && pending.conflicts.len() > 1 {
2433 // Auto-rename remaining conflicts silently
2434 for conflict_file in pending.conflicts.iter().skip(1) {
2435 if let Some(name) = conflict_file.file_name() {
2436 let unique = make_unique_name(&pending.dest_dir, &name.to_string_lossy());
2437 let dest = pending.dest_dir.join(&unique);
2438 let _ = match pending.operation {
2439 ClipboardOperation::Copy => {
2440 if conflict_file.is_dir() {
2441 garfield::core::copy_path(conflict_file, &pending.dest_dir)
2442 } else {
2443 std::fs::copy(conflict_file, &dest).map(|_| dest)
2444 }
2445 }
2446 ClipboardOperation::Cut => {
2447 std::fs::rename(conflict_file, &dest).map(|_| dest)
2448 }
2449 };
2450 }
2451 }
2452 self.refresh();
2453 }
2454 return;
2455 }
2456 }
2457 }
2458
2459 // No conflicts or all handled
2460 if !destinations.is_empty() {
2461 let undo_op = match pending.operation {
2462 ClipboardOperation::Copy => FileOperation::Copy { sources, destinations },
2463 ClipboardOperation::Cut => FileOperation::Move { sources, destinations },
2464 };
2465 self.undo_stack.push(undo_op);
2466 }
2467
2468 self.refresh();
2469 }
2470
2471 /// Ring the terminal bell.
2472 fn bell(&self) {
2473 // X11 bell
2474 let _ = self.window.connection().inner().bell(0);
2475 let _ = self.window.connection().flush();
2476 }
2477
2478 /// Handle auto-enter when file drag flash completes.
2479 /// Enters the directory or switches to the tab, then continues dragging.
2480 fn handle_file_drag_auto_enter(&mut self, target: DragTarget) {
2481 match target {
2482 DragTarget::Directory { path, .. } => {
2483 // Navigate into the directory
2484 self.navigate_to(path);
2485 // Continue dragging in the new directory
2486 self.file_drag.continue_after_enter();
2487 }
2488 DragTarget::Tab { index, .. } => {
2489 // Switch to the target tab
2490 if let Some(pane) = self.focused_pane_mut() {
2491 pane.set_active_tab(index);
2492 }
2493 self.sync_tab_bar();
2494 self.sync_breadcrumb();
2495 self.update_status_bar();
2496 // Continue dragging in the new tab
2497 self.file_drag.continue_after_enter();
2498 }
2499 DragTarget::Breadcrumb { path, .. } => {
2500 // Navigate to the breadcrumb segment directory
2501 self.navigate_to(path);
2502 // Continue dragging in the new directory
2503 self.file_drag.continue_after_enter();
2504 }
2505 }
2506 }
2507
2508 /// Detect a valid drag target at the given position.
2509 /// Checks tabs first (for switching), then directories in the view.
2510 fn detect_file_drag_target(&self, pos: Point) -> Option<DragTarget> {
2511 // Get the paths being dragged to exclude them as targets
2512 let dragged_paths = self.file_drag.dragged_paths()
2513 .map(|paths| paths.clone())
2514 .unwrap_or_default();
2515
2516 // Check tab bar first - can drop on any tab except if it contains the dragged item
2517 if let Some(tab_index) = self.tab_bar.tab_at_point(pos) {
2518 // Get the target path for this tab
2519 if let Some(pane) = self.focused_pane() {
2520 let tabs = pane.tabs();
2521 if let Some(tab) = tabs.get(tab_index) {
2522 let target_path = tab.current_path().clone();
2523 // Don't allow dropping into the same directory where items came from
2524 if let Some(current_tab) = pane.active_tab() {
2525 if current_tab.current_path() != &target_path {
2526 return Some(DragTarget::Tab {
2527 index: tab_index,
2528 target_path,
2529 });
2530 }
2531 }
2532 }
2533 }
2534 }
2535
2536 // Check breadcrumb segments - can navigate to any parent directory
2537 if let Some((path, bounds)) = self.breadcrumb.segment_at_point(pos) {
2538 // Don't target current directory
2539 if let Some(pane) = self.focused_pane() {
2540 if let Some(tab) = pane.active_tab() {
2541 if tab.current_path() != &path {
2542 return Some(DragTarget::Breadcrumb { path, bounds });
2543 }
2544 }
2545 }
2546 }
2547
2548 // Check directory entries in the current view
2549 if let Some(pane) = self.focused_pane() {
2550 if let Some(tab) = pane.active_tab() {
2551 if let Some((entry, bounds)) = tab.entry_bounds_at_point(pos) {
2552 // Only directories are valid drop targets
2553 if entry.is_dir() {
2554 // Don't allow dropping on itself or a dragged item
2555 if !dragged_paths.iter().any(|p| p == &entry.path) {
2556 return Some(DragTarget::Directory {
2557 path: entry.path.clone(),
2558 bounds,
2559 });
2560 }
2561 }
2562 }
2563 }
2564 }
2565
2566 None
2567 }
2568
2569 /// Show the context menu at the given position.
2570 fn show_context_menu(&mut self, pos: Point) {
2571 // Check if we're in the Trash folder
2572 let in_trash = self.focused_pane()
2573 .and_then(|p| p.active_tab())
2574 .map(|t| {
2575 if let Some(trash_dir) = garfield::core::trash_dir() {
2576 t.current_path().starts_with(&trash_dir)
2577 } else {
2578 false
2579 }
2580 })
2581 .unwrap_or(false);
2582
2583 // First, check what's under the cursor and potentially select it
2584 let (context_type, selected_count) = if let Some(pane) = self.focused_pane_mut() {
2585 if let Some(tab) = pane.active_tab_mut() {
2586 if let Some(entry) = tab.entry_at_point(pos).cloned() {
2587 // Right-clicked on an item
2588 let selected = tab.selected_paths();
2589
2590 if selected.len() > 1 && selected.contains(&entry.path) {
2591 // Item is part of multi-selection, keep it
2592 (ContextType::MultiSelection, selected.len())
2593 } else {
2594 // Select this item (replaces current selection)
2595 tab.select_by_name(&entry.name);
2596
2597 if entry.is_dir() {
2598 (ContextType::Folder, 1)
2599 } else {
2600 (ContextType::File, 1)
2601 }
2602 }
2603 } else {
2604 // Clicked on empty space - clear selection
2605 tab.clear_selection();
2606 (ContextType::EmptySpace, 0)
2607 }
2608 } else {
2609 (ContextType::EmptySpace, 0)
2610 }
2611 } else {
2612 (ContextType::EmptySpace, 0)
2613 };
2614
2615 let has_clipboard = self.clipboard.has_files();
2616 self.context_menu.show(pos, context_type, selected_count, has_clipboard, in_trash);
2617 }
2618
2619 /// Handle a context menu action.
2620 fn handle_context_menu_action(&mut self, action: ContextMenuAction) {
2621 match action {
2622 ContextMenuAction::Open => self.enter_selected(),
2623 ContextMenuAction::OpenWith(app) => self.open_with(&app),
2624 ContextMenuAction::OpenInNewTab => self.open_in_new_tab(),
2625 ContextMenuAction::Copy | ContextMenuAction::CopyAll => self.copy_selected(),
2626 ContextMenuAction::Cut | ContextMenuAction::CutAll => self.cut_selected(),
2627 ContextMenuAction::Duplicate => self.duplicate_selected(),
2628 ContextMenuAction::Rename => self.start_rename(),
2629 ContextMenuAction::Trash | ContextMenuAction::TrashAll => self.trash_selected(),
2630 ContextMenuAction::Delete | ContextMenuAction::DeleteAll => self.delete_selected_permanently(),
2631 ContextMenuAction::Properties => self.show_properties(),
2632 ContextMenuAction::NewFile => self.create_new_file(),
2633 ContextMenuAction::NewFolder => self.create_new_folder(),
2634 ContextMenuAction::Paste => self.paste(),
2635 ContextMenuAction::Refresh => self.refresh(),
2636 ContextMenuAction::ViewList => self.set_view_mode(ViewMode::List),
2637 ContextMenuAction::ViewGrid => self.set_view_mode(ViewMode::Grid),
2638 ContextMenuAction::ViewColumns => self.set_view_mode(ViewMode::Columns),
2639 ContextMenuAction::SortByName => self.set_sort_order(garfield::core::SortOrder::Name),
2640 ContextMenuAction::SortBySize => self.set_sort_order(garfield::core::SortOrder::Size),
2641 ContextMenuAction::SortByDate => self.set_sort_order(garfield::core::SortOrder::Modified),
2642 ContextMenuAction::SortByType => self.set_sort_order(garfield::core::SortOrder::Type),
2643 }
2644 }
2645
2646 /// Open selected item with a specific application.
2647 fn open_with(&mut self, app: &str) {
2648 let paths = self.get_selected_paths();
2649 let Some(path) = paths.first().cloned() else {
2650 return;
2651 };
2652
2653 // Handle $CUSTOM - show app picker
2654 if app == "$CUSTOM" {
2655 self.pending_open_with_path = Some(path);
2656 self.app_picker.show();
2657 return;
2658 }
2659
2660 // Resolve special application identifiers
2661 let resolved_app = match app {
2662 "$EDITOR" => self.resolve_text_editor(),
2663 other => Some(other.to_string()),
2664 };
2665
2666 let Some(app_cmd) = resolved_app else {
2667 self.status_bar.set_status_message("No suitable application found");
2668 return;
2669 };
2670
2671 match std::process::Command::new(&app_cmd).arg(&path).spawn() {
2672 Ok(_) => {
2673 self.status_bar.set_status_message(format!("Opened with {}", app_cmd));
2674 // Add to recently used files
2675 let mime_type = guess_mime_type(&path);
2676 if let Err(e) = self.recents.add_entry(&path, &mime_type) {
2677 tracing::warn!("Failed to add to recents: {}", e);
2678 }
2679 }
2680 Err(e) => self.status_bar.set_status_message(format!("Failed to open with {}: {}", app_cmd, e)),
2681 }
2682 }
2683
2684 /// Open file with the system's default application (xdg-open).
2685 fn open_file_with_default(&mut self, path: &PathBuf) {
2686 match std::process::Command::new("xdg-open").arg(path).spawn() {
2687 Ok(_) => {
2688 self.status_bar.set_status_message(format!("Opened {}", path.file_name().unwrap_or_default().to_string_lossy()));
2689 // Add to recently used files
2690 let mime_type = guess_mime_type(path);
2691 if let Err(e) = self.recents.add_entry(path, &mime_type) {
2692 tracing::warn!("Failed to add to recents: {}", e);
2693 }
2694 }
2695 Err(e) => self.status_bar.set_status_message(format!("Failed to open: {}", e)),
2696 }
2697 }
2698
2699 /// Open file with custom application (from input dialog).
2700 fn open_with_custom(&mut self, app_name: &str) {
2701 let Some(path) = self.pending_open_with_path.take() else {
2702 return;
2703 };
2704
2705 if app_name.is_empty() {
2706 self.status_bar.set_status_message("No application specified");
2707 return;
2708 }
2709
2710 match std::process::Command::new(app_name).arg(&path).spawn() {
2711 Ok(_) => {
2712 self.status_bar.set_status_message(format!("Opened with {}", app_name));
2713 // Add to recently used files
2714 let mime_type = guess_mime_type(&path);
2715 if let Err(e) = self.recents.add_entry(&path, &mime_type) {
2716 tracing::warn!("Failed to add to recents: {}", e);
2717 }
2718 }
2719 Err(e) => self.status_bar.set_status_message(format!("Failed to open with {}: {}", app_name, e)),
2720 }
2721 }
2722
2723 /// Resolve text editor from environment or common editors.
2724 fn resolve_text_editor(&self) -> Option<String> {
2725 // Check environment variables first
2726 if let Ok(editor) = std::env::var("VISUAL") {
2727 if !editor.is_empty() && self.command_exists(&editor) {
2728 return Some(editor);
2729 }
2730 }
2731 if let Ok(editor) = std::env::var("EDITOR") {
2732 if !editor.is_empty() && self.command_exists(&editor) {
2733 return Some(editor);
2734 }
2735 }
2736
2737 // Try common GUI text editors
2738 let editors = ["gedit", "kate", "mousepad", "xed", "pluma", "leafpad", "featherpad", "geany", "xfce4-terminal"];
2739 for editor in editors {
2740 if self.command_exists(editor) {
2741 return Some(editor.to_string());
2742 }
2743 }
2744
2745 // Fallback to xdg-open
2746 Some("xdg-open".to_string())
2747 }
2748
2749 /// Check if a command exists in PATH.
2750 fn command_exists(&self, cmd: &str) -> bool {
2751 // Extract just the command name (in case it's a full path or has args)
2752 let cmd_name = cmd.split_whitespace().next().unwrap_or(cmd);
2753 std::process::Command::new("which")
2754 .arg(cmd_name)
2755 .stdout(std::process::Stdio::null())
2756 .stderr(std::process::Stdio::null())
2757 .status()
2758 .map(|s| s.success())
2759 .unwrap_or(false)
2760 }
2761
2762 /// Open folder in new tab.
2763 fn open_in_new_tab(&mut self) {
2764 let entry_path = self.focused_pane()
2765 .and_then(|p| p.active_tab())
2766 .and_then(|t| t.selected_entry())
2767 .filter(|e| e.is_dir())
2768 .map(|e| e.path.clone());
2769
2770 if let Some(path) = entry_path {
2771 if let Some(pane) = self.focused_pane_mut() {
2772 pane.add_tab(path);
2773 }
2774 self.sync_tab_bar();
2775 self.sync_breadcrumb();
2776 self.update_status_bar();
2777 }
2778 }
2779
2780 /// Show properties dialog (placeholder).
2781 fn show_properties(&mut self) {
2782 self.status_bar.set_status_message("Properties dialog not implemented");
2783 }
2784
2785 /// Set sort order from context menu.
2786 fn set_sort_order(&mut self, order: garfield::core::SortOrder) {
2787 if let Some(pane) = self.focused_pane_mut() {
2788 if let Some(tab) = pane.active_tab_mut() {
2789 let current_dir = tab.sort_direction();
2790 // Toggle direction if same order
2791 let new_dir = if tab.sort_order() == order {
2792 match current_dir {
2793 garfield::core::SortDirection::Ascending => garfield::core::SortDirection::Descending,
2794 garfield::core::SortDirection::Descending => garfield::core::SortDirection::Ascending,
2795 }
2796 } else {
2797 garfield::core::SortDirection::Ascending
2798 };
2799 tab.set_sort(order, new_dir);
2800 }
2801 }
2802 }
2803
2804 /// Create a new folder in the current directory.
2805 fn create_new_folder(&mut self) {
2806 let current_dir = self.focused_pane()
2807 .and_then(|p| p.active_tab())
2808 .map(|t| t.current_path().clone());
2809
2810 let current_dir = match current_dir {
2811 Some(d) => d,
2812 None => return,
2813 };
2814
2815 // Generate unique name
2816 let base_name = "New Folder";
2817 let mut name = base_name.to_string();
2818 let mut counter = 1;
2819 while current_dir.join(&name).exists() {
2820 name = format!("{} ({})", base_name, counter);
2821 counter += 1;
2822 }
2823
2824 match create_directory(&current_dir, &name) {
2825 Ok(path) => {
2826 self.status_bar.set_status_message(format!("Created '{}'", name));
2827 self.undo_stack.push(FileOperation::CreateDir { path });
2828 self.refresh();
2829 // TODO: Start rename on the new folder
2830 }
2831 Err(e) => {
2832 self.status_bar.set_status_message(format!("Failed to create folder: {}", e));
2833 }
2834 }
2835 }
2836
2837 /// Create a new empty file in the current directory.
2838 fn create_new_file(&mut self) {
2839 use garfield::core::make_unique_name;
2840
2841 let current_dir = self.focused_pane()
2842 .and_then(|p| p.active_tab())
2843 .map(|t| t.current_path().clone());
2844
2845 let current_dir = match current_dir {
2846 Some(d) => d,
2847 None => return,
2848 };
2849
2850 // Generate unique name
2851 let unique_name = make_unique_name(&current_dir, "New File");
2852 let path = current_dir.join(&unique_name);
2853
2854 match std::fs::File::create(&path) {
2855 Ok(_) => {
2856 self.status_bar.set_status_message(format!("Created '{}'", unique_name));
2857 self.refresh();
2858
2859 // Select the new file and start rename
2860 if let Some(pane) = self.focused_pane_mut() {
2861 if let Some(tab) = pane.active_tab_mut() {
2862 if tab.select_by_name(&unique_name) {
2863 tab.start_rename();
2864 }
2865 }
2866 }
2867 }
2868 Err(e) => {
2869 self.status_bar.set_status_message(format!("Failed to create file: {}", e));
2870 }
2871 }
2872 }
2873
2874 /// Duplicate the selected files/folders in the current directory.
2875 fn duplicate_selected(&mut self) {
2876 use garfield::core::make_unique_name;
2877
2878 let selected = self.get_selected_paths();
2879 if selected.is_empty() {
2880 self.status_bar.set_status_message("No items selected");
2881 return;
2882 }
2883
2884 let dest_dir = self.focused_pane()
2885 .and_then(|p| p.active_tab())
2886 .map(|t| t.current_path().clone());
2887
2888 let dest_dir = match dest_dir {
2889 Some(d) => d,
2890 None => return,
2891 };
2892
2893 let mut success_count = 0;
2894 let mut last_created_name = String::new();
2895
2896 for path in &selected {
2897 if let Some(name) = path.file_name() {
2898 let name_str = name.to_string_lossy();
2899 let unique_name = make_unique_name(&dest_dir, &name_str);
2900 let dest = dest_dir.join(&unique_name);
2901
2902 let result = if path.is_dir() {
2903 garfield::core::copy_to_path(path, &dest)
2904 } else {
2905 std::fs::copy(path, &dest).map(|_| dest.clone())
2906 };
2907
2908 if result.is_ok() {
2909 success_count += 1;
2910 last_created_name = unique_name;
2911 }
2912 }
2913 }
2914
2915 if success_count > 0 {
2916 let msg = if success_count == 1 {
2917 format!("Duplicated as '{}'", last_created_name)
2918 } else {
2919 format!("Duplicated {} items", success_count)
2920 };
2921 self.status_bar.set_status_message(msg);
2922 self.refresh();
2923
2924 // Select the last duplicated item
2925 if !last_created_name.is_empty() {
2926 if let Some(pane) = self.focused_pane_mut() {
2927 if let Some(tab) = pane.active_tab_mut() {
2928 tab.select_by_name(&last_created_name);
2929 }
2930 }
2931 }
2932 } else {
2933 self.status_bar.set_status_message("Failed to duplicate items");
2934 }
2935 }
2936
2937 /// Undo the last file operation.
2938 fn undo(&mut self) {
2939 let op = match self.undo_stack.pop_undo() {
2940 Some(op) => op,
2941 None => {
2942 self.status_bar.set_status_message("Nothing to undo");
2943 return;
2944 }
2945 };
2946
2947 let result = self.perform_undo(&op);
2948 match result {
2949 Ok(msg) => {
2950 self.status_bar.set_status_message(format!("Undo: {}", msg));
2951 self.undo_stack.push_redo(op);
2952 }
2953 Err(msg) => {
2954 self.status_bar.set_status_message(format!("Undo failed: {}", msg));
2955 }
2956 }
2957 self.refresh();
2958 }
2959
2960 /// Perform the undo operation.
2961 fn perform_undo(&mut self, op: &FileOperation) -> Result<String, String> {
2962 use garfield::core::{delete_path, move_path, rename_path};
2963
2964 match op {
2965 FileOperation::Copy { destinations, .. } => {
2966 // Undo copy: delete the copied files
2967 for dest in destinations {
2968 if dest.exists() {
2969 delete_path(dest).map_err(|e| e.to_string())?;
2970 }
2971 }
2972 Ok(format!("Deleted {} copied item(s)", destinations.len()))
2973 }
2974 FileOperation::Move { sources, destinations } => {
2975 // Undo move: move files back to original locations
2976 for (src, dest) in sources.iter().zip(destinations.iter()) {
2977 if dest.exists() {
2978 if let Some(parent) = src.parent() {
2979 move_path(dest, parent).map_err(|e| e.to_string())?;
2980 }
2981 }
2982 }
2983 Ok(format!("Moved {} item(s) back", sources.len()))
2984 }
2985 FileOperation::Trash { trash_names, .. } => {
2986 // Undo trash: restore from trash
2987 for name in trash_names {
2988 restore_from_trash(name).map_err(|e| e.to_string())?;
2989 }
2990 Ok(format!("Restored {} item(s) from trash", trash_names.len()))
2991 }
2992 FileOperation::Rename { original, renamed } => {
2993 // Undo rename: rename back to original
2994 if renamed.exists() {
2995 if let Some(orig_name) = original.file_name() {
2996 rename_path(renamed, orig_name.to_string_lossy().as_ref())
2997 .map_err(|e| e.to_string())?;
2998 }
2999 }
3000 Ok("Renamed back".to_string())
3001 }
3002 FileOperation::CreateDir { path } => {
3003 // Undo create dir: delete the directory (only if empty)
3004 if path.exists() && path.is_dir() {
3005 std::fs::remove_dir(path).map_err(|e| e.to_string())?;
3006 }
3007 Ok("Deleted folder".to_string())
3008 }
3009 }
3010 }
3011
3012 /// Redo the last undone operation.
3013 fn redo(&mut self) {
3014 let op = match self.undo_stack.pop_redo() {
3015 Some(op) => op,
3016 None => {
3017 self.status_bar.set_status_message("Nothing to redo");
3018 return;
3019 }
3020 };
3021
3022 let result = self.perform_redo(&op);
3023 match result {
3024 Ok(msg) => {
3025 self.status_bar.set_status_message(format!("Redo: {}", msg));
3026 self.undo_stack.push(op);
3027 }
3028 Err(msg) => {
3029 self.status_bar.set_status_message(format!("Redo failed: {}", msg));
3030 }
3031 }
3032 self.refresh();
3033 }
3034
3035 /// Perform the redo operation.
3036 fn perform_redo(&mut self, op: &FileOperation) -> Result<String, String> {
3037 use garfield::core::{copy_path, move_path, rename_path};
3038
3039 match op {
3040 FileOperation::Copy { sources, destinations } => {
3041 // Redo copy: copy files again
3042 for (src, dest) in sources.iter().zip(destinations.iter()) {
3043 if src.exists() {
3044 if let Some(parent) = dest.parent() {
3045 copy_path(src, parent).map_err(|e| e.to_string())?;
3046 }
3047 }
3048 }
3049 Ok(format!("Copied {} item(s)", sources.len()))
3050 }
3051 FileOperation::Move { sources, destinations } => {
3052 // Redo move: move files again
3053 for (src, dest) in sources.iter().zip(destinations.iter()) {
3054 if src.exists() {
3055 if let Some(parent) = dest.parent() {
3056 move_path(src, parent).map_err(|e| e.to_string())?;
3057 }
3058 }
3059 }
3060 Ok(format!("Moved {} item(s)", sources.len()))
3061 }
3062 FileOperation::Trash { originals, .. } => {
3063 // Redo trash: trash the files again
3064 let results = trash_files(originals);
3065 let success = results.iter().filter(|r| r.is_ok()).count();
3066 Ok(format!("Trashed {} item(s)", success))
3067 }
3068 FileOperation::Rename { original, renamed } => {
3069 // Redo rename: rename again
3070 if original.exists() {
3071 if let Some(new_name) = renamed.file_name() {
3072 rename_path(original, new_name.to_string_lossy().as_ref())
3073 .map_err(|e| e.to_string())?;
3074 }
3075 }
3076 Ok("Renamed".to_string())
3077 }
3078 FileOperation::CreateDir { path } => {
3079 // Redo create dir: create the directory again
3080 std::fs::create_dir(path).map_err(|e| e.to_string())?;
3081 Ok("Created folder".to_string())
3082 }
3083 }
3084 }
3085
3086 /// Start inline rename for the selected file.
3087 fn start_rename(&mut self) {
3088 if let Some(pane) = self.focused_pane_mut() {
3089 if let Some(tab) = pane.active_tab_mut() {
3090 tab.start_rename();
3091 }
3092 }
3093 }
3094
3095 /// Check if rename is in progress.
3096 fn is_renaming(&self) -> bool {
3097 self.focused_pane()
3098 .and_then(|p| p.active_tab())
3099 .map_or(false, |t| t.is_renaming())
3100 }
3101
3102 /// Cancel rename operation.
3103 fn cancel_rename(&mut self) {
3104 if let Some(pane) = self.focused_pane_mut() {
3105 if let Some(tab) = pane.active_tab_mut() {
3106 tab.cancel_rename();
3107 }
3108 }
3109 }
3110
3111 /// Confirm rename operation.
3112 fn confirm_rename(&mut self) {
3113 let result = self.focused_pane_mut()
3114 .and_then(|p| p.active_tab_mut())
3115 .map(|t| t.confirm_rename());
3116
3117 match result {
3118 Some(Ok((original, renamed, new_name))) => {
3119 // Only record undo if the name actually changed
3120 if original != renamed {
3121 self.undo_stack.push(FileOperation::Rename {
3122 original,
3123 renamed,
3124 });
3125 }
3126 self.status_bar.set_status_message(format!("Renamed to '{}'", new_name));
3127 // Update status bar with new entry count
3128 self.update_status_bar();
3129 }
3130 Some(Err(msg)) => {
3131 self.status_bar.set_status_message(format!("Rename failed: {}", msg));
3132 }
3133 None => {}
3134 }
3135 }
3136
3137 /// Get paths of all selected files.
3138 fn get_selected_paths(&self) -> Vec<PathBuf> {
3139 self.focused_pane()
3140 .and_then(|p| p.active_tab())
3141 .map(|t| t.selected_paths())
3142 .unwrap_or_default()
3143 }
3144
3145 /// Set the view mode for the active tab.
3146 fn set_view_mode(&mut self, mode: ViewMode) {
3147 if let Some(pane) = self.focused_pane_mut() {
3148 if let Some(tab) = pane.active_tab_mut() {
3149 tab.set_view_mode(mode);
3150 }
3151 }
3152 self.status_bar.set_view_mode(mode.name());
3153 self.sync_toolbar_view();
3154 self.sync_toolbar_icon_size();
3155 self.sync_breadcrumb();
3156 self.update_status_bar();
3157 }
3158
3159 /// Handle a toolbar action.
3160 fn handle_toolbar_action(&mut self, action: ToolbarAction) {
3161 match action {
3162 ToolbarAction::ViewList => self.set_view_mode(ViewMode::List),
3163 ToolbarAction::ViewGrid => self.set_view_mode(ViewMode::Grid),
3164 ToolbarAction::ViewColumns => self.set_view_mode(ViewMode::Columns),
3165 ToolbarAction::NewTab => self.new_tab(),
3166 ToolbarAction::SplitHorizontal => self.split_horizontal(),
3167 ToolbarAction::SplitVertical => self.split_vertical(),
3168 ToolbarAction::GoBack => self.go_back(),
3169 ToolbarAction::GoForward => self.go_forward(),
3170 ToolbarAction::GoUp => self.go_up(),
3171 ToolbarAction::Help => self.help_modal.toggle(),
3172 ToolbarAction::Copy => self.copy_selected(),
3173 ToolbarAction::Cut => self.cut_selected(),
3174 ToolbarAction::Paste => self.paste(),
3175 ToolbarAction::Trash => self.trash_selected(),
3176 ToolbarAction::NewFolder => self.create_new_folder(),
3177 ToolbarAction::IconSizeSmall => self.set_icon_size(IconSize::Small),
3178 ToolbarAction::IconSizeMedium => self.set_icon_size(IconSize::Medium),
3179 ToolbarAction::IconSizeLarge => self.set_icon_size(IconSize::Large),
3180 }
3181 }
3182
3183 /// Set icon size for the active tab's grid view.
3184 fn set_icon_size(&mut self, size: IconSize) {
3185 if let Some(pane) = self.focused_pane_mut() {
3186 if let Some(tab) = pane.active_tab_mut() {
3187 tab.set_icon_size(size);
3188 }
3189 }
3190 self.sync_toolbar_icon_size();
3191 }
3192
3193 /// Sync toolbar active view with current tab's view mode.
3194 fn sync_toolbar_view(&mut self) {
3195 if let Some(pane) = self.focused_pane() {
3196 if let Some(tab) = pane.active_tab() {
3197 let action = match tab.view_mode() {
3198 ViewMode::List => ToolbarAction::ViewList,
3199 ViewMode::Grid => ToolbarAction::ViewGrid,
3200 ViewMode::Columns => ToolbarAction::ViewColumns,
3201 };
3202 self.toolbar.set_active_view(action);
3203 }
3204 }
3205 }
3206
3207 /// Sync toolbar icon size with current tab's grid view icon size.
3208 fn sync_toolbar_icon_size(&mut self) {
3209 if let Some(pane) = self.focused_pane() {
3210 if let Some(tab) = pane.active_tab() {
3211 let action = match tab.icon_size() {
3212 IconSize::Small => ToolbarAction::IconSizeSmall,
3213 IconSize::Medium => ToolbarAction::IconSizeMedium,
3214 IconSize::Large => ToolbarAction::IconSizeLarge,
3215 };
3216 self.toolbar.set_active_icon_size(action);
3217 }
3218 }
3219 }
3220
3221 // === Sync helpers ===
3222
3223 /// Sync tab bar with focused pane's tabs.
3224 fn sync_tab_bar(&mut self) {
3225 if let Some(pane) = self.focused_pane() {
3226 let tabs: Vec<TabInfo> = pane
3227 .tabs()
3228 .iter()
3229 .enumerate()
3230 .map(|(i, t)| TabInfo {
3231 title: t.title(),
3232 active: i == pane.active_tab_index(),
3233 })
3234 .collect();
3235 self.tab_bar.set_tabs(tabs, pane.active_tab_index());
3236 }
3237 }
3238
3239 /// Sync breadcrumb with active tab's path.
3240 fn sync_breadcrumb(&mut self) {
3241 // Check if we're showing recents
3242 let showing_recents = self.focused_pane()
3243 .and_then(|pane| pane.active_tab())
3244 .is_some_and(|tab| tab.is_showing_recents());
3245
3246 if showing_recents {
3247 self.breadcrumb.set_recents();
3248 } else {
3249 let path = self.focused_pane()
3250 .and_then(|pane| pane.active_tab())
3251 .map(|tab| tab.current_path().clone());
3252
3253 if let Some(path) = path {
3254 self.breadcrumb.set_path(&path);
3255 }
3256 }
3257 }
3258
3259 /// Process pending preview requests from column views.
3260 fn process_pending_previews(&mut self) {
3261 // Check focused pane's active tab for pending preview
3262 if let Some(pane) = self.focused_pane_mut() {
3263 if let Some(tab) = pane.active_tab_mut() {
3264 if let Some((path, sort_order, sort_direction)) = tab.take_pending_preview() {
3265 self.preview_loader.load(path, sort_order, sort_direction);
3266 }
3267 }
3268 }
3269 }
3270
3271 /// Process pending image preview requests.
3272 fn process_pending_image_previews(&mut self) {
3273 if let Some(pane) = self.focused_pane_mut() {
3274 if let Some(tab) = pane.active_tab_mut() {
3275 if let Some((path, max_width, max_height)) = tab.take_pending_image_preview() {
3276 self.image_preview_loader.load(path, max_width, max_height);
3277 }
3278 }
3279 }
3280 }
3281
3282 /// Process pending PDF preview requests.
3283 fn process_pending_pdf_previews(&mut self) {
3284 if let Some(pane) = self.focused_pane_mut() {
3285 if let Some(tab) = pane.active_tab_mut() {
3286 if let Some((path, max_width, max_height)) = tab.take_pending_pdf_preview() {
3287 self.pdf_preview_loader.load(path, max_width, max_height);
3288 }
3289 }
3290 }
3291 }
3292
3293 /// Update status bar.
3294 fn update_status_bar(&mut self) {
3295 let stats = self.focused_pane()
3296 .and_then(|pane| pane.active_tab())
3297 .map(|tab| (tab.visible_count(), tab.selection_count(), tab.selected_size(), tab.view_mode().name(), tab.current_path().to_path_buf()));
3298
3299 if let Some((visible_count, selected_count, selected_size, view_mode, path)) = stats {
3300 self.status_bar.update(visible_count, selected_count, selected_size);
3301 self.status_bar.set_view_mode(view_mode);
3302 self.status_bar.update_free_space(&path);
3303
3304 // Update toolbar file ops state
3305 let has_selection = selected_count > 0;
3306 let has_clipboard = self.clipboard.has_files();
3307 self.toolbar.set_file_ops_state(has_selection, has_clipboard);
3308 }
3309 }
3310
3311 /// Update layout.
3312 fn update_layout(&mut self, width: u32, height: u32) {
3313 // Preserve current sidebar width (or use default if sidebar is hidden)
3314 let current_sidebar_width = if self.sidebar.is_visible() {
3315 self.sidebar.bounds().width
3316 } else {
3317 SIDEBAR_WIDTH
3318 };
3319 let sidebar_w = if self.sidebar.is_visible() { current_sidebar_width } else { 0 };
3320 let header_height = TAB_BAR_HEIGHT + TOOLBAR_HEIGHT + BREADCRUMB_HEIGHT;
3321
3322 self.sidebar.set_bounds(Rect::new(0, 0, current_sidebar_width, height));
3323
3324 self.tab_bar.set_bounds(Rect::new(
3325 sidebar_w as i32,
3326 0,
3327 width - sidebar_w,
3328 TAB_BAR_HEIGHT,
3329 ));
3330
3331 let toolbar_bounds = Rect::new(
3332 sidebar_w as i32,
3333 TAB_BAR_HEIGHT as i32,
3334 width - sidebar_w,
3335 TOOLBAR_HEIGHT,
3336 );
3337 self.toolbar.set_bounds(toolbar_bounds);
3338
3339 let breadcrumb_bounds = Rect::new(
3340 sidebar_w as i32,
3341 (TAB_BAR_HEIGHT + TOOLBAR_HEIGHT) as i32,
3342 width - sidebar_w,
3343 BREADCRUMB_HEIGHT,
3344 );
3345 self.breadcrumb.set_bounds(breadcrumb_bounds);
3346 self.address_bar.set_bounds(breadcrumb_bounds);
3347
3348 // Calculate footer height (status bar + picker toolbar if present)
3349 let footer_height = if self.picker_toolbar.is_some() {
3350 PICKER_TOOLBAR_HEIGHT // Picker toolbar replaces status bar
3351 } else {
3352 STATUS_BAR_HEIGHT
3353 };
3354
3355 let content_bounds = Rect::new(
3356 sidebar_w as i32,
3357 header_height as i32,
3358 width - sidebar_w,
3359 height - header_height - footer_height,
3360 );
3361 self.root_pane.set_bounds(content_bounds);
3362
3363 // Update picker toolbar bounds at bottom (if in picker mode)
3364 if let Some(ref mut picker_toolbar) = self.picker_toolbar {
3365 picker_toolbar.set_bounds(Rect::new(
3366 sidebar_w as i32,
3367 (height - PICKER_TOOLBAR_HEIGHT) as i32,
3368 width - sidebar_w,
3369 PICKER_TOOLBAR_HEIGHT,
3370 ));
3371 } else {
3372 // Only show status bar when not in picker mode
3373 self.status_bar.set_bounds(Rect::new(
3374 sidebar_w as i32,
3375 (height - STATUS_BAR_HEIGHT) as i32,
3376 width - sidebar_w,
3377 STATUS_BAR_HEIGHT,
3378 ));
3379 }
3380
3381 self.help_modal.set_bounds(Rect::new(0, 0, width, height));
3382 self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height));
3383 self.conflict_dialog.set_bounds(Rect::new(0, 0, width, height));
3384 self.progress_dialog.set_bounds(Rect::new(0, 0, width, height));
3385 self.context_menu.set_bounds(Rect::new(0, 0, width, height));
3386 self.input_dialog.set_bounds(Rect::new(0, 0, width, height));
3387 self.app_picker.set_bounds(Rect::new(0, 0, width, height));
3388 }
3389
3390 /// Render the application.
3391 fn render(&mut self) -> Result<()> {
3392 let theme = self.renderer.theme().clone();
3393 let size = self.renderer.size();
3394 let sidebar_w = self.sidebar.width();
3395 let header_height = TAB_BAR_HEIGHT + TOOLBAR_HEIGHT + BREADCRUMB_HEIGHT;
3396
3397 tracing::trace!("render: size={}x{}, sidebar_w={}, root_pane_bounds={:?}",
3398 size.width, size.height, sidebar_w, self.root_pane.bounds());
3399
3400 // Clear background
3401 self.renderer.clear()?;
3402
3403 // Draw sidebar
3404 self.sidebar.render(&self.renderer)?;
3405
3406 // Draw tab bar
3407 self.tab_bar.render(&self.renderer)?;
3408
3409 // Update and draw toolbar (or picker toolbar)
3410 let (can_back, can_forward) = if let Some(pane) = self.focused_pane() {
3411 if let Some(tab) = pane.active_tab() {
3412 (tab.can_go_back(), tab.can_go_forward())
3413 } else {
3414 (false, false)
3415 }
3416 } else {
3417 (false, false)
3418 };
3419
3420 // Always render the regular toolbar
3421 self.toolbar.set_nav_state(can_back, can_forward);
3422 self.toolbar.render(&self.renderer)?;
3423
3424 // Draw breadcrumb or address bar
3425 if self.address_bar.is_active() {
3426 self.address_bar.render(&self.renderer)?;
3427 } else {
3428 self.breadcrumb.render(&self.renderer, can_back, can_forward)?;
3429 }
3430
3431 // Draw separator line under breadcrumb
3432 self.renderer.line(
3433 sidebar_w as f64,
3434 header_height as f64,
3435 size.width as f64,
3436 header_height as f64,
3437 theme.border,
3438 1.0,
3439 )?;
3440
3441 // Draw pane content
3442 self.root_pane.render(&self.renderer, Some(self.focused_pane_id))?;
3443
3444 // Draw status bar or picker toolbar at bottom
3445 if self.picker_toolbar.is_some() {
3446 // Picker mode: draw picker toolbar instead of status bar
3447 let has_valid_selection = self.has_valid_picker_selection();
3448 let picker_toolbar = self.picker_toolbar.as_mut().unwrap();
3449 picker_toolbar.set_accept_enabled(has_valid_selection);
3450 picker_toolbar.render(&self.renderer)?;
3451 } else {
3452 // Normal mode: draw status bar
3453 self.status_bar.render(&self.renderer)?;
3454 }
3455
3456 // Draw toolbar tooltip overlay (on top of other UI)
3457 self.toolbar.render_tooltip_overlay(&self.renderer)?;
3458
3459 // Draw drag label overlay (on top of other UI)
3460 self.render_drag_label()?;
3461
3462 // Draw file drag overlay (drag label and target highlight)
3463 self.render_file_drag()?;
3464
3465 // Draw help modal overlay (on top of everything)
3466 self.help_modal.render(&self.renderer)?;
3467
3468 // Draw confirm dialog overlay (on top of everything)
3469 self.confirm_dialog.render(&self.renderer)?;
3470
3471 // Draw conflict dialog overlay (on top of everything)
3472 self.conflict_dialog.render(&self.renderer)?;
3473
3474 // Draw input dialog overlay (on top of everything)
3475 self.input_dialog.render(&self.renderer)?;
3476
3477 // Draw app picker overlay (on top of everything)
3478 self.app_picker.render(&self.renderer)?;
3479
3480 // Draw context menu overlay
3481 self.context_menu.render(&self.renderer)?;
3482
3483 // Draw progress dialog overlay (on top of everything)
3484 self.progress_dialog.render(&self.renderer)?;
3485
3486 // Flush and copy to window
3487 self.renderer.flush();
3488 self.blit_surface()?;
3489
3490 Ok(())
3491 }
3492
3493 /// Render the drag label overlay when dragging a folder.
3494 fn render_drag_label(&self) -> Result<()> {
3495 // Only render if drag is active and we have a label
3496 if !self.drag_active {
3497 return Ok(());
3498 }
3499
3500 let (label, pos) = match (&self.drag_label, self.drag_current_pos) {
3501 (Some(label), Some(pos)) => (label, pos),
3502 _ => return Ok(()),
3503 };
3504
3505 let theme = self.renderer.theme();
3506
3507 // Create text style for the drag label
3508 let text_style = TextStyle::new()
3509 .font_family(&theme.font_family)
3510 .font_size(theme.font_size)
3511 .color(theme.item_foreground);
3512
3513 // Measure the text to size the background
3514 let text_size = self.renderer.measure_text(label, &text_style)?;
3515
3516 // Position the label slightly offset from the cursor
3517 let label_x = pos.x + 16;
3518 let label_y = pos.y + 8;
3519 let padding = 8;
3520
3521 // Draw background with rounded appearance
3522 let bg_rect = Rect::new(
3523 label_x - padding,
3524 label_y - padding / 2,
3525 text_size.width + (padding * 2) as u32,
3526 text_size.height + padding as u32,
3527 );
3528
3529 // Semi-transparent dark background
3530 let bg_color = gartk_core::Color::from_u8(40, 40, 45, 230);
3531 self.renderer.fill_rect(bg_rect, bg_color)?;
3532
3533 // Border
3534 let border_color = theme.selection_background.with_alpha(0.8);
3535 self.renderer.stroke_rect(bg_rect, border_color, 1.0)?;
3536
3537 // Folder icon prefix
3538 let icon_style = text_style.clone().color(theme.selection_background);
3539 self.renderer.text("*", label_x as f64, label_y as f64, &icon_style)?;
3540
3541 // Draw the text
3542 self.renderer.text(label, (label_x + 14) as f64, label_y as f64, &text_style)?;
3543
3544 Ok(())
3545 }
3546
3547 /// Render file drag overlay (drag label and target highlight).
3548 fn render_file_drag(&self) -> Result<()> {
3549 // Only render if file drag is actively dragging
3550 if !self.file_drag.is_dragging() {
3551 return Ok(());
3552 }
3553
3554 let theme = self.renderer.theme();
3555
3556 // Render target highlight if hovering and highlight should be shown
3557 if self.file_drag.should_show_highlight() {
3558 if let Some(target) = self.file_drag.current_target() {
3559 match target {
3560 DragTarget::Directory { bounds, .. } => {
3561 // Draw highlight rectangle around the directory
3562 let highlight_color = theme.selection_background.with_alpha(0.3);
3563 self.renderer.fill_rect(*bounds, highlight_color)?;
3564 self.renderer.stroke_rect(*bounds, theme.selection_background, 2.0)?;
3565 }
3566 DragTarget::Tab { index, .. } => {
3567 // Draw highlight under the tab
3568 if let Some(tab_bounds) = self.tab_bar.tab_bounds_at(*index) {
3569 let highlight_color = theme.selection_background.with_alpha(0.3);
3570 self.renderer.fill_rect(tab_bounds, highlight_color)?;
3571 self.renderer.stroke_rect(tab_bounds, theme.selection_background, 2.0)?;
3572 }
3573 }
3574 DragTarget::Breadcrumb { bounds, .. } => {
3575 // Draw highlight around the breadcrumb segment
3576 let highlight_color = theme.selection_background.with_alpha(0.3);
3577 self.renderer.fill_rect(*bounds, highlight_color)?;
3578 self.renderer.stroke_rect(*bounds, theme.selection_background, 2.0)?;
3579 }
3580 }
3581 }
3582 }
3583
3584 // Render drag label at cursor
3585 if let (Some(paths), Some(pos)) = (self.file_drag.dragged_paths(), self.file_drag.current_pos()) {
3586 let count = paths.len();
3587 let label = if count == 1 {
3588 paths.first()
3589 .and_then(|p| p.file_name())
3590 .map(|n| n.to_string_lossy().to_string())
3591 .unwrap_or_else(|| "1 item".to_string())
3592 } else {
3593 format!("{} items", count)
3594 };
3595
3596 // Create text style for the drag label
3597 let text_style = TextStyle::new()
3598 .font_family(&theme.font_family)
3599 .font_size(theme.font_size)
3600 .color(theme.item_foreground);
3601
3602 // Measure the text to size the background
3603 let text_size = self.renderer.measure_text(&label, &text_style)?;
3604
3605 // Position the label slightly offset from the cursor
3606 let label_x = pos.x + 16;
3607 let label_y = pos.y + 8;
3608 let padding = 8;
3609
3610 // Draw background
3611 let bg_rect = Rect::new(
3612 label_x - padding,
3613 label_y - padding / 2,
3614 text_size.width + (padding * 2) as u32,
3615 text_size.height + padding as u32,
3616 );
3617
3618 // Semi-transparent dark background
3619 let bg_color = gartk_core::Color::from_u8(40, 40, 45, 230);
3620 self.renderer.fill_rect(bg_rect, bg_color)?;
3621
3622 // Border
3623 let border_color = theme.selection_background.with_alpha(0.8);
3624 self.renderer.stroke_rect(bg_rect, border_color, 1.0)?;
3625
3626 // File icon prefix
3627 let icon_style = text_style.clone().color(theme.selection_background);
3628 self.renderer.text("≡", label_x as f64, label_y as f64, &icon_style)?;
3629
3630 // Draw the text
3631 self.renderer.text(&label, (label_x + 14) as f64, label_y as f64, &text_style)?;
3632 }
3633
3634 Ok(())
3635 }
3636
3637 /// Blit the rendered surface to the window.
3638 fn blit_surface(&mut self) -> Result<()> {
3639 let size = self.renderer.size();
3640 let window_size = self.window.size();
3641 let stride = self.renderer.surface().stride() as usize;
3642 let row_bytes = size.width as usize * 4; // 4 bytes per pixel (ARGB)
3643
3644 // Verify surface matches window size
3645 if size.width != window_size.width || size.height != window_size.height {
3646 tracing::warn!("Surface size {}x{} doesn't match window size {}x{}, skipping blit",
3647 size.width, size.height, window_size.width, window_size.height);
3648 return Ok(());
3649 }
3650
3651 let window_id = self.window.id();
3652 let depth = self.window.depth();
3653 let gc = self.gc;
3654 let conn = self.window.connection().clone();
3655
3656 // Get maximum request size (leave some headroom for request headers)
3657 let max_request_bytes = conn.maximum_request_bytes().saturating_sub(1024);
3658 let total_bytes = row_bytes * size.height as usize;
3659
3660 // Check if we need to chunk the image
3661 if total_bytes <= max_request_bytes {
3662 // Image fits in a single request
3663 tracing::trace!("blit_surface: {}x{} ({} bytes) in single request",
3664 size.width, size.height, total_bytes);
3665
3666 self.renderer.surface_mut().with_data(|data| {
3667 if stride == row_bytes {
3668 let _ = conn.inner().put_image(
3669 ImageFormat::Z_PIXMAP,
3670 window_id,
3671 gc,
3672 size.width as u16,
3673 size.height as u16,
3674 0,
3675 0,
3676 0,
3677 depth,
3678 data,
3679 );
3680 } else {
3681 let mut packed = Vec::with_capacity(total_bytes);
3682 for y in 0..size.height as usize {
3683 let row_start = y * stride;
3684 let row_end = row_start + row_bytes;
3685 if row_end <= data.len() {
3686 packed.extend_from_slice(&data[row_start..row_end]);
3687 }
3688 }
3689 let _ = conn.inner().put_image(
3690 ImageFormat::Z_PIXMAP,
3691 window_id,
3692 gc,
3693 size.width as u16,
3694 size.height as u16,
3695 0,
3696 0,
3697 0,
3698 depth,
3699 &packed,
3700 );
3701 }
3702 })?;
3703 } else {
3704 // Image too large - split into horizontal bands
3705 let rows_per_chunk = max_request_bytes / row_bytes;
3706 let rows_per_chunk = rows_per_chunk.max(1); // At least 1 row per chunk
3707
3708 tracing::debug!("blit_surface: {}x{} ({} bytes) exceeds max {} bytes, using {} rows per chunk",
3709 size.width, size.height, total_bytes, max_request_bytes, rows_per_chunk);
3710
3711 self.renderer.surface_mut().with_data(|data| {
3712 let mut y_offset = 0u32;
3713 while y_offset < size.height {
3714 let chunk_height = (size.height - y_offset).min(rows_per_chunk as u32);
3715 let chunk_bytes = row_bytes * chunk_height as usize;
3716
3717 // Extract this chunk's data
3718 let mut chunk_data = Vec::with_capacity(chunk_bytes);
3719 for row in 0..chunk_height as usize {
3720 let src_y = y_offset as usize + row;
3721 let row_start = src_y * stride;
3722 let row_end = row_start + row_bytes;
3723 if row_end <= data.len() {
3724 chunk_data.extend_from_slice(&data[row_start..row_end]);
3725 }
3726 }
3727
3728 let _ = conn.inner().put_image(
3729 ImageFormat::Z_PIXMAP,
3730 window_id,
3731 gc,
3732 size.width as u16,
3733 chunk_height as u16,
3734 0,
3735 y_offset as i16,
3736 0,
3737 depth,
3738 &chunk_data,
3739 );
3740
3741 y_offset += chunk_height;
3742 }
3743 })?;
3744 }
3745
3746 self.window.connection().flush()?;
3747
3748 Ok(())
3749 }
3750 }
3751
3752 impl Drop for App {
3753 fn drop(&mut self) {
3754 let _ = self.window.connection().inner().free_gc(self.gc);
3755 }
3756 }
3757
3758 /// Guess MIME type from file extension.
3759 fn guess_mime_type(path: &std::path::Path) -> String {
3760 let ext = path.extension()
3761 .and_then(|e| e.to_str())
3762 .map(|e| e.to_lowercase())
3763 .unwrap_or_default();
3764
3765 match ext.as_str() {
3766 // Images
3767 "png" => "image/png",
3768 "jpg" | "jpeg" => "image/jpeg",
3769 "gif" => "image/gif",
3770 "webp" => "image/webp",
3771 "svg" => "image/svg+xml",
3772 "bmp" => "image/bmp",
3773 "ico" => "image/x-icon",
3774 // Documents
3775 "pdf" => "application/pdf",
3776 "txt" => "text/plain",
3777 "md" => "text/markdown",
3778 "html" | "htm" => "text/html",
3779 "css" => "text/css",
3780 "js" => "text/javascript",
3781 "json" => "application/json",
3782 "xml" => "application/xml",
3783 // Code
3784 "rs" => "text/x-rust",
3785 "py" => "text/x-python",
3786 "c" | "h" => "text/x-c",
3787 "cpp" | "hpp" | "cc" => "text/x-c++",
3788 "java" => "text/x-java",
3789 "go" => "text/x-go",
3790 "lua" => "text/x-lua",
3791 "sh" | "bash" => "application/x-shellscript",
3792 "toml" => "application/toml",
3793 "yaml" | "yml" => "application/x-yaml",
3794 // Archives
3795 "zip" => "application/zip",
3796 "tar" => "application/x-tar",
3797 "gz" | "gzip" => "application/gzip",
3798 "xz" => "application/x-xz",
3799 "7z" => "application/x-7z-compressed",
3800 "rar" => "application/vnd.rar",
3801 // Audio
3802 "mp3" => "audio/mpeg",
3803 "wav" => "audio/wav",
3804 "flac" => "audio/flac",
3805 "ogg" => "audio/ogg",
3806 "m4a" => "audio/mp4",
3807 // Video
3808 "mp4" => "video/mp4",
3809 "mkv" => "video/x-matroska",
3810 "avi" => "video/x-msvideo",
3811 "webm" => "video/webm",
3812 "mov" => "video/quicktime",
3813 // Fallback
3814 _ => "application/octet-stream",
3815 }.to_string()
3816 }
3817