From e3b04be00e1226201336117697628b2cdc17d0aa Mon Sep 17 00:00:00 2001 From: Fred Clausen <43556888+fredclausen@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:22:14 -0600 Subject: [PATCH] feat: right-click context menus in the file-tree sidebar Add context menus to the sidebar: right-clicking a file offers Open, Copy path, Copy relative path, Reveal in file manager, Rename, and Delete (to trash); right-clicking a folder (or the empty tree area, which targets the root) offers New file, New folder, the same copy/reveal entries, Rename, and Delete. Filesystem operations live in a new fs_ops module (rename, create file, create dir, move-to-trash) returning a typed FsError, kept out of the UI loop so they are unit-testable. Rename and create names are collected through a modal dialog mirroring the existing unsaved-changes prompt; deletes go to the OS trash (recoverable) via the trash crate. Editor state is reconciled with disk changes: renaming a file or directory repoints any open tab whose path lived at or under it and migrates the workspace expanded-folder set; trashing a path closes the tabs it backed and prunes stale expanded entries. Menu choices are collected into a deferred TreeAction and applied after the tree borrow ends, matching the existing MenuAction pattern. --- Cargo.lock | 77 ++++++ Cargo.toml | 1 + frext/Cargo.toml | 1 + frext/src/app.rs | 599 ++++++++++++++++++++++++++++++++++++++++++-- frext/src/error.rs | 38 +++ frext/src/fs_ops.rs | 245 ++++++++++++++++++ frext/src/lib.rs | 1 + 7 files changed, 948 insertions(+), 14 deletions(-) create mode 100644 frext/src/fs_ops.rs diff --git a/Cargo.lock b/Cargo.lock index 2d19d9b..df7ff38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1024,6 +1024,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "trash", "ttf-parser", "two-face", ] @@ -3006,6 +3007,23 @@ version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +[[package]] +name = "trash" +version = "5.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +dependencies = [ + "libc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3077,6 +3095,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.45.1" @@ -3533,12 +3557,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index e886b92..ae48a65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ rfd = "0.17.2" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" +trash = { version = "5.2.6", default-features = false } ttf-parser = "0.25.1" two-face = "0.5.1" diff --git a/frext/Cargo.toml b/frext/Cargo.toml index a4070ae..94f1895 100644 --- a/frext/Cargo.toml +++ b/frext/Cargo.toml @@ -25,6 +25,7 @@ rfd = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +trash = { workspace = true } ttf-parser = { workspace = true } two-face = { workspace = true } diff --git a/frext/src/app.rs b/frext/src/app.rs index 6e64b87..a100cb7 100644 --- a/frext/src/app.rs +++ b/frext/src/app.rs @@ -56,6 +56,35 @@ pub struct FrextApp { /// Index of a tab awaiting unsaved-changes close confirmation, if any. /// While set, a modal dialog asks whether to save, discard, or cancel. pending_close: Option, + /// A pending name-entry prompt (rename / new file / new folder) from a + /// sidebar context menu, if any. While set, a modal collects the name. + pending_fs: Option, +} + +/// A pending filesystem name-entry prompt driven by a modal dialog. +struct FsPrompt { + /// Which operation the entered name applies to. + kind: FsPromptKind, + /// The path the operation targets: the entry being renamed, or the + /// directory a new file/folder is created inside. + target: PathBuf, + /// The live text of the name field. + name: String, + /// Set on the first frame so the text field can grab keyboard focus. + focus_requested: bool, + /// An error message from the previous attempt, shown inline in red. + error: Option, +} + +/// The operation a [`FsPrompt`] collects a name for. +#[derive(Clone, Copy, PartialEq, Eq)] +enum FsPromptKind { + /// Rename `target` to the entered name. + Rename, + /// Create a new file named by the entry inside `target` (a directory). + NewFile, + /// Create a new folder named by the entry inside `target` (a directory). + NewFolder, } /// A reserved line-number gutter column, to be filled by @@ -134,6 +163,7 @@ impl FrextApp { ..SearchState::default() }, pending_close: None, + pending_fs: None, }; for file in files { @@ -414,6 +444,272 @@ impl FrextApp { } } + /// Apply a deferred sidebar context-menu action after the tree borrow has + /// ended. The mutating actions (rename, create, trash) open a modal or + /// touch the filesystem and reconcile any affected open tabs. + fn apply_tree_action(&mut self, ctx: &egui::Context, action: TreeAction) { + match action { + TreeAction::Open(path) => { + if self.open_path(&path) { + self.persist_session(); + } + } + TreeAction::CopyPath(path) => { + ctx.copy_text(path.display().to_string()); + } + TreeAction::CopyRelativePath(path) => { + ctx.copy_text(self.relative_to_root(&path)); + } + TreeAction::Reveal(path) => reveal_in_file_manager(&path), + TreeAction::Rename(path) => { + self.pending_fs = Some(FsPrompt { + kind: FsPromptKind::Rename, + name: path + .file_name() + .map_or_else(String::new, |n| n.to_string_lossy().into_owned()), + target: path, + focus_requested: true, + error: None, + }); + } + TreeAction::NewFile(dir) => { + self.pending_fs = Some(FsPrompt { + kind: FsPromptKind::NewFile, + target: dir, + name: String::new(), + focus_requested: true, + error: None, + }); + } + TreeAction::NewFolder(dir) => { + self.pending_fs = Some(FsPrompt { + kind: FsPromptKind::NewFolder, + target: dir, + name: String::new(), + focus_requested: true, + error: None, + }); + } + TreeAction::Trash(path) => self.trash_path(&path), + } + } + + /// Render `path` relative to the workspace root for "Copy relative path". + /// Falls back to the full path display when there is no workspace or the + /// path is not under the root. + fn relative_to_root(&self, path: &Path) -> String { + if let Some(ws) = self.workspace.as_ref() { + if let Ok(rel) = path.strip_prefix(&ws.root) { + return rel.display().to_string(); + } + } + path.display().to_string() + } + + /// Move `path` to the OS trash, then reconcile editor state: any tab whose + /// file was trashed (the file itself, or a file under a trashed directory) + /// is closed, since its backing file no longer exists. + fn trash_path(&mut self, path: &Path) { + if let Err(err) = crate::fs_ops::move_to_trash(path) { + log::error!("{err}"); + return; + } + + self.close_tabs_under(path); + + // Drop any now-stale expanded-folder entries under the trashed path. + self.prune_workspace_expanded(); + } + + /// Close any tab whose backing file is `path` or lives under it (when + /// `path` is a directory). Iterates from the end so removals do not shift + /// unscanned indices. Split out from [`Self::trash_path`] so the + /// tab-reconciliation can be tested without touching the OS trash. + fn close_tabs_under(&mut self, path: &Path) { + for index in (0..self.tabs.len()).rev() { + let affected = self.tabs[index] + .path + .as_ref() + .is_some_and(|p| p == path || p.starts_with(path)); + if affected { + self.close_tab(index); + } + } + } + + /// Rename `from` to `new_name`, then reconcile editor and workspace state: + /// rewrite the path of any open tab whose file lived at (or under) `from`, + /// and migrate matching entries in the workspace's expanded-folder set. + /// + /// Returns the rename result so the caller can keep the modal open and + /// show the error on failure. + fn rename_path(&mut self, from: &Path, new_name: &str) -> Result<(), crate::error::FsError> { + let to = crate::fs_ops::rename(from, new_name)?; + if to == from { + return Ok(()); + } + + // Repoint any open tab whose path was `from` or lived beneath it. + let mut touched_tabs = false; + for tab in &mut self.tabs { + if let Some(tab_path) = tab.path.as_ref() { + if tab_path == from { + tab.path = Some(to.clone()); + touched_tabs = true; + } else if let Ok(rest) = tab_path.strip_prefix(from) { + tab.path = Some(to.join(rest)); + touched_tabs = true; + } + } + } + + // Migrate expanded-folder entries under the old path to the new one. + if let Some(ws) = self.workspace.as_mut() { + let migrated: Vec<(PathBuf, PathBuf)> = ws + .expanded + .iter() + .filter_map(|dir| { + if dir == from { + Some((dir.clone(), to.clone())) + } else { + dir.strip_prefix(from) + .ok() + .map(|rest| (dir.clone(), to.join(rest))) + } + }) + .collect(); + for (old, new) in migrated { + ws.expanded.remove(&old); + ws.expanded.insert(new); + } + } + + if touched_tabs { + self.persist_active_swap(); + } + self.persist_session(); + Ok(()) + } + + /// Drop expanded-folder entries that no longer point at a directory on + /// disk (e.g. after the folder was renamed away or trashed). + fn prune_workspace_expanded(&mut self) { + if let Some(ws) = self.workspace.as_mut() { + let stale: Vec = ws + .expanded + .iter() + .filter(|dir| !dir.is_dir()) + .cloned() + .collect(); + if !stale.is_empty() { + for dir in stale { + ws.expanded.remove(&dir); + } + self.persist_session(); + } + } + } + + /// Render and resolve the filesystem name-entry modal (rename / new file / + /// new folder). A no-op when nothing is pending. On a successful create, + /// a new file is also opened in a tab. + fn resolve_pending_fs(&mut self, ctx: &egui::Context) { + let Some(prompt) = self.pending_fs.as_mut() else { + return; + }; + + let (title, hint, action_label) = match prompt.kind { + FsPromptKind::Rename => ("Rename", "New name", "Rename"), + FsPromptKind::NewFile => ("New file", "File name", "Create"), + FsPromptKind::NewFolder => ("New folder", "Folder name", "Create"), + }; + + let mut submit = false; + let mut cancel = false; + + let modal = egui::Modal::new(egui::Id::new("frext_fs_prompt")).show(ctx, |ui| { + ui.set_min_width(320.0); + ui.heading(title); + ui.add_space(8.0); + + let field = ui.add( + egui::TextEdit::singleline(&mut prompt.name) + .hint_text(hint) + .desired_width(f32::INFINITY), + ); + if prompt.focus_requested { + field.request_focus(); + prompt.focus_requested = false; + } + if field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + submit = true; + } + + if let Some(error) = &prompt.error { + ui.add_space(4.0); + ui.colored_label(ui.visuals().error_fg_color, error); + } + + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui.button(action_label).clicked() { + submit = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + + if modal.should_close() { + cancel = true; + } + + if cancel { + self.pending_fs = None; + return; + } + if !submit { + return; + } + + // Apply the submitted name. On failure, keep the modal open and show + // the error inline. + // `pending_fs` is Some by the guard at the top of this method. + let Some(prompt) = self.pending_fs.take() else { + return; + }; + let result = match prompt.kind { + FsPromptKind::Rename => self + .rename_path(&prompt.target, &prompt.name) + .map(|()| None), + FsPromptKind::NewFile => { + crate::fs_ops::create_file(&prompt.target, &prompt.name).map(Some) + } + FsPromptKind::NewFolder => { + crate::fs_ops::create_dir(&prompt.target, &prompt.name).map(|_| None) + } + }; + + match result { + Ok(created_file) => { + if let Some(path) = created_file { + if self.open_path(&path) { + self.persist_session(); + } + } + } + Err(err) => { + // Re-open the prompt carrying the error message. + self.pending_fs = Some(FsPrompt { + error: Some(err.to_string()), + focus_requested: true, + ..prompt + }); + } + } + } + /// Open the find bar, seeding the pattern from the editor's current /// selection when there is a non-empty one, and request keyboard focus. fn open_search(&mut self, replace: bool) { @@ -790,6 +1086,7 @@ impl FrextApp { active_canonical: Option<&Path>, file_to_open: &mut Option, expand_changes: &mut Vec<(PathBuf, bool)>, + tree_action: &mut Option, ) { let (dirs, files) = crate::workspace::read_dir_split(dir); @@ -819,9 +1116,21 @@ impl FrextApp { ui.label(name); }) .body(|ui| { - Self::tree_dir(ui, ws, &sub, active_canonical, file_to_open, expand_changes); + Self::tree_dir( + ui, + ws, + &sub, + active_canonical, + file_to_open, + expand_changes, + tree_action, + ); }); + header + .response + .context_menu(|ui| Self::dir_context_menu(ui, &sub, tree_action)); + if header.response.clicked() { expand_changes.push((sub.clone(), !was_open)); } @@ -836,18 +1145,87 @@ impl FrextApp { let is_active = active_canonical .is_some_and(|active| file.canonicalize().ok().as_deref() == Some(active)); - let clicked = ui + let row = ui .horizontal(|ui| { Self::file_icon(ui, crate::file_icon::icon_for_file(&name)); - ui.selectable_label(is_active, name).clicked() + ui.selectable_label(is_active, name) }) .inner; - if clicked { + + row.context_menu(|ui| Self::file_context_menu(ui, &file, tree_action)); + + if row.clicked() { *file_to_open = Some(file); } } } + /// The right-click menu for a file row. Choices are recorded in + /// `tree_action` and applied after the tree borrow ends. + fn file_context_menu(ui: &mut egui::Ui, file: &Path, tree_action: &mut Option) { + if ui.button("Open").clicked() { + *tree_action = Some(TreeAction::Open(file.to_path_buf())); + ui.close(); + } + ui.separator(); + if ui.button("Copy path").clicked() { + *tree_action = Some(TreeAction::CopyPath(file.to_path_buf())); + ui.close(); + } + if ui.button("Copy relative path").clicked() { + *tree_action = Some(TreeAction::CopyRelativePath(file.to_path_buf())); + ui.close(); + } + if ui.button("Reveal in file manager").clicked() { + *tree_action = Some(TreeAction::Reveal(file.to_path_buf())); + ui.close(); + } + ui.separator(); + if ui.button("Rename\u{2026}").clicked() { + *tree_action = Some(TreeAction::Rename(file.to_path_buf())); + ui.close(); + } + if ui.button("Delete (move to trash)").clicked() { + *tree_action = Some(TreeAction::Trash(file.to_path_buf())); + ui.close(); + } + } + + /// The right-click menu for a folder header. Choices are recorded in + /// `tree_action` and applied after the tree borrow ends. + fn dir_context_menu(ui: &mut egui::Ui, dir: &Path, tree_action: &mut Option) { + if ui.button("New file\u{2026}").clicked() { + *tree_action = Some(TreeAction::NewFile(dir.to_path_buf())); + ui.close(); + } + if ui.button("New folder\u{2026}").clicked() { + *tree_action = Some(TreeAction::NewFolder(dir.to_path_buf())); + ui.close(); + } + ui.separator(); + if ui.button("Copy path").clicked() { + *tree_action = Some(TreeAction::CopyPath(dir.to_path_buf())); + ui.close(); + } + if ui.button("Copy relative path").clicked() { + *tree_action = Some(TreeAction::CopyRelativePath(dir.to_path_buf())); + ui.close(); + } + if ui.button("Reveal in file manager").clicked() { + *tree_action = Some(TreeAction::Reveal(dir.to_path_buf())); + ui.close(); + } + ui.separator(); + if ui.button("Rename\u{2026}").clicked() { + *tree_action = Some(TreeAction::Rename(dir.to_path_buf())); + ui.close(); + } + if ui.button("Delete (move to trash)").clicked() { + *tree_action = Some(TreeAction::Trash(dir.to_path_buf())); + ui.close(); + } + } + /// Draw the file-type icon for `stem` inline, sized to the current text /// row. A stem with no embedded artwork (which should not occur for stems /// produced by [`crate::file_icon`]) simply renders nothing, leaving the @@ -994,6 +1372,7 @@ impl eframe::App for FrextApp { if self.workspace.is_some() { let mut file_to_open: Option = None; let mut expand_changes: Vec<(PathBuf, bool)> = Vec::new(); + let mut tree_action: Option = None; let mut close_workspace = false; // The active tab's file, so the tree can highlight it. Compared @@ -1029,16 +1408,27 @@ impl eframe::App for FrextApp { ui.strong(root_label); }); ui.separator(); - egui::ScrollArea::vertical().show(ui, |ui| { - Self::tree_dir( - ui, - ws, - &root, - active_canonical.as_deref(), - &mut file_to_open, - &mut expand_changes, - ); - }); + let empty_area = egui::ScrollArea::vertical() + .show(ui, |ui| { + Self::tree_dir( + ui, + ws, + &root, + active_canonical.as_deref(), + &mut file_to_open, + &mut expand_changes, + &mut tree_action, + ); + // Claim the remaining empty space below the last + // row so a right-click there targets the root, + // while row right-clicks keep their own menus. + ui.allocate_response(ui.available_size(), egui::Sense::click()) + }) + .inner; + // Right-clicking the empty tree area acts on the root + // directory (new file / new folder there). + empty_area + .context_menu(|ui| Self::dir_context_menu(ui, &root, &mut tree_action)); } }); @@ -1059,6 +1449,9 @@ impl eframe::App for FrextApp { self.persist_session(); } } + if let Some(action) = tree_action { + self.apply_tree_action(&ctx, action); + } } } @@ -1192,6 +1585,10 @@ impl eframe::App for FrextApp { // Unsaved-changes confirmation modal, shown when a dirty tab's close // was requested. Resolved after rendering to keep the borrow simple. self.resolve_pending_close(&ctx); + + // Filesystem name-entry modal (rename / new file / new folder), + // resolved after rendering for the same borrow-simplicity reason. + self.resolve_pending_fs(&ctx); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { @@ -1205,6 +1602,36 @@ impl eframe::App for FrextApp { } } +/// Open `path` in the platform file manager, selecting it where the manager +/// supports it. On Linux this opens the containing directory via `xdg-open` +/// (there is no portable "reveal and select" call); failures are logged, not +/// propagated, so a missing opener never crashes the editor. +fn reveal_in_file_manager(path: &Path) { + // Reveal the containing directory so the manager opens somewhere useful + // even for a file (which most managers would otherwise try to *run*). + let target = if path.is_dir() { + path.to_path_buf() + } else { + path.parent() + .map_or_else(|| path.to_path_buf(), Path::to_path_buf) + }; + + #[cfg(target_os = "linux")] + let program = "xdg-open"; + #[cfg(target_os = "macos")] + let program = "open"; + #[cfg(target_os = "windows")] + let program = "explorer"; + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + if let Err(err) = std::process::Command::new(program).arg(&target).spawn() { + log::error!( + "failed to reveal {} in file manager: {err}", + target.display() + ); + } +} + /// Number of editor lines in `text`. /// /// At least one row, even for an empty buffer. A trailing newline opens a new @@ -1265,6 +1692,27 @@ enum CloseChoice { Cancel, } +/// A deferred sidebar context-menu action, collected while the file tree is +/// borrowed and applied after the borrow ends (mirroring [`MenuAction`]). +enum TreeAction { + /// Open `path` in a tab (reusing one already on that path). + Open(PathBuf), + /// Copy `path`'s absolute path to the clipboard. + CopyPath(PathBuf), + /// Copy `path`'s path relative to the workspace root to the clipboard. + CopyRelativePath(PathBuf), + /// Open `path` (or its containing directory) in the OS file manager. + Reveal(PathBuf), + /// Begin a rename of `path` (opens the name-entry modal). + Rename(PathBuf), + /// Begin creating a new file inside the directory `path`. + NewFile(PathBuf), + /// Begin creating a new folder inside the directory `path`. + NewFolder(PathBuf), + /// Move `path` to the OS trash. + Trash(PathBuf), +} + /// A deferred find/replace action, applied after the editor borrow ends. enum SearchAction { None, @@ -1761,4 +2209,127 @@ mod tests { fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn renaming_a_file_repoints_its_open_tab() { + let dir = temp_dir("rename-tab"); + let file = dir.join("old.txt"); + fs::write(&file, "body").unwrap(); + + let store = Store::at(dir.join("state")).unwrap(); + let mut app = FrextApp::with_files(store, std::slice::from_ref(&file)); + assert_eq!(app.tabs[app.active].path.as_deref(), Some(file.as_path())); + + app.rename_path(&file, "new.txt").unwrap(); + + let renamed = dir.join("new.txt"); + assert!(renamed.is_file()); + assert!(!file.exists()); + // The open tab now points at the new path. + assert_eq!( + app.tabs[app.active].path.as_deref(), + Some(renamed.as_path()) + ); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn renaming_a_directory_repoints_tabs_and_expanded_set() { + let dir = temp_dir("rename-dir"); + let sub = dir.join("src"); + fs::create_dir(&sub).unwrap(); + let file = sub.join("main.rs"); + fs::write(&file, "fn main() {}").unwrap(); + + let store = Store::at(dir.join("state")).unwrap(); + let mut app = FrextApp::with_args(store, std::slice::from_ref(&file), Some(dir.as_path())); + // Mark the sub-directory expanded so the rename must migrate it. + if let Some(ws) = app.workspace.as_mut() { + ws.set_expanded(&sub, true); + } + + app.rename_path(&sub, "lib").unwrap(); + + let new_sub = dir.join("lib"); + let new_file = new_sub.join("main.rs"); + assert!(new_file.is_file()); + // The tab under the renamed directory was repointed. + assert_eq!( + app.tabs[app.active].path.as_deref(), + Some(new_file.as_path()) + ); + // The expanded-folder entry migrated to the new path. + let ws = app.workspace.as_ref().unwrap(); + assert!(ws.is_expanded(&new_sub)); + assert!(!ws.is_expanded(&sub)); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn rename_refusing_to_clobber_surfaces_an_error() { + let dir = temp_dir("rename-clobber"); + let a = dir.join("a.txt"); + let b = dir.join("b.txt"); + fs::write(&a, "a").unwrap(); + fs::write(&b, "b").unwrap(); + + let mut app = app_in(&dir); + let err = app.rename_path(&a, "b.txt").unwrap_err(); + assert!(matches!(err, crate::error::FsError::AlreadyExists(_))); + // Both files remain. + assert!(a.exists() && b.exists()); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn close_tabs_under_closes_a_directorys_open_files() { + let dir = temp_dir("close-under"); + let sub = dir.join("pkg"); + fs::create_dir(&sub).unwrap(); + let inside = sub.join("a.txt"); + let outside = dir.join("b.txt"); + fs::write(&inside, "a").unwrap(); + fs::write(&outside, "b").unwrap(); + + let store = Store::at(dir.join("state")).unwrap(); + let mut app = FrextApp::with_files(store, &[inside.clone(), outside.clone()]); + // Both files are open (alongside the initial untitled tab). + let opened: Vec<_> = app.tabs.iter().filter_map(|t| t.path.clone()).collect(); + assert!(opened.contains(&inside) && opened.contains(&outside)); + + // Simulate the bookkeeping half of trashing the directory. + app.close_tabs_under(&sub); + + // The tab inside the directory is gone; the outside one survives. + let remaining: Vec<_> = app.tabs.iter().filter_map(|t| t.path.clone()).collect(); + assert!(remaining.contains(&outside)); + assert!(!remaining.contains(&inside)); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn relative_to_root_strips_the_workspace_root() { + let dir = temp_dir("relpath"); + let nested = dir.join("a").join("b.txt"); + + let store = Store::at(dir.join("state")).unwrap(); + let app = FrextApp::with_args(store, &[], Some(dir.as_path())); + + assert_eq!( + app.relative_to_root(&nested), + PathBuf::from("a/b.txt").display().to_string() + ); + // A path outside the root falls back to its full display. + let outside = PathBuf::from("/etc/hosts"); + assert_eq!( + app.relative_to_root(&outside), + outside.display().to_string() + ); + + fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/frext/src/error.rs b/frext/src/error.rs index 0bda47d..faf0478 100644 --- a/frext/src/error.rs +++ b/frext/src/error.rs @@ -30,3 +30,41 @@ pub enum SearchError { #[error("invalid regular expression: {0}")] InvalidRegex(String), } + +/// Errors that can occur while performing a file-tree filesystem operation +/// (rename, create, or move-to-trash) triggered from the sidebar. +#[derive(Debug, thiserror::Error)] +pub enum FsError { + /// The proposed name was empty or contained a path separator, so it could + /// not be used as a single file or directory name. + #[error("invalid name: {0:?}")] + InvalidName(String), + + /// The destination of a create or rename already exists, so the operation + /// was refused rather than clobbering it. + #[error("destination already exists: {0}")] + AlreadyExists(PathBuf), + + /// A path that should have had a parent directory did not (e.g. a + /// filesystem root), so a sibling could not be created or renamed. + #[error("path has no parent directory: {0}")] + NoParent(PathBuf), + + /// An I/O error occurred while creating or renaming. + #[error("i/o error for {path}: {source}")] + Io { + /// The path that was being operated on. + path: PathBuf, + /// The underlying I/O error. + source: std::io::Error, + }, + + /// Moving the entry to the OS trash failed. + #[error("could not move {path} to trash: {message}")] + Trash { + /// The path that could not be trashed. + path: PathBuf, + /// A human-readable description of the underlying failure. + message: String, + }, +} diff --git a/frext/src/fs_ops.rs b/frext/src/fs_ops.rs new file mode 100644 index 0000000..933efd2 --- /dev/null +++ b/frext/src/fs_ops.rs @@ -0,0 +1,245 @@ +//! Filesystem operations for the sidebar context menus: rename, create file, +//! create directory, and move-to-trash. +//! +//! These are factored out of the UI loop so the validation and the disk +//! effects can be unit-tested without an egui context. Every operation returns +//! a typed [`FsError`]; the UI layer logs failures (and surfaces them to the +//! user) rather than panicking. + +use std::path::{Path, PathBuf}; + +use crate::error::FsError; + +/// Validate that `name` is usable as a single file or directory name: it must +/// be non-empty, must not be `.` or `..`, and must not contain a path +/// separator (so it cannot escape its parent directory). +fn validate_name(name: &str) -> Result<(), FsError> { + if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') { + return Err(FsError::InvalidName(name.to_owned())); + } + Ok(()) +} + +/// The parent directory of `path`, or [`FsError::NoParent`] when it has none. +fn parent_of(path: &Path) -> Result<&Path, FsError> { + path.parent() + .filter(|p| !p.as_os_str().is_empty()) + .ok_or_else(|| FsError::NoParent(path.to_path_buf())) +} + +/// Rename the file or directory at `path` to `new_name` within the same +/// parent directory. Returns the new full path on success. +/// +/// Refuses to overwrite an existing entry, and rejects a `new_name` that is +/// empty or contains a path separator. Renaming to the current name is a +/// no-op that returns the original path. +pub fn rename(path: &Path, new_name: &str) -> Result { + validate_name(new_name)?; + let parent = parent_of(path)?; + let dest = parent.join(new_name); + + if dest == path { + return Ok(dest); + } + if dest.exists() { + return Err(FsError::AlreadyExists(dest)); + } + + std::fs::rename(path, &dest).map_err(|source| FsError::Io { + path: path.to_path_buf(), + source, + })?; + Ok(dest) +} + +/// Create an empty file named `name` inside the directory `dir`. Returns the +/// new file's path. +/// +/// Refuses to overwrite an existing entry and rejects an invalid `name`. +pub fn create_file(dir: &Path, name: &str) -> Result { + validate_name(name)?; + let dest = dir.join(name); + + if dest.exists() { + return Err(FsError::AlreadyExists(dest)); + } + + // `create_new` fails if the file already appeared between the check above + // and now, closing the small race without clobbering. + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&dest) + .map_err(|source| FsError::Io { + path: dest.clone(), + source, + })?; + Ok(dest) +} + +/// Create a directory named `name` inside the directory `dir`. Returns the new +/// directory's path. +/// +/// Refuses to overwrite an existing entry and rejects an invalid `name`. +pub fn create_dir(dir: &Path, name: &str) -> Result { + validate_name(name)?; + let dest = dir.join(name); + + if dest.exists() { + return Err(FsError::AlreadyExists(dest)); + } + + std::fs::create_dir(&dest).map_err(|source| FsError::Io { + path: dest.clone(), + source, + })?; + Ok(dest) +} + +/// Move the file or directory at `path` to the operating system's trash, so +/// the deletion is recoverable. +pub fn move_to_trash(path: &Path) -> Result<(), FsError> { + trash::delete(path).map_err(|err| FsError::Trash { + path: path.to_path_buf(), + message: err.to_string(), + }) +} + +#[cfg(test)] +mod tests { + // `unwrap` is acceptable in test code: a panic on an unexpected `Err` + // is exactly the failure signal we want from a test. + #![allow(clippy::unwrap_used)] + + use super::*; + + /// An isolated temp directory unique to a test. + fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "frext-fsops-{tag}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn validate_name_rejects_empty_dots_and_separators() { + assert!(validate_name("").is_err()); + assert!(validate_name(".").is_err()); + assert!(validate_name("..").is_err()); + assert!(validate_name("a/b").is_err()); + assert!(validate_name("a\\b").is_err()); + assert!(validate_name("ok.txt").is_ok()); + // A leading-dot dotfile is a legitimate single name. + assert!(validate_name(".gitignore").is_ok()); + } + + #[test] + fn rename_moves_within_parent_and_returns_new_path() { + let dir = temp_dir("rename"); + let src = dir.join("old.txt"); + std::fs::write(&src, "body").unwrap(); + + let dest = rename(&src, "new.txt").unwrap(); + + assert_eq!(dest, dir.join("new.txt")); + assert!(!src.exists()); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "body"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn rename_refuses_to_clobber_existing() { + let dir = temp_dir("rename-clobber"); + let src = dir.join("a.txt"); + let other = dir.join("b.txt"); + std::fs::write(&src, "a").unwrap(); + std::fs::write(&other, "b").unwrap(); + + let err = rename(&src, "b.txt").unwrap_err(); + assert!(matches!(err, FsError::AlreadyExists(_))); + // Both files are untouched. + assert_eq!(std::fs::read_to_string(&src).unwrap(), "a"); + assert_eq!(std::fs::read_to_string(&other).unwrap(), "b"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn rename_to_same_name_is_a_noop() { + let dir = temp_dir("rename-same"); + let src = dir.join("same.txt"); + std::fs::write(&src, "x").unwrap(); + + let dest = rename(&src, "same.txt").unwrap(); + assert_eq!(dest, src); + assert!(src.exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn rename_rejects_invalid_name() { + let dir = temp_dir("rename-invalid"); + let src = dir.join("a.txt"); + std::fs::write(&src, "x").unwrap(); + + assert!(matches!( + rename(&src, "../escape").unwrap_err(), + FsError::InvalidName(_) + )); + assert!(src.exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn create_file_makes_an_empty_file() { + let dir = temp_dir("create-file"); + + let path = create_file(&dir, "fresh.txt").unwrap(); + assert!(path.is_file()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), ""); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn create_file_refuses_existing() { + let dir = temp_dir("create-file-exists"); + std::fs::write(dir.join("there.txt"), "x").unwrap(); + + let err = create_file(&dir, "there.txt").unwrap_err(); + assert!(matches!(err, FsError::AlreadyExists(_))); + // The pre-existing file's contents are untouched. + assert_eq!(std::fs::read_to_string(dir.join("there.txt")).unwrap(), "x"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn create_dir_makes_a_directory() { + let dir = temp_dir("create-dir"); + + let path = create_dir(&dir, "sub").unwrap(); + assert!(path.is_dir()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn create_dir_refuses_existing() { + let dir = temp_dir("create-dir-exists"); + std::fs::create_dir(dir.join("sub")).unwrap(); + + let err = create_dir(&dir, "sub").unwrap_err(); + assert!(matches!(err, FsError::AlreadyExists(_))); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/frext/src/lib.rs b/frext/src/lib.rs index 98f5a59..02f656e 100644 --- a/frext/src/lib.rs +++ b/frext/src/lib.rs @@ -10,6 +10,7 @@ pub mod file_icon; mod file_icon_bytes; mod file_icon_table; pub mod font; +pub mod fs_ops; pub mod highlight; pub mod icon; pub mod persistence;