From 2725b6acfa5a4eeaf7192d037c2f3113d1309b84 Mon Sep 17 00:00:00 2001 From: YinBuLiao <745802827@qq.com> Date: Thu, 15 Oct 2026 15:15:54 +0800 Subject: [PATCH 1/3] fix(welcome): recreate missing Windows intro scripts Cache script bytes and pending writes instead of permanently memoizing a temporary path or a failed first write. Recreate missing files under the per-layout lock, retaining retry state after partial I/O failures. Refs #622. --- .../src/window_context/nebula_fetch_art.rs | 2 +- nebula_app/src/window_context/welcome.rs | 228 ++++++++++++++++-- tools/gen_fetch_art.py | 2 +- 3 files changed, 204 insertions(+), 28 deletions(-) diff --git a/nebula_app/src/window_context/nebula_fetch_art.rs b/nebula_app/src/window_context/nebula_fetch_art.rs index d32cff902..f3612d11c 100644 --- a/nebula_app/src/window_context/nebula_fetch_art.rs +++ b/nebula_app/src/window_context/nebula_fetch_art.rs @@ -3,7 +3,7 @@ //! `=` ring with front/back occlusion, sparse starfield. //! Regenerate with `python tools/gen_fetch_art.py`. -#[cfg(windows)] +#[cfg(any(windows, test))] pub const NEBULA_STAR_ART: &[&str] = &[ " \x1b[38;2;214;170;78m. \x1b[38;2;150;128;224m. \x1b[38;2;150;128;224m.\x1b[0m", " \x1b[38;2;214;170;78m. \x1b[38;2;150;128;224m.\x1b[0m", diff --git a/nebula_app/src/window_context/welcome.rs b/nebula_app/src/window_context/welcome.rs index da67a816f..600e0e9db 100644 --- a/nebula_app/src/window_context/welcome.rs +++ b/nebula_app/src/window_context/welcome.rs @@ -1,16 +1,22 @@ //! Nebula 新 tab 欢迎页。 //! //! Windows 上渲染彩色字符 logo + fastfetch 风格的信息卡(通过一个缓存的 -//! PowerShell 脚本秒出);非 Windows 上回退到 `fastfetch`/`uname`。全部是 -//! 无状态纯函数,与 [`WindowContext`](super::WindowContext) 完全解耦—— -//! 唯一对外入口是 [`nebula_fastfetch_intro_command`]。 +//! PowerShell 脚本秒出);非 Windows 上回退到 `fastfetch`/`uname`。 +//! 欢迎内容与 [`WindowContext`](super::WindowContext) 的状态解耦;缓存只保留 +//! 脚本字节,临时文件按需重建。入口是 [`nebula_fastfetch_intro_command`]。 #[cfg(windows)] use crate::display::NebulaShell; -#[cfg(windows)] -use std::sync::OnceLock; +#[cfg(any(windows, test))] +use std::sync::Mutex; -#[cfg(windows)] +#[cfg(any(windows, test))] +struct CachedIntroScript { + bytes: Vec, + needs_write: bool, +} + +#[cfg(any(windows, test))] fn nebula_fastfetch_text(narrow: bool) -> String { let cyan = "\x1b[38;2;0;229;255m"; let green = "\x1b[38;2;0;205;64m"; @@ -109,33 +115,66 @@ fn nebula_fastfetch_text(narrow: bool) -> String { text } +#[cfg(any(windows, test))] +fn nebula_fastfetch_script_bytes(narrow: bool) -> Vec { + let text = nebula_fastfetch_text(narrow).replace("\r\n", "\n").replace('\r', "\n"); + let script = format!( + "$OutputEncoding = [System.Text.Encoding]::UTF8\r\n\ + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8\r\n\ + [Console]::Write(@'\r\n{text}\r\n'@)\r\n" + ); + + // Windows PowerShell 5.1 会把无 BOM UTF-8 当作本地 ANSI;这里写 BOM, + // 保证字符 logo 与 ANSI 序列在脚本文件中稳定解析。 + let mut bytes = Vec::with_capacity(3 + script.len()); + bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + bytes.extend_from_slice(script.as_bytes()); + bytes +} + +#[cfg(any(windows, test))] +fn cached_fastfetch_script_path( + cell: &Mutex>, + path: std::path::PathBuf, + narrow: bool, +) -> Option { + cached_fastfetch_script_path_with(cell, path, narrow, |path, bytes| std::fs::write(path, bytes)) +} + +#[cfg(any(windows, test))] +fn cached_fastfetch_script_path_with( + cell: &Mutex>, + path: std::path::PathBuf, + narrow: bool, + write_script: impl FnOnce(&std::path::Path, &[u8]) -> std::io::Result<()>, +) -> Option { + // Keep the bytes, not the lifetime of a file in the system temp directory. + // Serialize recreation so callers in this process cannot read a partial write. + let mut cached = cell.lock().ok()?; + let script = cached.get_or_insert_with(|| CachedIntroScript { + bytes: nebula_fastfetch_script_bytes(narrow), + needs_write: true, + }); + if script.needs_write || !path.is_file() { + // Initial requests replace files left by older processes. Keep this flag + // set after an error, including a failed write that leaves a partial file. + script.needs_write = true; + write_script(&path, &script.bytes).ok()?; + script.needs_write = false; + } + Some(path) +} + #[cfg(windows)] fn nebula_fastfetch_script_path(narrow: bool) -> Option { - static WIDE: OnceLock> = OnceLock::new(); - static NARROW: OnceLock> = OnceLock::new(); + static WIDE: Mutex> = Mutex::new(None); + static NARROW: Mutex> = Mutex::new(None); let (cell, file) = if narrow { (&NARROW, "nebula_fastfetch_intro_narrow.ps1") } else { (&WIDE, "nebula_fastfetch_intro.ps1") }; - cell.get_or_init(|| { - let path = std::env::temp_dir().join(file); - let text = nebula_fastfetch_text(narrow).replace("\r\n", "\n").replace('\r', "\n"); - let script = format!( - "$OutputEncoding = [System.Text.Encoding]::UTF8\r\n\ - [Console]::OutputEncoding = [System.Text.Encoding]::UTF8\r\n\ - [Console]::Write(@'\r\n{text}\r\n'@)\r\n" - ); - - // Windows PowerShell 5.1 会把无 BOM UTF-8 当作本地 ANSI;这里写 BOM, - // 保证字符 logo 与 ANSI 序列在脚本文件中稳定解析。 - let mut bytes = Vec::with_capacity(3 + script.len()); - bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); - bytes.extend_from_slice(script.as_bytes()); - std::fs::write(&path, bytes).ok()?; - Some(path) - }) - .clone() + cached_fastfetch_script_path(cell, std::env::temp_dir().join(file), narrow) } #[cfg(windows)] @@ -185,3 +224,140 @@ pub(crate) fn nebula_fastfetch_intro_command_for( ) -> Vec { nebula_fastfetch_intro_command() } + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::{Arc, Barrier}; + use std::thread; + + use super::*; + + #[test] + fn deleted_scripts_are_recreated_for_both_layouts() { + let root = tempfile::tempdir().unwrap(); + let mut layouts = Vec::new(); + for narrow in [false, true] { + let cache = Default::default(); + let path = root.path().join(format!("intro-{narrow}.ps1")); + assert_eq!( + cached_fastfetch_script_path(&cache, path.clone(), narrow), + Some(path.clone()) + ); + let original = fs::read(&path).unwrap(); + assert!(original.starts_with(&[0xEF, 0xBB, 0xBF])); + let text = std::str::from_utf8(&original[3..]).unwrap(); + assert!(text.starts_with("$OutputEncoding = [System.Text.Encoding]::UTF8\r\n")); + assert!(text.contains("[Console]::Write(@'\r\n")); + assert!(text.ends_with("\r\n'@)\r\n")); + fs::remove_file(&path).unwrap(); + assert_eq!( + cached_fastfetch_script_path(&cache, path.clone(), narrow), + Some(path.clone()) + ); + assert_eq!(fs::read(&path).unwrap(), original); + layouts.push(original); + } + assert_ne!(layouts[0], layouts[1]); + } + + #[test] + fn failed_first_write_can_be_retried() { + let root = tempfile::tempdir().unwrap(); + let parent = root.path().join("scripts"); + fs::write(&parent, b"not a directory").unwrap(); + let path = parent.join("intro.ps1"); + let cache = Default::default(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), None); + fs::remove_file(&parent).unwrap(); + fs::create_dir(&parent).unwrap(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), Some(path.clone())); + assert!(fs::read(path).unwrap().starts_with(&[0xEF, 0xBB, 0xBF])); + } + + #[test] + fn first_request_replaces_a_previous_process_script() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("intro.ps1"); + fs::write(&path, b"old application script").unwrap(); + let cache = Default::default(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), Some(path.clone())); + assert!(fs::read(path).unwrap().starts_with(&[0xEF, 0xBB, 0xBF])); + } + + #[test] + fn cached_request_keeps_an_existing_script() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("intro.ps1"); + let cache = Default::default(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), Some(path.clone())); + let marker = b"existing cached script"; + fs::write(&path, marker).unwrap(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), Some(path.clone())); + assert_eq!(fs::read(path).unwrap(), marker); + } + + #[test] + fn concurrent_requests_recreate_a_complete_cached_script() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("intro.ps1"); + let cache = Arc::new(Mutex::new(None)); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), true), Some(path.clone())); + let expected = Arc::new(fs::read(&path).unwrap()); + fs::remove_file(&path).unwrap(); + let barrier = Arc::new(Barrier::new(8)); + let threads: Vec<_> = (0..8) + .map(|_| { + let cache = Arc::clone(&cache); + let path = path.clone(); + let expected = Arc::clone(&expected); + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + assert_eq!( + cached_fastfetch_script_path(&cache, path.clone(), true), + Some(path.clone()) + ); + assert_eq!(fs::read(path).unwrap(), *expected); + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + } + + #[test] + fn failed_recreation_can_be_retried_without_losing_cached_bytes() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("intro.ps1"); + let cache = Default::default(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), Some(path.clone())); + let expected = fs::read(&path).unwrap(); + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), None); + fs::remove_dir(&path).unwrap(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), Some(path.clone())); + assert_eq!(fs::read(path).unwrap(), expected); + } + + #[test] + fn partial_write_failure_does_not_become_a_successful_cache_hit() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("intro.ps1"); + let cache = Default::default(); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), Some(path.clone())); + let expected = fs::read(&path).unwrap(); + fs::remove_file(&path).unwrap(); + let result = + cached_fastfetch_script_path_with(&cache, path.clone(), false, |path, bytes| { + fs::write(path, &bytes[..7])?; + Err(std::io::Error::other("synthetic partial write")) + }); + assert_eq!(result, None); + assert_eq!(fs::read(&path).unwrap(), &expected[..7]); + assert_eq!(cached_fastfetch_script_path(&cache, path.clone(), false), Some(path.clone())); + assert_eq!(fs::read(path).unwrap(), expected); + } +} diff --git a/tools/gen_fetch_art.py b/tools/gen_fetch_art.py index af1dd2531..f064c8093 100644 --- a/tools/gen_fetch_art.py +++ b/tools/gen_fetch_art.py @@ -140,7 +140,7 @@ def ring_at(x, y): "//! Latitude-banded ball (o/0 indigo, * cyan, ~ magenta), golden tilted\n" "//! `=` ring with front/back occlusion, sparse starfield.\n" "//! Regenerate with `python tools/gen_fetch_art.py`.\n\n" - "#[cfg(windows)]\n" + "#[cfg(any(windows, test))]\n" "pub const NEBULA_STAR_ART: &[&str] = &[\n" ) body = "".join(f' "{l}",\n' for l in rs_lines) From cf77ccd4c69bddad24bf8f82a1adb87081013260 Mon Sep 17 00:00:00 2001 From: YinBuLiao <745802827@qq.com> Date: Thu, 15 Oct 2026 19:14:25 +0800 Subject: [PATCH 2/3] fix(keymap): release macOS Control character defaults Use the Command defaults for the GPUI workspace while preserving plain Control characters for terminal input and explicit user overrides. Keep legacy keymap semantics through caller-selected immutable policies, and match GPUI digit aliases consistently when displaying, clearing or restoring shortcuts. Refs #656. --- .../2026-09-21-macos-command-key-aliases.md | 3 + ...-10-15-macos-control-character-defaults.md | 79 +++ nebula_app/src/display/keymap.rs | 485 ++++++++++++++---- .../src/gpui_shell/settings_pane/keymap.rs | 18 +- .../src/gpui_shell/settings_pane/tests.rs | 8 +- .../gpui_shell/workspace/keyboard_bindings.rs | 24 +- .../workspace/keyboard_bindings/tests.rs | 183 ++++++- 7 files changed, 659 insertions(+), 141 deletions(-) create mode 100644 architecture/notes/nebula_app/input/2026-10-15-macos-control-character-defaults.md 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 index 1055e1a32..c435ef7a6 100644 --- 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 @@ -4,6 +4,9 @@ Implemented for issue #238. +The retained plain-Control defaults and Ctrl-only keycap display decision are +superseded by [macOS Control characters and GUI defaults](../input/2026-10-15-macos-control-character-defaults.md). + ## Context The GPUI shell registered a macOS ⌘ shortcut set that existed nowhere else. diff --git a/architecture/notes/nebula_app/input/2026-10-15-macos-control-character-defaults.md b/architecture/notes/nebula_app/input/2026-10-15-macos-control-character-defaults.md new file mode 100644 index 000000000..168e7410d --- /dev/null +++ b/architecture/notes/nebula_app/input/2026-10-15-macos-control-character-defaults.md @@ -0,0 +1,79 @@ +# macOS Control characters and GUI defaults + +## Status + +Proposed with the fix for #656. + +## Context + +The GPUI workspace registered Windows-style Control shortcuts on every host and +then added macOS Command aliases. Control+K opened the Shell picker before the +terminal could receive kill-line. Control+V and character shortcuts for font size +and numbered tabs had the same ownership conflict. + +## Evidence + +The regression dispatches real workspace key events through TerminalView to its +owned `Msg::Input` receiver. The original implementation fails the macOS default, +settings and Control+K delivery cases. A separate regression demonstrates that +`cmd+0` and captured `cmd+digit0` can otherwise disagree about explicit ownership. + +## Decision + +The GPUI default source releases character keys with exactly Control on macOS. +Command aliases remain GUI defaults. Named Control+Tab keys, Control+Shift and +Control+Alt navigation, component editing bindings and explicit user bindings +retain their existing ownership. The separate OS global quick-terminal hotkey +is outside this workspace policy. + +The shared keymap accepts an explicit immutable frontend policy. GPUI callers +select its Command defaults and digit-key identity; legacy callers retain their +existing model. Separate immutable caches keep this decision independent of +initialization order, compile-time feature combinations and CLI parsing. + +Within GPUI, stored `digitN` and logical `N` have the same identity as the existing +runtime adapter. Default lookup, shadowing, clear and reset use that identity. +Stored rows and their ordering are unchanged; clearing one action must not +disable another action explicitly assigned to the equivalent key. + +## Rejected alternatives + +- Removing every Control binding would also remove established navigation and + terminal mode controls, and require unrelated shortcut replacements. +- A global null for Control+K would interfere with explicit overrides and input + components instead of correcting the default source. +- Changing native modifier translation or C0 encoding would alter correctly + delivered terminal keys without fixing GUI ownership. +- Selecting a frontend from Cargo features is insufficient when a binary also + supports `--legacy-shell`; a mutable process-wide selector is unnecessary. + +## Consequences + +macOS GPUI users can explicitly bind Control+K back to the picker. Removing that +override returns it to terminal input; resetting the picker restores Command+K. +Existing Control+K pass-through rows remain valid. Other hosts retain their +Control defaults. This does not add the optional ClearHistory action proposed in +#653; its macOS Ctrl+K default assertion needs to follow this platform policy. + +The defaults and aliases are merged on a cold initialization path. Settings +display reuses the cached bindings; key matching introduces no new service, +dependency, persistent schema or per-event default-table rebuild. + +## Validation + +Focused regressions cover terminal C0 delivery, Command dispatch, explicit +overrides, removal and restoration, numeric aliases and cross-window updates. +Existing tests retain component paste, numbered-tab focus and negotiated input +coverage. Virtual GPUI events and owned shell probes do not establish physical +AppKit keyboard, arbitrary user keymaps or interactive CLI acceptance. + +## Supersedes + +The retained plain-Control defaults and Ctrl-only keycap display decision in +[macOS command-key aliases](../gpui_shell/2026-09-21-macos-command-key-aliases.md). +Its shared alias authority and stored-keybind contracts remain in force. + +## Revisit when + +The workspace changes its navigation defaults, the renderer selection changes, +or GPUI stops normalizing stored physical digit names into logical digits. diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 140184cc1..02542f040 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -96,6 +96,32 @@ pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[ ("贴入 AI 修复建议", "Paste AI fix suggestion", "Ctrl+."), ]; +/// A caller-selected keyboard model; no process-global frontend state is needed. +#[derive(Clone, Copy)] +pub(crate) struct ShortcutPolicy { + gpui: bool, +} + +const LEGACY: ShortcutPolicy = ShortcutPolicy { gpui: false }; +pub(crate) const GPUI: ShortcutPolicy = ShortcutPolicy { gpui: true }; + +const GPUI_READONLY_ROWS: &[(&str, &str, &str)] = &[ + ("切换到第 N 个标签页", "Select tab N", "Option+1..9 / Command+1..9"), + READONLY_ROWS[1], + READONLY_ROWS[2], +]; + +/// Apply only to GPUI GUI defaults, never user bindings or terminal modes. +pub(crate) fn gui_character_shortcut_enabled(mods: ModifiersState) -> bool { + !cfg!(target_os = "macos") || mods != ModifiersState::CONTROL +} + +impl ShortcutPolicy { + pub(crate) fn readonly_rows(self) -> &'static [(&'static str, &'static str, &'static str)] { + if self.gpui && cfg!(target_os = "macos") { GPUI_READONLY_ROWS } else { READONLY_ROWS } + } +} + /// macOS 的 ⌘ 命令键别名:`(存储写法, 动作)`。 /// /// GPUI 壳在配置表之外**追加**一整套 ⌘ 绑定:配置表的 macOS 段只覆盖其中 @@ -108,8 +134,7 @@ pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[ /// 录制结果仍可使用 `win` / `super`;GPUI 适配层按解析后的按键规范化, /// 覆盖与恢复默认不依赖这些别名或修饰键顺序的逐字相等。 /// -/// 这张表只进 [`default_shortcuts`](解绑/恢复口径),不进 `effective_combo` -/// 的键帽反查:设置页仍按配置表的 Ctrl 键显示。 +/// GPUI 默认缓存统一合并这张表:注册、展示、解绑和恢复使用同一套 ⌘ 键。 /// /// 别名表是共享数据;实际注册由平台层入口限定为 macOS,便于跨平台核验分派。 pub(crate) const MACOS_COMMAND_ALIASES: &[(&str, Action)] = &[ @@ -130,6 +155,15 @@ pub(crate) const MACOS_COMMAND_ALIASES: &[(&str, Action)] = &[ ("alt+cmd+down", Action::FocusPaneDown), ("shift+cmd+]", Action::SelectNextTab), ("shift+cmd+[", Action::SelectPreviousTab), + ("cmd+1", Action::SelectTab1), + ("cmd+2", Action::SelectTab2), + ("cmd+3", Action::SelectTab3), + ("cmd+4", Action::SelectTab4), + ("cmd+5", Action::SelectTab5), + ("cmd+6", Action::SelectTab6), + ("cmd+7", Action::SelectTab7), + ("cmd+8", Action::SelectTab8), + ("cmd+9", Action::SelectLastTab), ("shift+cmd+g", Action::ToggleGitPanel), ("cmd+plus", Action::IncreaseFontSize), ("cmd+=", Action::IncreaseFontSize), @@ -165,90 +199,184 @@ mod group_tests { } } -/// 内置默认表的进程级缓存:设置页每帧反查 25+ 个动作,重建整表太贵。 +/// The original free functions retain the legacy model for legacy callers. +#[cfg(test)] fn cached_defaults() -> &'static [KeyBinding] { - static DEFAULTS: OnceLock> = OnceLock::new(); - DEFAULTS.get_or_init(default_key_bindings) + LEGACY.cached_defaults() } pub(crate) fn default_shortcuts() -> Vec<(String, Action)> { - let mut shortcuts: Vec<(String, Action)> = cached_defaults() - .iter() - .filter_map(|binding| { - canonical_combo(binding.mods, &binding.trigger) - .map(|combo| (combo, binding.action.clone())) - }) - .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 + LEGACY.default_shortcuts() } -/// Editing one action moves its shortcut: old keys pass through to the terminal. -/// Keep explicit bindings for other actions so the editor can report conflicts. pub(crate) fn rebind_action(raw: &mut Vec<(String, String)>, action: &Action, combo: String) { - clear_action(raw, action); - raw.push((combo, action_storage_name(action))); + LEGACY.rebind_action(raw, action, combo); } -/// Preserve the existing keybind format: ReceiveChar explicitly passes a key -/// to the terminal. Clearing an action disables its defaults and its custom key. pub(crate) fn clear_action(raw: &mut Vec<(String, String)>, action: &Action) { - let mut combos: Vec = default_shortcuts() - .into_iter() - .filter(|(_, candidate)| candidate == action) - .map(|(combo, _)| combo) - .collect(); - combos.extend( - raw.iter() - .filter(|(_, name)| parse_action(name).as_ref() == Some(action)) - .map(|(combo, _)| combo.clone()), - ); - raw.retain(|(_, name)| parse_action(name).as_ref() != Some(action)); - for combo in combos { - let key = parse_combo(&combo); - if raw + LEGACY.clear_action(raw, action); +} + +pub(crate) fn reset_action(raw: &mut Vec<(String, String)>, action: &Action) { + LEGACY.reset_action(raw, action); +} + +fn is_numbered_tab_action(action: &Action) -> bool { + matches!( + action, + Action::SelectTab1 + | Action::SelectTab2 + | Action::SelectTab3 + | Action::SelectTab4 + | Action::SelectTab5 + | Action::SelectTab6 + | Action::SelectTab7 + | Action::SelectTab8 + | Action::SelectTab9 + | Action::SelectLastTab + ) +} + +impl ShortcutPolicy { + /// Both caches are immutable derived data, selected by the caller each time. + fn cached_defaults(self) -> &'static [KeyBinding] { + static LEGACY_DEFAULTS: OnceLock> = OnceLock::new(); + static GPUI_DEFAULTS: OnceLock> = OnceLock::new(); + if !self.gpui { + return LEGACY_DEFAULTS.get_or_init(default_key_bindings); + } + GPUI_DEFAULTS.get_or_init(|| { + let mut defaults = default_key_bindings(); + if cfg!(target_os = "macos") { + // Only these actions belong to the GUI default source. Native + // Ctrl+L and mode-specific vi/search actions stay in the model. + defaults.retain(|binding| { + let gui_action = is_numbered_tab_action(&binding.action) + || MACOS_COMMAND_ALIASES + .iter() + .any(|(_, action)| action == &binding.action); + !gui_action + || !matches!( + &binding.trigger, + BindingKey::Keycode { key: Key::Character(_), .. } + ) + || gui_character_shortcut_enabled(binding.mods) + }); + for (combo, action) in MACOS_COMMAND_ALIASES { + let (mods, trigger) = + parse_combo(combo).expect("valid built-in Command shortcut"); + // GPUI aliases own their runtime chords, including Cmd+W. + defaults.retain(|binding| { + binding.mods != mods || !self.same_binding_key(&binding.trigger, &trigger) + }); + defaults.push(KeyBinding { + trigger, + mods, + mode: BindingMode::empty(), + notmode: BindingMode::empty(), + action: action.clone(), + }); + } + } + defaults + }) + } + + pub(crate) fn default_shortcuts(self) -> Vec<(String, Action)> { + let mut shortcuts: Vec<(String, Action)> = self + .cached_defaults() .iter() - .rev() - .find(|(candidate, _)| parse_combo(candidate) == key) - .is_some_and(|(_, name)| parse_action(name) != Some(Action::ReceiveChar)) - { - continue; + .filter_map(|binding| { + if self.gpui && cfg!(target_os = "macos") { + for (combo, action) in MACOS_COMMAND_ALIASES { + if action == &binding.action + && parse_combo(combo).is_some_and(|(mods, trigger)| { + mods == binding.mods + && self.same_binding_key(&trigger, &binding.trigger) + }) + { + return Some(((*combo).to_owned(), binding.action.clone())); + } + } + } + canonical_combo(binding.mods, &binding.trigger) + .map(|combo| (combo, binding.action.clone())) + }) + .collect(); + if !self.gpui && cfg!(target_os = "macos") { + // Preserve the old clear/reset vocabulary without feeding GPUI + // winners into the legacy runtime/default-keycap model. Numbered + // Command aliases were added for GPUI, not to this legacy extra set. + let mut extra: Vec<(String, Action)> = Vec::new(); + for (combo, action) in MACOS_COMMAND_ALIASES { + if is_numbered_tab_action(action) { + continue; + } + let covered = shortcuts + .iter() + .chain(extra.iter()) + .any(|(known, candidate)| candidate == action && self.same_combo(known, combo)); + if !covered { + extra.push(((*combo).to_owned(), action.clone())); + } + } + shortcuts.extend(extra); } - raw.retain(|(candidate, name)| { - parse_combo(candidate) != key || parse_action(name) != Some(Action::ReceiveChar) - }); - raw.push((combo, "ReceiveChar".into())); + shortcuts } -} -pub(crate) fn reset_action(raw: &mut Vec<(String, String)>, action: &Action) { - let defaults = default_shortcuts(); - raw.retain(|(combo, name)| { - let parsed = parse_action(name); - parsed.as_ref() != Some(action) - && !(parsed == Some(Action::ReceiveChar) - && defaults.iter().any(|(default, candidate)| { - candidate == action && parse_combo(combo) == parse_combo(default) - })) - }); + /// Editing one action moves its shortcut; explicit other actions survive. + pub(crate) fn rebind_action( + self, + raw: &mut Vec<(String, String)>, + action: &Action, + combo: String, + ) { + self.clear_action(raw, action); + raw.push((combo, action_storage_name(action))); + } + + pub(crate) fn clear_action(self, raw: &mut Vec<(String, String)>, action: &Action) { + let mut combos: Vec = self + .default_shortcuts() + .into_iter() + .filter(|(_, candidate)| candidate == action) + .map(|(combo, _)| combo) + .collect(); + combos.extend( + raw.iter() + .filter(|(_, name)| parse_action(name).as_ref() == Some(action)) + .map(|(combo, _)| combo.clone()), + ); + raw.retain(|(_, name)| parse_action(name).as_ref() != Some(action)); + for combo in combos { + if raw + .iter() + .rev() + .find(|(candidate, _)| self.same_combo(candidate, &combo)) + .is_some_and(|(_, name)| parse_action(name) != Some(Action::ReceiveChar)) + { + continue; + } + raw.retain(|(candidate, name)| { + !self.same_combo(candidate, &combo) + || parse_action(name) != Some(Action::ReceiveChar) + }); + raw.push((combo, "ReceiveChar".into())); + } + } + + pub(crate) fn reset_action(self, raw: &mut Vec<(String, String)>, action: &Action) { + let defaults = self.default_shortcuts(); + raw.retain(|(combo, name)| { + let parsed = parse_action(name); + parsed.as_ref() != Some(action) + && !(parsed == Some(Action::ReceiveChar) + && defaults.iter().any(|(default, candidate)| { + candidate == action && self.same_combo(combo, default) + })) + }); + } } // ---- combo 文本 ↔ 结构 ---- @@ -310,6 +438,46 @@ const DIGIT_CODES: [(&str, KeyCode); 10] = [ ("digit0", KeyCode::Digit0), ]; +impl ShortcutPolicy { + fn same_binding_key(self, left: &BindingKey, right: &BindingKey) -> bool { + if left == right { + return true; + } + // The GPUI adapter folds stored digitN scancodes into N (including captured + // Command digits). Match that runtime identity without changing stored rows + // or the legacy shell's distinct physical/logical keyboard semantics. + if self.gpui { + let pair = match (left, right) { + ( + BindingKey::Scancode(PhysicalKey::Code(code)), + BindingKey::Keycode { key: Key::Character(text), .. }, + ) + | ( + BindingKey::Keycode { key: Key::Character(text), .. }, + BindingKey::Scancode(PhysicalKey::Code(code)), + ) => Some((code, text.as_str())), + _ => None, + }; + if let Some((code, text)) = pair { + return DIGIT_CODES.iter().any(|(name, candidate)| { + code == candidate && name.strip_prefix("digit") == Some(text) + }); + } + } + false + } + + fn same_combo(self, left: &str, right: &str) -> bool { + match (parse_combo(left), parse_combo(right)) { + (Some((left_mods, left_key)), Some((right_mods, right_key))) => { + left_mods == right_mods && self.same_binding_key(&left_key, &right_key) + }, + (None, None) => true, + _ => false, + } + } +} + /// `ctrl+shift+t` → (mods, key)。`plus` 是 `+` 键的别名(`+` 是分隔符)。 pub(crate) fn parse_combo(combo: &str) -> Option<(ModifiersState, BindingKey)> { let mut mods = ModifiersState::empty(); @@ -493,37 +661,50 @@ pub(crate) fn build_bindings(raw: &[(String, String)]) -> Vec { /// 一个动作当前的有效键(设置页展示用)。 /// 返回 (展示文本, 是否来自用户自定义);None = 未绑定。 pub(crate) fn effective_combo(action: &Action, custom: &[KeyBinding]) -> Option<(String, bool)> { - // custom 已是倒序(最新在前)。该动作的候选行必须没被更新的同键行 - // 遮蔽(手编文件可能给同一个键堆多行,运行时只有最前面那条生效—— - // 展示必须与匹配层口径一致,否则显示的键按下去没反应)。 - for (index, binding) in custom.iter().enumerate() { - if binding.action != *action { - continue; - } - let shadowed_by_newer = - custom[..index].iter().any(|b| b.trigger == binding.trigger && b.mods == binding.mods); - if !shadowed_by_newer { - return display_combo(binding.mods, &binding.trigger).map(|text| (text, true)); + LEGACY.effective_combo(action, custom) +} + +impl ShortcutPolicy { + pub(crate) fn effective_combo( + self, + action: &Action, + custom: &[KeyBinding], + ) -> Option<(String, bool)> { + // custom 已是倒序(最新在前)。该动作的候选行必须没被更新的同键行 + // 遮蔽(手编文件可能给同一个键堆多行,运行时只有最前面那条生效—— + // 展示必须与匹配层口径一致,否则显示的键按下去没反应)。 + for (index, binding) in custom.iter().enumerate() { + if binding.action != *action { + continue; + } + let shadowed_by_newer = custom[..index].iter().any(|b| { + self.same_binding_key(&b.trigger, &binding.trigger) && b.mods == binding.mods + }); + if !shadowed_by_newer { + return display_combo(binding.mods, &binding.trigger).map(|text| (text, true)); + } } + // 默认表:倒序扫描让 platform/nebula 键(靠后 extend)压过 vi/legacy + // 键;跳过 Numpad 专属与 Copy/Paste 媒体键这类不适合展示的触发器。 + let shadowed = |candidate: &KeyBinding| { + custom.iter().any(|b| { + self.same_binding_key(&b.trigger, &candidate.trigger) && b.mods == candidate.mods + }) + }; + self.cached_defaults() + .iter() + .rev() + .filter(|b| b.action == *action) + .filter(|b| { + !matches!( + &b.trigger, + BindingKey::Keycode { key: Key::Named(NamedKey::Copy | NamedKey::Paste), .. } + | BindingKey::Keycode { location: KeyLocation::Numpad, .. } + ) + }) + .find(|b| !shadowed(b)) + .and_then(|b| display_combo(b.mods, &b.trigger).map(|text| (text, false))) } - // 默认表:倒序扫描让 platform/nebula 键(靠后 extend)压过 vi/legacy - // 键;跳过 Numpad 专属与 Copy/Paste 媒体键这类不适合展示的触发器。 - let shadowed = |candidate: &KeyBinding| { - custom.iter().any(|b| b.trigger == candidate.trigger && b.mods == candidate.mods) - }; - cached_defaults() - .iter() - .rev() - .filter(|b| b.action == *action) - .filter(|b| { - !matches!( - &b.trigger, - BindingKey::Keycode { key: Key::Named(NamedKey::Copy | NamedKey::Paste), .. } - | BindingKey::Keycode { location: KeyLocation::Numpad, .. } - ) - }) - .find(|b| !shadowed(b)) - .and_then(|b| display_combo(b.mods, &b.trigger).map(|text| (text, false))) } // ---- 捕获 ---- @@ -671,6 +852,108 @@ pub(crate) fn gpui_mods_prefix(modifiers: &::gpui::Modifiers) -> String { #[cfg(test)] mod tests { + #[test] + #[cfg(all(target_os = "macos", feature = "gpui-shell"))] + fn command_digit_aliases_preserve_ownership_clear_and_reset() { + use super::*; + for (logical, physical, action) in [ + ("cmd+0", "cmd+digit0", Action::ResetFontSize), + ("cmd+1", "cmd+digit1", Action::SelectTab1), + ("cmd+9", "cmd+digit9", Action::SelectLastTab), + ] { + for combo in [logical, physical] { + let mut raw = vec![(combo.into(), "ReceiveChar".into())]; + let fallback = (action == Action::SelectTab1).then(|| ("Option+1".into(), false)); + assert_eq!( + GPUI.effective_combo(&action, &build_bindings(&raw)), + fallback, + "{combo}" + ); + GPUI.reset_action(&mut raw, &action); + assert!(raw.is_empty(), "reset must release either spelling: {combo}"); + assert!(GPUI.effective_combo(&action, &[]).is_some()); + + let mut raw = vec![(combo.into(), "ToggleShellPicker".into())]; + GPUI.clear_action(&mut raw, &action); + assert!(raw.contains(&(combo.into(), "ToggleShellPicker".into()))); + assert!( + !raw.iter().any(|(candidate, name)| GPUI.same_combo(candidate, combo) + && parse_action(name) == Some(Action::ReceiveChar)), + "clearing another action must preserve the explicit owner of {combo}" + ); + assert_eq!(GPUI.effective_combo(&action, &build_bindings(&raw)), None); + } + } + } + + #[test] + #[cfg(target_os = "macos")] + fn control_character_defaults_and_reset_use_command_on_macos() { + use super::*; + let defaults = GPUI.default_shortcuts(); + for combo in ["ctrl+k", "ctrl+digit1", "ctrl+1"] { + assert!( + !defaults.iter().any(|(candidate, _)| parse_combo(candidate) == parse_combo(combo)), + "{combo} must not be a GUI default" + ); + } + let action = Action::ToggleShellPicker; + assert_eq!(GPUI.effective_combo(&action, &[]), Some(("Command+K".into(), false))); + let mut raw = vec![("ctrl+k".into(), "ReceiveChar".into())]; + GPUI.rebind_action(&mut raw, &action, "ctrl+k".into()); + assert_eq!( + GPUI.effective_combo(&action, &build_bindings(&raw)), + Some(("Ctrl+K".into(), true)) + ); + GPUI.reset_action(&mut raw, &action); + assert_eq!( + GPUI.effective_combo(&action, &build_bindings(&raw)), + Some(("Command+K".into(), false)) + ); + let mut old_rows = vec![("ctrl+k".into(), "ReceiveChar".into())]; + GPUI.reset_action(&mut old_rows, &action); + assert_eq!(old_rows, [("ctrl+k".into(), "ReceiveChar".into())]); + assert_eq!( + GPUI.effective_combo(&action, &build_bindings(&old_rows)), + Some(("Command+K".into(), false)) + ); + } + + #[test] + fn frontend_policies_do_not_share_defaults_or_digit_identity() { + use super::*; + let legacy_defaults = default_shortcuts(); + let gpui_defaults = GPUI.default_shortcuts(); + let legacy_picker = effective_combo(&Action::ToggleShellPicker, &[]); + let gpui_picker = GPUI.effective_combo(&Action::ToggleShellPicker, &[]); + let (_, logical) = parse_combo("ctrl+1").unwrap(); + let (_, physical) = parse_combo("ctrl+digit1").unwrap(); + let custom = build_bindings(&[ + ("ctrl+1".into(), "ToggleShellPicker".into()), + ("ctrl+digit1".into(), "ReceiveChar".into()), + ]); + for _ in 0..3 { + assert!(GPUI.same_binding_key(&logical, &physical)); + assert!(!LEGACY.same_binding_key(&logical, &physical)); + assert_eq!(GPUI.default_shortcuts(), gpui_defaults); + assert_eq!(default_shortcuts(), legacy_defaults); + assert_eq!(GPUI.effective_combo(&Action::ToggleShellPicker, &[]), gpui_picker); + assert_eq!(effective_combo(&Action::ToggleShellPicker, &[]), legacy_picker); + assert_eq!( + effective_combo(&Action::ToggleShellPicker, &custom), + Some(("Ctrl+1".into(), true)) + ); + assert_eq!(GPUI.effective_combo(&Action::ToggleShellPicker, &custom), gpui_picker); + } + if cfg!(target_os = "macos") { + assert_eq!(legacy_picker, Some(("Ctrl+K".into(), false))); + assert_eq!(gpui_picker, Some(("Command+K".into(), false))); + assert!(READONLY_ROWS[0].2.contains("Ctrl+1")); + assert!(!GPUI.readonly_rows()[0].2.contains("Ctrl+1")); + assert_eq!(GPUI.effective_combo(&Action::Quit, &[]), Some(("Command+Q".into(), false))); + } + } + #[test] fn rename_binding_moves_persists_clears_and_restores() { use super::*; diff --git a/nebula_app/src/gpui_shell/settings_pane/keymap.rs b/nebula_app/src/gpui_shell/settings_pane/keymap.rs index c2dc0fa06..4f5370ee1 100644 --- a/nebula_app/src/gpui_shell/settings_pane/keymap.rs +++ b/nebula_app/src/gpui_shell/settings_pane/keymap.rs @@ -71,7 +71,8 @@ impl SettingsPane { || *flat != keymap::QUICK_TERMINAL_ROW }) .any(|flat| matches(self.keymap_row_haystack(flat))) - || keymap::READONLY_ROWS + || keymap::GPUI + .readonly_rows() .iter() .any(|(zh, en, combo)| matches(format!("{zh} {en} {combo}").to_lowercase())) || matches(Self::confirmation_shortcuts_search_text()) @@ -88,7 +89,7 @@ impl SettingsPane { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) + .and_then(|(action, ..)| keymap::GPUI.effective_combo(action, &custom)) .map(|(combo, _)| combo) .unwrap_or_default() }; @@ -127,7 +128,7 @@ impl SettingsPane { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) + .and_then(|(action, ..)| keymap::GPUI.effective_combo(action, &custom)) .map(|(combo, _)| combo) }; combos.push(combo.filter(|combo| !combo.is_empty())); @@ -241,7 +242,7 @@ impl SettingsPane { return; } let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; - keymap::rebind_action(&mut self.keymap_binds, action, combo); + keymap::GPUI.rebind_action(&mut self.keymap_binds, action, combo); self.persist_keybinds(cx); } @@ -255,7 +256,7 @@ impl SettingsPane { return; } let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; - keymap::clear_action(&mut self.keymap_binds, action); + keymap::GPUI.clear_action(&mut self.keymap_binds, action); self.persist_keybinds(cx); } @@ -271,7 +272,7 @@ impl SettingsPane { return; } let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; - keymap::reset_action(&mut self.keymap_binds, action); + keymap::GPUI.reset_action(&mut self.keymap_binds, action); self.persist_keybinds(cx); } @@ -358,7 +359,7 @@ impl SettingsPane { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .and_then(|(action, ..)| keymap::effective_combo(action, &custom)) + .and_then(|(action, ..)| keymap::GPUI.effective_combo(action, &custom)) .map(|(combo, custom)| (combo, custom)) .unwrap_or_else(|| (String::new(), false)) }; @@ -460,7 +461,8 @@ impl SettingsPane { // 只读键位和确认对话框快捷键(不可编辑),随搜索过滤。 let query = self.keymap_query(cx); - let mut readonly: Vec<(&str, &str)> = keymap::READONLY_ROWS + let mut readonly: Vec<(&str, &str)> = keymap::GPUI + .readonly_rows() .iter() .filter(|(zh, en, combo)| { query diff --git a/nebula_app/src/gpui_shell/settings_pane/tests.rs b/nebula_app/src/gpui_shell/settings_pane/tests.rs index be51f1e09..7bb787c28 100644 --- a/nebula_app/src/gpui_shell/settings_pane/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/tests.rs @@ -238,11 +238,9 @@ fn rename_keymap_row_is_searchable_and_enters_capture(cx: &mut gpui::TestAppCont let saved = nebula_settings::keybind_pairs(); assert_eq!(pane.read_with(cx, |pane, _| pane.keymap_binds.clone()), saved); if dismissal == "confirm" { - let (combo, custom) = keymap::effective_combo( - &crate::config::Action::RenameTab, - &keymap::build_bindings(&saved), - ) - .unwrap(); + let (combo, custom) = keymap::GPUI + .effective_combo(&crate::config::Action::RenameTab, &keymap::build_bindings(&saved)) + .unwrap(); assert!(custom); assert_eq!(keymap::parse_combo(&combo), keymap::parse_combo("enter")); } else { diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 401f12f97..457827d69 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -8,7 +8,7 @@ use super::*; const USER_KEY_BINDING_SOURCE: gpui::KeyBindingMetaIndex = gpui::KeyBindingMetaIndex(1); const WORKSPACE_DEFAULT_SOURCE: gpui::KeyBindingMetaIndex = gpui::KeyBindingMetaIndex(2); -/// 工作区静态默认绑定的 combo 集([`init`] 的测试镜像)。 +/// 工作区静态默认绑定的候选 combo 集;注册前还会应用平台默认策略。 #[cfg(test)] pub(super) const STATIC_DEFAULT_COMBOS: &[&str] = &[ "ctrl-shift-t", @@ -129,7 +129,7 @@ pub(super) fn default_workspace_bindings() -> Vec { KeyBinding::new("ctrl-shift-o", OpenQuickJump, None), ]; // Tab selection and rename use the same defaults displayed by Settings. - bindings.extend(crate::display::keymap::default_shortcuts().into_iter().filter_map( + bindings.extend(crate::display::keymap::GPUI.default_shortcuts().into_iter().filter_map( |(combo, action)| { if action == crate::config::Action::RenameTab { return custom_workspace_binding(&combo, &action); @@ -138,7 +138,19 @@ pub(super) fn default_workspace_bindings() -> Vec { Some(KeyBinding::new(&gpui_binding_combo(&combo), action, None)) }, )); - bindings.into_iter().map(default_binding_source).collect() + bindings + .into_iter() + .filter(|binding| { + binding.keystrokes().iter().all(|stroke| { + let stroke = stroke.inner(); + stroke.key.chars().count() != 1 + || crate::display::keymap::gui_character_shortcut_enabled( + crate::display::keymap::gpui_modifiers(&stroke.modifiers), + ) + }) + }) + .map(default_binding_source) + .collect() } fn default_binding_source(binding: KeyBinding) -> KeyBinding { @@ -150,10 +162,8 @@ fn default_binding_source(binding: KeyBinding) -> KeyBinding { } } -/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。 -/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着 -/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。 -/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。 +/// macOS 使用 ⌘ 承载字符类 GUI 默认键;仅 Control 的字符组合交给终端。 +/// 保留现有 Ctrl+Tab、Ctrl+Shift 和 Ctrl+Alt 导航组合及显式用户覆盖。 /// /// 键位来自 `display::keymap::MACOS_COMMAND_ALIASES`:设置页的反查、解绑与 /// 恢复读的是同一张表,两处不会再各自漂移。注册必须留在这里、且早于用户 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 8ee9d24ac..1a68f476c 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -1,5 +1,28 @@ use super::*; +fn is_macos() -> bool { + crate::platform::Platform::current() == crate::platform::Platform::MacOS +} + +#[test] +fn control_character_defaults_follow_the_host_platform() { + use gpui::{KeyContext, Keymap, Keystroke}; + let keymap = Keymap::new(default_workspace_bindings()); + let contexts = [KeyContext::parse(crate::gpui_shell::terminal::KEY_CONTEXT).unwrap()]; + for combo in ["ctrl-k", "ctrl-v", "ctrl-,", "ctrl-0", "ctrl-=", "ctrl--", "ctrl-1"] { + let (bindings, pending) = + keymap.bindings_for_input(&[Keystroke::parse(combo).unwrap()], &contexts); + assert!(!pending); + assert_eq!(bindings.is_empty(), is_macos(), "{combo}"); + } + // Named keys and the established modified navigation chords retain their defaults. + for combo in ["ctrl-tab", "ctrl-shift-t", "ctrl-alt-left"] { + let (bindings, pending) = + keymap.bindings_for_input(&[Keystroke::parse(combo).unwrap()], &contexts); + assert!(!pending && !bindings.is_empty(), "{combo}"); + } +} + #[test] fn command_aliases_and_modifier_order_share_one_runtime_identity() { let expected = gpui_binding_combo("shift+cmd+k"); @@ -174,7 +197,11 @@ fn replacing_overrides_preserves_component_keys_and_foreign_registrations() { keymap = Keymap::new(with_user_keybindings(&keymap, Vec::new())); assert_eq!(keymap.bindings().len(), expected - 1); let (bindings, _) = keymap.bindings_for_input(&key, &terminal_context); - assert!(bindings[0].action().as_any().is::()); + if is_macos() { + assert!(bindings.is_empty(), "restoring macOS defaults releases Control+V"); + } else { + assert!(bindings[0].action().as_any().is::()); + } let (bindings, _) = keymap.bindings_for_input( &[Keystroke::parse("f12").unwrap()], &[KeyContext::parse("OtherControl").unwrap()], @@ -287,6 +314,79 @@ mod dispatch { }); } + #[gpui::test] + fn control_character_dispatch_reaches_the_terminal_receiver(cx: &mut TestAppContext) { + use nebula_terminal::event_loop::Msg; + + let (directory, workspace, mut cx) = open_workspace(0, cx); + let receiver = cx.update(|window, cx| { + crate::gpui_shell::terminal::init(cx); + crate::gpui_shell::scientific_render::init(cx); + workspace.update(cx, |workspace, cx| { + workspace.add_terminal_with( + crate::session::LaunchSession::Shell { + name: "control character fixture".into(), + program: "pebrel-test-missing-shell-executable".into(), + args: Vec::new(), + }, + Some(directory.path().to_owned()), + None, + window, + cx, + ); + let terminal = workspace.tabs[workspace.active].focused_view().unwrap().clone(); + terminal.update(cx, |view, _| view.install_completion_test_session()) + }) + }); + cx.run_until_parked(); + let drain = || { + receiver + .try_iter() + .filter_map(|message| match message { + Msg::Input(bytes) => Some(bytes.into_owned()), + _ => None, + }) + .flatten() + .collect::>() + }; + for (combo, expected) in [ + ("ctrl-a", 0x01), + ("ctrl-e", 0x05), + ("ctrl-w", 0x17), + ("ctrl-c", 0x03), + ("ctrl-k", 0x0b), + ] { + press(combo, &mut cx); + if is_macos() || combo != "ctrl-k" { + assert_eq!(drain(), [expected], "{combo}"); + assert!(!workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open)); + } else { + assert!(workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open)); + assert!(drain().is_empty()); + press("escape", &mut cx); + } + } + if is_macos() { + press("ctrl-v", &mut cx); + assert_eq!(drain(), [0x16], "Control+V remains terminal quoted-insert"); + press("cmd-k", &mut cx); + assert!(workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open)); + assert!(drain().is_empty(), "Command+K must not leak terminal input"); + press("escape", &mut cx); + workspace.update(&mut cx, |workspace, cx| { + workspace.update_keybinds(vec![("ctrl+k".into(), "ToggleShellPicker".into())], cx); + }); + press("ctrl-k", &mut cx); + assert!(workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open)); + assert!(drain().is_empty(), "an explicit Control binding must still win"); + press("escape", &mut cx); + workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(Vec::new(), cx)); + press("ctrl-k", &mut cx); + assert_eq!(drain(), [0x0b], "removing the override restores terminal input"); + assert!(!workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open)); + } + } + #[gpui::test] fn command_shortcut_registration_follows_runtime_platform(cx: &mut TestAppContext) { let (_directory, workspace, mut cx) = open_workspace(1, cx); @@ -298,6 +398,30 @@ mod dispatch { ); } + #[gpui::test] + fn command_digit_clearing_keeps_an_explicit_workspace_action(cx: &mut TestAppContext) { + use crate::config::Action; + use crate::display::keymap; + let (_directory, workspace, mut cx) = open_workspace(1, cx); + for (logical, physical, pressed, action) in [ + ("cmd+0", "cmd+digit0", "cmd-0", Action::ResetFontSize), + ("cmd+1", "cmd+digit1", "cmd-1", Action::SelectTab1), + ("cmd+9", "cmd+digit9", "cmd-9", Action::SelectLastTab), + ] { + for combo in [logical, physical] { + let mut raw = vec![(combo.into(), "ToggleShellPicker".into())]; + keymap::GPUI.clear_action(&mut raw, &action); + workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(raw, cx)); + press(pressed, &mut cx); + assert!( + workspace.read_with(&cx, |workspace, _| workspace.shell_picker_open), + "clearing {action:?} must not disable the explicit picker on {combo}" + ); + press("escape", &mut cx); + } + } + } + fn paste_in_command_dialog(raw: Vec<(String, String)>, restore: bool, cx: &mut TestAppContext) { // Mouse events can render another frame. Keep the entry animation from // moving Save away from its sampled bounds on slower native CI runners. @@ -385,15 +509,16 @@ mod dispatch { let (_first_dir, first, mut first_cx) = open_workspace(1, cx); let (_second_dir, second, mut second_cx) = open_workspace(1, cx); let baseline = first_cx.update(|_, cx| cx.key_bindings().borrow().bindings().len()); + let (stored, pressed) = if is_macos() { ("cmd+k", "cmd-k") } else { ("ctrl+k", "ctrl-k") }; for _ in 0..5 { first.update(&mut first_cx, |workspace, cx| { - workspace.update_keybinds(vec![("ctrl+k".into(), "ReceiveChar".into())], cx); + workspace.update_keybinds(vec![(stored.into(), "ReceiveChar".into())], cx); }); - press("ctrl-k", &mut second_cx); + press(pressed, &mut second_cx); assert!(!second.read_with(&second_cx, |workspace, _| workspace.shell_picker_open)); second .update(&mut second_cx, |workspace, cx| workspace.update_keybinds(Vec::new(), cx)); - press("ctrl-k", &mut first_cx); + press(pressed, &mut first_cx); assert!(first.read_with(&first_cx, |workspace, _| workspace.shell_picker_open)); press("escape", &mut first_cx); assert_eq!( @@ -475,7 +600,7 @@ mod dispatch { 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); + keymap::GPUI.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!( @@ -494,7 +619,7 @@ mod dispatch { 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); + keymap::GPUI.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); @@ -665,10 +790,11 @@ mod dispatch { workspace.tab_meta[8].has_bell = true; cx.notify(); }); - for modifier in ["ctrl", "alt"] { + for modifier in [if is_macos() { "cmd" } else { "ctrl" }, "alt"] { for digit in (1..=9).rev() { press(&format!("{modifier}-{digit}"), &mut cx); - assert_active(&workspace, digit - 1, &mut cx); + let index = if modifier == "cmd" && digit == 9 { 9 } else { digit - 1 }; + assert_active(&workspace, index, &mut cx); } } assert!(!workspace.read_with(&cx, |workspace, _| workspace.tab_meta[8].has_bell)); @@ -684,7 +810,7 @@ mod dispatch { workspace.update(cx, |workspace, cx| workspace.open_settings(window, cx)); }); press("alt-9", &mut cx); - press("ctrl-4", &mut cx); + press(if is_macos() { "cmd-4" } else { "ctrl-4" }, &mut cx); cx.update(|window, cx| { let workspace = workspace.read(cx); assert!(workspace.settings_open); @@ -694,7 +820,7 @@ mod dispatch { }); // The active index was already zero: this still has to leave Settings // and return focus to the tab, rather than treating it as a no-op. - press("ctrl-1", &mut cx); + press(if is_macos() { "cmd-1" } else { "ctrl-1" }, &mut cx); assert_active(&workspace, 0, &mut cx); cx.update(|window, cx| { @@ -718,7 +844,7 @@ mod dispatch { }); press("ctrl-1", &mut cx); assert_active(&workspace, 2, &mut cx); - press("ctrl-2", &mut cx); + press(if is_macos() { "cmd-2" } else { "ctrl-2" }, &mut cx); press("alt-8", &mut cx); assert_active(&workspace, 2, &mut cx); @@ -727,10 +853,14 @@ mod dispatch { }); press("ctrl-1", &mut cx); assert_active(&workspace, 2, &mut cx); - // Removing this override must find the shared display spelling - // ("Ctrl+1") and restore it despite the stored spelling ("ctrl+1"). + // Removing the override restores this host's defaults: Control digits + // remain terminal input on macOS, where Command selects a tab. workspace.update(&mut cx, |workspace, cx| workspace.update_keybinds(Vec::new(), cx)); press("ctrl-1", &mut cx); + if is_macos() { + assert_active(&workspace, 2, &mut cx); + press("cmd-1", &mut cx); + } assert_active(&workspace, 0, &mut cx); } @@ -746,7 +876,7 @@ mod dispatch { workspace.tabs_position = position; workspace.update_keybinds(Vec::new(), cx); }); - press("ctrl-3", &mut cx); + press(if is_macos() { "cmd-3" } else { "ctrl-3" }, &mut cx); press("f2", &mut cx); assert_eq!( workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)), @@ -756,7 +886,7 @@ mod dispatch { cx.run_until_parked(); let mut raw = Vec::new(); - keymap::rebind_action(&mut raw, &Action::RenameTab, "ctrl+alt+r".into()); + keymap::GPUI.rebind_action(&mut raw, &Action::RenameTab, "ctrl+alt+r".into()); let saved = nebula_settings::apply_keybinds("", &raw); let mut raw = nebula_settings::keybind_pairs_from_text(&saved); workspace.update(&mut cx, |w, cx| w.update_keybinds(raw.clone(), cx)); @@ -785,12 +915,12 @@ mod dispatch { ); }); - keymap::clear_action(&mut raw, &Action::RenameTab); + keymap::GPUI.clear_action(&mut raw, &Action::RenameTab); workspace.update(&mut cx, |w, cx| w.update_keybinds(raw.clone(), cx)); press("ctrl-alt-r", &mut cx); press("f2", &mut cx); assert!(workspace.read_with(&cx, |w, _| w.tab_rename.is_none())); - keymap::reset_action(&mut raw, &Action::RenameTab); + keymap::GPUI.reset_action(&mut raw, &Action::RenameTab); workspace.update(&mut cx, |w, cx| w.update_keybinds(raw, cx)); press("f2", &mut cx); assert_eq!( @@ -869,16 +999,29 @@ mod dispatch { TermMode::REPORT_ALL_KEYS_AS_ESC, ] { probe.update(cx, |probe, _| probe.mode = mode); - for modifier in ["ctrl", "alt"] { + for modifier in [if is_macos() { "cmd" } else { "ctrl" }, "alt"] { for digit in 1..=9 { press(&format!("{modifier}-{digit}"), cx); probe.read_with(cx, |probe, _| { - assert_eq!(probe.selected, Some(digit - 1)); + let index = if modifier == "cmd" && digit == 9 { 9 } else { digit - 1 }; + assert_eq!(probe.selected, Some(index)); assert!(probe.input.is_empty(), "tab shortcuts must not reach PTY input"); assert!(probe.encoded.is_empty()); }); } } + if is_macos() { + for digit in 1..=9 { + let combo = format!("ctrl-{digit}"); + press(&combo, cx); + probe.update(cx, |probe, _| { + assert_eq!(probe.selected, Some(8)); + assert_eq!(probe.input, [Keystroke::parse(&combo).unwrap()]); + probe.input.clear(); + probe.encoded.clear(); + }); + } + } } // Plain digits and additional modifiers retain their terminal meaning. for combo in ["1", "alt-0", "ctrl-alt-2", "ctrl-shift-2"] { @@ -910,7 +1053,7 @@ mod dispatch { gpui_component::init(cx); init(cx); let mut raw = Vec::new(); - crate::display::keymap::rebind_action( + crate::display::keymap::GPUI.rebind_action( &mut raw, &crate::config::Action::RenameTab, "ctrl+alt+r".into(), From 39da88db89c63585fba2e8c6a0cc642ea8dd3dbf Mon Sep 17 00:00:00 2001 From: YinBuLiao <745802827@qq.com> Date: Thu, 15 Oct 2026 19:37:49 +0800 Subject: [PATCH 3/3] test(keymap): use native tab shortcuts in settings navigation Exercise Command digits on macOS and Control digits on other hosts while retaining the settings navigation and invalid-index assertions. Refs #656. --- .../workspace/settings_navigation/ui_tests.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs b/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs index e91352f66..a58994d58 100644 --- a/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs +++ b/nebula_app/src/gpui_shell/workspace/settings_navigation/ui_tests.rs @@ -73,6 +73,14 @@ fn tab_bounds(selector: &'static str, cx: &mut VisualTestContext) -> gpui::Bound cx.debug_bounds(selector).expect("visible tab bounds") } +fn numbered_tab_shortcuts() -> [&'static str; 2] { + if crate::platform::Platform::current() == crate::platform::Platform::MacOS { + ["cmd-1", "cmd-3"] + } else { + ["ctrl-1", "ctrl-3"] + } +} + #[gpui::test] fn switching_top_tabs_keeps_settings_reachable_until_explicit_close(cx: &mut TestAppContext) { let (_directory, workspace, mut cx) = open_workspace(2, cx); @@ -110,10 +118,11 @@ fn switching_top_tabs_keeps_settings_reachable_until_explicit_close(cx: &mut Tes #[gpui::test] fn keyboard_navigation_can_return_to_background_settings(cx: &mut TestAppContext) { let (_directory, workspace, mut cx) = open_workspace(2, cx); - for shortcut in ["ctrl-1", "alt-1"] { + let [first_tab, third_tab] = numbered_tab_shortcuts(); + for shortcut in [first_tab, "alt-1"] { cx.simulate_keystrokes(shortcut); assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open)); - cx.simulate_keystrokes("ctrl-3"); + cx.simulate_keystrokes(third_tab); assert!(workspace.read_with(&cx, |workspace, _| workspace.settings_open)); cx.simulate_keystrokes("ctrl-tab"); assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open)); @@ -122,7 +131,7 @@ fn keyboard_navigation_can_return_to_background_settings(cx: &mut TestAppContext } cx.simulate_keystrokes("ctrl-shift-w"); assert_eq!(workspace.read_with(&cx, |workspace, _| workspace.top_tab_count()), 2); - cx.simulate_keystrokes("ctrl-3"); + cx.simulate_keystrokes(third_tab); assert!(!workspace.read_with(&cx, |workspace, _| workspace.settings_open)); } @@ -130,7 +139,7 @@ fn keyboard_navigation_can_return_to_background_settings(cx: &mut TestAppContext fn settings_survives_overflow_and_layout_changes(cx: &mut TestAppContext) { let (_directory, workspace, mut cx) = open_workspace(12, cx); cx.simulate_resize(gpui::size(px(760.0), px(900.0))); - cx.simulate_keystrokes("ctrl-1"); + cx.simulate_keystrokes(numbered_tab_shortcuts()[0]); for position in [nebula_settings::TabsPositionName::Sidebar, nebula_settings::TabsPositionName::Top] {