gardesk/garfield / a66df49

Browse files

core: add file operations (copy, cut, paste, trash, new folder)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a66df49aa3ef160830a8b8cee55b880bc43f50fb
Parents
c657243
Tree
ff53cf0

8 changed files

StatusFile+-
M garfield/src/app.rs 184 1
A garfield/src/core/clipboard.rs 104 0
M garfield/src/core/mod.rs 9 0
A garfield/src/core/operations.rs 236 0
A garfield/src/core/trash.rs 219 0
M garfield/src/ui/help_modal.rs 9 0
M garfield/src/ui/tab.rs 9 0
M garfield/src/ui/toolbar.rs 191 1
garfield/src/app.rsmodified
@@ -1,5 +1,10 @@
11
 //! Application state and event loop.
22
 
3
+use garfield::core::{
4
+    Clipboard, ClipboardOperation,
5
+    copy_files, move_files, delete_files, create_directory,
6
+    trash_files,
7
+};
38
 use garfield::ui::pane::SplitDirection;
49
 use garfield::ui::{AddressBar, Breadcrumb, HelpModal, Pane, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
510
 use anyhow::Result;
@@ -67,6 +72,8 @@ pub struct App {
6772
     drag_current_pos: Option<Point>,
6873
     /// Whether drag is actively in progress (moved past threshold).
6974
     drag_active: bool,
75
+    /// Clipboard for file operations.
76
+    clipboard: Clipboard,
7077
 }
7178
 
7279
 impl App {
@@ -200,6 +207,7 @@ impl App {
200207
             drag_start_pos: None,
201208
             drag_current_pos: None,
202209
             drag_active: false,
210
+            clipboard: Clipboard::new(),
203211
         };
204212
 
205213
         app.update_status_bar();
@@ -630,9 +638,13 @@ impl App {
630638
             }
631639
         }
632640
 
633
-        // Ctrl+Shift keybinds (splits)
641
+        // Ctrl+Shift keybinds (splits, new folder)
634642
         if modifiers.ctrl && modifiers.shift {
635643
             match key {
644
+                Key::Char('n') | Key::Char('N') => {
645
+                    self.create_new_folder();
646
+                    return;
647
+                }
636648
                 Key::Char('h') | Key::Char('H') => {
637649
                     self.split_horizontal();
638650
                     return;
@@ -737,11 +749,35 @@ impl App {
737749
                     }
738750
                     return;
739751
                 }
752
+                Key::Char('c') | Key::Char('C') => {
753
+                    self.copy_selected();
754
+                    return;
755
+                }
756
+                Key::Char('x') | Key::Char('X') => {
757
+                    self.cut_selected();
758
+                    return;
759
+                }
760
+                Key::Char('v') | Key::Char('V') => {
761
+                    self.paste();
762
+                    return;
763
+                }
740764
                 _ => {}
741765
             }
742766
         }
743767
 
768
+        // Shift+Delete for permanent delete
769
+        if modifiers.shift && *key == Key::Delete {
770
+            self.delete_selected_permanently();
771
+            return;
772
+        }
773
+
744774
         match key {
775
+            Key::Delete => {
776
+                self.trash_selected();
777
+            }
778
+            Key::F2 => {
779
+                self.start_rename();
780
+            }
745781
             Key::Escape => {
746782
                 // Cancel drag first if active
747783
                 if self.drag_active || self.drag_source_path.is_some() {
@@ -1110,6 +1146,143 @@ impl App {
11101146
         self.update_status_bar();
11111147
     }
11121148
 
1149
+    // === File Operations ===
1150
+
1151
+    /// Copy selected files to clipboard.
1152
+    fn copy_selected(&mut self) {
1153
+        let paths = self.get_selected_paths();
1154
+        if !paths.is_empty() {
1155
+            self.clipboard.copy(paths);
1156
+            self.update_status_bar();
1157
+        }
1158
+    }
1159
+
1160
+    /// Cut selected files to clipboard.
1161
+    fn cut_selected(&mut self) {
1162
+        let paths = self.get_selected_paths();
1163
+        if !paths.is_empty() {
1164
+            self.clipboard.cut(paths);
1165
+            self.update_status_bar();
1166
+        }
1167
+    }
1168
+
1169
+    /// Paste files from clipboard to current directory.
1170
+    fn paste(&mut self) {
1171
+        let dest_dir = self.focused_pane()
1172
+            .and_then(|p| p.active_tab())
1173
+            .map(|t| t.current_path().clone());
1174
+
1175
+        let dest_dir = match dest_dir {
1176
+            Some(d) => d,
1177
+            None => return,
1178
+        };
1179
+
1180
+        if let Some((files, op)) = self.clipboard.take() {
1181
+            let result = match op {
1182
+                ClipboardOperation::Copy => copy_files(&files, &dest_dir),
1183
+                ClipboardOperation::Cut => move_files(&files, &dest_dir),
1184
+            };
1185
+
1186
+            // Show result in status bar or log errors
1187
+            if !result.success {
1188
+                // TODO: Show error dialog
1189
+                eprintln!("File operation failed: {:?}", result.error);
1190
+            }
1191
+
1192
+            self.refresh();
1193
+        }
1194
+    }
1195
+
1196
+    /// Move selected files to trash.
1197
+    fn trash_selected(&mut self) {
1198
+        let paths = self.get_selected_paths();
1199
+        if paths.is_empty() {
1200
+            return;
1201
+        }
1202
+
1203
+        let results = trash_files(&paths);
1204
+        let failed: Vec<_> = results.iter()
1205
+            .filter_map(|r| r.as_ref().err())
1206
+            .collect();
1207
+
1208
+        if !failed.is_empty() {
1209
+            // TODO: Show error dialog
1210
+            eprintln!("Failed to trash {} files", failed.len());
1211
+        }
1212
+
1213
+        self.refresh();
1214
+    }
1215
+
1216
+    /// Delete selected files permanently.
1217
+    fn delete_selected_permanently(&mut self) {
1218
+        let paths = self.get_selected_paths();
1219
+        if paths.is_empty() {
1220
+            return;
1221
+        }
1222
+
1223
+        // TODO: Show confirmation dialog
1224
+        // For now, just perform the delete
1225
+        let result = delete_files(&paths);
1226
+
1227
+        if !result.success {
1228
+            eprintln!("Delete failed: {:?}", result.error);
1229
+        }
1230
+
1231
+        self.refresh();
1232
+    }
1233
+
1234
+    /// Create a new folder in the current directory.
1235
+    fn create_new_folder(&mut self) {
1236
+        let current_dir = self.focused_pane()
1237
+            .and_then(|p| p.active_tab())
1238
+            .map(|t| t.current_path().clone());
1239
+
1240
+        let current_dir = match current_dir {
1241
+            Some(d) => d,
1242
+            None => return,
1243
+        };
1244
+
1245
+        // Generate unique name
1246
+        let base_name = "New Folder";
1247
+        let mut name = base_name.to_string();
1248
+        let mut counter = 1;
1249
+        while current_dir.join(&name).exists() {
1250
+            name = format!("{} ({})", base_name, counter);
1251
+            counter += 1;
1252
+        }
1253
+
1254
+        match create_directory(&current_dir, &name) {
1255
+            Ok(_) => {
1256
+                self.refresh();
1257
+                // TODO: Start rename on the new folder
1258
+            }
1259
+            Err(e) => {
1260
+                // TODO: Show error dialog
1261
+                eprintln!("Failed to create folder: {}", e);
1262
+            }
1263
+        }
1264
+    }
1265
+
1266
+    /// Start inline rename for the selected file.
1267
+    fn start_rename(&mut self) {
1268
+        // TODO: Implement inline rename UI
1269
+        // For now, just log that rename was requested
1270
+        if let Some(entry) = self.focused_pane()
1271
+            .and_then(|p| p.active_tab())
1272
+            .and_then(|t| t.selected_entry())
1273
+        {
1274
+            eprintln!("Rename requested for: {}", entry.name);
1275
+        }
1276
+    }
1277
+
1278
+    /// Get paths of all selected files.
1279
+    fn get_selected_paths(&self) -> Vec<PathBuf> {
1280
+        self.focused_pane()
1281
+            .and_then(|p| p.active_tab())
1282
+            .map(|t| t.selected_paths())
1283
+            .unwrap_or_default()
1284
+    }
1285
+
11131286
     /// Set the view mode for the active tab.
11141287
     fn set_view_mode(&mut self, mode: ViewMode) {
11151288
         if let Some(pane) = self.focused_pane_mut() {
@@ -1135,6 +1308,11 @@ impl App {
11351308
             ToolbarAction::GoForward => self.go_forward(),
11361309
             ToolbarAction::GoUp => self.go_up(),
11371310
             ToolbarAction::Help => self.help_modal.toggle(),
1311
+            ToolbarAction::Copy => self.copy_selected(),
1312
+            ToolbarAction::Cut => self.cut_selected(),
1313
+            ToolbarAction::Paste => self.paste(),
1314
+            ToolbarAction::Trash => self.trash_selected(),
1315
+            ToolbarAction::NewFolder => self.create_new_folder(),
11381316
         }
11391317
     }
11401318
 
@@ -1191,6 +1369,11 @@ impl App {
11911369
             self.status_bar.update(visible_count, selected_count, selected_size);
11921370
             self.status_bar.set_view_mode(view_mode);
11931371
             self.status_bar.update_free_space(&path);
1372
+
1373
+            // Update toolbar file ops state
1374
+            let has_selection = selected_count > 0;
1375
+            let has_clipboard = self.clipboard.has_files();
1376
+            self.toolbar.set_file_ops_state(has_selection, has_clipboard);
11941377
         }
11951378
     }
11961379
 
garfield/src/core/clipboard.rsadded
@@ -0,0 +1,104 @@
1
+//! Clipboard state for file operations.
2
+
3
+use std::path::PathBuf;
4
+
5
+/// Operation type for clipboard contents.
6
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7
+pub enum ClipboardOperation {
8
+    /// Files will be copied.
9
+    Copy,
10
+    /// Files will be moved (cut).
11
+    Cut,
12
+}
13
+
14
+/// Clipboard holding files for copy/cut operations.
15
+#[derive(Debug, Clone, Default)]
16
+pub struct Clipboard {
17
+    /// Files in the clipboard.
18
+    files: Vec<PathBuf>,
19
+    /// Operation to perform.
20
+    operation: Option<ClipboardOperation>,
21
+}
22
+
23
+impl Clipboard {
24
+    /// Create a new empty clipboard.
25
+    pub fn new() -> Self {
26
+        Self::default()
27
+    }
28
+
29
+    /// Copy files to clipboard.
30
+    pub fn copy(&mut self, files: Vec<PathBuf>) {
31
+        self.files = files;
32
+        self.operation = Some(ClipboardOperation::Copy);
33
+    }
34
+
35
+    /// Cut files to clipboard.
36
+    pub fn cut(&mut self, files: Vec<PathBuf>) {
37
+        self.files = files;
38
+        self.operation = Some(ClipboardOperation::Cut);
39
+    }
40
+
41
+    /// Clear the clipboard.
42
+    pub fn clear(&mut self) {
43
+        self.files.clear();
44
+        self.operation = None;
45
+    }
46
+
47
+    /// Get files in the clipboard.
48
+    pub fn files(&self) -> &[PathBuf] {
49
+        &self.files
50
+    }
51
+
52
+    /// Get the operation type.
53
+    pub fn operation(&self) -> Option<ClipboardOperation> {
54
+        self.operation
55
+    }
56
+
57
+    /// Check if clipboard has files.
58
+    pub fn has_files(&self) -> bool {
59
+        !self.files.is_empty() && self.operation.is_some()
60
+    }
61
+
62
+    /// Check if this is a cut operation.
63
+    pub fn is_cut(&self) -> bool {
64
+        self.operation == Some(ClipboardOperation::Cut)
65
+    }
66
+
67
+    /// Get status text for display.
68
+    pub fn status_text(&self) -> Option<String> {
69
+        if !self.has_files() {
70
+            return None;
71
+        }
72
+
73
+        let count = self.files.len();
74
+        let op = match self.operation {
75
+            Some(ClipboardOperation::Copy) => "copied",
76
+            Some(ClipboardOperation::Cut) => "cut",
77
+            None => return None,
78
+        };
79
+
80
+        Some(if count == 1 {
81
+            format!("1 item {}", op)
82
+        } else {
83
+            format!("{} items {}", count, op)
84
+        })
85
+    }
86
+
87
+    /// Take files from clipboard (consumes for cut operations).
88
+    pub fn take(&mut self) -> Option<(Vec<PathBuf>, ClipboardOperation)> {
89
+        if !self.has_files() {
90
+            return None;
91
+        }
92
+
93
+        let files = std::mem::take(&mut self.files);
94
+        let op = self.operation.take()?;
95
+
96
+        // For copy, restore the files so they can be pasted again
97
+        if op == ClipboardOperation::Copy {
98
+            self.files = files.clone();
99
+            self.operation = Some(op);
100
+        }
101
+
102
+        Some((files, op))
103
+    }
104
+}
garfield/src/core/mod.rsmodified
@@ -1,9 +1,18 @@
11
 //! Core file system operations.
22
 
3
+pub mod clipboard;
34
 pub mod entry;
45
 pub mod history;
6
+pub mod operations;
7
+pub mod trash;
58
 
9
+pub use clipboard::{Clipboard, ClipboardOperation};
610
 pub use entry::{
711
     read_directory, sort_entries, EntryType, FileEntry, SortDirection, SortOrder,
812
 };
913
 pub use history::History;
14
+pub use operations::{
15
+    copy_files, copy_path, create_directory, delete_files, delete_path,
16
+    make_unique_name, move_files, move_path, rename_path, OperationResult,
17
+};
18
+pub use trash::{empty_trash, restore_from_trash, trash_file, trash_files, trash_dir};
garfield/src/core/operations.rsadded
@@ -0,0 +1,236 @@
1
+//! File operations (copy, move, delete, rename).
2
+
3
+use std::fs;
4
+use std::io;
5
+use std::path::{Path, PathBuf};
6
+
7
+/// Result of a file operation.
8
+#[derive(Debug)]
9
+pub struct OperationResult {
10
+    /// Whether the operation succeeded.
11
+    pub success: bool,
12
+    /// Error message if failed.
13
+    pub error: Option<String>,
14
+    /// Files that were processed.
15
+    pub processed: Vec<PathBuf>,
16
+    /// Files that failed.
17
+    pub failed: Vec<(PathBuf, String)>,
18
+}
19
+
20
+impl OperationResult {
21
+    /// Create a successful result.
22
+    pub fn success(processed: Vec<PathBuf>) -> Self {
23
+        Self {
24
+            success: true,
25
+            error: None,
26
+            processed,
27
+            failed: Vec::new(),
28
+        }
29
+    }
30
+
31
+    /// Create a failed result.
32
+    pub fn failure(error: String) -> Self {
33
+        Self {
34
+            success: false,
35
+            error: Some(error),
36
+            processed: Vec::new(),
37
+            failed: Vec::new(),
38
+        }
39
+    }
40
+
41
+    /// Create a partial result.
42
+    pub fn partial(processed: Vec<PathBuf>, failed: Vec<(PathBuf, String)>) -> Self {
43
+        Self {
44
+            success: failed.is_empty(),
45
+            error: if failed.is_empty() {
46
+                None
47
+            } else {
48
+                Some(format!("{} files failed", failed.len()))
49
+            },
50
+            processed,
51
+            failed,
52
+        }
53
+    }
54
+}
55
+
56
+/// Copy a file or directory to a destination.
57
+pub fn copy_path(source: &Path, dest_dir: &Path) -> io::Result<PathBuf> {
58
+    let file_name = source.file_name().ok_or_else(|| {
59
+        io::Error::new(io::ErrorKind::InvalidInput, "Invalid source path")
60
+    })?;
61
+    let dest = dest_dir.join(file_name);
62
+
63
+    if source.is_dir() {
64
+        copy_dir_recursive(source, &dest)?;
65
+    } else {
66
+        fs::copy(source, &dest)?;
67
+    }
68
+
69
+    Ok(dest)
70
+}
71
+
72
+/// Copy a directory recursively.
73
+fn copy_dir_recursive(source: &Path, dest: &Path) -> io::Result<()> {
74
+    fs::create_dir_all(dest)?;
75
+
76
+    for entry in fs::read_dir(source)? {
77
+        let entry = entry?;
78
+        let entry_path = entry.path();
79
+        let dest_path = dest.join(entry.file_name());
80
+
81
+        if entry_path.is_dir() {
82
+            copy_dir_recursive(&entry_path, &dest_path)?;
83
+        } else {
84
+            fs::copy(&entry_path, &dest_path)?;
85
+        }
86
+    }
87
+
88
+    Ok(())
89
+}
90
+
91
+/// Move a file or directory to a destination.
92
+pub fn move_path(source: &Path, dest_dir: &Path) -> io::Result<PathBuf> {
93
+    let file_name = source.file_name().ok_or_else(|| {
94
+        io::Error::new(io::ErrorKind::InvalidInput, "Invalid source path")
95
+    })?;
96
+    let dest = dest_dir.join(file_name);
97
+
98
+    // Try rename first (fast, same filesystem)
99
+    if fs::rename(source, &dest).is_ok() {
100
+        return Ok(dest);
101
+    }
102
+
103
+    // Fall back to copy + delete (cross-filesystem)
104
+    if source.is_dir() {
105
+        copy_dir_recursive(source, &dest)?;
106
+        fs::remove_dir_all(source)?;
107
+    } else {
108
+        fs::copy(source, &dest)?;
109
+        fs::remove_file(source)?;
110
+    }
111
+
112
+    Ok(dest)
113
+}
114
+
115
+/// Delete a file or directory permanently.
116
+pub fn delete_path(path: &Path) -> io::Result<()> {
117
+    if path.is_dir() {
118
+        fs::remove_dir_all(path)
119
+    } else {
120
+        fs::remove_file(path)
121
+    }
122
+}
123
+
124
+/// Rename a file or directory.
125
+pub fn rename_path(path: &Path, new_name: &str) -> io::Result<PathBuf> {
126
+    let parent = path.parent().ok_or_else(|| {
127
+        io::Error::new(io::ErrorKind::InvalidInput, "Cannot get parent directory")
128
+    })?;
129
+    let new_path = parent.join(new_name);
130
+
131
+    if new_path.exists() {
132
+        return Err(io::Error::new(
133
+            io::ErrorKind::AlreadyExists,
134
+            format!("'{}' already exists", new_name),
135
+        ));
136
+    }
137
+
138
+    fs::rename(path, &new_path)?;
139
+    Ok(new_path)
140
+}
141
+
142
+/// Create a new directory.
143
+pub fn create_directory(parent: &Path, name: &str) -> io::Result<PathBuf> {
144
+    let new_path = parent.join(name);
145
+
146
+    if new_path.exists() {
147
+        return Err(io::Error::new(
148
+            io::ErrorKind::AlreadyExists,
149
+            format!("'{}' already exists", name),
150
+        ));
151
+    }
152
+
153
+    fs::create_dir(&new_path)?;
154
+    Ok(new_path)
155
+}
156
+
157
+/// Generate a unique name if the target already exists.
158
+pub fn make_unique_name(dest_dir: &Path, name: &str) -> String {
159
+    let path = dest_dir.join(name);
160
+    if !path.exists() {
161
+        return name.to_string();
162
+    }
163
+
164
+    // Split name into base and extension
165
+    let (base, ext) = if let Some(dot_pos) = name.rfind('.') {
166
+        (&name[..dot_pos], Some(&name[dot_pos..]))
167
+    } else {
168
+        (name, None)
169
+    };
170
+
171
+    // Try adding numbers until we find a unique name
172
+    for i in 1..1000 {
173
+        let new_name = match ext {
174
+            Some(ext) => format!("{} ({}){}", base, i, ext),
175
+            None => format!("{} ({})", base, i),
176
+        };
177
+        if !dest_dir.join(&new_name).exists() {
178
+            return new_name;
179
+        }
180
+    }
181
+
182
+    // Fallback with timestamp
183
+    let timestamp = std::time::SystemTime::now()
184
+        .duration_since(std::time::UNIX_EPOCH)
185
+        .map(|d| d.as_secs())
186
+        .unwrap_or(0);
187
+    match ext {
188
+        Some(ext) => format!("{}_{}{}", base, timestamp, ext),
189
+        None => format!("{}_{}", base, timestamp),
190
+    }
191
+}
192
+
193
+/// Copy multiple files to a destination.
194
+pub fn copy_files(sources: &[PathBuf], dest_dir: &Path) -> OperationResult {
195
+    let mut processed = Vec::new();
196
+    let mut failed = Vec::new();
197
+
198
+    for source in sources {
199
+        match copy_path(source, dest_dir) {
200
+            Ok(dest) => processed.push(dest),
201
+            Err(e) => failed.push((source.clone(), e.to_string())),
202
+        }
203
+    }
204
+
205
+    OperationResult::partial(processed, failed)
206
+}
207
+
208
+/// Move multiple files to a destination.
209
+pub fn move_files(sources: &[PathBuf], dest_dir: &Path) -> OperationResult {
210
+    let mut processed = Vec::new();
211
+    let mut failed = Vec::new();
212
+
213
+    for source in sources {
214
+        match move_path(source, dest_dir) {
215
+            Ok(dest) => processed.push(dest),
216
+            Err(e) => failed.push((source.clone(), e.to_string())),
217
+        }
218
+    }
219
+
220
+    OperationResult::partial(processed, failed)
221
+}
222
+
223
+/// Delete multiple files permanently.
224
+pub fn delete_files(paths: &[PathBuf]) -> OperationResult {
225
+    let mut processed = Vec::new();
226
+    let mut failed = Vec::new();
227
+
228
+    for path in paths {
229
+        match delete_path(path) {
230
+            Ok(()) => processed.push(path.clone()),
231
+            Err(e) => failed.push((path.clone(), e.to_string())),
232
+        }
233
+    }
234
+
235
+    OperationResult::partial(processed, failed)
236
+}
garfield/src/core/trash.rsadded
@@ -0,0 +1,219 @@
1
+//! Freedesktop trash support.
2
+
3
+use std::fs;
4
+use std::io;
5
+use std::path::{Path, PathBuf};
6
+use std::time::SystemTime;
7
+
8
+/// Get the trash directory path.
9
+pub fn trash_dir() -> Option<PathBuf> {
10
+    dirs::data_dir().map(|d| d.join("Trash"))
11
+}
12
+
13
+/// Get the trash files directory.
14
+pub fn trash_files_dir() -> Option<PathBuf> {
15
+    trash_dir().map(|d| d.join("files"))
16
+}
17
+
18
+/// Get the trash info directory.
19
+pub fn trash_info_dir() -> Option<PathBuf> {
20
+    trash_dir().map(|d| d.join("info"))
21
+}
22
+
23
+/// Ensure trash directories exist.
24
+fn ensure_trash_dirs() -> io::Result<(PathBuf, PathBuf)> {
25
+    let files_dir = trash_files_dir().ok_or_else(|| {
26
+        io::Error::new(io::ErrorKind::NotFound, "Cannot determine trash directory")
27
+    })?;
28
+    let info_dir = trash_info_dir().ok_or_else(|| {
29
+        io::Error::new(io::ErrorKind::NotFound, "Cannot determine trash info directory")
30
+    })?;
31
+
32
+    fs::create_dir_all(&files_dir)?;
33
+    fs::create_dir_all(&info_dir)?;
34
+
35
+    Ok((files_dir, info_dir))
36
+}
37
+
38
+/// Generate a unique trash name.
39
+fn unique_trash_name(files_dir: &Path, name: &str) -> String {
40
+    if !files_dir.join(name).exists() {
41
+        return name.to_string();
42
+    }
43
+
44
+    let (base, ext) = if let Some(dot_pos) = name.rfind('.') {
45
+        (&name[..dot_pos], Some(&name[dot_pos..]))
46
+    } else {
47
+        (name, None)
48
+    };
49
+
50
+    for i in 1..10000 {
51
+        let new_name = match ext {
52
+            Some(ext) => format!("{}.{}{}", base, i, ext),
53
+            None => format!("{}.{}", base, i),
54
+        };
55
+        if !files_dir.join(&new_name).exists() {
56
+            return new_name;
57
+        }
58
+    }
59
+
60
+    // Fallback with timestamp
61
+    let timestamp = SystemTime::now()
62
+        .duration_since(std::time::UNIX_EPOCH)
63
+        .map(|d| d.as_millis())
64
+        .unwrap_or(0);
65
+    format!("{}.{}", name, timestamp)
66
+}
67
+
68
+/// Create a .trashinfo file content.
69
+fn create_trash_info(original_path: &Path) -> String {
70
+    let path_encoded = original_path
71
+        .to_string_lossy()
72
+        .replace('%', "%25")
73
+        .replace('\n', "%0A")
74
+        .replace('\r', "%0D");
75
+
76
+    let deletion_date = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S");
77
+
78
+    format!(
79
+        "[Trash Info]\nPath={}\nDeletionDate={}\n",
80
+        path_encoded, deletion_date
81
+    )
82
+}
83
+
84
+/// Move a file to trash.
85
+pub fn trash_file(path: &Path) -> io::Result<PathBuf> {
86
+    let (files_dir, info_dir) = ensure_trash_dirs()?;
87
+
88
+    let original_name = path
89
+        .file_name()
90
+        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid path"))?
91
+        .to_string_lossy()
92
+        .to_string();
93
+
94
+    let trash_name = unique_trash_name(&files_dir, &original_name);
95
+    let trash_path = files_dir.join(&trash_name);
96
+    let info_path = info_dir.join(format!("{}.trashinfo", trash_name));
97
+
98
+    // Write .trashinfo file first
99
+    let info_content = create_trash_info(path);
100
+    fs::write(&info_path, info_content)?;
101
+
102
+    // Move file to trash
103
+    if let Err(_) = fs::rename(path, &trash_path) {
104
+        // If rename fails, try copy + delete (cross-filesystem)
105
+        if path.is_dir() {
106
+            copy_dir_recursive(path, &trash_path)?;
107
+            fs::remove_dir_all(path)?;
108
+        } else {
109
+            fs::copy(path, &trash_path)?;
110
+            fs::remove_file(path)?;
111
+        }
112
+    }
113
+
114
+    Ok(trash_path)
115
+}
116
+
117
+/// Copy a directory recursively.
118
+fn copy_dir_recursive(source: &Path, dest: &Path) -> io::Result<()> {
119
+    fs::create_dir_all(dest)?;
120
+
121
+    for entry in fs::read_dir(source)? {
122
+        let entry = entry?;
123
+        let entry_path = entry.path();
124
+        let dest_path = dest.join(entry.file_name());
125
+
126
+        if entry_path.is_dir() {
127
+            copy_dir_recursive(&entry_path, &dest_path)?;
128
+        } else {
129
+            fs::copy(&entry_path, &dest_path)?;
130
+        }
131
+    }
132
+
133
+    Ok(())
134
+}
135
+
136
+/// Move multiple files to trash.
137
+pub fn trash_files(paths: &[PathBuf]) -> Vec<Result<PathBuf, (PathBuf, String)>> {
138
+    paths
139
+        .iter()
140
+        .map(|path| {
141
+            trash_file(path)
142
+                .map_err(|e| (path.clone(), e.to_string()))
143
+        })
144
+        .collect()
145
+}
146
+
147
+/// Restore a file from trash to its original location.
148
+pub fn restore_from_trash(trash_name: &str) -> io::Result<PathBuf> {
149
+    let (files_dir, info_dir) = ensure_trash_dirs()?;
150
+
151
+    let trash_path = files_dir.join(trash_name);
152
+    let info_path = info_dir.join(format!("{}.trashinfo", trash_name));
153
+
154
+    if !trash_path.exists() {
155
+        return Err(io::Error::new(
156
+            io::ErrorKind::NotFound,
157
+            "File not found in trash",
158
+        ));
159
+    }
160
+
161
+    // Read original path from .trashinfo
162
+    let info_content = fs::read_to_string(&info_path)?;
163
+    let original_path = parse_trash_info_path(&info_content).ok_or_else(|| {
164
+        io::Error::new(io::ErrorKind::InvalidData, "Invalid .trashinfo file")
165
+    })?;
166
+
167
+    // Restore the file
168
+    if let Some(parent) = original_path.parent() {
169
+        fs::create_dir_all(parent)?;
170
+    }
171
+
172
+    fs::rename(&trash_path, &original_path)?;
173
+    fs::remove_file(&info_path)?;
174
+
175
+    Ok(original_path)
176
+}
177
+
178
+/// Parse the original path from .trashinfo content.
179
+fn parse_trash_info_path(content: &str) -> Option<PathBuf> {
180
+    for line in content.lines() {
181
+        if let Some(path) = line.strip_prefix("Path=") {
182
+            // URL decode the path
183
+            let decoded = path
184
+                .replace("%0A", "\n")
185
+                .replace("%0D", "\r")
186
+                .replace("%25", "%");
187
+            return Some(PathBuf::from(decoded));
188
+        }
189
+    }
190
+    None
191
+}
192
+
193
+/// Empty the trash.
194
+pub fn empty_trash() -> io::Result<()> {
195
+    let (files_dir, info_dir) = ensure_trash_dirs()?;
196
+
197
+    // Remove all files
198
+    if files_dir.exists() {
199
+        for entry in fs::read_dir(&files_dir)? {
200
+            let entry = entry?;
201
+            let path = entry.path();
202
+            if path.is_dir() {
203
+                fs::remove_dir_all(&path)?;
204
+            } else {
205
+                fs::remove_file(&path)?;
206
+            }
207
+        }
208
+    }
209
+
210
+    // Remove all .trashinfo files
211
+    if info_dir.exists() {
212
+        for entry in fs::read_dir(&info_dir)? {
213
+            let entry = entry?;
214
+            fs::remove_file(entry.path())?;
215
+        }
216
+    }
217
+
218
+    Ok(())
219
+}
garfield/src/ui/help_modal.rsmodified
@@ -53,6 +53,15 @@ const KEYBINDS: &[(&str, &[KeybindEntry])] = &[
5353
         KeybindEntry { key: "Ctrl+Click", description: "Toggle selection" },
5454
         KeybindEntry { key: "Shift+Click", description: "Range select" },
5555
     ]),
56
+    ("File Operations", &[
57
+        KeybindEntry { key: "Ctrl+C", description: "Copy" },
58
+        KeybindEntry { key: "Ctrl+X", description: "Cut" },
59
+        KeybindEntry { key: "Ctrl+V", description: "Paste" },
60
+        KeybindEntry { key: "Delete", description: "Move to trash" },
61
+        KeybindEntry { key: "Shift+Delete", description: "Delete permanently" },
62
+        KeybindEntry { key: "F2", description: "Rename" },
63
+        KeybindEntry { key: "Ctrl+Shift+N", description: "New folder" },
64
+    ]),
5665
     ("Other", &[
5766
         KeybindEntry { key: "Ctrl+L", description: "Edit address" },
5867
         KeybindEntry { key: "F5", description: "Refresh" },
garfield/src/ui/tab.rsmodified
@@ -211,6 +211,15 @@ impl Tab {
211211
         }
212212
     }
213213
 
214
+    /// Get paths of all selected entries.
215
+    pub fn selected_paths(&self) -> Vec<std::path::PathBuf> {
216
+        match self.view_mode {
217
+            ViewMode::List => self.list_view.selected_entries().iter().map(|e| e.path.clone()).collect(),
218
+            ViewMode::Grid => self.grid_view.selected_entries().iter().map(|e| e.path.clone()).collect(),
219
+            ViewMode::Columns => self.column_view.selected_entries().iter().map(|e| e.path.clone()).collect(),
220
+        }
221
+    }
222
+
214223
     /// Get the entry at the given position (for drag detection).
215224
     pub fn entry_at_point(&self, pos: Point) -> Option<&FileEntry> {
216225
         match self.view_mode {
garfield/src/ui/toolbar.rsmodified
@@ -39,6 +39,16 @@ pub enum ToolbarAction {
3939
     GoUp,
4040
     /// Show help modal.
4141
     Help,
42
+    /// Copy selected files.
43
+    Copy,
44
+    /// Cut selected files.
45
+    Cut,
46
+    /// Paste files from clipboard.
47
+    Paste,
48
+    /// Delete selected files to trash.
49
+    Trash,
50
+    /// Create new folder.
51
+    NewFolder,
4252
 }
4353
 
4454
 /// A toolbar button.
@@ -57,6 +67,8 @@ pub struct Toolbar {
5767
     active_view: ToolbarAction,
5868
     can_go_back: bool,
5969
     can_go_forward: bool,
70
+    has_selection: bool,
71
+    has_clipboard: bool,
6072
 }
6173
 
6274
 impl Toolbar {
@@ -69,6 +81,8 @@ impl Toolbar {
6981
             active_view: ToolbarAction::ViewList,
7082
             can_go_back: false,
7183
             can_go_forward: false,
84
+            has_selection: false,
85
+            has_clipboard: false,
7286
         };
7387
         toolbar.layout_buttons();
7488
         toolbar
@@ -91,6 +105,12 @@ impl Toolbar {
91105
         self.can_go_forward = can_forward;
92106
     }
93107
 
108
+    /// Set file operation state.
109
+    pub fn set_file_ops_state(&mut self, has_selection: bool, has_clipboard: bool) {
110
+        self.has_selection = has_selection;
111
+        self.has_clipboard = has_clipboard;
112
+    }
113
+
94114
     /// Layout buttons.
95115
     fn layout_buttons(&mut self) {
96116
         self.buttons.clear();
@@ -150,6 +170,26 @@ impl Toolbar {
150170
             x += BUTTON_SIZE as i32 + BUTTON_PADDING as i32;
151171
         }
152172
 
173
+        x += GROUP_SEPARATOR as i32;
174
+
175
+        // File operation buttons
176
+        let file_buttons = [
177
+            (ToolbarAction::Copy, "Copy (Ctrl+C)"),
178
+            (ToolbarAction::Cut, "Cut (Ctrl+X)"),
179
+            (ToolbarAction::Paste, "Paste (Ctrl+V)"),
180
+            (ToolbarAction::Trash, "Delete (Del)"),
181
+            (ToolbarAction::NewFolder, "New Folder (Ctrl+Shift+N)"),
182
+        ];
183
+
184
+        for (action, tooltip) in file_buttons {
185
+            self.buttons.push(ToolbarButton {
186
+                action,
187
+                bounds: Rect::new(x, y, BUTTON_SIZE, BUTTON_SIZE),
188
+                tooltip,
189
+            });
190
+            x += BUTTON_SIZE as i32 + BUTTON_PADDING as i32;
191
+        }
192
+
153193
         // Help button (right-aligned)
154194
         let help_x = self.bounds.x + self.bounds.width as i32 - BUTTON_SIZE as i32 - BUTTON_PADDING as i32;
155195
         self.buttons.push(ToolbarButton {
@@ -173,10 +213,13 @@ impl Toolbar {
173213
     pub fn on_click(&self, pos: Point) -> Option<ToolbarAction> {
174214
         for button in &self.buttons {
175215
             if button.bounds.contains_point(pos) {
176
-                // Don't trigger disabled nav buttons
216
+                // Don't trigger disabled buttons
177217
                 match button.action {
178218
                     ToolbarAction::GoBack if !self.can_go_back => return None,
179219
                     ToolbarAction::GoForward if !self.can_go_forward => return None,
220
+                    ToolbarAction::Copy | ToolbarAction::Cut | ToolbarAction::Trash
221
+                        if !self.has_selection => return None,
222
+                    ToolbarAction::Paste if !self.has_clipboard => return None,
180223
                     _ => return Some(button.action),
181224
                 }
182225
             }
@@ -283,6 +326,8 @@ impl Toolbar {
283326
         let is_disabled = match button.action {
284327
             ToolbarAction::GoBack => !self.can_go_back,
285328
             ToolbarAction::GoForward => !self.can_go_forward,
329
+            ToolbarAction::Copy | ToolbarAction::Cut | ToolbarAction::Trash => !self.has_selection,
330
+            ToolbarAction::Paste => !self.has_clipboard,
286331
             _ => false,
287332
         };
288333
 
@@ -321,6 +366,11 @@ impl Toolbar {
321366
             ToolbarAction::SplitHorizontal => self.draw_split_h_icon(renderer, cx, cy, icon_color)?,
322367
             ToolbarAction::SplitVertical => self.draw_split_v_icon(renderer, cx, cy, icon_color)?,
323368
             ToolbarAction::Help => self.draw_help_icon(renderer, cx, cy, icon_color)?,
369
+            ToolbarAction::Copy => self.draw_copy_icon(renderer, cx, cy, icon_color)?,
370
+            ToolbarAction::Cut => self.draw_cut_icon(renderer, cx, cy, icon_color)?,
371
+            ToolbarAction::Paste => self.draw_paste_icon(renderer, cx, cy, icon_color)?,
372
+            ToolbarAction::Trash => self.draw_trash_icon(renderer, cx, cy, icon_color)?,
373
+            ToolbarAction::NewFolder => self.draw_new_folder_icon(renderer, cx, cy, icon_color)?,
324374
         }
325375
 
326376
         Ok(())
@@ -457,4 +507,144 @@ impl Toolbar {
457507
         renderer.fill_rect(Rect::new((cx - 1.0) as i32, (cy + 3.0) as i32, 3, 3), color)?;
458508
         Ok(())
459509
     }
510
+
511
+    fn draw_copy_icon(&self, renderer: &Renderer, cx: f64, cy: f64, color: gartk_core::Color) -> Result<()> {
512
+        // Two overlapping rectangles (copy symbol)
513
+        let w = 7.0;
514
+        let h = 9.0;
515
+        let offset = 3.0;
516
+
517
+        // Back rectangle (slightly offset)
518
+        let bx = cx - w/2.0 - offset/2.0;
519
+        let by = cy - h/2.0 - offset/2.0;
520
+        renderer.stroke_rect(Rect::new(bx as i32, by as i32, w as u32, h as u32), color, 1.5)?;
521
+
522
+        // Front rectangle
523
+        let fx = cx - w/2.0 + offset/2.0;
524
+        let fy = cy - h/2.0 + offset/2.0;
525
+        renderer.fill_rect(Rect::new(fx as i32, fy as i32, w as u32, h as u32), color.with_alpha(0.3))?;
526
+        renderer.stroke_rect(Rect::new(fx as i32, fy as i32, w as u32, h as u32), color, 1.5)?;
527
+        Ok(())
528
+    }
529
+
530
+    fn draw_cut_icon(&self, renderer: &Renderer, cx: f64, cy: f64, color: gartk_core::Color) -> Result<()> {
531
+        // Scissors shape - two circles with crossed lines
532
+        let r = 3.0;
533
+        let segments = 12;
534
+
535
+        // Left circle (handle)
536
+        let lcx = cx - 4.0;
537
+        let lcy = cy + 3.0;
538
+        for i in 0..segments {
539
+            let a1 = (i as f64 / segments as f64) * std::f64::consts::TAU;
540
+            let a2 = ((i + 1) as f64 / segments as f64) * std::f64::consts::TAU;
541
+            renderer.line(
542
+                lcx + r * a1.cos(), lcy + r * a1.sin(),
543
+                lcx + r * a2.cos(), lcy + r * a2.sin(),
544
+                color, 1.5
545
+            )?;
546
+        }
547
+
548
+        // Right circle (handle)
549
+        let rcx = cx + 4.0;
550
+        let rcy = cy + 3.0;
551
+        for i in 0..segments {
552
+            let a1 = (i as f64 / segments as f64) * std::f64::consts::TAU;
553
+            let a2 = ((i + 1) as f64 / segments as f64) * std::f64::consts::TAU;
554
+            renderer.line(
555
+                rcx + r * a1.cos(), rcy + r * a1.sin(),
556
+                rcx + r * a2.cos(), rcy + r * a2.sin(),
557
+                color, 1.5
558
+            )?;
559
+        }
560
+
561
+        // Blades (crossed lines going up)
562
+        renderer.line(lcx, lcy - r, cx + 2.0, cy - 6.0, color, 1.5)?;
563
+        renderer.line(rcx, rcy - r, cx - 2.0, cy - 6.0, color, 1.5)?;
564
+        Ok(())
565
+    }
566
+
567
+    fn draw_paste_icon(&self, renderer: &Renderer, cx: f64, cy: f64, color: gartk_core::Color) -> Result<()> {
568
+        // Clipboard with paper
569
+        let w = 10.0;
570
+        let h = 12.0;
571
+
572
+        // Clipboard outline
573
+        let bx = cx - w/2.0;
574
+        let by = cy - h/2.0 + 1.0;
575
+        renderer.stroke_rect(Rect::new(bx as i32, by as i32, w as u32, h as u32), color, 1.5)?;
576
+
577
+        // Clip at top (small rectangle)
578
+        let clip_w = 5.0;
579
+        let clip_h = 3.0;
580
+        let clip_x = cx - clip_w/2.0;
581
+        let clip_y = by - clip_h/2.0;
582
+        renderer.fill_rect(Rect::new(clip_x as i32, clip_y as i32, clip_w as u32, clip_h as u32), color)?;
583
+
584
+        // Lines on clipboard (document content)
585
+        let line_y1 = by + 4.0;
586
+        let line_y2 = by + 7.0;
587
+        renderer.line(bx + 2.0, line_y1, bx + w - 2.0, line_y1, color, 1.5)?;
588
+        renderer.line(bx + 2.0, line_y2, bx + w - 2.0, line_y2, color, 1.5)?;
589
+        Ok(())
590
+    }
591
+
592
+    fn draw_trash_icon(&self, renderer: &Renderer, cx: f64, cy: f64, color: gartk_core::Color) -> Result<()> {
593
+        // Trash can shape
594
+        let w = 10.0;
595
+        let h = 10.0;
596
+
597
+        // Body (trapezoid-ish)
598
+        let bx = cx - w/2.0;
599
+        let by = cy - h/2.0 + 2.0;
600
+        let bw = w;
601
+        let bh = h - 2.0;
602
+        renderer.line(bx, by, bx + 1.0, by + bh, color, 1.5)?;
603
+        renderer.line(bx + 1.0, by + bh, bx + bw - 1.0, by + bh, color, 1.5)?;
604
+        renderer.line(bx + bw - 1.0, by + bh, bx + bw, by, color, 1.5)?;
605
+
606
+        // Lid
607
+        let lid_y = cy - h/2.0;
608
+        renderer.line(cx - w/2.0 - 1.0, lid_y, cx + w/2.0 + 1.0, lid_y, color, 2.0)?;
609
+
610
+        // Handle on lid
611
+        renderer.line(cx - 2.0, lid_y, cx - 2.0, lid_y - 2.0, color, 1.5)?;
612
+        renderer.line(cx - 2.0, lid_y - 2.0, cx + 2.0, lid_y - 2.0, color, 1.5)?;
613
+        renderer.line(cx + 2.0, lid_y - 2.0, cx + 2.0, lid_y, color, 1.5)?;
614
+
615
+        // Vertical lines inside
616
+        renderer.line(cx - 2.0, by + 2.0, cx - 2.0, by + bh - 2.0, color, 1.0)?;
617
+        renderer.line(cx, by + 2.0, cx, by + bh - 2.0, color, 1.0)?;
618
+        renderer.line(cx + 2.0, by + 2.0, cx + 2.0, by + bh - 2.0, color, 1.0)?;
619
+        Ok(())
620
+    }
621
+
622
+    fn draw_new_folder_icon(&self, renderer: &Renderer, cx: f64, cy: f64, color: gartk_core::Color) -> Result<()> {
623
+        // Folder shape with plus sign
624
+        let w = 12.0;
625
+        let h = 9.0;
626
+        let tab_w = 5.0;
627
+        let tab_h = 2.0;
628
+
629
+        let fx = cx - w/2.0;
630
+        let fy = cy - h/2.0;
631
+
632
+        // Folder tab
633
+        renderer.line(fx, fy + tab_h, fx, fy, color, 1.5)?;
634
+        renderer.line(fx, fy, fx + tab_w, fy, color, 1.5)?;
635
+        renderer.line(fx + tab_w, fy, fx + tab_w + 2.0, fy + tab_h, color, 1.5)?;
636
+
637
+        // Folder body
638
+        renderer.line(fx + tab_w + 2.0, fy + tab_h, fx + w, fy + tab_h, color, 1.5)?;
639
+        renderer.line(fx + w, fy + tab_h, fx + w, fy + h, color, 1.5)?;
640
+        renderer.line(fx + w, fy + h, fx, fy + h, color, 1.5)?;
641
+        renderer.line(fx, fy + h, fx, fy + tab_h, color, 1.5)?;
642
+
643
+        // Plus sign in center
644
+        let plus_size = 4.0;
645
+        let pcy = cy + 1.0;
646
+        renderer.line(cx - plus_size/2.0, pcy, cx + plus_size/2.0, pcy, color, 1.5)?;
647
+        renderer.line(cx, pcy - plus_size/2.0, cx, pcy + plus_size/2.0, color, 1.5)?;
648
+        Ok(())
649
+    }
460650
 }