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:
@@ -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/`.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user