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:
+138
-7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user