@@ -6,7 +6,7 @@ use garfield::core::{ |
| 6 | 6 | trash_files, restore_from_trash, |
| 7 | 7 | }; |
| 8 | 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 | 10 | use anyhow::Result; |
| 11 | 11 | use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme}; |
| 12 | 12 | use gartk_render::{Renderer, Surface, TextStyle}; |
@@ -76,12 +76,30 @@ pub struct App { |
| 76 | 76 | clipboard: Clipboard, |
| 77 | 77 | /// Confirmation dialog. |
| 78 | 78 | confirm_dialog: ConfirmDialog, |
| 79 | + /// Conflict resolution dialog. |
| 80 | + conflict_dialog: ConflictDialog, |
| 79 | 81 | /// Progress dialog for long operations. |
| 80 | 82 | progress_dialog: ProgressDialog, |
| 81 | 83 | /// Paths pending delete confirmation. |
| 82 | 84 | pending_delete_paths: Vec<PathBuf>, |
| 83 | 85 | /// Undo/redo stack for file operations. |
| 84 | 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 | 105 | impl App { |
@@ -174,6 +192,9 @@ impl App { |
| 174 | 192 | // Create confirm dialog (full window bounds) |
| 175 | 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 | 198 | // Create progress dialog (full window bounds) |
| 178 | 199 | let progress_dialog = ProgressDialog::new(Rect::new(0, 0, width, height)); |
| 179 | 200 | |
@@ -223,9 +244,11 @@ impl App { |
| 223 | 244 | drag_active: false, |
| 224 | 245 | clipboard: Clipboard::new(), |
| 225 | 246 | confirm_dialog, |
| 247 | + conflict_dialog, |
| 226 | 248 | progress_dialog, |
| 227 | 249 | pending_delete_paths: Vec::new(), |
| 228 | 250 | undo_stack: UndoStack::new(), |
| 251 | + pending_paste: None, |
| 229 | 252 | }; |
| 230 | 253 | |
| 231 | 254 | app.update_status_bar(); |
@@ -324,6 +347,14 @@ impl App { |
| 324 | 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 | 358 | // Check help modal (clicking outside closes it) |
| 328 | 359 | if self.help_modal.on_click(pos) { |
| 329 | 360 | return; |
@@ -557,6 +588,12 @@ impl App { |
| 557 | 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 | 597 | // Handle sidebar resize in progress |
| 561 | 598 | if self.sidebar_resizing { |
| 562 | 599 | let new_width = (pos.x - self.sidebar.bounds().x).max(0) as u32; |
@@ -641,6 +678,14 @@ impl App { |
| 641 | 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 | 689 | // Handle help modal when visible |
| 645 | 690 | if self.help_modal.is_visible() { |
| 646 | 691 | match key { |
@@ -1280,6 +1325,40 @@ impl App { |
| 1280 | 1325 | }; |
| 1281 | 1326 | |
| 1282 | 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 | 1362 | let count = files.len(); |
| 1284 | 1363 | let sources = files.clone(); |
| 1285 | 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 | 1617 | /// Create a new folder in the current directory. |
| 1402 | 1618 | fn create_new_folder(&mut self) { |
| 1403 | 1619 | let current_dir = self.focused_pane() |
@@ -1784,6 +2000,7 @@ impl App { |
| 1784 | 2000 | |
| 1785 | 2001 | self.help_modal.set_bounds(Rect::new(0, 0, width, height)); |
| 1786 | 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 | 2004 | self.progress_dialog.set_bounds(Rect::new(0, 0, width, height)); |
| 1788 | 2005 | } |
| 1789 | 2006 | |
@@ -1851,6 +2068,9 @@ impl App { |
| 1851 | 2068 | // Draw confirm dialog overlay (on top of everything) |
| 1852 | 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 | 2074 | // Draw progress dialog overlay (on top of everything) |
| 1855 | 2075 | self.progress_dialog.render(&self.renderer)?; |
| 1856 | 2076 | |