@@ -1,12 +1,12 @@ |
| 1 | 1 | //! Application state and event loop. |
| 2 | 2 | |
| 3 | 3 | use garfield::core::{ |
| 4 | | - Clipboard, ClipboardOperation, |
| 4 | + Clipboard, ClipboardOperation, FileOperation, UndoStack, |
| 5 | 5 | copy_files, move_files, delete_files, create_directory, |
| 6 | | - trash_files, |
| 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, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT}; |
| 9 | +use garfield::ui::{AddressBar, Breadcrumb, ConfirmDialog, 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,8 +76,12 @@ pub struct App { |
| 76 | 76 | clipboard: Clipboard, |
| 77 | 77 | /// Confirmation dialog. |
| 78 | 78 | confirm_dialog: ConfirmDialog, |
| 79 | + /// Progress dialog for long operations. |
| 80 | + progress_dialog: ProgressDialog, |
| 79 | 81 | /// Paths pending delete confirmation. |
| 80 | 82 | pending_delete_paths: Vec<PathBuf>, |
| 83 | + /// Undo/redo stack for file operations. |
| 84 | + undo_stack: UndoStack, |
| 81 | 85 | } |
| 82 | 86 | |
| 83 | 87 | impl App { |
@@ -170,6 +174,9 @@ impl App { |
| 170 | 174 | // Create confirm dialog (full window bounds) |
| 171 | 175 | let confirm_dialog = ConfirmDialog::new(Rect::new(0, 0, width, height)); |
| 172 | 176 | |
| 177 | + // Create progress dialog (full window bounds) |
| 178 | + let progress_dialog = ProgressDialog::new(Rect::new(0, 0, width, height)); |
| 179 | + |
| 173 | 180 | // Content area bounds (for panes) |
| 174 | 181 | let content_bounds = Rect::new( |
| 175 | 182 | sidebar_w as i32, |
@@ -216,7 +223,9 @@ impl App { |
| 216 | 223 | drag_active: false, |
| 217 | 224 | clipboard: Clipboard::new(), |
| 218 | 225 | confirm_dialog, |
| 226 | + progress_dialog, |
| 219 | 227 | pending_delete_paths: Vec::new(), |
| 228 | + undo_stack: UndoStack::new(), |
| 220 | 229 | }; |
| 221 | 230 | |
| 222 | 231 | app.update_status_bar(); |
@@ -301,6 +310,12 @@ impl App { |
| 301 | 310 | |
| 302 | 311 | /// Handle mouse press. |
| 303 | 312 | fn handle_mouse_press(&mut self, pos: Point, modifiers: &gartk_core::Modifiers, button: Option<MouseButton>) { |
| 313 | + // Check progress dialog first (blocks all other input) |
| 314 | + if self.progress_dialog.is_visible() { |
| 315 | + self.progress_dialog.on_click(pos); |
| 316 | + return; |
| 317 | + } |
| 318 | + |
| 304 | 319 | // Check confirm dialog first |
| 305 | 320 | if self.confirm_dialog.is_visible() { |
| 306 | 321 | if let Some(result) = self.confirm_dialog.on_click(pos) { |
@@ -530,6 +545,12 @@ impl App { |
| 530 | 545 | |
| 531 | 546 | /// Handle mouse move. |
| 532 | 547 | fn handle_mouse_move(&mut self, pos: Point) { |
| 548 | + // Handle progress dialog hover |
| 549 | + if self.progress_dialog.is_visible() { |
| 550 | + self.progress_dialog.on_mouse_move(pos); |
| 551 | + return; |
| 552 | + } |
| 553 | + |
| 533 | 554 | // Handle confirm dialog hover |
| 534 | 555 | if self.confirm_dialog.is_visible() { |
| 535 | 556 | self.confirm_dialog.on_mouse_move(pos); |
@@ -606,6 +627,12 @@ impl App { |
| 606 | 627 | |
| 607 | 628 | /// Handle a key press. |
| 608 | 629 | fn handle_key(&mut self, key: &Key, modifiers: &gartk_core::Modifiers) { |
| 630 | + // Handle progress dialog when visible (blocks all other input) |
| 631 | + if self.progress_dialog.is_visible() { |
| 632 | + self.progress_dialog.handle_key(key); |
| 633 | + return; |
| 634 | + } |
| 635 | + |
| 609 | 636 | // Handle confirm dialog when visible |
| 610 | 637 | if self.confirm_dialog.is_visible() { |
| 611 | 638 | if let Some(result) = self.confirm_dialog.handle_key(key) { |
@@ -817,6 +844,14 @@ impl App { |
| 817 | 844 | self.paste(); |
| 818 | 845 | return; |
| 819 | 846 | } |
| 847 | + Key::Char('z') | Key::Char('Z') => { |
| 848 | + self.undo(); |
| 849 | + return; |
| 850 | + } |
| 851 | + Key::Char('y') | Key::Char('Y') => { |
| 852 | + self.redo(); |
| 853 | + return; |
| 854 | + } |
| 820 | 855 | _ => {} |
| 821 | 856 | } |
| 822 | 857 | } |
@@ -1246,17 +1281,31 @@ impl App { |
| 1246 | 1281 | |
| 1247 | 1282 | if let Some((files, op)) = self.clipboard.take() { |
| 1248 | 1283 | let count = files.len(); |
| 1284 | + let sources = files.clone(); |
| 1249 | 1285 | let result = match op { |
| 1250 | 1286 | ClipboardOperation::Copy => copy_files(&files, &dest_dir), |
| 1251 | 1287 | ClipboardOperation::Cut => move_files(&files, &dest_dir), |
| 1252 | 1288 | }; |
| 1253 | 1289 | |
| 1254 | | - // Show result in status bar |
| 1255 | | - if result.success { |
| 1290 | + // Show result in status bar and record for undo |
| 1291 | + if result.success && !result.processed.is_empty() { |
| 1256 | 1292 | let action = if op == ClipboardOperation::Copy { "copied" } else { "moved" }; |
| 1257 | 1293 | let msg = if count == 1 { format!("1 item {}", action) } else { format!("{} items {}", count, action) }; |
| 1258 | 1294 | self.status_bar.set_status_message(msg); |
| 1259 | | - } else { |
| 1295 | + |
| 1296 | + // Record for undo |
| 1297 | + let undo_op = match op { |
| 1298 | + ClipboardOperation::Copy => FileOperation::Copy { |
| 1299 | + sources, |
| 1300 | + destinations: result.processed.clone(), |
| 1301 | + }, |
| 1302 | + ClipboardOperation::Cut => FileOperation::Move { |
| 1303 | + sources, |
| 1304 | + destinations: result.processed.clone(), |
| 1305 | + }, |
| 1306 | + }; |
| 1307 | + self.undo_stack.push(undo_op); |
| 1308 | + } else if !result.success { |
| 1260 | 1309 | let msg = format!("Operation failed: {}", result.error.as_deref().unwrap_or("unknown error")); |
| 1261 | 1310 | self.status_bar.set_status_message(msg); |
| 1262 | 1311 | } |
@@ -1277,6 +1326,19 @@ impl App { |
| 1277 | 1326 | let success_count = results.iter().filter(|r| r.is_ok()).count(); |
| 1278 | 1327 | let failed_count = results.iter().filter(|r| r.is_err()).count(); |
| 1279 | 1328 | |
| 1329 | + // Collect successful trash operations for undo |
| 1330 | + let mut trashed_originals = Vec::new(); |
| 1331 | + let mut trash_names = Vec::new(); |
| 1332 | + for (i, result) in results.iter().enumerate() { |
| 1333 | + if let Ok(trash_path) = result { |
| 1334 | + trashed_originals.push(paths[i].clone()); |
| 1335 | + // Extract the trash entry name (filename in trash/files/) |
| 1336 | + if let Some(name) = trash_path.file_name() { |
| 1337 | + trash_names.push(name.to_string_lossy().to_string()); |
| 1338 | + } |
| 1339 | + } |
| 1340 | + } |
| 1341 | + |
| 1280 | 1342 | if failed_count > 0 { |
| 1281 | 1343 | let msg = format!("Moved {} to trash, {} failed", success_count, failed_count); |
| 1282 | 1344 | self.status_bar.set_status_message(msg); |
@@ -1285,6 +1347,14 @@ impl App { |
| 1285 | 1347 | self.status_bar.set_status_message(msg); |
| 1286 | 1348 | } |
| 1287 | 1349 | |
| 1350 | + // Record for undo if any succeeded |
| 1351 | + if !trashed_originals.is_empty() { |
| 1352 | + self.undo_stack.push(FileOperation::Trash { |
| 1353 | + originals: trashed_originals, |
| 1354 | + trash_names, |
| 1355 | + }); |
| 1356 | + } |
| 1357 | + |
| 1288 | 1358 | self.refresh(); |
| 1289 | 1359 | } |
| 1290 | 1360 | |
@@ -1349,8 +1419,9 @@ impl App { |
| 1349 | 1419 | } |
| 1350 | 1420 | |
| 1351 | 1421 | match create_directory(¤t_dir, &name) { |
| 1352 | | - Ok(_) => { |
| 1422 | + Ok(path) => { |
| 1353 | 1423 | self.status_bar.set_status_message(format!("Created '{}'", name)); |
| 1424 | + self.undo_stack.push(FileOperation::CreateDir { path }); |
| 1354 | 1425 | self.refresh(); |
| 1355 | 1426 | // TODO: Start rename on the new folder |
| 1356 | 1427 | } |
@@ -1360,6 +1431,155 @@ impl App { |
| 1360 | 1431 | } |
| 1361 | 1432 | } |
| 1362 | 1433 | |
| 1434 | + /// Undo the last file operation. |
| 1435 | + fn undo(&mut self) { |
| 1436 | + let op = match self.undo_stack.pop_undo() { |
| 1437 | + Some(op) => op, |
| 1438 | + None => { |
| 1439 | + self.status_bar.set_status_message("Nothing to undo"); |
| 1440 | + return; |
| 1441 | + } |
| 1442 | + }; |
| 1443 | + |
| 1444 | + let result = self.perform_undo(&op); |
| 1445 | + match result { |
| 1446 | + Ok(msg) => { |
| 1447 | + self.status_bar.set_status_message(format!("Undo: {}", msg)); |
| 1448 | + self.undo_stack.push_redo(op); |
| 1449 | + } |
| 1450 | + Err(msg) => { |
| 1451 | + self.status_bar.set_status_message(format!("Undo failed: {}", msg)); |
| 1452 | + } |
| 1453 | + } |
| 1454 | + self.refresh(); |
| 1455 | + } |
| 1456 | + |
| 1457 | + /// Perform the undo operation. |
| 1458 | + fn perform_undo(&mut self, op: &FileOperation) -> Result<String, String> { |
| 1459 | + use garfield::core::{delete_path, move_path, rename_path}; |
| 1460 | + |
| 1461 | + match op { |
| 1462 | + FileOperation::Copy { destinations, .. } => { |
| 1463 | + // Undo copy: delete the copied files |
| 1464 | + for dest in destinations { |
| 1465 | + if dest.exists() { |
| 1466 | + delete_path(dest).map_err(|e| e.to_string())?; |
| 1467 | + } |
| 1468 | + } |
| 1469 | + Ok(format!("Deleted {} copied item(s)", destinations.len())) |
| 1470 | + } |
| 1471 | + FileOperation::Move { sources, destinations } => { |
| 1472 | + // Undo move: move files back to original locations |
| 1473 | + for (src, dest) in sources.iter().zip(destinations.iter()) { |
| 1474 | + if dest.exists() { |
| 1475 | + if let Some(parent) = src.parent() { |
| 1476 | + move_path(dest, parent).map_err(|e| e.to_string())?; |
| 1477 | + } |
| 1478 | + } |
| 1479 | + } |
| 1480 | + Ok(format!("Moved {} item(s) back", sources.len())) |
| 1481 | + } |
| 1482 | + FileOperation::Trash { trash_names, .. } => { |
| 1483 | + // Undo trash: restore from trash |
| 1484 | + for name in trash_names { |
| 1485 | + restore_from_trash(name).map_err(|e| e.to_string())?; |
| 1486 | + } |
| 1487 | + Ok(format!("Restored {} item(s) from trash", trash_names.len())) |
| 1488 | + } |
| 1489 | + FileOperation::Rename { original, renamed } => { |
| 1490 | + // Undo rename: rename back to original |
| 1491 | + if renamed.exists() { |
| 1492 | + if let Some(orig_name) = original.file_name() { |
| 1493 | + rename_path(renamed, orig_name.to_string_lossy().as_ref()) |
| 1494 | + .map_err(|e| e.to_string())?; |
| 1495 | + } |
| 1496 | + } |
| 1497 | + Ok("Renamed back".to_string()) |
| 1498 | + } |
| 1499 | + FileOperation::CreateDir { path } => { |
| 1500 | + // Undo create dir: delete the directory (only if empty) |
| 1501 | + if path.exists() && path.is_dir() { |
| 1502 | + std::fs::remove_dir(path).map_err(|e| e.to_string())?; |
| 1503 | + } |
| 1504 | + Ok("Deleted folder".to_string()) |
| 1505 | + } |
| 1506 | + } |
| 1507 | + } |
| 1508 | + |
| 1509 | + /// Redo the last undone operation. |
| 1510 | + fn redo(&mut self) { |
| 1511 | + let op = match self.undo_stack.pop_redo() { |
| 1512 | + Some(op) => op, |
| 1513 | + None => { |
| 1514 | + self.status_bar.set_status_message("Nothing to redo"); |
| 1515 | + return; |
| 1516 | + } |
| 1517 | + }; |
| 1518 | + |
| 1519 | + let result = self.perform_redo(&op); |
| 1520 | + match result { |
| 1521 | + Ok(msg) => { |
| 1522 | + self.status_bar.set_status_message(format!("Redo: {}", msg)); |
| 1523 | + self.undo_stack.push(op); |
| 1524 | + } |
| 1525 | + Err(msg) => { |
| 1526 | + self.status_bar.set_status_message(format!("Redo failed: {}", msg)); |
| 1527 | + } |
| 1528 | + } |
| 1529 | + self.refresh(); |
| 1530 | + } |
| 1531 | + |
| 1532 | + /// Perform the redo operation. |
| 1533 | + fn perform_redo(&mut self, op: &FileOperation) -> Result<String, String> { |
| 1534 | + use garfield::core::{copy_path, move_path, rename_path}; |
| 1535 | + |
| 1536 | + match op { |
| 1537 | + FileOperation::Copy { sources, destinations } => { |
| 1538 | + // Redo copy: copy files again |
| 1539 | + for (src, dest) in sources.iter().zip(destinations.iter()) { |
| 1540 | + if src.exists() { |
| 1541 | + if let Some(parent) = dest.parent() { |
| 1542 | + copy_path(src, parent).map_err(|e| e.to_string())?; |
| 1543 | + } |
| 1544 | + } |
| 1545 | + } |
| 1546 | + Ok(format!("Copied {} item(s)", sources.len())) |
| 1547 | + } |
| 1548 | + FileOperation::Move { sources, destinations } => { |
| 1549 | + // Redo move: move files again |
| 1550 | + for (src, dest) in sources.iter().zip(destinations.iter()) { |
| 1551 | + if src.exists() { |
| 1552 | + if let Some(parent) = dest.parent() { |
| 1553 | + move_path(src, parent).map_err(|e| e.to_string())?; |
| 1554 | + } |
| 1555 | + } |
| 1556 | + } |
| 1557 | + Ok(format!("Moved {} item(s)", sources.len())) |
| 1558 | + } |
| 1559 | + FileOperation::Trash { originals, .. } => { |
| 1560 | + // Redo trash: trash the files again |
| 1561 | + let results = trash_files(originals); |
| 1562 | + let success = results.iter().filter(|r| r.is_ok()).count(); |
| 1563 | + Ok(format!("Trashed {} item(s)", success)) |
| 1564 | + } |
| 1565 | + FileOperation::Rename { original, renamed } => { |
| 1566 | + // Redo rename: rename again |
| 1567 | + if original.exists() { |
| 1568 | + if let Some(new_name) = renamed.file_name() { |
| 1569 | + rename_path(original, new_name.to_string_lossy().as_ref()) |
| 1570 | + .map_err(|e| e.to_string())?; |
| 1571 | + } |
| 1572 | + } |
| 1573 | + Ok("Renamed".to_string()) |
| 1574 | + } |
| 1575 | + FileOperation::CreateDir { path } => { |
| 1576 | + // Redo create dir: create the directory again |
| 1577 | + std::fs::create_dir(path).map_err(|e| e.to_string())?; |
| 1578 | + Ok("Created folder".to_string()) |
| 1579 | + } |
| 1580 | + } |
| 1581 | + } |
| 1582 | + |
| 1363 | 1583 | /// Start inline rename for the selected file. |
| 1364 | 1584 | fn start_rename(&mut self) { |
| 1365 | 1585 | if let Some(pane) = self.focused_pane_mut() { |
@@ -1392,7 +1612,14 @@ impl App { |
| 1392 | 1612 | .map(|t| t.confirm_rename()); |
| 1393 | 1613 | |
| 1394 | 1614 | match result { |
| 1395 | | - Some(Ok(new_name)) => { |
| 1615 | + Some(Ok((original, renamed, new_name))) => { |
| 1616 | + // Only record undo if the name actually changed |
| 1617 | + if original != renamed { |
| 1618 | + self.undo_stack.push(FileOperation::Rename { |
| 1619 | + original, |
| 1620 | + renamed, |
| 1621 | + }); |
| 1622 | + } |
| 1396 | 1623 | self.status_bar.set_status_message(format!("Renamed to '{}'", new_name)); |
| 1397 | 1624 | } |
| 1398 | 1625 | Some(Err(msg)) => { |
@@ -1557,6 +1784,7 @@ impl App { |
| 1557 | 1784 | |
| 1558 | 1785 | self.help_modal.set_bounds(Rect::new(0, 0, width, height)); |
| 1559 | 1786 | self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height)); |
| 1787 | + self.progress_dialog.set_bounds(Rect::new(0, 0, width, height)); |
| 1560 | 1788 | } |
| 1561 | 1789 | |
| 1562 | 1790 | /// Render the application. |
@@ -1623,6 +1851,9 @@ impl App { |
| 1623 | 1851 | // Draw confirm dialog overlay (on top of everything) |
| 1624 | 1852 | self.confirm_dialog.render(&self.renderer)?; |
| 1625 | 1853 | |
| 1854 | + // Draw progress dialog overlay (on top of everything) |
| 1855 | + self.progress_dialog.render(&self.renderer)?; |
| 1856 | + |
| 1626 | 1857 | // Flush and copy to window |
| 1627 | 1858 | self.renderer.flush(); |
| 1628 | 1859 | self.blit_surface()?; |