From 73fb9e2c8c80f8b66f779c495c03f7ab801e8c1d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:55:37 +0800 Subject: [PATCH] fix(file-tree): escape abandons an inline edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tree's rename / new-file / new-folder box subscribed to PressEnter and Blur and no other key. So the way out of an edit you had changed your mind about was to click somewhere else, and the other key already under your hands — Return — commits the rename instead of abandoning it. Every other box the app opens pairs Escape with Return: sftp_open_edit says so out loud ("every other box in the app opens focused and answers Return"), and the switcher, the branch inputs and the graph search all handle both. The local file tree, the surface people touch most, was the one that did not. Both rows an edit can be drawn in are wired, through one free function rather than a closure each, so they cannot drift apart on which keys they answer — a test per row, each failing when only its own site loses the handler. --- CHANGELOG.md | 6 +++ src/ui/file_tree.rs | 121 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 571d79cd..59660bca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Escape now abandons an inline edit in the file tree. Renaming a file, or + naming a new file or folder, answered Return and nothing else: changing your + mind meant clicking somewhere else to blur the box, and Return — the other + key already under your hands — commits the rename rather than abandoning it. + Every other box the app opens (the SFTP forms, the switcher, the branch + inputs, the commit graph's search) already paired the two keys. - Confirmation dialogs no longer let a filename write its own lines of the question. A name carrying a newline — off a cloned repository, an extracted archive or a remote SFTP listing — used to be interpolated raw into the diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 80358d30..a9d97b91 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1197,6 +1197,12 @@ impl Tty7App { cx.notify(); } + /// Escape abandons an inline edit, as it does in every other box the app + /// opens: the SFTP forms, the switcher, the branch inputs, the graph + /// search. This one had `PressEnter` and `Blur` and nothing else, so + /// changing your mind mid-rename meant clicking somewhere else to blur it + /// — and Return, the other key already under your hands, commits the + /// rename rather than abandoning it. fn file_tree_cancel_edit(&mut self, cx: &mut Context) { self.file_tree.editing = None; self.file_tree.editing_subs.clear(); @@ -1799,7 +1805,12 @@ impl Tty7App { let label: AnyElement = if renaming { let input = self.file_tree.editing.as_ref().unwrap().input().clone(); - Input::new(&input).xsmall().into_any_element() + div() + .flex_1() + .min_w_0() + .child(Input::new(&input).xsmall()) + .on_key_down(cx.listener(escape_cancels_the_edit)) + .into_any_element() } else { div() .flex_1() @@ -1926,6 +1937,7 @@ impl Tty7App { .pr_1() .py_0p5() .child(Input::new(&input).xsmall()) + .on_key_down(cx.listener(escape_cancels_the_edit)) .into_any_element(), ); } @@ -2273,6 +2285,22 @@ fn event_can_change_a_row(path: &Path, show_hidden: bool) -> bool { .is_some_and(|n| n.to_string_lossy().starts_with('.')) } +/// Escape, on either of the two rows an inline edit can be drawn in. +/// +/// A free function rather than a closure at each site, so the rename row and +/// the new-file row cannot drift apart on which keys they answer. +fn escape_cancels_the_edit( + app: &mut Tty7App, + ev: &gpui::KeyDownEvent, + _window: &mut Window, + cx: &mut Context, +) { + if ev.keystroke.key == "escape" { + app.file_tree_cancel_edit(cx); + cx.stop_propagation(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -3740,4 +3768,95 @@ mod drop_gpui_tests { let _ = std::fs::remove_dir_all(&root); let _ = std::fs::remove_dir_all(&from); } + + /// Escape abandons an inline rename, and abandoning it renames nothing. + /// + /// The edit answered `PressEnter` and `Blur` and no other key, so the way + /// out of a rename you had changed your mind about was to click somewhere + /// else. Return — the other key already under your hands, and the one that + /// every *other* box in the app pairs with Escape — commits it instead. + #[gpui::test] + fn escape_abandons_a_rename_without_performing_it(cx: &mut TestAppContext) { + let _serial = serial(); + let root = scratch("rename-escape"); + std::fs::write(root.join("keep.txt"), "body").unwrap(); + let (app, mut vcx, _pane) = files_panel_on(cx, &root); + + let target = root.join("keep.txt"); + app.update_in(&mut vcx, |app, window, cx| { + app.file_tree_begin_edit(TreeEditKind::Rename, &target, false, window, cx); + }); + app.update_in(&mut vcx, |app, window, cx| { + let input = app + .file_tree + .editing + .as_ref() + .expect("the rename box is open") + .input() + .clone(); + input.update(cx, |st, cx| st.set_value("renamed.txt", window, cx)); + }); + vcx.run_until_parked(); + + vcx.simulate_keystrokes("escape"); + vcx.run_until_parked(); + + app.update_in(&mut vcx, |app, _window, _cx| { + assert!( + app.file_tree.editing.is_none(), + "escape left the rename box open" + ); + }); + assert!( + root.join("keep.txt").exists(), + "escape renamed the file it was meant to abandon" + ); + assert!( + !root.join("renamed.txt").exists(), + "the typed name reached disk after escape" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// The same key on the other row an edit can be drawn in. + /// + /// A new file is a different element from a rename — its own row, appended + /// under the directory rather than replacing a name in place — so it wires + /// the handler separately and can lose it separately. + #[gpui::test] + fn escape_abandons_a_new_file_without_creating_it(cx: &mut TestAppContext) { + let _serial = serial(); + let root = scratch("newfile-escape"); + let (app, mut vcx, _pane) = files_panel_on(cx, &root); + + app.update_in(&mut vcx, |app, window, cx| { + app.file_tree_begin_edit(TreeEditKind::NewFile, &root, true, window, cx); + }); + app.update_in(&mut vcx, |app, window, cx| { + let input = app + .file_tree + .editing + .as_ref() + .expect("the new-file box is open") + .input() + .clone(); + input.update(cx, |st, cx| st.set_value("never.txt", window, cx)); + }); + vcx.run_until_parked(); + + vcx.simulate_keystrokes("escape"); + vcx.run_until_parked(); + + app.update_in(&mut vcx, |app, _window, _cx| { + assert!( + app.file_tree.editing.is_none(), + "escape left the new-file box open" + ); + }); + assert!( + !root.join("never.txt").exists(), + "escape created the file it was meant to abandon" + ); + let _ = std::fs::remove_dir_all(&root); + } }