diff --git a/README.md b/README.md index 30f8f47..63f9ece 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,14 @@ and give a chapter its own folder of scenes — nesting goes eight levels deep. file just as dragging does. * **+ New from template** puts its `untitled-N.md` in the folder of the file you are editing, so working inside a part doesn't scatter new files at the top. +* **Right-click a file** for a menu of the things you can do to it: + **📝 Rename…**, **⧉ Copy path**, **🗄 Archive** and **🗑 Delete…**. It acts on + the file you clicked — right-clicking selects it first — so you do not have to + select a file before reaching for the operation. Rename opens a small dialog + with the path already filled in and the box focused; it stays open if the name + is rejected, so a clash never costs you what you typed. Delete asks first. + These are the same operations as the buttons under the list, which stay where + they are. Folders are skipped when they cannot hold manuscript prose: anything starting with a `.` (so `.git` is never walked), plus `target/` and `node_modules/`. diff --git a/src/app/file_list.rs b/src/app/file_list.rs index 4e3664a..a94f76e 100644 --- a/src/app/file_list.rs +++ b/src/app/file_list.rs @@ -172,6 +172,9 @@ impl App { let mut clicked: Option = None; let mut toggled: Option = None; let mut dropped: Option = None; + // What the right-click menu picked, applied further down: the + // row closure below is still borrowing `self` to draw with. + let mut action: Option = None; let pointer = ui.input(|i| i.pointer.interact_pos()); // Filtering happens on the flat list, so folders left with no // files simply stop appearing. @@ -349,6 +352,14 @@ impl App { if label.clicked() { clicked = Some(idx); } + label.context_menu(|ui| { + file_context_menu( + ui, + &name, + idx, + &mut action, + ); + }); if let Some(goal) = meta.goal { row_goal_bar( ui, @@ -440,6 +451,10 @@ impl App { if let Some(drop) = dropped { self.apply_drop(drop); } + if let Some(action) = action { + let ctx = ui.ctx().clone(); + self.apply_file_action(action, &ctx); + } ui.separator(); ui.horizontal(|ui| { @@ -517,6 +532,74 @@ impl App { } } +/// What a file row's right-click menu asked for. The menu records the choice +/// rather than acting on it, because the list is drawn inside a closure that is +/// still borrowing `self`; [`App::apply_file_action`] runs it once that ends. +/// +/// Each carries the row's own index, so the menu acts on the file you clicked +/// even when a different one is selected. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum FileAction { + /// Open the rename dialog on this file. + Rename(usize), + /// Put its workspace-relative path on the clipboard. + CopyPath(usize), + /// Move it to the archive folder. + Archive(usize), + /// Open the delete confirmation for it. + Delete(usize), +} + +impl FileAction { + /// The row this action came from. + pub(super) fn index(self) -> usize { + match self { + FileAction::Rename(i) + | FileAction::CopyPath(i) + | FileAction::Archive(i) + | FileAction::Delete(i) => i, + } + } +} + +/// The right-click menu on a file row, offering the same operations as the +/// buttons under the list without having to select the file first. +fn file_context_menu(ui: &mut egui::Ui, name: &str, idx: usize, action: &mut Option) { + ui.label(egui::RichText::new(base_name(name)).strong()); + ui.separator(); + let mut pick = |ui: &mut egui::Ui, label: &str, hover: &str, chosen: FileAction| { + if ui.button(label).on_hover_text(hover).clicked() { + *action = Some(chosen); + ui.close_menu(); + } + }; + pick( + ui, + "\u{1f4dd} Rename\u{2026}", + "Rename the file, or edit the folder part of its path to move it", + FileAction::Rename(idx), + ); + pick( + ui, + "\u{29c9} Copy path", + "Copy the file's path within the workspace to the clipboard", + FileAction::CopyPath(idx), + ); + ui.separator(); + pick( + ui, + "\u{1f5c4} Archive", + "Move it into the archive folder and out of the manuscript, keeping it on disk", + FileAction::Archive(idx), + ); + pick( + ui, + "\u{1f5d1} Delete\u{2026}", + "Remove it from disk \u{2014} asks first", + FileAction::Delete(idx), + ); +} + /// Outline a whole-row drop target while a file is dragged over it, and report /// whether it is being hovered (so the caller can look for the release). fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool { @@ -639,6 +722,21 @@ pub(super) fn row_goal_bar( mod tests { use super::*; + #[test] + fn every_file_action_carries_the_row_it_came_from() { + // The four arms of `index` are near-identical, so a copy-paste slip + // would silently point an action at the wrong file — and for Delete + // that is destructive. Pin every variant to the row it was built with. + for action in [ + FileAction::Rename(3), + FileAction::CopyPath(3), + FileAction::Archive(3), + FileAction::Delete(3), + ] { + assert_eq!(action.index(), 3, "{action:?} lost its row index"); + } + } + #[test] fn chapter_title_falls_back_to_position() { // No override, no header title -> 1-based index with a period (width 1). diff --git a/src/app/mod.rs b/src/app/mod.rs index 64fc4c9..fbaa9eb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -196,6 +196,13 @@ pub struct App { new_name: String, rename_input: String, pending_delete: bool, + /// Whether the rename dialog opened from a file row's context menu is up. + show_rename: bool, + /// Set alongside `show_rename` so the dialog's box takes focus on the frame + /// it appears, rather than needing a click before you can type. + rename_focus: bool, + /// Whether the delete confirmation opened from that menu is up. + confirm_delete: bool, status: String, show_log: bool, git_log: String, @@ -361,6 +368,9 @@ impl App { new_name: String::new(), rename_input: String::new(), pending_delete: false, + show_rename: false, + rename_focus: false, + confirm_delete: false, status: String::new(), show_log: false, git_log: String::new(), @@ -516,6 +526,14 @@ impl eframe::App for App { self.template_settings_window(ctx); } + if self.show_rename { + self.rename_window(ctx); + } + + if self.confirm_delete { + self.confirm_delete_window(ctx); + } + if self.show_new_project { self.new_project_window(ctx); } diff --git a/src/app/workspace.rs b/src/app/workspace.rs index 65d2a9e..67bed00 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -611,22 +611,26 @@ impl App { order::child_dir_matching(self.workspace(), role).unwrap_or_else(|| role.to_string()) } - pub(super) fn rename_selected(&mut self) { - let Some(idx) = self.selected else { return }; + /// Rename the selected file to whatever `rename_input` holds, reporting + /// whether it went through so a caller showing a dialog knows to keep it + /// open (and the typed name with it) when the name is unusable. + pub(super) fn rename_selected(&mut self) -> bool { + let Some(idx) = self.selected else { return false }; // A path in the box (`part-2/ch-07`) both renames and moves the file. let Some(new_name) = sanitize_rel_path(&self.rename_input) else { self.status = "Enter a new name".to_string(); - return; + return false; }; let Some(old_name) = self.files.get(idx).cloned() else { - return; + return false; }; if new_name == old_name { - return; + // Already what was asked for, so the caller can close. + return true; } if self.path_for(&new_name).exists() { self.status = format!("{new_name} already exists"); - return; + return false; } // Persist any pending edits under the old name first. self.save_current(); @@ -638,8 +642,12 @@ impl App { self.persist_order(); self.reveal(&new_name); self.status = format!("Renamed to {new_name}"); + true + } + Err(e) => { + self.status = format!("Rename failed: {e}"); + false } - Err(e) => self.status = format!("Rename failed: {e}"), } } @@ -1017,6 +1025,129 @@ impl App { } /// Settings dialog for the markdown seeded into template-backed new files. + /// Carry out what a file row's right-click menu picked. + /// + /// Right-clicking acts on the row you clicked, so that row becomes the + /// selection first — every operation here works from `selected`, and + /// [`App::select`] is what primes `rename_input` with the current path. + /// Selecting is a no-op when the row is already current, so this never + /// reloads the buffer out from under an edit. + pub(super) fn apply_file_action(&mut self, action: FileAction, ctx: &egui::Context) { + self.select(action.index()); + match action { + FileAction::Rename(_) => { + self.show_rename = true; + self.rename_focus = true; + } + FileAction::CopyPath(idx) => { + if let Some(name) = self.files.get(idx) { + ctx.copy_text(name.clone()); + self.status = format!("Copied {name}"); + } + } + FileAction::Archive(_) => self.archive_selected(), + FileAction::Delete(_) => self.confirm_delete = true, + } + } + + /// The rename dialog opened from a file row's context menu. It edits the + /// same `rename_input` as the box under the list, so the two stay in step. + pub(super) fn rename_window(&mut self, ctx: &egui::Context) { + let Some(current) = self.selected.and_then(|i| self.files.get(i)).cloned() else { + // The file went away underneath us (deleted, or the workspace + // changed); there is nothing left to rename. + self.show_rename = false; + return; + }; + let mut open = self.show_rename; + let mut submit = false; + let mut cancel = false; + egui::Window::new("Rename file") + .open(&mut open) + .resizable(false) + .collapsible(false) + .default_width(320.0) + .show(ctx, |ui| { + ui.label(egui::RichText::new(current.as_str()).small().weak()); + ui.add_space(4.0); + let r = ui.add( + egui::TextEdit::singleline(&mut self.rename_input) + .desired_width(f32::INFINITY) + .hint_text("name, or a path to move it"), + ); + if self.rename_focus { + r.request_focus(); + self.rename_focus = false; + } + submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.label( + egui::RichText::new( + "The path within the workspace \u{2014} edit the folder part to \ + move the file. The .md extension is added for you.", + ) + .small() + .weak(), + ); + ui.separator(); + ui.horizontal(|ui| { + submit |= ui.button("Rename").clicked(); + cancel |= ui.button("Cancel").clicked(); + }); + }); + if cancel || !open { + self.show_rename = false; + return; + } + if submit { + if self.rename_selected() { + self.show_rename = false; + } else { + // Rejected (already taken, or empty): keep the dialog up with + // the typed text intact and take the focus back so it can be + // fixed straight away. The status bar says what was wrong. + self.rename_focus = true; + } + } + } + + /// The confirmation behind the context menu's Delete, which is the one + /// entry there that cannot be undone from inside the app. + pub(super) fn confirm_delete_window(&mut self, ctx: &egui::Context) { + let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else { + self.confirm_delete = false; + return; + }; + let mut open = self.confirm_delete; + let mut delete = false; + let mut cancel = false; + egui::Window::new("Delete file?") + .open(&mut open) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + ui.label(name.as_str()); + ui.label( + egui::RichText::new( + "This removes it from disk. Archive it instead to take it out \ + of the manuscript but keep the file.", + ) + .small() + .weak(), + ); + ui.separator(); + ui.horizontal(|ui| { + delete |= ui + .button(egui::RichText::new("\u{1f5d1} Delete").color(egui::Color32::RED)) + .clicked(); + cancel |= ui.button("Cancel").clicked(); + }); + }); + self.confirm_delete = open && !cancel && !delete; + if delete { + self.delete_selected(); + } + } + pub(super) fn template_settings_window(&mut self, ctx: &egui::Context) { let mut open = self.show_template_settings; let mut close_clicked = false;