Add a right-click context menu to file rows

Renaming, archiving and deleting a file all lived in buttons under the
list, and all of them required selecting the file first. The same
operations are now on the row itself: right-click a file for Rename,
Copy path, Archive and Delete.

The menu records what was picked rather than acting on it, because the
list is drawn inside a closure still borrowing `self` — the same
deferred pattern the click and drag-drop handling already use. Right-
clicking selects the row first, so the menu always acts on the file you
clicked; selecting is a no-op when it is already current, so this cannot
reload the buffer out from under an unsaved edit.

Rename opens a focused dialog with the path pre-filled, and stays open
if the name is rejected so a clash does not cost what was typed;
rename_selected reports success for that. Delete asks first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
This commit is contained in:
landon
2026-09-07 11:57:49 -05:00
parent 70046b0382
commit aa050cf411
4 changed files with 262 additions and 7 deletions
+8
View File
@@ -221,6 +221,14 @@ and give a chapter its own folder of scenes — nesting goes eight levels deep.
file just as dragging does. file just as dragging does.
* ** New from template** puts its `untitled-N.md` in the folder of the file you * ** 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. 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 Folders are skipped when they cannot hold manuscript prose: anything starting
with a `.` (so `.git` is never walked), plus `target/` and `node_modules/`. with a `.` (so `.git` is never walked), plus `target/` and `node_modules/`.
+98
View File
@@ -172,6 +172,9 @@ impl App {
let mut clicked: Option<usize> = None; let mut clicked: Option<usize> = None;
let mut toggled: Option<String> = None; let mut toggled: Option<String> = None;
let mut dropped: Option<FileDrop> = None; let mut dropped: Option<FileDrop> = 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<FileAction> = None;
let pointer = ui.input(|i| i.pointer.interact_pos()); let pointer = ui.input(|i| i.pointer.interact_pos());
// Filtering happens on the flat list, so folders left with no // Filtering happens on the flat list, so folders left with no
// files simply stop appearing. // files simply stop appearing.
@@ -349,6 +352,14 @@ impl App {
if label.clicked() { if label.clicked() {
clicked = Some(idx); clicked = Some(idx);
} }
label.context_menu(|ui| {
file_context_menu(
ui,
&name,
idx,
&mut action,
);
});
if let Some(goal) = meta.goal { if let Some(goal) = meta.goal {
row_goal_bar( row_goal_bar(
ui, ui,
@@ -440,6 +451,10 @@ impl App {
if let Some(drop) = dropped { if let Some(drop) = dropped {
self.apply_drop(drop); self.apply_drop(drop);
} }
if let Some(action) = action {
let ctx = ui.ctx().clone();
self.apply_file_action(action, &ctx);
}
ui.separator(); ui.separator();
ui.horizontal(|ui| { 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<FileAction>) {
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 /// 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). /// whether it is being hovered (so the caller can look for the release).
fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool { fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
@@ -639,6 +722,21 @@ pub(super) fn row_goal_bar(
mod tests { mod tests {
use super::*; 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] #[test]
fn chapter_title_falls_back_to_position() { fn chapter_title_falls_back_to_position() {
// No override, no header title -> 1-based index with a period (width 1). // No override, no header title -> 1-based index with a period (width 1).
+18
View File
@@ -196,6 +196,13 @@ pub struct App {
new_name: String, new_name: String,
rename_input: String, rename_input: String,
pending_delete: bool, 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, status: String,
show_log: bool, show_log: bool,
git_log: String, git_log: String,
@@ -361,6 +368,9 @@ impl App {
new_name: String::new(), new_name: String::new(),
rename_input: String::new(), rename_input: String::new(),
pending_delete: false, pending_delete: false,
show_rename: false,
rename_focus: false,
confirm_delete: false,
status: String::new(), status: String::new(),
show_log: false, show_log: false,
git_log: String::new(), git_log: String::new(),
@@ -516,6 +526,14 @@ impl eframe::App for App {
self.template_settings_window(ctx); 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 { if self.show_new_project {
self.new_project_window(ctx); self.new_project_window(ctx);
} }
+138 -7
View File
@@ -611,22 +611,26 @@ impl App {
order::child_dir_matching(self.workspace(), role).unwrap_or_else(|| role.to_string()) order::child_dir_matching(self.workspace(), role).unwrap_or_else(|| role.to_string())
} }
pub(super) fn rename_selected(&mut self) { /// Rename the selected file to whatever `rename_input` holds, reporting
let Some(idx) = self.selected else { return }; /// 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. // 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 { let Some(new_name) = sanitize_rel_path(&self.rename_input) else {
self.status = "Enter a new name".to_string(); self.status = "Enter a new name".to_string();
return; return false;
}; };
let Some(old_name) = self.files.get(idx).cloned() else { let Some(old_name) = self.files.get(idx).cloned() else {
return; return false;
}; };
if new_name == old_name { 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() { if self.path_for(&new_name).exists() {
self.status = format!("{new_name} already exists"); self.status = format!("{new_name} already exists");
return; return false;
} }
// Persist any pending edits under the old name first. // Persist any pending edits under the old name first.
self.save_current(); self.save_current();
@@ -638,8 +642,12 @@ impl App {
self.persist_order(); self.persist_order();
self.reveal(&new_name); self.reveal(&new_name);
self.status = format!("Renamed to {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. /// 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) { pub(super) fn template_settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_template_settings; let mut open = self.show_template_settings;
let mut close_clicked = false; let mut close_clicked = false;