@@ -1,12 +1,12 @@ |
| 1 | //! Application state and event loop. | 1 | //! Application state and event loop. |
| 2 | | 2 | |
| 3 | use garfield::core::{ | 3 | use garfield::core::{ |
| 4 | - Clipboard, ClipboardOperation, | 4 | + Clipboard, ClipboardOperation, FileOperation, UndoStack, |
| 5 | copy_files, move_files, delete_files, create_directory, | 5 | copy_files, move_files, delete_files, create_directory, |
| 6 | - trash_files, | 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, 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 | 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,8 +76,12 @@ pub struct App { |
| 76 | clipboard: Clipboard, | 76 | clipboard: Clipboard, |
| 77 | /// Confirmation dialog. | 77 | /// Confirmation dialog. |
| 78 | confirm_dialog: ConfirmDialog, | 78 | confirm_dialog: ConfirmDialog, |
| | 79 | + /// Progress dialog for long operations. |
| | 80 | + progress_dialog: ProgressDialog, |
| 79 | /// Paths pending delete confirmation. | 81 | /// Paths pending delete confirmation. |
| 80 | pending_delete_paths: Vec<PathBuf>, | 82 | pending_delete_paths: Vec<PathBuf>, |
| | 83 | + /// Undo/redo stack for file operations. |
| | 84 | + undo_stack: UndoStack, |
| 81 | } | 85 | } |
| 82 | | 86 | |
| 83 | impl App { | 87 | impl App { |
@@ -170,6 +174,9 @@ impl App { |
| 170 | // Create confirm dialog (full window bounds) | 174 | // Create confirm dialog (full window bounds) |
| 171 | let confirm_dialog = ConfirmDialog::new(Rect::new(0, 0, width, height)); | 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 | // Content area bounds (for panes) | 180 | // Content area bounds (for panes) |
| 174 | let content_bounds = Rect::new( | 181 | let content_bounds = Rect::new( |
| 175 | sidebar_w as i32, | 182 | sidebar_w as i32, |
@@ -216,7 +223,9 @@ impl App { |
| 216 | drag_active: false, | 223 | drag_active: false, |
| 217 | clipboard: Clipboard::new(), | 224 | clipboard: Clipboard::new(), |
| 218 | confirm_dialog, | 225 | confirm_dialog, |
| | 226 | + progress_dialog, |
| 219 | pending_delete_paths: Vec::new(), | 227 | pending_delete_paths: Vec::new(), |
| | 228 | + undo_stack: UndoStack::new(), |
| 220 | }; | 229 | }; |
| 221 | | 230 | |
| 222 | app.update_status_bar(); | 231 | app.update_status_bar(); |
@@ -301,6 +310,12 @@ impl App { |
| 301 | | 310 | |
| 302 | /// Handle mouse press. | 311 | /// Handle mouse press. |
| 303 | fn handle_mouse_press(&mut self, pos: Point, modifiers: &gartk_core::Modifiers, button: Option<MouseButton>) { | 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 | // Check confirm dialog first | 319 | // Check confirm dialog first |
| 305 | if self.confirm_dialog.is_visible() { | 320 | if self.confirm_dialog.is_visible() { |
| 306 | if let Some(result) = self.confirm_dialog.on_click(pos) { | 321 | if let Some(result) = self.confirm_dialog.on_click(pos) { |
@@ -530,6 +545,12 @@ impl App { |
| 530 | | 545 | |
| 531 | /// Handle mouse move. | 546 | /// Handle mouse move. |
| 532 | fn handle_mouse_move(&mut self, pos: Point) { | 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 | // Handle confirm dialog hover | 554 | // Handle confirm dialog hover |
| 534 | if self.confirm_dialog.is_visible() { | 555 | if self.confirm_dialog.is_visible() { |
| 535 | self.confirm_dialog.on_mouse_move(pos); | 556 | self.confirm_dialog.on_mouse_move(pos); |
@@ -606,6 +627,12 @@ impl App { |
| 606 | | 627 | |
| 607 | /// Handle a key press. | 628 | /// Handle a key press. |
| 608 | fn handle_key(&mut self, key: &Key, modifiers: &gartk_core::Modifiers) { | 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 | // Handle confirm dialog when visible | 636 | // Handle confirm dialog when visible |
| 610 | if self.confirm_dialog.is_visible() { | 637 | if self.confirm_dialog.is_visible() { |
| 611 | if let Some(result) = self.confirm_dialog.handle_key(key) { | 638 | if let Some(result) = self.confirm_dialog.handle_key(key) { |
@@ -817,6 +844,14 @@ impl App { |
| 817 | self.paste(); | 844 | self.paste(); |
| 818 | return; | 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 | if let Some((files, op)) = self.clipboard.take() { | 1282 | if let Some((files, op)) = self.clipboard.take() { |
| 1248 | let count = files.len(); | 1283 | let count = files.len(); |
| | 1284 | + let sources = files.clone(); |
| 1249 | let result = match op { | 1285 | let result = match op { |
| 1250 | ClipboardOperation::Copy => copy_files(&files, &dest_dir), | 1286 | ClipboardOperation::Copy => copy_files(&files, &dest_dir), |
| 1251 | ClipboardOperation::Cut => move_files(&files, &dest_dir), | 1287 | ClipboardOperation::Cut => move_files(&files, &dest_dir), |
| 1252 | }; | 1288 | }; |
| 1253 | | 1289 | |
| 1254 | - // Show result in status bar | 1290 | + // Show result in status bar and record for undo |
| 1255 | - if result.success { | 1291 | + if result.success && !result.processed.is_empty() { |
| 1256 | let action = if op == ClipboardOperation::Copy { "copied" } else { "moved" }; | 1292 | let action = if op == ClipboardOperation::Copy { "copied" } else { "moved" }; |
| 1257 | let msg = if count == 1 { format!("1 item {}", action) } else { format!("{} items {}", count, action) }; | 1293 | let msg = if count == 1 { format!("1 item {}", action) } else { format!("{} items {}", count, action) }; |
| 1258 | self.status_bar.set_status_message(msg); | 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 | let msg = format!("Operation failed: {}", result.error.as_deref().unwrap_or("unknown error")); | 1309 | let msg = format!("Operation failed: {}", result.error.as_deref().unwrap_or("unknown error")); |
| 1261 | self.status_bar.set_status_message(msg); | 1310 | self.status_bar.set_status_message(msg); |
| 1262 | } | 1311 | } |
@@ -1277,6 +1326,19 @@ impl App { |
| 1277 | let success_count = results.iter().filter(|r| r.is_ok()).count(); | 1326 | let success_count = results.iter().filter(|r| r.is_ok()).count(); |
| 1278 | let failed_count = results.iter().filter(|r| r.is_err()).count(); | 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 | if failed_count > 0 { | 1342 | if failed_count > 0 { |
| 1281 | let msg = format!("Moved {} to trash, {} failed", success_count, failed_count); | 1343 | let msg = format!("Moved {} to trash, {} failed", success_count, failed_count); |
| 1282 | self.status_bar.set_status_message(msg); | 1344 | self.status_bar.set_status_message(msg); |
@@ -1285,6 +1347,14 @@ impl App { |
| 1285 | self.status_bar.set_status_message(msg); | 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 | self.refresh(); | 1358 | self.refresh(); |
| 1289 | } | 1359 | } |
| 1290 | | 1360 | |
@@ -1349,8 +1419,9 @@ impl App { |
| 1349 | } | 1419 | } |
| 1350 | | 1420 | |
| 1351 | match create_directory(¤t_dir, &name) { | 1421 | match create_directory(¤t_dir, &name) { |
| 1352 | - Ok(_) => { | 1422 | + Ok(path) => { |
| 1353 | self.status_bar.set_status_message(format!("Created '{}'", name)); | 1423 | self.status_bar.set_status_message(format!("Created '{}'", name)); |
| | 1424 | + self.undo_stack.push(FileOperation::CreateDir { path }); |
| 1354 | self.refresh(); | 1425 | self.refresh(); |
| 1355 | // TODO: Start rename on the new folder | 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 | /// Start inline rename for the selected file. | 1583 | /// Start inline rename for the selected file. |
| 1364 | fn start_rename(&mut self) { | 1584 | fn start_rename(&mut self) { |
| 1365 | if let Some(pane) = self.focused_pane_mut() { | 1585 | if let Some(pane) = self.focused_pane_mut() { |
@@ -1392,7 +1612,14 @@ impl App { |
| 1392 | .map(|t| t.confirm_rename()); | 1612 | .map(|t| t.confirm_rename()); |
| 1393 | | 1613 | |
| 1394 | match result { | 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 | self.status_bar.set_status_message(format!("Renamed to '{}'", new_name)); | 1623 | self.status_bar.set_status_message(format!("Renamed to '{}'", new_name)); |
| 1397 | } | 1624 | } |
| 1398 | Some(Err(msg)) => { | 1625 | Some(Err(msg)) => { |
@@ -1557,6 +1784,7 @@ impl App { |
| 1557 | | 1784 | |
| 1558 | self.help_modal.set_bounds(Rect::new(0, 0, width, height)); | 1785 | self.help_modal.set_bounds(Rect::new(0, 0, width, height)); |
| 1559 | self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height)); | 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 | /// Render the application. | 1790 | /// Render the application. |
@@ -1623,6 +1851,9 @@ impl App { |
| 1623 | // Draw confirm dialog overlay (on top of everything) | 1851 | // Draw confirm dialog overlay (on top of everything) |
| 1624 | self.confirm_dialog.render(&self.renderer)?; | 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 | // Flush and copy to window | 1857 | // Flush and copy to window |
| 1627 | self.renderer.flush(); | 1858 | self.renderer.flush(); |
| 1628 | self.blit_surface()?; | 1859 | self.blit_surface()?; |