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(), 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] { 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)