diff --git a/architecture/notes/nebula_app/gpui_shell/2026-09-21-macos-command-key-aliases.md b/architecture/notes/nebula_app/gpui_shell/2026-09-21-macos-command-key-aliases.md new file mode 100644 index 0000000..1055e1a --- /dev/null +++ b/architecture/notes/nebula_app/gpui_shell/2026-09-21-macos-command-key-aliases.md @@ -0,0 +1,116 @@ +# macOS command-key aliases + +## Status + +Implemented for issue #238. + +## Context + +The GPUI shell registered a macOS ⌘ shortcut set that existed nowhere else. +`bind_macos_command_keys` hand-wrote roughly two dozen bindings after +`default_workspace_bindings`, while `display::keymap::default_shortcuts()` — the +table that `clear_action` / `reset_action`, the settings keymap page and the +keybind reload path read — knew nothing about them. Half of those keys also +appear in the config macOS platform table (`config/bindings/defaults.rs`); the +other half only existed in the shell. + +Two user-visible consequences followed. Clearing the Shell picker row wrote only +`Ctrl+K:ReceiveChar`, so the static ⌘K binding kept consuming the key and the +panel still opened. `Action::Quit`, declared for macOS as `"q", SUPER`, fell into +the `_ => None` arm of `workspace_binding_in_context`, so ⌘Q did nothing at all +(GPUI installs no application menu). The display layer also rendered the super +modifier as `Win+`, so the settings page and the lines the app wrote into +`pebrel_settings.txt` showed `Ctrl+Win+F` / `Win+F` on macOS. + +## Evidence + +- Issue #238 reproduction, with the settings file the released build wrote: + `keybind=Ctrl+Win+F:ReceiveChar`, `keybind=Ctrl+K:ReceiveChar`, + `keybind=shift+win+k:ReceiveChar` — the unbind persisted, yet ⌘K still opened + the picker. +- `display::keymap` regressions (`clearing_releases_the_macos_command_alias`, + `macos_command_aliases_round_trip_through_storage`, + `super_modifier_renders_per_platform`) and shell regressions + (`every_macos_command_alias_binds_a_gpui_action`, + `released_cmd_k_is_swallowed_by_no_action`). +- GPUI keymap semantics: a no-action binding for a keystroke suppresses matching + bindings from equal or weaker sources, and a keystroke with no remaining + binding falls through to the view's key handler — that is why a cleared ⌘ key + reaches the terminal instead of being eaten. +- Manual acceptance on macOS 26 (arm64, debug build, window driven through + accessibility): default ⌘K opens the picker; clearing the row writes both + `Ctrl+K:ReceiveChar` and `cmd+k:ReceiveChar` and releases ⌘K and Ctrl+K without + a restart; restoring the row brings ⌘K back; ⌘Q quits with `clean_exit` true. + +## Decision + +`display::keymap::MACOS_COMMAND_ALIASES` is the single authority for the shell's +⌘ set. `init()` derives the static gpui bindings from it, and +`default_shortcuts()` adds it to the unbind/restore vocabulary on macOS, deduped +against entries the config table already provides for the same action and key. +The alias table writes `cmd+…` into `keybind=` lines. Existing files and shortcut +capture can still use `win+…`, and manually edited files can use `super+…` or a +different modifier order. The GPUI adapter parses and canonicalizes its binding +strings before comparing overrides and restored defaults. Display spelling must +not determine a shortcut's runtime identity. + +`Action::Quit` maps to a `QuitApp` action whose handler defers +`windowing::quit_all`, the same path as tray quit, so ⌘Q saves the session and +drafts before stopping PTYs. `mods_prefix` renders the super modifier per +platform, which also corrects the spelling of lines the app writes. + +## Rejected alternatives + +- Showing the ⌘ aliases on the settings keymap page (reverse-looking-up the + alias table in `effective_combo`) changes about twenty keycaps on macOS, the + shadowing verdicts other rows derive from them, and needs its own visual + acceptance. The page keeps showing the config table's Ctrl key. +- Moving the ⌘ set into the config macOS platform table would feed the legacy + terminal shell UI-only actions and keep the ⌘K collision, which the table + already resolves differently (`Esc("\x0c")` plus `ClearHistory`). +- Comparing raw display/storage spellings fails for equivalent `Win+…`, + `Cmd+…` and `super+…` keys. Display text is not a canonical identity; + `Ctrl++`-style display spellings also do not parse back as storage strings. +- Mapping the removed static ⌘ bindings to `ReceiveChar` from `init()` was not + needed: the user keybind table already injects the no-action binding. + +## Consequences + +- Cleared actions now also write `keybind=cmd+…:ReceiveChar` lines. Users coming + from an earlier build must clear the row once more, because their existing file + predates the alias vocabulary and holds no line for the ⌘ half. +- The keymap page still shows Ctrl keys for actions whose macOS binding set + includes ⌘. Releasing them works; seeing them does not. +- ⌘K after unbinding does not clear the screen. The GPUI shell has no + clear-screen action (`Action::Esc` and `Action::ClearHistory` are unmapped) and + the terminal encoder ignores pure ⌘ combinations, so the key is released but + inert. Out of scope here; `Ctrl+L` still passes through to the shell. +- ⌘B and ⌘, stay static bindings: they have no editable keymap row, so there is + nothing to clear. + +## Validation + +`cargo fmt --all -- --check`, `python3 scripts/check_architecture.py --base +f7ca0ec`, `python3 scripts/check_platform_cfg.py` (budget unchanged at 484) and +the `display::keymap` / `keyboard_bindings` unit suites pass; the two new +released-key tests fail without the change. Manual acceptance is listed under +Evidence. The alias table is shared data; runtime registration uses the existing +`Platform::current()` entry and remains macOS-only. The two macOS restore +regressions remain platform-gated. This avoids duplicating platform compile +branches in the UI adapter without increasing the current cfg budget. +A cross-platform GPUI dispatch regression confirms that sharing the table does +not register Command shortcuts on Windows/Linux. + +The 2026-09-23 review reproduced two restore failures through GPUI keyboard +dispatch: recording Cmd+K then resetting the row, and resetting the old +`Ctrl+Win+F:ReceiveChar` override from #238. Both failed before canonicalization. +Regression coverage includes those paths and equivalent modifier spellings/order. + +## Supersedes + +None. + +## Revisit when + +The keymap page should show platform-native keycaps, or macOS ⌘K should be +assigned to a clear-screen action instead of the Shell picker. diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index b7442e0..5b3cb37 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -88,6 +88,52 @@ pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[ ("贴入 AI 修复建议", "Paste AI fix suggestion", "Ctrl+."), ]; +/// macOS 的 ⌘ 命令键别名:`(存储写法, 动作)`。 +/// +/// GPUI 壳在配置表之外**追加**一整套 ⌘ 绑定:配置表的 macOS 段只覆盖其中 +/// 一半,另一半(⌘K、⌘D、⌘⇧P…)是壳自己加的。这张表是那套绑定的唯一权威: +/// `init()` 按它注册静态绑定,解绑与恢复按它写回 `keybind=` 行。留在表外 +/// 就成了影子绑定——`clear_action` 不知道它,于是出现「⌘K 解绑后仍然弹出 +/// Shell 选择器」(#238)。 +/// +/// 表里的写法会写进设置文件,超级键写 `cmd`(macOS 的读法)。历史配置和 +/// 录制结果仍可使用 `win` / `super`;GPUI 适配层按解析后的按键规范化, +/// 覆盖与恢复默认不依赖这些别名或修饰键顺序的逐字相等。 +/// +/// 这张表只进 [`default_shortcuts`](解绑/恢复口径),不进 `effective_combo` +/// 的键帽反查:设置页仍按配置表的 Ctrl 键显示。 +/// +/// 别名表是共享数据;实际注册由平台层入口限定为 macOS,便于跨平台核验分派。 +pub(crate) const MACOS_COMMAND_ALIASES: &[(&str, Action)] = &[ + ("cmd+t", Action::CreateNewTab), + ("cmd+n", Action::CreateNewWindow), + // ⌘W 关标签页是 GPUI 壳的实际行为;配置表的 macOS 段把 ⌘W 记作 Quit, + // 这里必须压过它,否则解绑恢复会把 ⌘W 还给退出。 + ("cmd+w", Action::CloseTab), + ("shift+cmd+p", Action::ToggleCommandPalette), + ("cmd+k", Action::ToggleShellPicker), + ("shift+cmd+f", Action::ToggleFilesPanel), + ("cmd+d", Action::SplitRight), + ("shift+cmd+d", Action::SplitDown), + ("shift+cmd+enter", Action::ToggleZoom), + ("alt+cmd+left", Action::FocusPaneLeft), + ("alt+cmd+right", Action::FocusPaneRight), + ("alt+cmd+up", Action::FocusPaneUp), + ("alt+cmd+down", Action::FocusPaneDown), + ("shift+cmd+]", Action::SelectNextTab), + ("shift+cmd+[", Action::SelectPreviousTab), + ("shift+cmd+g", Action::ToggleGitPanel), + ("cmd+plus", Action::IncreaseFontSize), + ("cmd+=", Action::IncreaseFontSize), + ("cmd+minus", Action::DecreaseFontSize), + ("cmd+digit0", Action::ResetFontSize), + ("cmd+c", Action::Copy), + ("cmd+v", Action::Paste), + ("ctrl+cmd+f", Action::ToggleFullscreen), + ("shift+cmd+o", Action::OpenQuickJump), + ("cmd+q", Action::Quit), +]; + pub(crate) fn action_label( row: &(Action, &'static str, &'static str), language: super::UiLanguage, @@ -118,13 +164,33 @@ fn cached_defaults() -> &'static [KeyBinding] { } pub(crate) fn default_shortcuts() -> Vec<(String, Action)> { - cached_defaults() + let mut shortcuts: Vec<(String, Action)> = cached_defaults() .iter() .filter_map(|binding| { display_combo(binding.mods, &binding.trigger) .map(|combo| (combo, binding.action.clone())) }) - .collect() + .collect(); + // macOS 的 ⌘ 键也算默认键:`clear_action` / `reset_action` 按这份表决定要 + // 解掉、要还回哪些键,不带上它就会漏掉 ⌘ 那一半(#238)。 + #[cfg(target_os = "macos")] + { + let mut extra: Vec<(String, Action)> = Vec::new(); + for (combo, action) in MACOS_COMMAND_ALIASES { + let parsed = parse_combo(combo); + // 配置表已经给了同一动作的同一个键(⌘T、⌘C、⌘Q…)就不重复追加: + // 解绑会把每条 combo 都写进设置文件,重复条目只会多留一行废话。 + let covered = shortcuts + .iter() + .chain(extra.iter()) + .any(|(known, candidate)| candidate == action && parse_combo(known) == parsed); + if !covered { + extra.push(((*combo).to_owned(), action.clone())); + } + } + shortcuts.extend(extra); + } + shortcuts } /// Editing one action moves its shortcut: old keys pass through to the terminal. @@ -335,7 +401,10 @@ pub(crate) fn mods_prefix(mods: ModifiersState) -> String { out.push_str("Alt+"); } if mods.super_key() { - out.push_str("Win+"); + // macOS 的超级键是 ⌘。键帽必须写 Cmd+,否则设置页会出现 + // `Ctrl+Win+F`(全屏)、`Win+F`(搜索)这类用户认不出的组合(#238)。 + // 存储侧不受影响:`win+` / `super+` / `cmd+` 同等解析。 + out.push_str(if cfg!(target_os = "macos") { "Cmd+" } else { "Win+" }); } out } @@ -805,4 +874,62 @@ mod tests { ); } } + + /// 超级键键帽随平台渲染:macOS 是 ⌘,其它平台是 Win。 + #[test] + fn super_modifier_renders_per_platform() { + let expected = if cfg!(target_os = "macos") { "Cmd+" } else { "Win+" }; + assert_eq!(mods_prefix(ModifiersState::SUPER), expected); + assert_eq!( + mods_prefix(ModifiersState::CONTROL | ModifiersState::SUPER), + format!("Ctrl+{expected}") + ); + } + + /// 别名条目要能在存储格式里往返:`clear_action` 把表里的写法逐字写进 + /// 设置文件,解析层必须读回同一个组合。 + #[cfg(target_os = "macos")] + #[test] + fn macos_command_aliases_round_trip_through_storage() { + for (combo, _) in MACOS_COMMAND_ALIASES { + let (mods, key) = parse_combo(combo).unwrap_or_else(|| panic!("{combo} 解析失败")); + assert!(mods.super_key(), "{combo} 必须是 ⌘ 组合"); + let canonical = + canonical_combo(mods, &key).unwrap_or_else(|| panic!("{combo} 无法写成存储格式")); + assert_eq!(parse_combo(&canonical), Some((mods, key)), "{combo} 存储往返不一致"); + } + } + + /// #238:macOS 上 Shell 选择器除了配置表的 Ctrl+K 还有一条壳自带的 + /// ⌘K。解绑必须把两条都解掉,否则静态默认绑定继续抢键,用户看到的就是 + /// 「解绑了但 ⌘K 照样弹面板」。 + #[cfg(target_os = "macos")] + #[test] + fn clearing_releases_the_macos_command_alias() { + let action = Action::ToggleShellPicker; + // 设置页仍按配置表的 Ctrl+K 展示:别名只进解绑口径,不进键帽反查。 + assert_eq!(effective_combo(&action, &[]), Some(("Ctrl+K".to_owned(), false))); + let mut raw = Vec::new(); + clear_action(&mut raw, &action); + // 解绑使用表里的写法;旧配置和录制结果的等价拼写由 GPUI 层规范化。 + assert!( + raw.iter().any(|(combo, _)| combo == "cmd+k"), + "⌘K 必须按表里的写法逐字写回,实际 {raw:?}" + ); + // Ctrl+K 来自配置表、⌘K 来自别名表,两条都要落成 ReceiveChar。 + for combo in ["ctrl+k", "cmd+k"] { + let parsed = parse_combo(combo); + assert!( + raw.iter().any(|(candidate, name)| parse_combo(candidate) == parsed + && parse_action(name) == Some(Action::ReceiveChar)), + "{combo} 必须落成 ReceiveChar,否则运行时解不掉静态默认绑定" + ); + } + assert_eq!(effective_combo(&action, &build_bindings(&raw)), None, "解绑后不应再有有效键"); + reset_action(&mut raw, &action); + assert!( + effective_combo(&action, &build_bindings(&raw)).is_some(), + "恢复要把两组默认键都还回来" + ); + } } diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 1ccd710..f1ce80d 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -44,22 +44,30 @@ pub(super) const STATIC_DEFAULT_COMBOS: &[&str] = &[ /// 存储格式 combo(`ctrl+shift+t`)→ gpui 绑定串(`ctrl-shift-t`)。键名 /// 两套体系同构(小写命名键 + 单字符);digitN 折回数字,plus/minus 折回 /// `+`/`-`(`+` 是存储分隔符,必须先占位再替换)。 +/// 最后由 GPUI 解析并规范化修饰键别名和顺序,保证旧 Win+、新 Cmd+ 和 +/// 录制结果在覆盖/恢复默认时具有同一个运行时身份。 pub(super) fn gpui_binding_combo(combo: &str) -> String { - combo + let combo = combo .to_ascii_lowercase() .replace("plus", "\u{1}") .replace("minus", "\u{2}") .replace('+', "-") .replace("digit", "") .replace('\u{1}', "+") - .replace('\u{2}', "-") + .replace('\u{2}', "-"); + gpui::Keystroke::parse(&combo).map(|key| key.unparse()).unwrap_or(combo) } /// 注册工作区快捷键;在 `gpui_component::init` 之后调用一次。 pub(super) fn init(cx: &mut App) { cx.bind_keys(default_workspace_bindings()); - #[cfg(target_os = "macos")] - bind_macos_command_keys(cx); + // 平台判定复用已有入口;共享表不意味着给其他平台注册 ⌘ 快捷键。 + if crate::platform::Platform::current() == crate::platform::Platform::MacOS { + bind_macos_command_keys(cx); + } + // 退出不属于任何视图,挂全局兜底:⌘Q 与用户自定义的 `keybind=…:Quit` + // 都落到与托盘退出同一条「先落盘会话与草稿,再停 PTY」的路径。 + cx.on_action(|_: &QuitApp, cx: &mut App| cx.defer(super::windowing::quit_all)); } /// 工作区静态默认键位表;与 [`STATIC_DEFAULT_COMBOS`] 互为镜像。 @@ -132,37 +140,23 @@ pub(super) fn default_workspace_bindings() -> Vec { /// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着 /// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。 /// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。 -#[cfg(target_os = "macos")] +/// +/// 键位来自 `display::keymap::MACOS_COMMAND_ALIASES`:设置页的反查、解绑与 +/// 恢复读的是同一张表,两处不会再各自漂移。注册必须留在这里、且早于用户 +/// 自定义键,这样 `clear_action` 注入的 NoAction 才压得住静态默认绑定。 fn bind_macos_command_keys(cx: &mut App) { - cx.bind_keys([ - KeyBinding::new("cmd-t", NewTerminal, None), - KeyBinding::new("cmd-n", NewWindow, None), - KeyBinding::new("cmd-w", CloseActiveTerminal, None), + let mut bindings: Vec = crate::display::keymap::MACOS_COMMAND_ALIASES + .iter() + .filter_map(|(combo, action)| workspace_binding_in_context(combo, action, None)) + .collect(); + // 剩下这些没有对应的 `config::Action`,或者需要单独的作用域。 + bindings.extend([ KeyBinding::new("cmd-b", ToggleSidebar, None), KeyBinding::new("cmd-,", OpenSettings, None), - KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None), - KeyBinding::new("cmd-k", ToggleShellPicker, None), - KeyBinding::new("cmd-shift-f", ToggleFileTree, None), - KeyBinding::new("cmd-d", SplitRight, None), - KeyBinding::new("cmd-shift-d", SplitDown, None), - KeyBinding::new("cmd-shift-enter", ToggleZoom, None), - KeyBinding::new("cmd-alt-left", FocusPaneLeft, None), - KeyBinding::new("cmd-alt-right", FocusPaneRight, None), - KeyBinding::new("cmd-alt-up", FocusPaneUp, None), - KeyBinding::new("cmd-alt-down", FocusPaneDown, None), - KeyBinding::new("cmd-shift-]", SelectNextTab, None), - KeyBinding::new("cmd-shift-[", SelectPreviousTab, None), - KeyBinding::new("cmd-shift-g", ToggleGitPanel, None), - KeyBinding::new("cmd-=", IncreaseFontSize, None), - KeyBinding::new("cmd-+", IncreaseFontSize, None), - KeyBinding::new("cmd--", DecreaseFontSize, None), - KeyBinding::new("cmd-0", ResetFontSize, None), - KeyBinding::new("cmd-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), - KeyBinding::new("cmd-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), + // 设置页与对话框的输入框自带 ⌘V,作用域必须和终端分开。 KeyBinding::new("cmd-v", gpui_component::input::Paste, Some("Input")), - KeyBinding::new("cmd-ctrl-f", ToggleFullscreen, None), - KeyBinding::new("cmd-shift-o", OpenQuickJump, None), ]); + cx.bind_keys(bindings); } /// Typed GPUI adapter for the shared numbered/last-tab actions. The existing @@ -173,6 +167,12 @@ pub(super) struct SelectTab { index: Option, } +/// 退出应用,macOS 上绑 ⌘Q(`Action::Quit` 的 GPUI 侧落点)。处理注册在 +/// [`init`] 的全局兜底上,与绑定同一处维护。 +#[derive(Clone, Debug, PartialEq, gpui::Action)] +#[action(namespace = nebula_workspace, no_json)] +pub(super) struct QuitApp; + impl SelectTab { fn from_config(action: &crate::config::Action) -> Option { use crate::config::Action; @@ -250,8 +250,9 @@ fn workspace_binding_in_context( )), Action::ToggleFullscreen => Some(KeyBinding::new(&combo, ToggleFullscreen, scope)), Action::OpenQuickJump => Some(KeyBinding::new(&combo, OpenQuickJump, scope)), - // Mask the app action. ReceiveChar entries from the editor allow - // the terminal input handler to encode the released key. + // macOS 的退出键走与托盘退出同一条路径:先落盘会话与草稿,再停 PTY。 + Action::Quit => Some(KeyBinding::new(&combo, QuitApp, scope)), + // 屏蔽应用动作后,ReceiveChar 仍交给终端编码,保留改键释放旧键的语义。 Action::None | Action::ReceiveChar => Some(KeyBinding::new(&combo, gpui::NoAction, scope)), _ => None, } diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs index 3929aec..9c9a035 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -1,4 +1,90 @@ use super::*; + +#[test] +fn command_aliases_and_modifier_order_share_one_runtime_identity() { + let expected = gpui_binding_combo("shift+cmd+k"); + for combo in ["Shift+Win+K", "cmd+shift+k", "super+shift+k"] { + assert_eq!(gpui_binding_combo(combo), expected, "{combo}"); + } + assert_ne!(gpui_binding_combo("cmd+k"), expected); + assert_ne!(gpui_binding_combo("ctrl+shift+k"), expected); +} + +mod macos_command_keys { + use super::*; + + /// #238:别名表里的每条都必须落到 gpui 绑定上。表和运行时各写一份时, + /// 设置页显示的键可能按下去没有反应;合一之后这条守住两边不再漂移。 + #[test] + fn every_macos_command_alias_binds_a_gpui_action() { + for (combo, action) in crate::display::keymap::MACOS_COMMAND_ALIASES { + assert!( + workspace_binding_in_context(combo, action, None).is_some(), + "{combo} → {action:?} 没有对应的 gpui 绑定" + ); + } + // ⌘Q 必须落到退出动作,否则退出键不会走保存会话与草稿的路径。 + let quit = workspace_binding_in_context("cmd+q", &crate::config::Action::Quit, None) + .expect("⌘Q 必须有绑定"); + assert!(quit.action().as_any().is::(), "⌘Q 必须绑定 QuitApp"); + } + + #[test] + fn command_close_and_quit_dispatch_to_distinct_actions() { + use gpui::{KeyContext, Keymap, Keystroke}; + // 合并后仍按共享别名表分派,防止配置中的 ⌘W/Quit 抢走关闭标签页动作。 + let bindings = crate::display::keymap::MACOS_COMMAND_ALIASES + .iter() + .filter_map(|(combo, action)| workspace_binding_in_context(combo, action, None)) + .collect(); + let keymap = Keymap::new(bindings); + for context in ["Root", crate::gpui_shell::terminal::KEY_CONTEXT] { + let contexts = [KeyContext::parse(context).unwrap()]; + for (combo, quit) in [("cmd-w", false), ("cmd-q", true)] { + let input = [Keystroke::parse(combo).unwrap()]; + let (bindings, pending) = keymap.bindings_for_input(&input, &contexts); + assert!(!pending); + let action = bindings.first().expect("命令键必须有明确动作").action(); + if quit { + assert!(action.as_any().is::()); + } else { + assert!(action.as_any().is::()); + } + } + } + } + + /// #238:⌘K 被解绑后,用户注入的 NoAction 必须压得住静态默认绑定。 + #[test] + fn released_cmd_k_is_swallowed_by_no_action() { + use crate::config::Action; + use gpui::{KeyContext, Keymap, Keystroke}; + let contexts = [ + KeyContext::parse("Root").unwrap(), + KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap(), + ]; + let input = [Keystroke::parse("cmd-k").unwrap()]; + // 反向对照:只有静态默认键时 ⌘K 必须命中 Shell 选择器,否则本用例空跑。 + let default = + custom_workspace_binding("cmd+k", &Action::ToggleShellPicker).expect("静态默认键"); + let (bindings, pending) = + Keymap::new(vec![default.clone()]).bindings_for_input(&input, &contexts); + assert!(!pending, "对照组必须命中"); + assert!(bindings[0].action().as_any().is::()); + // `update_keybinds` 在两个作用域各注入一条 NoAction,这里照抄运行时形状。 + let clear = workspace_binding_in_context("cmd+k", &Action::ReceiveChar, None).unwrap(); + let clear_terminal = workspace_binding_in_context( + "cmd+k", + &Action::ReceiveChar, + Some(crate::gpui_shell::terminal::KEY_CONTEXT), + ) + .unwrap(); + let released = Keymap::new(vec![default, clear, clear_terminal]); + let (bindings, pending) = released.bindings_for_input(&input, &contexts); + assert!(bindings.is_empty() && !pending, "解绑后不应有任何应用动作消费 ⌘K"); + } +} + #[test] fn cleared_shortcut_reaches_terminal_and_can_be_restored_without_restart() { use crate::config::Action; @@ -120,6 +206,65 @@ mod dispatch { }); } + #[gpui::test] + fn command_shortcut_registration_follows_runtime_platform(cx: &mut TestAppContext) { + let (_directory, workspace, mut cx) = open_workspace(1, cx); + press("cmd-k", &mut cx); + assert_eq!( + workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open), + crate::platform::Platform::current() == crate::platform::Platform::MacOS, + "共享别名表只应在 macOS 注册原生命令键" + ); + } + + #[gpui::test] + #[cfg(target_os = "macos")] + fn recorded_command_key_can_restore_default(cx: &mut TestAppContext) { + use crate::display::keymap; + let (_directory, workspace, mut cx) = open_workspace(1, cx); + press("cmd-k", &mut cx); + assert!(workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open)); + press("escape", &mut cx); + let keymap::CaptureOutcome::Bind(combo) = + keymap::capture_gpui(&Keystroke::parse("cmd-k").unwrap()) + else { + panic!("expected captured shortcut") + }; + assert_eq!(combo, "win+k"); + let mut raw = vec![(combo, "ToggleShellPicker".into())]; + workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw.clone(), cx)); + press("cmd-k", &mut cx); + assert!(workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open)); + press("escape", &mut cx); + keymap::reset_action(&mut raw, &crate::config::Action::ToggleShellPicker); + workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw, cx)); + press("cmd-k", &mut cx); + assert!( + workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open), + "Reset after recording Cmd+K must restore its default action" + ); + } + + #[gpui::test] + #[cfg(target_os = "macos")] + fn old_fullscreen_unbind_can_restore_default(cx: &mut TestAppContext) { + use crate::display::keymap; + let (_directory, workspace, mut cx) = open_workspace(1, cx); + for combo in ["Ctrl+Cmd+F", "Ctrl+Win+F", "super+ctrl+f"] { + let mut raw = vec![(combo.into(), "ReceiveChar".into())]; + workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw.clone(), cx)); + press("ctrl-cmd-f", &mut cx); + assert!(!cx.update(|window, _| window.is_fullscreen())); + keymap::reset_action(&mut raw, &crate::config::Action::ToggleFullscreen); + assert!(raw.is_empty()); + workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw, cx)); + press("ctrl-cmd-f", &mut cx); + assert!(cx.update(|window, _| window.is_fullscreen()), "reset failed for {combo}"); + press("ctrl-cmd-f", &mut cx); + assert!(!cx.update(|window, _| window.is_fullscreen())); + } + } + #[gpui::test] fn digits_switch_real_tabs_and_focus_in_both_tab_layouts(cx: &mut TestAppContext) { let (_directory, workspace, mut cx) = open_workspace(10, cx);