gardesk/garfield / bfdcd76

Browse files

core: add progress dialog and undo/redo system

- Add ProgressDialog component with progress bar and cancel button
- Add UndoStack for tracking file operations (copy, move, trash, rename, create folder)
- Track operations for undo: paste, trash, rename, create folder
- Implement undo() and redo() methods with reverse operations
- Add Ctrl+Z (undo) and Ctrl+Y (redo) keybindings
- Update help modal with undo/redo shortcuts
- Modify Tab.confirm_rename to return paths for undo tracking
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
bfdcd76a53a5f63794c20844f93eb55e55f7ec11
Parents
bd6afd9
Tree
dd64552

7 changed files

StatusFile+-
M garfield/src/app.rs 239 8
M garfield/src/core/mod.rs 2 0
A garfield/src/core/undo.rs 144 0
M garfield/src/ui/dialog.rs 300 1
M garfield/src/ui/help_modal.rs 2 0
M garfield/src/ui/mod.rs 1 1
M garfield/src/ui/tab.rs 11 9
garfield/src/app.rsmodified
@@ -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(&current_dir, &name) {
1421
         match create_directory(&current_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()?;
garfield/src/core/mod.rsmodified
@@ -5,6 +5,7 @@ pub mod entry;
5
 pub mod history;
5
 pub mod history;
6
 pub mod operations;
6
 pub mod operations;
7
 pub mod trash;
7
 pub mod trash;
8
+pub mod undo;
8
 
9
 
9
 pub use clipboard::{Clipboard, ClipboardOperation};
10
 pub use clipboard::{Clipboard, ClipboardOperation};
10
 pub use entry::{
11
 pub use entry::{
@@ -16,3 +17,4 @@ pub use operations::{
16
     make_unique_name, move_files, move_path, rename_path, OperationResult,
17
     make_unique_name, move_files, move_path, rename_path, OperationResult,
17
 };
18
 };
18
 pub use trash::{empty_trash, restore_from_trash, trash_file, trash_files, trash_dir};
19
 pub use trash::{empty_trash, restore_from_trash, trash_file, trash_files, trash_dir};
20
+pub use undo::{FileOperation, UndoStack};
garfield/src/core/undo.rsadded
@@ -0,0 +1,144 @@
1
+//! Undo/redo system for file operations.
2
+
3
+use std::path::PathBuf;
4
+
5
+/// Maximum number of operations to keep in history.
6
+const MAX_HISTORY: usize = 100;
7
+
8
+/// A file operation that can be undone.
9
+#[derive(Debug, Clone)]
10
+pub enum FileOperation {
11
+    /// Copy files (sources, created destinations).
12
+    Copy {
13
+        sources: Vec<PathBuf>,
14
+        destinations: Vec<PathBuf>,
15
+    },
16
+    /// Move files (sources, destinations).
17
+    Move {
18
+        sources: Vec<PathBuf>,
19
+        destinations: Vec<PathBuf>,
20
+    },
21
+    /// Trash files (original paths, trash entry names).
22
+    Trash {
23
+        originals: Vec<PathBuf>,
24
+        trash_names: Vec<String>,
25
+    },
26
+    /// Rename file (original path, new path).
27
+    Rename {
28
+        original: PathBuf,
29
+        renamed: PathBuf,
30
+    },
31
+    /// Create directory (path).
32
+    CreateDir {
33
+        path: PathBuf,
34
+    },
35
+}
36
+
37
+impl FileOperation {
38
+    /// Get a human-readable description of this operation.
39
+    pub fn description(&self) -> String {
40
+        match self {
41
+            FileOperation::Copy { sources, .. } => {
42
+                if sources.len() == 1 {
43
+                    format!("Copy '{}'", sources[0].file_name().unwrap_or_default().to_string_lossy())
44
+                } else {
45
+                    format!("Copy {} items", sources.len())
46
+                }
47
+            }
48
+            FileOperation::Move { sources, .. } => {
49
+                if sources.len() == 1 {
50
+                    format!("Move '{}'", sources[0].file_name().unwrap_or_default().to_string_lossy())
51
+                } else {
52
+                    format!("Move {} items", sources.len())
53
+                }
54
+            }
55
+            FileOperation::Trash { originals, .. } => {
56
+                if originals.len() == 1 {
57
+                    format!("Trash '{}'", originals[0].file_name().unwrap_or_default().to_string_lossy())
58
+                } else {
59
+                    format!("Trash {} items", originals.len())
60
+                }
61
+            }
62
+            FileOperation::Rename { original, renamed } => {
63
+                format!(
64
+                    "Rename '{}' to '{}'",
65
+                    original.file_name().unwrap_or_default().to_string_lossy(),
66
+                    renamed.file_name().unwrap_or_default().to_string_lossy()
67
+                )
68
+            }
69
+            FileOperation::CreateDir { path } => {
70
+                format!("Create folder '{}'", path.file_name().unwrap_or_default().to_string_lossy())
71
+            }
72
+        }
73
+    }
74
+}
75
+
76
+/// Manages undo/redo stacks for file operations.
77
+#[derive(Debug, Default)]
78
+pub struct UndoStack {
79
+    /// Operations that can be undone (most recent last).
80
+    undo_stack: Vec<FileOperation>,
81
+    /// Operations that can be redone (most recent last).
82
+    redo_stack: Vec<FileOperation>,
83
+}
84
+
85
+impl UndoStack {
86
+    /// Create a new undo stack.
87
+    pub fn new() -> Self {
88
+        Self::default()
89
+    }
90
+
91
+    /// Push an operation onto the undo stack.
92
+    /// Clears the redo stack since the history has diverged.
93
+    pub fn push(&mut self, op: FileOperation) {
94
+        self.undo_stack.push(op);
95
+        self.redo_stack.clear();
96
+
97
+        // Limit history size
98
+        if self.undo_stack.len() > MAX_HISTORY {
99
+            self.undo_stack.remove(0);
100
+        }
101
+    }
102
+
103
+    /// Check if undo is available.
104
+    pub fn can_undo(&self) -> bool {
105
+        !self.undo_stack.is_empty()
106
+    }
107
+
108
+    /// Check if redo is available.
109
+    pub fn can_redo(&self) -> bool {
110
+        !self.redo_stack.is_empty()
111
+    }
112
+
113
+    /// Pop an operation from the undo stack for undoing.
114
+    /// The operation should be performed, then moved to redo.
115
+    pub fn pop_undo(&mut self) -> Option<FileOperation> {
116
+        self.undo_stack.pop()
117
+    }
118
+
119
+    /// Push an operation onto the redo stack (after undoing).
120
+    pub fn push_redo(&mut self, op: FileOperation) {
121
+        self.redo_stack.push(op);
122
+    }
123
+
124
+    /// Pop an operation from the redo stack for redoing.
125
+    pub fn pop_redo(&mut self) -> Option<FileOperation> {
126
+        self.redo_stack.pop()
127
+    }
128
+
129
+    /// Get description of the next undo operation.
130
+    pub fn undo_description(&self) -> Option<String> {
131
+        self.undo_stack.last().map(|op| op.description())
132
+    }
133
+
134
+    /// Get description of the next redo operation.
135
+    pub fn redo_description(&self) -> Option<String> {
136
+        self.redo_stack.last().map(|op| op.description())
137
+    }
138
+
139
+    /// Clear all history.
140
+    pub fn clear(&mut self) {
141
+        self.undo_stack.clear();
142
+        self.redo_stack.clear();
143
+    }
144
+}
garfield/src/ui/dialog.rsmodified
@@ -1,8 +1,9 @@
1
-//! Modal dialog component for confirmations.
1
+//! Modal dialog components for confirmations and progress.
2
 
2
 
3
 use anyhow::Result;
3
 use anyhow::Result;
4
 use gartk_core::{Key, Point, Rect};
4
 use gartk_core::{Key, Point, Rect};
5
 use gartk_render::{Renderer, TextStyle};
5
 use gartk_render::{Renderer, TextStyle};
6
+use std::time::Instant;
6
 
7
 
7
 /// Dialog button type.
8
 /// Dialog button type.
8
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -284,3 +285,301 @@ impl ConfirmDialog {
284
         Ok(())
285
         Ok(())
285
     }
286
     }
286
 }
287
 }
