diff --git a/architecture/notes/nebula_app/input/2026-09-23-configurable-tab-rename.md b/architecture/notes/nebula_app/input/2026-09-23-configurable-tab-rename.md new file mode 100644 index 0000000..6378cfd --- /dev/null +++ b/architecture/notes/nebula_app/input/2026-09-23-configurable-tab-rename.md @@ -0,0 +1,68 @@ +# Configurable tab rename uses the shared keymap + +## Status + +Implemented for review. + +## Context + +Tab rename registered F2 directly in the GPUI workspace. The editable keymap did +not contain a rename action, so users could not release that key for a terminal +application. Adding a settings row alone would leave the original F2 binding active. + +## Evidence + +The workspace previously registered `f2` with `RenameActiveTab`, independently +of the shared defaults and settings action list. The keymap editor previously +added a replacement chord without disabling the action's original default. + +## Decision + +Add the shared `RenameTab` action with default F2 and expose its localized label +in the Tabs keymap group. Both shells use the existing `keybind=combo:action` +storage format, clear/reset operations and effective-binding calculation. + +Editing an action now uses one shared rebinding operation. It releases the +action's model-defined defaults and previous custom chords through `ReceiveChar`, +then assigns the replacement. Other explicit key owners are preserved. Raw +handwritten keybind entries retain their existing ordering and override semantics. + +The GPUI default comes from the shared table and adapts to the existing rename +action. Runtime overrides are registered at workspace and terminal scope. Menu +shortcut hints follow the effective binding instead of a hardcoded F2 label. + +## Rejected alternatives + +- Add only an editable row: changes the display while F2 still intercepts input. +- Detect a particular CLI and change its keys: terminal input ownership belongs + to the user's keymap and the application's negotiated keyboard protocol. +- Add another persistent rename-shortcut setting: duplicates the existing keymap. +- Remove the default for everyone: changes existing behavior without a user choice. + +## Consequences + +The default remains F2. Users can move it, clear it, or explicitly restore it; +changes apply without restarting the workspace. Keymap row edits move a binding +rather than leaving the model's previous chords as hidden aliases. Moving or +clearing the rename binding allows F2 to reach terminal applications. + +## Validation + +Model tests cover persistence round trips, default/custom binding release, +clear/reset and preservation of another action's explicit ownership. GPUI tests +exercise actual event dispatch in both tab layouts, menu binding lookup, the +rendered settings row and capture cancellation. A terminal-context probe verifies +F2 delivery with VT, Win32 and enhanced keyboard modes after remapping. + +The explicit legacy-shell build and independent translation contracts are also +checked. Virtual-window tests do not claim an authenticated CLI session or full +desktop visual acceptance. + +## Supersedes + +The workspace-only F2 default and UI row edits that retained old default chords. + +## Revisit when + +The keymap gains explicit multiple-shortcut editing or a unified ownership model +for additional platform-specific aliases. diff --git a/nebula_app/i18n/de-DE.json b/nebula_app/i18n/de-DE.json index 2c668ec..e43307f 100644 --- a/nebula_app/i18n/de-DE.json +++ b/nebula_app/i18n/de-DE.json @@ -73,6 +73,7 @@ "delete": "Löschen", "edit": "Bearbeiten", "rename": "Umbenennen", + "rename_tab": "Tab umbenennen", "copy": "Kopieren", "paste": "Einfügen", "cut": "Ausschneiden", diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 137445a..30aa94d 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -237,6 +237,7 @@ "delete": "Delete", "edit": "Edit", "rename": "Rename", + "rename_tab": "Rename tab", "copy": "Copy", "paste": "Paste", "cut": "Cut", diff --git a/nebula_app/i18n/es-ES.json b/nebula_app/i18n/es-ES.json index ddd85cf..773b594 100644 --- a/nebula_app/i18n/es-ES.json +++ b/nebula_app/i18n/es-ES.json @@ -73,6 +73,7 @@ "delete": "Eliminar", "edit": "Editar", "rename": "Cambiar nombre", + "rename_tab": "Renombrar pestaña", "copy": "Copiar", "paste": "Pegar", "cut": "Cortar", diff --git a/nebula_app/i18n/fr-FR.json b/nebula_app/i18n/fr-FR.json index c6f6e89..bbd5169 100644 --- a/nebula_app/i18n/fr-FR.json +++ b/nebula_app/i18n/fr-FR.json @@ -123,6 +123,7 @@ "delete": "Supprimer", "edit": "Modifier", "rename": "Renommer", + "rename_tab": "Renommer l’onglet", "copy": "Copier", "paste": "Coller", "cut": "Couper", diff --git a/nebula_app/i18n/it-IT.json b/nebula_app/i18n/it-IT.json index 1de3815..08159b7 100644 --- a/nebula_app/i18n/it-IT.json +++ b/nebula_app/i18n/it-IT.json @@ -73,6 +73,7 @@ "delete": "Elimina", "edit": "Modifica", "rename": "Rinomina", + "rename_tab": "Rinomina scheda", "copy": "Copia", "paste": "Incolla", "cut": "Taglia", diff --git a/nebula_app/i18n/ja-JP.json b/nebula_app/i18n/ja-JP.json index 1b83876..2ed02bf 100644 --- a/nebula_app/i18n/ja-JP.json +++ b/nebula_app/i18n/ja-JP.json @@ -73,6 +73,7 @@ "delete": "削除", "edit": "編集", "rename": "名前を変更", + "rename_tab": "タブの名前を変更", "copy": "コピー", "paste": "貼り付け", "cut": "切り取り", diff --git a/nebula_app/i18n/ko-KR.json b/nebula_app/i18n/ko-KR.json index 19a9fd6..b676c9e 100644 --- a/nebula_app/i18n/ko-KR.json +++ b/nebula_app/i18n/ko-KR.json @@ -73,6 +73,7 @@ "delete": "삭제", "edit": "편집", "rename": "이름 바꾸기", + "rename_tab": "탭 이름 바꾸기", "copy": "복사", "paste": "붙여넣기", "cut": "잘라내기", diff --git a/nebula_app/i18n/pt-BR.json b/nebula_app/i18n/pt-BR.json index 645a6e3..6b7366e 100644 --- a/nebula_app/i18n/pt-BR.json +++ b/nebula_app/i18n/pt-BR.json @@ -73,6 +73,7 @@ "delete": "Excluir", "edit": "Editar", "rename": "Renomear", + "rename_tab": "Renomear aba", "copy": "Copiar", "paste": "Colar", "cut": "Recortar", diff --git a/nebula_app/i18n/ru-RU.json b/nebula_app/i18n/ru-RU.json index e039409..fd75e5c 100644 --- a/nebula_app/i18n/ru-RU.json +++ b/nebula_app/i18n/ru-RU.json @@ -73,6 +73,7 @@ "delete": "Удалить", "edit": "Изменить", "rename": "Переименовать", + "rename_tab": "Переименовать вкладку", "copy": "Копировать", "paste": "Вставить", "cut": "Вырезать", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 607bb1b..babb11c 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -237,6 +237,7 @@ "delete": "删除", "edit": "编辑", "rename": "重命名", + "rename_tab": "重命名标签页", "copy": "复制", "paste": "粘贴", "cut": "剪切", diff --git a/nebula_app/i18n/zh-TW.json b/nebula_app/i18n/zh-TW.json index 23e8cda..7f220dd 100644 --- a/nebula_app/i18n/zh-TW.json +++ b/nebula_app/i18n/zh-TW.json @@ -97,6 +97,7 @@ "delete": "刪除", "edit": "編輯", "rename": "重新命名", + "rename_tab": "重新命名分頁", "copy": "複製", "paste": "貼上", "cut": "剪下", diff --git a/nebula_app/src/config/bindings.rs b/nebula_app/src/config/bindings.rs index 2c9485d..f65c0f2 100644 --- a/nebula_app/src/config/bindings.rs +++ b/nebula_app/src/config/bindings.rs @@ -226,6 +226,9 @@ pub enum Action { /// Close the focused tab (or pane, when the tab is split). CloseTab, + /// Rename the active tab. + RenameTab, + /// Toggle a left/right split in the focused tab. SplitRight, diff --git a/nebula_app/src/config/bindings/defaults.rs b/nebula_app/src/config/bindings/defaults.rs index 7fc1e7d..85806e5 100644 --- a/nebula_app/src/config/bindings/defaults.rs +++ b/nebula_app/src/config/bindings/defaults.rs @@ -92,7 +92,6 @@ pub fn default_key_bindings() -> Vec { ArrowLeft, +BindingMode::APP_CURSOR, ~BindingMode::VI, ~BindingMode::SEARCH; Action::Esc("\x1bOD".into()); // Legacy keys handling which can't be automatically encoded. F1, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOP".into()); - F2, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOQ".into()); F3, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOR".into()); F4, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1bOS".into()); Tab, ModifiersState::SHIFT, ~BindingMode::VI, ~BindingMode::SEARCH, ~BindingMode::REPORT_ALL_KEYS_AS_ESC, ~BindingMode::DISAMBIGUATE_ESC_CODES; Action::Esc("\x1b[Z".into()); @@ -201,6 +200,7 @@ pub fn nebula_key_bindings() -> Vec { KeyBinding; "t", ctrl_shift; Action::CreateNewTab; "w", ctrl_shift; Action::CloseTab; + F2; Action::RenameTab; Tab, ctrl; Action::SelectNextTab; Tab, ctrl_shift; Action::SelectPreviousTab; "e", ctrl_shift; Action::CreateNewWindow; diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs index 2382bbc..c209117 100644 --- a/nebula_app/src/display/context_menu.rs +++ b/nebula_app/src/display/context_menu.rs @@ -99,6 +99,10 @@ struct Row { danger: bool, } +fn row_shortcut<'a>(row: &Row, rename: &'a str) -> &'a str { + if matches!(row.action, ContextMenuAction::RenameTab(_)) { rename } else { row.hint } +} + #[derive(Debug, Clone)] struct MenuLayout { panel: (f32, f32, f32, f32), @@ -225,7 +229,7 @@ fn layout(menu: &ContextMenu, size: SizeInfo, scale: f32, animated_y_offset: f32 ContextMenuAction::RenameTab(index), ICON_EDIT, "重命名", - "F2", + "", cursor_y, false, )); @@ -520,10 +524,14 @@ pub(super) fn draw(display: &mut Display) { } // 右键菜单和命令面板共用同一颗键帽组件;快捷键不再是贴在右缘的一串 // 灰字,独立小键帽给出清晰的可按暗示,也让两类浮层形成同一套视觉语法。 + let rename = keymap::effective_combo(&crate::config::Action::RenameTab, &display.nebula_keymap) + .map(|(combo, _)| combo) + .unwrap_or_default(); for row in &layout.rows { - if !row.hint.is_empty() { + let hint = row_shortcut(row, &rename); + if !hint.is_empty() { let combo = keycap::layout_combo( - row.hint, + hint, row.rect.0 + row.rect.2 - s(10.0), row.rect.1 + row.rect.3 * 0.5, size.cell_width(), @@ -558,9 +566,10 @@ pub(super) fn draw(display: &mut Display) { row.label, &mut display.glyph_cache, ); - if !row.hint.is_empty() { + let hint = row_shortcut(row, &rename); + if !hint.is_empty() { let combo = keycap::layout_combo( - row.hint, + hint, row.rect.0 + row.rect.2 - s(10.0), row.rect.1 + row.rect.3 * 0.5, size.cell_width(), diff --git a/nebula_app/src/display/keymap.rs b/nebula_app/src/display/keymap.rs index 80679e3..b7442e0 100644 --- a/nebula_app/src/display/keymap.rs +++ b/nebula_app/src/display/keymap.rs @@ -45,6 +45,7 @@ pub(crate) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[ // -- 标签页 -- (Action::CreateNewTab, "新建标签页", "New tab"), (Action::CloseTab, "关闭标签页 / 分屏", "Close tab / pane"), + (Action::RenameTab, "重命名标签页", "Rename tab"), (Action::SelectNextTab, "下一个标签页", "Next tab"), (Action::SelectPreviousTab, "上一个标签页", "Previous tab"), // -- 窗格 -- @@ -74,7 +75,7 @@ pub(crate) const EDITABLE_ACTIONS: &[(Action, &str, &str)] = &[ /// `usize` 是本组行数;区间连续覆盖全部可编辑行(含第 0 行快速终端)。 pub(crate) const GROUPS: &[(&str, &str, usize)] = &[ ("全局", "Global", 6), - ("标签页", "Tabs", 4), + ("标签页", "Tabs", 5), ("窗格", "Panes", 7), ("侧栏面板", "Side panels", 2), ("终端", "Terminal", 9), @@ -87,6 +88,17 @@ pub(crate) const READONLY_ROWS: &[(&str, &str, &str)] = &[ ("贴入 AI 修复建议", "Paste AI fix suggestion", "Ctrl+."), ]; +pub(crate) fn action_label( + row: &(Action, &'static str, &'static str), + language: super::UiLanguage, +) -> &'static str { + if row.0 == Action::RenameTab { + language.text(crate::i18n::Message::CommonRenameTab) + } else { + language.pick(row.1, row.2) + } +} + #[cfg(test)] mod group_tests { use super::*; @@ -115,6 +127,13 @@ pub(crate) fn default_shortcuts() -> Vec<(String, Action)> { .collect() } +/// 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))); +} + /// 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) { @@ -574,6 +593,56 @@ pub(crate) fn gpui_mods_prefix(modifiers: &::gpui::Modifiers) -> String { #[cfg(test)] mod tests { + #[test] + fn rename_binding_moves_persists_clears_and_restores() { + use super::*; + let action = Action::RenameTab; + assert_eq!(effective_combo(&action, &[]), Some(("F2".into(), false))); + let mut raw = Vec::new(); + rebind_action(&mut raw, &action, "ctrl+alt+r".into()); + let saved = nebula_settings::apply_keybinds("theme=nord\n", &raw); + let mut restored = nebula_settings::keybind_pairs_from_text(&saved); + let bindings = build_bindings(&restored); + assert_eq!(effective_combo(&action, &bindings), Some(("Ctrl+Alt+R".into(), true))); + let (mods, trigger) = parse_combo("f2").unwrap(); + let f2 = bindings.iter().find(|b| b.mods == mods && b.trigger == trigger).unwrap(); + assert_eq!(f2.action, Action::ReceiveChar, "the old default must reach the CLI"); + clear_action(&mut restored, &action); + assert_eq!(effective_combo(&action, &build_bindings(&restored)), None); + reset_action(&mut restored, &action); + assert_eq!( + effective_combo(&action, &build_bindings(&restored)), + Some(("F2".into(), false)) + ); + } + + #[test] + fn rename_rebinding_preserves_explicit_ownership_of_the_old_key() { + use super::*; + let mut raw = vec![("f2".into(), "CreateNewTab".into())]; + rebind_action(&mut raw, &Action::RenameTab, "ctrl+alt+r".into()); + assert_eq!(raw[0], ("f2".into(), "CreateNewTab".into())); + assert!( + !raw.iter() + .any(|(key, action)| key.eq_ignore_ascii_case("f2") && action == "ReceiveChar") + ); + } + + #[test] + fn rename_is_editable_and_localized_in_every_language() { + use super::*; + let row = + EDITABLE_ACTIONS.iter().find(|(action, ..)| *action == Action::RenameTab).unwrap(); + for language in super::super::UiLanguage::ALL { + assert_eq!( + action_label(row, *language), + language.text(crate::i18n::Message::CommonRenameTab) + ); + assert!(!action_label(row, *language).is_empty()); + } + assert_eq!(action_label(row, super::super::UiLanguage::ZhCn), "重命名标签页"); + } + #[test] fn clearing_ctrl_k_survives_persistence_and_restore_is_explicit() { use super::*; diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index c227cee..dab06c2 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -5670,7 +5670,7 @@ impl Display { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .map(|(_, zh, en)| self.nebula_language.pick(zh, en).to_owned()) + .map(|row| keymap::action_label(row, self.nebula_language).to_owned()) .unwrap_or_default() } }; @@ -7202,8 +7202,7 @@ impl Display { } } - /// 捕获完成:`combo` 归属该行动作。同 combo 的旧自定义行被移除(键随 - /// 最后写入者),该动作旧的自定义行也移除(一动作一自定义键)。 + /// Apply the shared rebinding policy, then refresh the editor. pub fn keymap_assign(&mut self, row: usize, combo: String) { if row == keymap::QUICK_TERMINAL_ROW { self.nebula_keymap_capture = None; @@ -7215,14 +7214,15 @@ impl Display { self.pending_update.dirty = true; return; } - let action_row = row.saturating_sub(1); - let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(action_row) else { return }; - let name = keymap::action_storage_name(action); - self.nebula_keybinds.retain(|(c, a)| c != &combo && !a.eq_ignore_ascii_case(&name)); - self.nebula_keybinds.push((combo, name)); + let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; + keymap::rebind_action(&mut self.nebula_keybinds, action, combo); self.keymap_commit(); } + pub fn active_tab_index(&self) -> usize { + self.nebula_active_tab + } + /// Bare Backspace disables the action, preserving pass-through on reload. pub fn keymap_clear_custom(&mut self, row: usize) { if row == keymap::QUICK_TERMINAL_ROW { diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index a9e5142..8113dc9 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -7338,15 +7338,14 @@ pub(super) fn draw_text( } let i = flat; let ty = rect.1 + (kh - cell_h) / 2.0; - let (zh_label, en_label) = if i == keymap::QUICK_TERMINAL_ROW { + let label = if i == keymap::QUICK_TERMINAL_ROW { if view.quick_hotkey_error.is_some() { - ("快速终端(注册失败)", "Quick terminal (failed)") + language.pick("快速终端(注册失败)", "Quick terminal (failed)") } else { - ("快速终端", "Quick terminal") + language.pick("快速终端", "Quick terminal") } } else { - let (_, zh, en) = keymap::EDITABLE_ACTIONS[i - 1]; - (zh, en) + keymap::action_label(&keymap::EDITABLE_ACTIONS[i - 1], language) }; r.draw_chrome_text( size, @@ -7357,7 +7356,7 @@ pub(super) fn draw_text( } else { sk.ink }, - language.pick(zh_label, en_label), + label, gc, ); let hovered = view.hover == SettingsHit::KeymapRow(slot); diff --git a/nebula_app/src/gpui_shell/settings_pane/keymap.rs b/nebula_app/src/gpui_shell/settings_pane/keymap.rs index 084bf07..85d3ae1 100644 --- a/nebula_app/src/gpui_shell/settings_pane/keymap.rs +++ b/nebula_app/src/gpui_shell/settings_pane/keymap.rs @@ -101,7 +101,7 @@ impl SettingsPane { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .map(|(_, zh, en)| language.pick(zh, en).to_owned()) + .map(|row| keymap::action_label(row, language).to_owned()) .unwrap_or_default() } }; @@ -164,9 +164,7 @@ impl SettingsPane { return; } let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(row - 1) else { return }; - let name = keymap::action_storage_name(action); - self.keymap_binds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name)); - self.keymap_binds.push((combo, name)); + keymap::rebind_action(&mut self.keymap_binds, action, combo); self.persist_keybinds(cx); } @@ -271,7 +269,7 @@ impl SettingsPane { } else { keymap::EDITABLE_ACTIONS .get(flat - 1) - .map(|(_, zh, en)| language.pick(zh, en).to_owned()) + .map(|row| keymap::action_label(row, language).to_owned()) .unwrap_or_default() .into() }; diff --git a/nebula_app/src/gpui_shell/settings_pane/tests.rs b/nebula_app/src/gpui_shell/settings_pane/tests.rs index bdbe0a4..d88ae43 100644 --- a/nebula_app/src/gpui_shell/settings_pane/tests.rs +++ b/nebula_app/src/gpui_shell/settings_pane/tests.rs @@ -1,5 +1,49 @@ use super::*; +#[cfg(feature = "gpui-test-support")] +#[gpui::test] +fn rename_keymap_row_is_searchable_and_enters_capture(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + cx.set_global(crate::gpui_shell::config::Settings::load(ThemeName::Nord)); + }); + let mut pane_out = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let pane = cx.new(|cx| SettingsPane::new(window, cx)); + pane.update(cx, |pane, cx| { + pane.keymap_binds.clear(); + pane.settings_search_input + .update(cx, |input, cx| input.replace_all("Rename tab", window, cx)); + }); + pane_out = Some(pane.clone()); + gpui_component::Root::new(pane, window, cx) + }); + let pane = pane_out.unwrap(); + cx.simulate_resize(gpui::size(px(1280.0), px(900.0))); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let row = crate::display::keymap::EDITABLE_ACTIONS + .iter() + .position(|(action, ..)| *action == crate::config::Action::RenameTab) + .unwrap() + + 1; + let bounds = cx + .debug_bounds(Box::leak(format!("settings-keymap-row-{row}").into_boxed_str())) + .expect("rename row is rendered"); + assert!(bounds.size.width > px(0.0) && bounds.size.height > px(0.0)); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + assert_eq!(pane.read_with(cx, |pane, _| pane.keymap_capture), Some(row)); + cx.simulate_event(gpui::KeyDownEvent { + keystroke: gpui::Keystroke::parse("escape").unwrap(), + is_held: false, + prefer_character_input: false, + }); + cx.run_until_parked(); + assert_eq!(pane.read_with(cx, |pane, _| pane.keymap_capture), None); +} + #[cfg(feature = "gpui-test-support")] #[gpui::test] fn settings_search_replaces_keymap_search_and_keeps_section_queries(cx: &mut gpui::TestAppContext) { diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs index 8dd3357..1ccd710 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings.rs @@ -81,8 +81,6 @@ pub(super) fn default_workspace_bindings() -> Vec { // ctrl+shift+s 上下、ctrl+shift+enter 缩放、ctrl+alt+方向切聚焦。 KeyBinding::new("ctrl-shift-d", SplitRight, None), KeyBinding::new("ctrl-shift-s", SplitDown, None), - // F2 重命名活动标签(旧壳同键位);右键菜单的键帽读的就是这条。 - KeyBinding::new("f2", RenameActiveTab, None), KeyBinding::new("ctrl-shift-enter", ToggleZoom, None), KeyBinding::new("ctrl-alt-left", FocusPaneLeft, None), KeyBinding::new("ctrl-alt-right", FocusPaneRight, None), @@ -117,10 +115,12 @@ pub(super) fn default_workspace_bindings() -> Vec { KeyBinding::new("alt-enter", ToggleFullscreen, None), KeyBinding::new("ctrl-shift-o", OpenQuickJump, None), ]; - // Numeric shortcuts come from the same defaults shown by Settings and - // used by the legacy shell, including each platform's existing modifiers. + // Tab selection and rename use the same defaults displayed by Settings. bindings.extend(crate::display::keymap::default_shortcuts().into_iter().filter_map( |(combo, action)| { + if action == crate::config::Action::RenameTab { + return custom_workspace_binding(&combo, &action); + } let action = SelectTab::from_config(&action)?; Some(KeyBinding::new(&gpui_binding_combo(&combo), action, None)) }, @@ -223,6 +223,7 @@ fn workspace_binding_in_context( Action::CreateNewTab => Some(KeyBinding::new(&combo, NewTerminal, scope)), Action::CreateNewWindow => Some(KeyBinding::new(&combo, NewWindow, scope)), Action::CloseTab => Some(KeyBinding::new(&combo, CloseActiveTerminal, scope)), + Action::RenameTab => Some(KeyBinding::new(&combo, RenameActiveTab, scope)), Action::ToggleFilesPanel => Some(KeyBinding::new(&combo, ToggleFileTree, scope)), Action::ToggleGitPanel => Some(KeyBinding::new(&combo, ToggleGitPanel, scope)), Action::SplitRight => Some(KeyBinding::new(&combo, SplitRight, scope)), @@ -249,8 +250,8 @@ fn workspace_binding_in_context( )), Action::ToggleFullscreen => Some(KeyBinding::new(&combo, ToggleFullscreen, scope)), Action::OpenQuickJump => Some(KeyBinding::new(&combo, OpenQuickJump, scope)), - // `none` 禁用键:gpui 的 NoAction 绑定在最高优先级命中时吞掉按键, - // 与旧壳 keybind=combo:none 的语义一致。 + // Mask the app action. ReceiveChar entries from the editor allow + // the terminal input handler to encode the released key. Action::None | Action::ReceiveChar => Some(KeyBinding::new(&combo, gpui::NoAction, scope)), _ => None, } diff --git a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs index f31baef..3929aec 100644 --- a/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/keyboard_bindings/tests.rs @@ -200,9 +200,78 @@ mod dispatch { assert_active(&workspace, 0, &mut cx); } + #[gpui::test] + fn rename_shortcut_remaps_clears_and_restores_in_both_tab_layouts(cx: &mut TestAppContext) { + use crate::config::Action; + use crate::display::keymap; + let (_directory, workspace, mut cx) = open_workspace(3, cx); + for position in + [nebula_settings::TabsPositionName::Sidebar, nebula_settings::TabsPositionName::Top] + { + workspace.update(&mut cx, |workspace, cx| { + workspace.tabs_position = position; + workspace.update_keybinds(Vec::new(), cx); + }); + press("ctrl-3", &mut cx); + press("f2", &mut cx); + assert_eq!( + workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)), + Some(2) + ); + cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx))); + cx.run_until_parked(); + + let mut raw = Vec::new(); + keymap::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)); + press("f2", &mut cx); + assert!(workspace.read_with(&cx, |w, _| w.tab_rename.is_none())); + press("ctrl-alt-r", &mut cx); + assert_eq!( + workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)), + Some(2) + ); + cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx))); + cx.run_until_parked(); + cx.update(|window, _| { + let bindings = window.bindings_for_action(&RenameActiveTab); + assert!( + bindings + .iter() + .any(|b| b.match_keystrokes(&[Keystroke::parse("ctrl-alt-r").unwrap()]) + == Some(false)) + ); + assert!( + !bindings + .iter() + .any(|b| b.match_keystrokes(&[Keystroke::parse("f2").unwrap()]) + == Some(false)) + ); + }); + + keymap::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); + workspace.update(&mut cx, |w, cx| w.update_keybinds(raw, cx)); + press("f2", &mut cx); + assert_eq!( + workspace.read_with(&cx, |w, _| w.tab_rename.as_ref().map(|r| r.ix)), + Some(2) + ); + cx.update(|window, cx| workspace.update(cx, |w, cx| w.cancel_rename(window, cx))); + cx.run_until_parked(); + } + } + struct TerminalKeyProbe { focus: FocusHandle, selected: Option, + renamed: usize, mode: TermMode, input: Vec, encoded: Vec, @@ -215,6 +284,7 @@ mod dispatch { .on_action(cx.listener(|this, action: &SelectTab, _, _| { this.selected = action.index_for(10); })) + .on_action(cx.listener(|this, _: &RenameActiveTab, _, _| this.renamed += 1)) .child( div() .size_full() @@ -247,6 +317,7 @@ mod dispatch { let probe = cx.new(|cx| TerminalKeyProbe { focus: cx.focus_handle(), selected: None, + renamed: 0, mode: TermMode::empty(), input: Vec::new(), encoded: Vec::new(), @@ -298,4 +369,67 @@ mod dispatch { assert!(!probe.encoded.is_empty()); }); } + + #[gpui::test] + fn remapped_rename_releases_f2_to_negotiated_terminal_input(cx: &mut TestAppContext) { + cx.update(|cx| { + gpui_component::init(cx); + init(cx); + let mut raw = Vec::new(); + crate::display::keymap::rebind_action( + &mut raw, + &crate::config::Action::RenameTab, + "ctrl+alt+r".into(), + ); + for (combo, name) in raw { + let action = crate::display::keymap::parse_action(&name).unwrap(); + for scope in [None, Some(crate::gpui_shell::terminal::KEY_CONTEXT)] { + cx.bind_keys([workspace_binding_in_context(&combo, &action, scope).unwrap()]); + } + } + }); + let mut probe_out = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let probe = cx.new(|cx| TerminalKeyProbe { + focus: cx.focus_handle(), + selected: None, + renamed: 0, + mode: TermMode::empty(), + input: Vec::new(), + encoded: Vec::new(), + }); + let focus = probe.read(cx).focus.clone(); + focus.focus(window, cx); + probe_out = Some(probe.clone()); + Root::new(probe, window, cx) + }); + let probe = probe_out.unwrap(); + for mode in [ + TermMode::empty(), + TermMode::WIN32_INPUT_MODE, + TermMode::DISAMBIGUATE_ESC_CODES, + TermMode::REPORT_ALL_KEYS_AS_ESC, + ] { + probe.update(cx, |p, _| { + p.mode = mode; + p.input.clear(); + p.encoded.clear(); + p.renamed = 0; + }); + press("ctrl-alt-r", cx); + probe.read_with(cx, |p, _| { + assert_eq!(p.renamed, 1); + assert!(p.input.is_empty()); + }); + press("f2", cx); + probe.read_with(cx, |p, _| { + assert_eq!(p.renamed, 1); + assert_eq!(p.input.last(), Some(&Keystroke::parse("f2").unwrap())); + assert!(!p.encoded.is_empty(), "F2 must reach the CLI"); + if mode.is_empty() { + assert_eq!(p.encoded, b"\x1bOQ"); + } + }); + } + } } diff --git a/nebula_app/src/input/action.rs b/nebula_app/src/input/action.rs index fdafc96..4935bef 100644 --- a/nebula_app/src/input/action.rs +++ b/nebula_app/src/input/action.rs @@ -357,6 +357,10 @@ impl Execute for Action { // and profiles all ride the shared tab-request/display plumbing, // which is platform-independent (the tab bar is self-drawn). Action::CloseTab => ctx.nebula_tab(crate::event::TabRequest::Close), + Action::RenameTab => { + let index = ctx.display().active_tab_index(); + ctx.nebula_tab(crate::event::TabRequest::BeginRename(index)); + }, Action::SplitRight => ctx.nebula_tab(crate::event::TabRequest::SplitToggle( crate::display::SplitDirection::LeftRight, )),