fix(terminal): stop menu click-through and surface failed file opens (#541, #542) (#575)

* fix(terminal): stop terminal pop-up menus leaking clicks into the grid (#541)

The completion menu and the reverse-search menu (the floating panel and
the input-bar row alike) carry no click handlers of their own, and in
gpui an element without handlers, cursor or occlude inserts no hitbox —
the same rule the app pins with a test pair in app.rs. A press that
missed every row therefore fell straight through to the live grid: it
moved the cursor and cleared or started a selection there, and a
modified click even opened whatever link happened to sit under the
menu, so the menu read as broken while the damage landed elsewhere.

All three menu roots now occlude, the same remedy the terminal search
bar already uses, so a click on menu background is swallowed where it
lands. Making the individual candidates clickable instead is a separate
feature, not part of this fix.

* fix(terminal): toast a file link that fails to open instead of dying silently (#542)

The external half of open_file_link has no failure channel: a misspelled
link_file_command or a missing xdg-open only produces a log::warn, and
the click reads as a dead link — while the path was only ever underlined
because the pane's own host verified it exists, so "nothing happens" is
the worst possible answer. The URL half at least toasts a failed
loopback forward; the built-in editor arm reports downstream of
OpenFileRequested; the two spawn arms had nothing.

open_file_path and run_file_command now return io::Result, and
open_file_link turns an Err into the same kind of notification a failed
image paste raises, naming the path and the error. The file tree's
directory fallback — the one other caller, handing a path to the OS
association — gets the same toast instead of silence. A template whose
tokens all expand to nothing (a lone {line} on a link with no line
number — a blank template never gets this far, sanitize maps it to
None) reports as an InvalidInput config error rather than a silent
no-op. Spawn is still all that is reported: an opener that spawns fine
and then exits non-zero is nobody's to see, and a test pins both error
paths.

* test(terminal): pin the press a pop-up menu has to swallow (#541)

The menus occlude now, but nothing held them to it: a bare div over the
grid renders the same and only the mouse can tell the difference. This
presses on a history row and asks the grid whether it started selecting,
then takes the menu away and presses again — the second half is what
keeps the first from passing on a pane the mouse never reached.

* docs(changelog): say what a leaked press actually did (#541)

A press on a menu never moved the terminal cursor and the menus have no
buttons to miss; what it did was clear the selection, drag out a new one,
underline the text under the row on hover, and open the link beneath it
on Ctrl+click.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
Hongwei Qin
2026-08-13 15:54:28 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 3cd90c6ca6
commit 8b42905fca
7 changed files with 212 additions and 19 deletions
+12
View File
@@ -235,6 +235,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
already staged; the toast now carries the plan's actual reason ("Write a
commit message first"), the same words the panel's own button shows on its
tooltip. (#546)
- **Terminal pop-up menus no longer leak clicks into the grid behind them** —
the completion menu and the reverse-search menu (both the floating panel and
the input-bar row) inserted no hitbox of their own, so a press on one fell
straight through to the terminal: it cleared whatever was selected and
dragged out a new selection, merely moving over a row underlined the text
beneath it, and a Ctrl+click opened the link the menu was covering. All
three occlude now, so a press on a menu stops at the menu. (#541)
- **A file link that fails to open says so** — clicking a file path whose
opener is missing, or whose `link_file_command` template expands to nothing,
used to fail into a logfile line and nothing else; the click now raises the
same kind of toast a failed image upload does, naming the path and the
error. (#542)
## [26.8.3] - 2026-08-12
+181 -18
View File
@@ -2810,6 +2810,29 @@ impl TerminalView {
);
}
/// Same shape as `warn_image_upload_failed`: one toast per failed open, so
/// a broken `link_file_command` surfaces as a config problem instead of a
/// "dead link" (#542). Spawn is all that is reported — a spawned opener
/// that exits non-zero is nobody's to see.
fn warn_file_open_failed(
&self,
path: &std::path::Path,
reason: &std::io::Error,
window: &mut Window,
cx: &mut Context<Self>,
) {
log::warn!("failed to open file link {}: {reason}", path.display());
let path = path.display().to_string();
let reason = reason.to_string();
window.push_notification(
crate::ui::i18n::t_fmt(
crate::ui::i18n::L10nKey::LinkFileOpenFailed,
&[("path", path.as_str()), ("error", reason.as_str())],
),
cx,
);
}
pub fn clear_scrollback(&mut self, cx: &mut Context<Self>) {
use alacritty_terminal::vte::ansi::{ClearMode, Handler as _};
@@ -4716,7 +4739,7 @@ impl TerminalView {
is_dir,
},
..,
) => self.open_file_link(path, line, column, is_dir, cx),
) => self.open_file_link(path, line, column, is_dir, window, cx),
LinkAt::Unresolved { candidate, pending } => {
return self.report_unresolved_link(&candidate, pending, window, cx);
}
@@ -4737,6 +4760,7 @@ impl TerminalView {
line: Option<u32>,
column: Option<u32>,
is_dir: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let cfg = cx.global::<Config>();
@@ -4752,19 +4776,30 @@ impl TerminalView {
true => mode,
false => LinkFileOpen::Internal,
};
match (mode, command) {
// Both external arms have to answer for a failed spawn (#542): a
// misspelled `link_file_command` or a missing opener used to hit only
// the log, and the link read as dead — while the path was only ever
// underlined because it verifiably exists. The built-in editor has
// its own error path downstream of OpenFileRequested.
let outcome = match (mode, command) {
(LinkFileOpen::Command, Some(template)) => {
run_file_command(&template, &path, line, column)
}
(LinkFileOpen::System, _) => open_file_path(&path),
// Told to run a command, with no command left to run: falling back
// to the built-in editor beats the click doing nothing.
(LinkFileOpen::Internal | LinkFileOpen::Command, _) => cx.emit(OpenFileRequested {
path,
line,
column,
is_dir,
}),
(LinkFileOpen::Internal | LinkFileOpen::Command, _) => {
cx.emit(OpenFileRequested {
path,
line,
column,
is_dir,
});
return;
}
};
if let Err(e) = outcome {
self.warn_file_open_failed(&path, &e, window, cx);
}
}
@@ -5166,6 +5201,10 @@ impl TerminalView {
.top(cy_top)
.right_4()
.h(self.line_height)
// The row floats over live grid cells; a bare div inserts no
// hitbox, so a click aimed at it started a selection in the
// text underneath (#541).
.occlude()
.flex()
.items_center()
.font_family(self.font.family.clone())
@@ -5461,6 +5500,11 @@ impl TerminalView {
.absolute()
.left(x)
.top(y)
// The menu has no click handlers of its own, and a handlerless
// element inserts no hitbox — so without this the press falls
// through to the grid: the selection there is cleared, or a
// modified click opens whatever link lies under the row (#541).
.occlude()
.flex()
.flex_col()
.py_1()
@@ -5594,6 +5638,10 @@ impl TerminalView {
.absolute()
.left(px(GRID_PAD_X))
.top(y)
// Same fall-through as the completion menu (#541): a bare div
// inserts no hitbox, so a click on a history row landed on the
// grid beneath it.
.occlude()
.flex()
.flex_col()
.py_1()
@@ -6064,7 +6112,7 @@ fn select_end_copy(enabled: bool, grid: bool, editor: bool) -> SelectEndCopy {
/// Hands a path to whatever the OS has it associated with. Also the fallback
/// for a directory the file tree cannot reach.
pub(crate) fn open_file_path(path: &std::path::Path) {
pub(crate) fn open_file_path(path: &std::path::Path) -> std::io::Result<()> {
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
@@ -6072,9 +6120,8 @@ pub(crate) fn open_file_path(path: &std::path::Path) {
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(opener).arg(path).spawn() {
log::warn!("failed to open {}: {e}", path.display());
}
std::process::Command::new(opener).arg(path).spawn()?;
Ok(())
}
fn run_file_command(
@@ -6082,15 +6129,19 @@ fn run_file_command(
path: &std::path::Path,
line: Option<u32>,
column: Option<u32>,
) {
) -> std::io::Result<()> {
let argv = expand_file_command_template(template, path, line, column);
let Some((program, args)) = argv.split_first() else {
log::warn!("link_file_command is empty; ignoring file link");
return;
// Sanitize maps a blank template to None, so the only way here is a
// template whose tokens all expand to nothing (a lone `{line}` on a
// link with no line number). Still a config error worth reporting.
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"link_file_command expanded to nothing",
));
};
if let Err(e) = std::process::Command::new(program).args(args).spawn() {
log::warn!("failed to run link_file_command {template:?}: {e}");
}
std::process::Command::new(program).args(args).spawn()?;
Ok(())
}
fn expand_file_command_template(
@@ -7065,6 +7116,18 @@ mod tests {
assert_eq!(argv, vec!["code", "--goto", "{other}"]);
}
/// #542's contract: a failed open is an `Err` the click site can toast,
/// not a line in a logfile nobody is watching.
#[test]
fn a_file_command_that_cannot_spawn_comes_back_as_an_error() {
let path = Path::new("/tmp/wherever.rs");
// The binary does not exist, so the spawn itself fails.
assert!(super::run_file_command("tty7-no-such-binary {path}", path, None, None).is_err());
// A template whose tokens all expand to nothing is a config error,
// not a silent no-op.
assert!(super::run_file_command("{line}", path, None, None).is_err());
}
#[test]
fn clipboard_image_transcodes_bmp_to_png_and_passes_png_through() {
use gpui::{Image, ImageFormat};
@@ -11739,4 +11802,104 @@ mod gpui_tests {
})
.unwrap();
}
/// #541: the history menu floats over live grid cells, and a `div` that
/// carries no handler of its own inserts no hitbox — so the press went
/// straight through to the grid behind the menu, which cleared the
/// selection, dragged out a new one, or (with the link modifier held)
/// opened whatever link the menu was covering.
///
/// The second half is the other side of the pair `src/ui/app.rs` keeps for
/// the resize handle: with the menu gone the very same press must still
/// reach the grid, or this would pass on a pane that never sees a mouse.
#[gpui::test]
fn a_press_on_the_history_menu_never_reaches_the_grid(cx: &mut TestAppContext) {
use gpui::{MouseMoveEvent, PlatformInput};
crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
prompt_ready(&window, cx, &mut daemon);
wait_for_input_active(&window, cx);
window
.update(cx, |view, window, cx| {
window.activate_window();
view.focus_handle.focus(window, cx);
view.history = vec!["echo one".to_string(), "echo two".to_string()];
view.history_frecency = vec![1.0, 1.0];
view.start_reverse_search();
cx.notify();
})
.unwrap();
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
vcx.update(|window, _| window.refresh());
vcx.run_until_parked();
// The menu is laid out one row under the cursor, plus the gap
// `render_reverse_search_menu` leaves; this lands in the middle of its
// first row, where a candidate is drawn.
let lh = window.update(cx, |view, _, _| view.line_height).unwrap();
let at = point(
px(GRID_PAD_X) + px(10.),
px(GRID_PAD_Y) + lh * 2.0 + px(10.),
);
let press = |vcx: &mut gpui::VisualTestContext| {
vcx.update(|window, cx| {
window.dispatch_event(
PlatformInput::MouseMove(MouseMoveEvent {
position: at,
pressed_button: None,
modifiers: Modifiers::none(),
}),
cx,
);
window.dispatch_event(
PlatformInput::MouseDown(MouseDownEvent {
button: MouseButton::Left,
position: at,
modifiers: Modifiers::none(),
click_count: 1,
first_mouse: false,
}),
cx,
);
});
vcx.run_until_parked();
};
press(&mut vcx);
window
.update(cx, |view, _, _| {
assert!(
view.reverse_search.is_some(),
"the press must not have closed the menu either"
);
assert!(
!view.selecting,
"the menu swallowed the press, so no selection started under it"
);
})
.unwrap();
window
.update(cx, |view, _, cx| {
view.reverse_search = None;
cx.notify();
})
.unwrap();
vcx.update(|window, _| window.refresh());
vcx.run_until_parked();
press(&mut vcx);
window
.update(cx, |view, _, _| {
assert!(
view.selecting,
"with no menu over it the same press is the grid's to take"
);
})
.unwrap();
}
}
+15 -1
View File
@@ -1024,7 +1024,21 @@ impl Tty7App {
// another machine, where handing the name to a local file manager
// would open whatever this one keeps at that path, or nothing.
if self.can_spawn_locally(cx) {
crate::terminal::view::open_file_path(path);
// The OS association can fail to spawn like any other opener
// (#542): say so with the same words a failed file link uses.
if let Err(e) = crate::terminal::view::open_file_path(path) {
log::warn!("failed to open {}: {e}", path.display());
window.push_notification(
t_fmt(
L10nKey::LinkFileOpenFailed,
&[
("path", &path.display().to_string()),
("error", &e.to_string()),
],
),
cx,
);
}
} else {
window.push_notification(
t_fmt(
+1
View File
@@ -892,6 +892,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SftpImagePasteUploadFailed => {
"Could not upload the pasted image to {host}: {error}"
}
L10nKey::LinkFileOpenFailed => "Could not open {path}: {error}",
L10nKey::ForwardPanelTitle => "Forwards",
L10nKey::ForwardDisconnected => "Disconnected",
L10nKey::ForwardDisconnectedFrom => "Disconnected from {host}",
+1
View File
@@ -942,6 +942,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SftpImagePasteUploadFailed => {
"貼り付けた画像を {host} にアップロードできませんでした: {error}"
}
L10nKey::LinkFileOpenFailed => "{path} を開けませんでした: {error}",
L10nKey::ForwardPanelTitle => "ポートフォワード",
L10nKey::ForwardDisconnected => "切断済み",
L10nKey::ForwardDisconnectedFrom => "{host} から切断されました",
+1
View File
@@ -662,6 +662,7 @@ l10n_keys! {
SftpTransferError,
SftpTransferListFailed,
SftpImagePasteUploadFailed,
LinkFileOpenFailed,
ForwardPanelTitle,
ForwardDisconnected,
ForwardDisconnectedFrom,
+1
View File
@@ -848,6 +848,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SftpTransferError => "错误",
L10nKey::SftpTransferListFailed => "无法获取传输状态:{error}",
L10nKey::SftpImagePasteUploadFailed => "无法将粘贴的图片上传到 {host}{error}",
L10nKey::LinkFileOpenFailed => "无法打开 {path}{error}",
L10nKey::ForwardPanelTitle => "端口转发",
L10nKey::ForwardDisconnected => "已断开",
L10nKey::ForwardDisconnectedFrom => "与 {host} 的连接已断开",