288
+
289
+/// Progress information for an operation.
290
+#[derive(Debug, Clone)]
291
+pub struct ProgressInfo {
292
+    /// Current item being processed.
293
+    pub current_item: String,
294
+    /// Current item index (1-based).
295
+    pub current: usize,
296
+    /// Total items.
297
+    pub total: usize,
298
+    /// Bytes processed (for copy/move).
299
+    pub bytes_done: u64,
300
+    /// Total bytes (for copy/move).
301
+    pub bytes_total: u64,
302
+}
303
+
304
+impl ProgressInfo {
305
+    /// Create new progress info.
306
+    pub fn new(total: usize) -> Self {
307
+        Self {
308
+            current_item: String::new(),
309
+            current: 0,
310
+            total,
311
+            bytes_done: 0,
312
+            bytes_total: 0,
313
+        }
314
+    }
315
+
316
+    /// Get progress as a fraction (0.0 to 1.0).
317
+    pub fn fraction(&self) -> f64 {
318
+        if self.total == 0 {
319
+            0.0
320
+        } else {
321
+            self.current as f64 / self.total as f64
322
+        }
323
+    }
324
+}
325
+
326
+/// A modal progress dialog for long operations.
327
+pub struct ProgressDialog {
328
+    /// Window bounds (for centering).
329
+    bounds: Rect,
330
+    /// Dialog title (operation name).
331
+    title: String,
332
+    /// Whether the dialog is visible.
333
+    visible: bool,
334
+    /// Progress information.
335
+    progress: ProgressInfo,
336
+    /// Whether the operation can be cancelled.
337
+    cancellable: bool,
338
+    /// Whether cancel was requested.
339
+    cancel_requested: bool,
340
+    /// Whether the cancel button is hovered.
341
+    cancel_hovered: bool,
342
+    /// Time when dialog was shown.
343
+    start_time: Option<Instant>,
344
+}
345
+
346
+impl ProgressDialog {
347
+    /// Create a new progress dialog.
348
+    pub fn new(bounds: Rect) -> Self {
349
+        Self {
350
+            bounds,
351
+            title: String::new(),
352
+            visible: false,
353
+            progress: ProgressInfo::new(0),
354
+            cancellable: true,
355
+            cancel_requested: false,
356
+            cancel_hovered: false,
357
+            start_time: None,
358
+        }
359
+    }
360
+
361
+    /// Set bounds.
362
+    pub fn set_bounds(&mut self, bounds: Rect) {
363
+        self.bounds = bounds;
364
+    }
365
+
366
+    /// Show the progress dialog.
367
+    pub fn show(&mut self, title: &str, total: usize, cancellable: bool) {
368
+        self.title = title.to_string();
369
+        self.progress = ProgressInfo::new(total);
370
+        self.cancellable = cancellable;
371
+        self.cancel_requested = false;
372
+        self.cancel_hovered = false;
373
+        self.visible = true;
374
+        self.start_time = Some(Instant::now());
375
+    }
376
+
377
+    /// Update progress.
378
+    pub fn update(&mut self, current: usize, current_item: &str) {
379
+        self.progress.current = current;
380
+        self.progress.current_item = current_item.to_string();
381
+    }
382
+
383
+    /// Update byte progress.
384
+    pub fn update_bytes(&mut self, bytes_done: u64, bytes_total: u64) {
385
+        self.progress.bytes_done = bytes_done;
386
+        self.progress.bytes_total = bytes_total;
387
+    }
388
+
389
+    /// Check if visible.
390
+    pub fn is_visible(&self) -> bool {
391
+        self.visible
392
+    }
393
+
394
+    /// Check if cancel was requested.
395
+    pub fn is_cancel_requested(&self) -> bool {
396
+        self.cancel_requested
397
+    }
398
+
399
+    /// Hide the dialog.
400
+    pub fn hide(&mut self) {
401
+        self.visible = false;
402
+        self.start_time = None;
403
+    }
404
+
405
+    /// Handle key press.
406
+    pub fn handle_key(&mut self, key: &Key) -> bool {
407
+        if !self.visible {
408
+            return false;
409
+        }
410
+
411
+        match key {
412
+            Key::Escape if self.cancellable => {
413
+                self.cancel_requested = true;
414
+                true
415
+            }
416
+            _ => true, // Consume all keys while dialog is visible
417
+        }
418
+    }
419
+
420
+    /// Handle mouse move.
421
+    pub fn on_mouse_move(&mut self, pos: Point) {
422
+        if !self.visible || !self.cancellable {
423
+            return;
424
+        }
425
+
426
+        let cancel_rect = self.cancel_button_rect();
427
+        self.cancel_hovered = cancel_rect.contains_point(pos);
428
+    }
429
+
430
+    /// Handle click.
431
+    pub fn on_click(&mut self, pos: Point) -> bool {
432
+        if !self.visible {
433
+            return false;
434
+        }
435
+
436
+        if self.cancellable {
437
+            let cancel_rect = self.cancel_button_rect();
438
+            if cancel_rect.contains_point(pos) {
439
+                self.cancel_requested = true;
440
+                return true;
441
+            }
442
+        }
443
+
444
+        // Consume click but don't do anything else
445
+        true
446
+    }
447
+
448
+    /// Get the dialog rectangle (centered in bounds).
449
+    fn dialog_rect(&self) -> Rect {
450
+        let dialog_width = 450.min(self.bounds.width.saturating_sub(40));
451
+        let dialog_height = 160.min(self.bounds.height.saturating_sub(40));
452
+        let x = self.bounds.x + (self.bounds.width as i32 - dialog_width as i32) / 2;
453
+        let y = self.bounds.y + (self.bounds.height as i32 - dialog_height as i32) / 2;
454
+        Rect::new(x, y, dialog_width, dialog_height)
455
+    }
456
+
457
+    /// Get progress bar rectangle.
458
+    fn progress_bar_rect(&self) -> Rect {
459
+        let dialog = self.dialog_rect();
460
+        let bar_width = dialog.width.saturating_sub(40);
461
+        let bar_height = 8;
462
+        let x = dialog.x + 20;
463
+        let y = dialog.y + 80;
464
+        Rect::new(x, y, bar_width, bar_height)
465
+    }
466
+
467
+    /// Get cancel button rectangle.
468
+    fn cancel_button_rect(&self) -> Rect {
469
+        let dialog = self.dialog_rect();
470
+        let button_width = 80;
471
+        let button_height = 28;
472
+        let x = dialog.x + (dialog.width as i32 - button_width as i32) / 2;
473
+        let y = dialog.y + dialog.height as i32 - button_height as i32 - 16;
474
+        Rect::new(x, y, button_width, button_height)
475
+    }
476
+
477
+    /// Render the dialog.
478
+    pub fn render(&self, renderer: &Renderer) -> Result<()> {
479
+        if !self.visible {
480
+            return Ok(());
481
+        }
482
+
483
+        let theme = renderer.theme();
484
+
485
+        // Dim background overlay
486
+        renderer.fill_rect(self.bounds, gartk_core::Color::from_u8(0, 0, 0, 180))?;
487
+
488
+        let dialog_rect = self.dialog_rect();
489
+
490
+        // Dialog background
491
+        renderer.fill_rounded_rect(dialog_rect, 8.0, theme.background)?;
492
+        renderer.stroke_rounded_rect(dialog_rect, 8.0, theme.border, 1.0)?;
493
+
494
+        // Title
495
+        let title_style = TextStyle::new()
496
+            .font_family(&theme.font_family)
497
+            .font_size(theme.font_size + 2.0)
498
+            .color(theme.foreground);
499
+
500
+        renderer.text(
501
+            &self.title,
502
+            (dialog_rect.x + 20) as f64,
503
+            (dialog_rect.y + 20) as f64,
504
+            &title_style,
505
+        )?;
506
+
507
+        // Current item
508
+        let item_style = TextStyle::new()
509
+            .font_family(&theme.font_family)
510
+            .font_size(theme.font_size - 1.0)
511
+            .color(theme.item_foreground);
512
+
513
+        let item_text = if self.progress.current_item.is_empty() {
514
+            "Preparing...".to_string()
515
+        } else {
516
+            // Truncate long paths
517
+            let max_len = 50;
518
+            if self.progress.current_item.len() > max_len {
519
+                format!("...{}", &self.progress.current_item[self.progress.current_item.len() - max_len..])
520
+            } else {
521
+                self.progress.current_item.clone()
522
+            }
523
+        };
524
+
525
+        renderer.text(
526
+            &item_text,
527
+            (dialog_rect.x + 20) as f64,
528
+            (dialog_rect.y + 50) as f64,
529
+            &item_style,
530
+        )?;
531
+
532
+        // Progress bar background
533
+        let bar_rect = self.progress_bar_rect();
534
+        renderer.fill_rounded_rect(bar_rect, 4.0, theme.item_background)?;
535
+
536
+        // Progress bar fill
537
+        let progress_fraction = self.progress.fraction();
538
+        if progress_fraction > 0.0 {
539
+            let fill_width = ((bar_rect.width as f64 * progress_fraction) as u32).max(1);
540
+            let fill_rect = Rect::new(bar_rect.x, bar_rect.y, fill_width, bar_rect.height);
541
+            let progress_color = gartk_core::Color::from_hex("#4a9eff").unwrap_or(theme.selection_background);
542
+            renderer.fill_rounded_rect(fill_rect, 4.0, progress_color)?;
543
+        }
544
+
545
+        // Progress text (e.g., "3 of 10")
546
+        let progress_text = format!("{} of {}", self.progress.current, self.progress.total);
547
+        let progress_style = TextStyle::new()
548
+            .font_family(&theme.font_family)
549
+            .font_size(theme.font_size - 1.0)
550
+            .color(theme.item_foreground);
551
+
552
+        let text_width = renderer.measure_text(&progress_text, &progress_style)?.width;
553
+        let text_x = bar_rect.x + (bar_rect.width as i32 - text_width as i32) / 2;
554
+        renderer.text(
555
+            &progress_text,
556
+            text_x as f64,
557
+            (bar_rect.y + bar_rect.height as i32 + 8) as f64,
558
+            &progress_style,
559
+        )?;
560
+
561
+        // Cancel button (if cancellable)
562
+        if self.cancellable {
563
+            let cancel_rect = self.cancel_button_rect();
564
+            let cancel_bg = if self.cancel_hovered {
565
+                theme.item_hover_background
566
+            } else {
567
+                theme.item_background
568
+            };
569
+            renderer.fill_rounded_rect(cancel_rect, 4.0, cancel_bg)?;
570
+
571
+            let button_style = TextStyle::new()
572
+                .font_family(&theme.font_family)
573
+                .font_size(theme.font_size)
574
+                .color(theme.foreground);
575
+
576
+            let cancel_text = "Cancel";
577
+            let cancel_width = renderer.measure_text(cancel_text, &button_style)?.width;
578
+            let cancel_x = cancel_rect.x + (cancel_rect.width as i32 - cancel_width as i32) / 2;
579
+            let cancel_y = cancel_rect.y + (cancel_rect.height as i32 - theme.font_size as i32) / 2;
580
+            renderer.text(cancel_text, cancel_x as f64, cancel_y as f64, &button_style)?;
581
+        }
582
+
583
+        Ok(())
584
+    }
585
+}
garfield/src/ui/help_modal.rsmodified
@@ -57,6 +57,8 @@ const KEYBINDS: &[(&str, &[KeybindEntry])] = &[
57
         KeybindEntry { key: "Ctrl+C", description: "Copy" },
57
         KeybindEntry { key: "Ctrl+C", description: "Copy" },
58
         KeybindEntry { key: "Ctrl+X", description: "Cut" },
58
         KeybindEntry { key: "Ctrl+X", description: "Cut" },
59
         KeybindEntry { key: "Ctrl+V", description: "Paste" },
59
         KeybindEntry { key: "Ctrl+V", description: "Paste" },
60
+        KeybindEntry { key: "Ctrl+Z", description: "Undo" },
61
+        KeybindEntry { key: "Ctrl+Y", description: "Redo" },
60
         KeybindEntry { key: "Delete", description: "Move to trash" },
62
         KeybindEntry { key: "Delete", description: "Move to trash" },
61
         KeybindEntry { key: "Shift+Delete", description: "Delete permanently" },
63
         KeybindEntry { key: "Shift+Delete", description: "Delete permanently" },
62
         KeybindEntry { key: "F2", description: "Rename" },
64
         KeybindEntry { key: "F2", description: "Rename" },
garfield/src/ui/mod.rsmodified
@@ -15,7 +15,7 @@ pub mod tab_bar;
15
 pub mod toolbar;
15
 pub mod toolbar;
16
 
16
 
17
 pub use address_bar::AddressBar;
17
 pub use address_bar::AddressBar;
18
-pub use dialog::{ConfirmDialog, DialogResult};
18
+pub use dialog::{ConfirmDialog, DialogResult, ProgressDialog, ProgressInfo};
19
 pub use breadcrumb::Breadcrumb;
19
 pub use breadcrumb::Breadcrumb;
20
 pub use column_view::{ColumnClickResult, ColumnView};
20
 pub use column_view::{ColumnClickResult, ColumnView};
21
 pub use grid_view::GridView;
21
 pub use grid_view::GridView;
garfield/src/ui/tab.rsmodified
@@ -478,7 +478,9 @@ impl Tab {
478
     }
478
     }
