@@ -6,7 +6,7 @@ use garfield::core::{ |
| 6 | trash_files, restore_from_trash, | 6 | trash_files, restore_from_trash, |
| 7 | }; | 7 | }; |
| 8 | use garfield::ui::pane::SplitDirection; | 8 | use garfield::ui::pane::SplitDirection; |
| 9 | -use garfield::ui::{AddressBar, Breadcrumb, ConfirmDialog, DialogResult, HelpModal, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT}; | 9 | +use garfield::ui::{AddressBar, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, DialogResult, HelpModal, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT}; |
| 10 | use anyhow::Result; | 10 | use anyhow::Result; |
| 11 | use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme}; | 11 | use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme}; |
| 12 | use gartk_render::{Renderer, Surface, TextStyle}; | 12 | use gartk_render::{Renderer, Surface, TextStyle}; |
@@ -76,12 +76,30 @@ pub struct App { |
| 76 | clipboard: Clipboard, | 76 | clipboard: Clipboard, |
| 77 | /// Confirmation dialog. | 77 | /// Confirmation dialog. |
| 78 | confirm_dialog: ConfirmDialog, | 78 | confirm_dialog: ConfirmDialog, |
| | 79 | + /// Conflict resolution dialog. |
| | 80 | + conflict_dialog: ConflictDialog, |
| 79 | /// Progress dialog for long operations. | 81 | /// Progress dialog for long operations. |
| 80 | progress_dialog: ProgressDialog, | 82 | progress_dialog: ProgressDialog, |
| 81 | /// Paths pending delete confirmation. | 83 | /// Paths pending delete confirmation. |
| 82 | pending_delete_paths: Vec<PathBuf>, | 84 | pending_delete_paths: Vec<PathBuf>, |
| 83 | /// Undo/redo stack for file operations. | 85 | /// Undo/redo stack for file operations. |
| 84 | undo_stack: UndoStack, | 86 | undo_stack: UndoStack, |
| | 87 | + /// Pending paste operation with conflicts. |
| | 88 | + pending_paste: Option<PendingPaste>, |
| | 89 | +} |
| | 90 | + |
| | 91 | +/// State for a paste operation with conflicts. |
| | 92 | +struct PendingPaste { |
| | 93 | + /// Files to paste. |
| | 94 | + files: Vec<PathBuf>, |
| | 95 | + /// Clipboard operation type. |
| | 96 | + operation: ClipboardOperation, |
| | 97 | + /// Destination directory. |
| | 98 | + dest_dir: PathBuf, |
| | 99 | + /// Files that conflict (exist in destination). |
| | 100 | + conflicts: Vec<PathBuf>, |
| | 101 | + /// Current conflict index being resolved. |
| | 102 | + current_conflict: usize, |
| 85 | } | 103 | } |
| 86 | | 104 | |
| 87 | impl App { | 105 | impl App { |
@@ -174,6 +192,9 @@ impl App { |
| 174 | // Create confirm dialog (full window bounds) | 192 | // Create confirm dialog (full window bounds) |
| 175 | let confirm_dialog = ConfirmDialog::new(Rect::new(0, 0, width, height)); | 193 | let confirm_dialog = ConfirmDialog::new(Rect::new(0, 0, width, height)); |
| 176 | | 194 | |
| | 195 | + // Create conflict dialog (full window bounds) |
| | 196 | + let conflict_dialog = ConflictDialog::new(Rect::new(0, 0, width, height)); |
| | 197 | + |
| 177 | // Create progress dialog (full window bounds) | 198 | // Create progress dialog (full window bounds) |
| 178 | let progress_dialog = ProgressDialog::new(Rect::new(0, 0, width, height)); | 199 | let progress_dialog = ProgressDialog::new(Rect::new(0, 0, width, height)); |
| 179 | | 200 | |
@@ -223,9 +244,11 @@ impl App { |
| 223 | drag_active: false, | 244 | drag_active: false, |
| 224 | clipboard: Clipboard::new(), | 245 | clipboard: Clipboard::new(), |
| 225 | confirm_dialog, | 246 | confirm_dialog, |
| | 247 | + conflict_dialog, |
| 226 | progress_dialog, | 248 | progress_dialog, |
| 227 | pending_delete_paths: Vec::new(), | 249 | pending_delete_paths: Vec::new(), |
| 228 | undo_stack: UndoStack::new(), | 250 | undo_stack: UndoStack::new(), |
| | 251 | + pending_paste: None, |
| 229 | }; | 252 | }; |
| 230 | | 253 | |
| 231 | app.update_status_bar(); | 254 | app.update_status_bar(); |
@@ -324,6 +347,14 @@ impl App { |
| 324 | return; | 347 | return; |
| 325 | } | 348 | } |
| 326 | | 349 | |
| | 350 | + // Check conflict dialog |
| | 351 | + if self.conflict_dialog.is_visible() { |
| | 352 | + if let Some(action) = self.conflict_dialog.on_click(pos) { |
| | 353 | + self.handle_conflict_action(action); |
| | 354 | + } |
| | 355 | + return; |
| | 356 | + } |
| | 357 | + |
| 327 | // Check help modal (clicking outside closes it) | 358 | // Check help modal (clicking outside closes it) |
| 328 | if self.help_modal.on_click(pos) { | 359 | if self.help_modal.on_click(pos) { |
| 329 | return; | 360 | return; |
@@ -557,6 +588,12 @@ impl App { |
| 557 | return; | 588 | return; |
| 558 | } | 589 | } |
| 559 | | 590 | |
| | 591 | + // Handle conflict dialog hover |
| | 592 | + if self.conflict_dialog.is_visible() { |
| | 593 | + self.conflict_dialog.on_mouse_move(pos); |
| | 594 | + return; |
| | 595 | + } |
| | 596 | + |
| 560 | // Handle sidebar resize in progress | 597 | // Handle sidebar resize in progress |
| 561 | if self.sidebar_resizing { | 598 | if self.sidebar_resizing { |
| 562 | let new_width = (pos.x - self.sidebar.bounds().x).max(0) as u32; | 599 | let new_width = (pos.x - self.sidebar.bounds().x).max(0) as u32; |
@@ -641,6 +678,14 @@ impl App { |
| 641 | return; | 678 | return; |
| 642 | } | 679 | } |
| 643 | | 680 | |
| | 681 | + // Handle conflict dialog when visible |
| | 682 | + if self.conflict_dialog.is_visible() { |
| | 683 | + if let Some(action) = self.conflict_dialog.handle_key(key) { |
| | 684 | + self.handle_conflict_action(action); |
| | 685 | + } |
| | 686 | + return; |
| | 687 | + } |
| | 688 | + |
| 644 | // Handle help modal when visible | 689 | // Handle help modal when visible |
| 645 | if self.help_modal.is_visible() { | 690 | if self.help_modal.is_visible() { |
| 646 | match key { | 691 | match key { |
@@ -1280,6 +1325,40 @@ impl App { |
| 1280 | }; | 1325 | }; |
| 1281 | | 1326 | |
| 1282 | if let Some((files, op)) = self.clipboard.take() { | 1327 | if let Some((files, op)) = self.clipboard.take() { |
| | 1328 | + // Check for conflicts first |
| | 1329 | + let conflicts: Vec<PathBuf> = files.iter() |
| | 1330 | + .filter_map(|f| { |
| | 1331 | + f.file_name().and_then(|name| { |
| | 1332 | + let dest = dest_dir.join(name); |
| | 1333 | + if dest.exists() { Some(f.clone()) } else { None } |
| | 1334 | + }) |
| | 1335 | + }) |
| | 1336 | + .collect(); |
| | 1337 | + |
| | 1338 | + if !conflicts.is_empty() { |
| | 1339 | + // Ring bell to alert user |
| | 1340 | + self.bell(); |
| | 1341 | + |
| | 1342 | + // Show conflict dialog for the first conflict |
| | 1343 | + let first_conflict_name = conflicts[0] |
| | 1344 | + .file_name() |
| | 1345 | + .map(|n| n.to_string_lossy().to_string()) |
| | 1346 | + .unwrap_or_default(); |
| | 1347 | + |
| | 1348 | + self.conflict_dialog.show(&first_conflict_name); |
| | 1349 | + |
| | 1350 | + // Store pending paste state |
| | 1351 | + self.pending_paste = Some(PendingPaste { |
| | 1352 | + files, |
| | 1353 | + operation: op, |
| | 1354 | + dest_dir, |
| | 1355 | + conflicts, |
| | 1356 | + current_conflict: 0, |
| | 1357 | + }); |
| | 1358 | + return; |
| | 1359 | + } |
| | 1360 | + |
| | 1361 | + // No conflicts - proceed with paste |
| 1283 | let count = files.len(); | 1362 | let count = files.len(); |
| 1284 | let sources = files.clone(); | 1363 | let sources = files.clone(); |
| 1285 | let result = match op { | 1364 | let result = match op { |
@@ -1398,6 +1477,143 @@ impl App { |
| 1398 | } | 1477 | } |
| 1399 | } | 1478 | } |
| 1400 | | 1479 | |
| | 1480 | + /// Handle conflict dialog result. |
| | 1481 | + fn handle_conflict_action(&mut self, action: ConflictAction) { |
| | 1482 | + let pending = match self.pending_paste.take() { |
| | 1483 | + Some(p) => p, |
| | 1484 | + None => return, |
| | 1485 | + }; |
| | 1486 | + |
| | 1487 | + let apply_to_all = self.conflict_dialog.apply_to_all(); |
| | 1488 | + |
| | 1489 | + match action { |
| | 1490 | + ConflictAction::Cancel => { |
| | 1491 | + // Cancel the entire operation |
| | 1492 | + self.status_bar.set_status_message("Paste cancelled"); |
| | 1493 | + } |
| | 1494 | + ConflictAction::Skip => { |
| | 1495 | + // Skip conflicting files, paste the rest |
| | 1496 | + self.complete_paste_with_skip(pending, apply_to_all); |
| | 1497 | + } |
| | 1498 | + ConflictAction::Replace => { |
| | 1499 | + // Replace conflicting files |
| | 1500 | + self.complete_paste_with_replace(pending, apply_to_all); |
| | 1501 | + } |
| | 1502 | + ConflictAction::KeepBoth => { |
| | 1503 | + // Auto-rename and paste all |
| | 1504 | + self.complete_paste_with_rename(pending, apply_to_all); |
| | 1505 | + } |
| | 1506 | + } |
| | 1507 | + } |
| | 1508 | + |
| | 1509 | + /// Complete paste, skipping conflicts. |
| | 1510 | + fn complete_paste_with_skip(&mut self, pending: PendingPaste, _apply_to_all: bool) { |
| | 1511 | + let non_conflicting: Vec<_> = pending.files.iter() |
| | 1512 | + .filter(|f| !pending.conflicts.contains(f)) |
| | 1513 | + .cloned() |
| | 1514 | + .collect(); |
| | 1515 | + |
| | 1516 | + if non_conflicting.is_empty() { |
| | 1517 | + self.status_bar.set_status_message("All files skipped (conflicts)"); |
| | 1518 | + return; |
| | 1519 | + } |
| | 1520 | + |
| | 1521 | + let result = match pending.operation { |
| | 1522 | + ClipboardOperation::Copy => copy_files(&non_conflicting, &pending.dest_dir), |
| | 1523 | + ClipboardOperation::Cut => move_files(&non_conflicting, &pending.dest_dir), |
| | 1524 | + }; |
| | 1525 | + |
| | 1526 | + let skipped = pending.conflicts.len(); |
| | 1527 | + if result.success { |
| | 1528 | + let action = if pending.operation == ClipboardOperation::Copy { "copied" } else { "moved" }; |
| | 1529 | + self.status_bar.set_status_message(format!("{} {} (skipped {})", non_conflicting.len(), action, skipped)); |
| | 1530 | + } |
| | 1531 | + self.refresh(); |
| | 1532 | + } |
| | 1533 | + |
| | 1534 | + /// Complete paste, replacing conflicts. |
| | 1535 | + fn complete_paste_with_replace(&mut self, pending: PendingPaste, _apply_to_all: bool) { |
| | 1536 | + // Delete conflicting files first |
| | 1537 | + for conflict in &pending.conflicts { |
| | 1538 | + if let Some(name) = conflict.file_name() { |
| | 1539 | + let dest = pending.dest_dir.join(name); |
| | 1540 | + let _ = std::fs::remove_file(&dest).or_else(|_| std::fs::remove_dir_all(&dest)); |
| | 1541 | + } |
| | 1542 | + } |
| | 1543 | + |
| | 1544 | + let result = match pending.operation { |
| | 1545 | + ClipboardOperation::Copy => copy_files(&pending.files, &pending.dest_dir), |
| | 1546 | + ClipboardOperation::Cut => move_files(&pending.files, &pending.dest_dir), |
| | 1547 | + }; |
| | 1548 | + |
| | 1549 | + if result.success { |
| | 1550 | + let action = if pending.operation == ClipboardOperation::Copy { "copied" } else { "moved" }; |
| | 1551 | + self.status_bar.set_status_message(format!("{} {} (replaced {})", pending.files.len(), action, pending.conflicts.len())); |
| | 1552 | + } |
| | 1553 | + self.refresh(); |
| | 1554 | + } |
| | 1555 | + |
| | 1556 | + /// Complete paste, auto-renaming conflicts. |
| | 1557 | + fn complete_paste_with_rename(&mut self, pending: PendingPaste, _apply_to_all: bool) { |
| | 1558 | + use garfield::core::make_unique_name; |
| | 1559 | + |
| | 1560 | + let mut success_count = 0; |
| | 1561 | + let mut sources = Vec::new(); |
| | 1562 | + let mut destinations = Vec::new(); |
| | 1563 | + |
| | 1564 | + for file in &pending.files { |
| | 1565 | + if let Some(name) = file.file_name() { |
| | 1566 | + let dest = pending.dest_dir.join(name); |
| | 1567 | + let final_dest = if dest.exists() { |
| | 1568 | + // Auto-rename |
| | 1569 | + let unique_name = make_unique_name(&pending.dest_dir, name.to_string_lossy().as_ref()); |
| | 1570 | + pending.dest_dir.join(unique_name) |
| | 1571 | + } else { |
| | 1572 | + dest |
| | 1573 | + }; |
| | 1574 | + |
| | 1575 | + let result = match pending.operation { |
| | 1576 | + ClipboardOperation::Copy => { |
| | 1577 | + if file.is_dir() { |
| | 1578 | + garfield::core::copy_path(file, &pending.dest_dir) |
| | 1579 | + } else { |
| | 1580 | + std::fs::copy(file, &final_dest).map(|_| final_dest.clone()) |
| | 1581 | + } |
| | 1582 | + } |
| | 1583 | + ClipboardOperation::Cut => { |
| | 1584 | + std::fs::rename(file, &final_dest).map(|_| final_dest.clone()) |
| | 1585 | + } |
| | 1586 | + }; |
| | 1587 | + |
| | 1588 | + if let Ok(dest_path) = result { |
| | 1589 | + success_count += 1; |
| | 1590 | + sources.push(file.clone()); |
| | 1591 | + destinations.push(dest_path); |
| | 1592 | + } |
| | 1593 | + } |
| | 1594 | + } |
| | 1595 | + |
| | 1596 | + // Record for undo |
| | 1597 | + if !destinations.is_empty() { |
| | 1598 | + let undo_op = match pending.operation { |
| | 1599 | + ClipboardOperation::Copy => FileOperation::Copy { sources, destinations }, |
| | 1600 | + ClipboardOperation::Cut => FileOperation::Move { sources, destinations }, |
| | 1601 | + }; |
| | 1602 | + self.undo_stack.push(undo_op); |
| | 1603 | + } |
| | 1604 | + |
| | 1605 | + let action = if pending.operation == ClipboardOperation::Copy { "copied" } else { "moved" }; |
| | 1606 | + self.status_bar.set_status_message(format!("{} {} (auto-renamed conflicts)", success_count, action)); |
| | 1607 | + self.refresh(); |
| | 1608 | + } |
| | 1609 | + |
| | 1610 | + /// Ring the terminal bell. |
| | 1611 | + fn bell(&self) { |
| | 1612 | + // X11 bell |
| | 1613 | + let _ = self.window.connection().inner().bell(0); |
| | 1614 | + let _ = self.window.connection().flush(); |
| | 1615 | + } |
| | 1616 | + |
| 1401 | /// Create a new folder in the current directory. | 1617 | /// Create a new folder in the current directory. |
| 1402 | fn create_new_folder(&mut self) { | 1618 | fn create_new_folder(&mut self) { |
| 1403 | let current_dir = self.focused_pane() | 1619 | let current_dir = self.focused_pane() |
@@ -1784,6 +2000,7 @@ impl App { |
| 1784 | | 2000 | |
| 1785 | self.help_modal.set_bounds(Rect::new(0, 0, width, height)); | 2001 | self.help_modal.set_bounds(Rect::new(0, 0, width, height)); |
| 1786 | self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height)); | 2002 | self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height)); |
| | 2003 | + self.conflict_dialog.set_bounds(Rect::new(0, 0, width, height)); |
| 1787 | self.progress_dialog.set_bounds(Rect::new(0, 0, width, height)); | 2004 | self.progress_dialog.set_bounds(Rect::new(0, 0, width, height)); |
| 1788 | } | 2005 | } |
| 1789 | | 2006 | |
@@ -1851,6 +2068,9 @@ impl App { |
| 1851 | // Draw confirm dialog overlay (on top of everything) | 2068 | // Draw confirm dialog overlay (on top of everything) |
| 1852 | self.confirm_dialog.render(&self.renderer)?; | 2069 | self.confirm_dialog.render(&self.renderer)?; |
| 1853 | | 2070 | |
| | 2071 | + // Draw conflict dialog overlay (on top of everything) |
| | 2072 | + self.conflict_dialog.render(&self.renderer)?; |
| | 2073 | + |
| 1854 | // Draw progress dialog overlay (on top of everything) | 2074 | // Draw progress dialog overlay (on top of everything) |
| 1855 | self.progress_dialog.render(&self.renderer)?; | 2075 | self.progress_dialog.render(&self.renderer)?; |
| 1856 | | 2076 | |