479
 
479
 
480
     /// Confirm rename operation. Returns Ok(new_name) on success, Err(message) on failure.
480
     /// Confirm rename operation. Returns Ok(new_name) on success, Err(message) on failure.
481
-    pub fn confirm_rename(&mut self) -> Result<String, String> {
481
+    /// Confirm and perform the rename.
482
+    /// Returns (original_path, new_path, new_name) on success.
483
+    pub fn confirm_rename(&mut self) -> Result<(PathBuf, PathBuf, String), String> {
482
         let state = match self.renaming.take() {
484
         let state = match self.renaming.take() {
483
             Some(s) => s,
485
             Some(s) => s,
484
             None => return Err("No rename in progress".to_string()),
486
             None => return Err("No rename in progress".to_string()),
@@ -491,9 +493,14 @@ impl Tab {
491
             return Err("Name cannot be empty".to_string());
493
             return Err("Name cannot be empty".to_string());
492
         }
494
         }
493
 
495
 
496
+        // Get the entry path
497
+        let visible = self.visible_entries();
498
+        let entry = visible.get(state.index).ok_or("Entry not found")?;
499
+        let entry_path = entry.path.clone();
500
+
494
         if new_name == state.original {
501
         if new_name == state.original {
495
             // No change, just cancel silently
502
             // No change, just cancel silently
496
-            return Ok(state.original);
503
+            return Ok((entry_path.clone(), entry_path, state.original));
497
         }
504
         }
498
 
505
 
499
         // Validate: no path separators
506
         // Validate: no path separators
@@ -501,16 +508,11 @@ impl Tab {
501
             return Err("Name cannot contain path separators".to_string());
508
             return Err("Name cannot contain path separators".to_string());
502
         }
509
         }
503
 
510
 
504
-        // Get the entry path
505
-        let visible = self.visible_entries();
506
-        let entry = visible.get(state.index).ok_or("Entry not found")?;
507
-        let entry_path = entry.path.clone();
508
-
509
         // Perform the rename
511
         // Perform the rename
510
         match rename_path(&entry_path, new_name) {
512
         match rename_path(&entry_path, new_name) {
511
-            Ok(_) => {
513
+            Ok(new_path) => {
512
                 self.refresh();
514
                 self.refresh();
513
-                Ok(new_name.to_string())
515
+                Ok((entry_path, new_path, new_name.to_string()))
514
             }
516
             }
515
             Err(e) => Err(e.to_string()),
517
             Err(e) => Err(e.to_string()),
516
         }
518
         }