Merge pull request #272 from Kuddev/fix/configurable-tab-rename-20260923

fix(keymap): let users change or clear the F2 tab rename shortcut
This commit is contained in:
Kuddev authored and GitHub committed 2026-09-24 11:01:02 +08:00
commit 7eefb46285
23 files changed
+372 -32

No files matched your search

@@ -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.
+1
View File
@@ -73,6 +73,7 @@
"delete": "Löschen",
"edit": "Bearbeiten",
"rename": "Umbenennen",
"rename_tab": "Tab umbenennen",
"copy": "Kopieren",
"paste": "Einfügen",
"cut": "Ausschneiden",
+1
View File
@@ -237,6 +237,7 @@
"delete": "Delete",
"edit": "Edit",
"rename": "Rename",
"rename_tab": "Rename tab",
"copy": "Copy",
"paste": "Paste",
"cut": "Cut",
+1
View File
@@ -73,6 +73,7 @@
"delete": "Eliminar",
"edit": "Editar",
"rename": "Cambiar nombre",
"rename_tab": "Renombrar pestaña",
"copy": "Copiar",
"paste": "Pegar",
"cut": "Cortar",
+1
View File
@@ -123,6 +123,7 @@
"delete": "Supprimer",
"edit": "Modifier",
"rename": "Renommer",
"rename_tab": "Renommer l’onglet",
"copy": "Copier",
"paste": "Coller",
"cut": "Couper",
+1
View File
@@ -73,6 +73,7 @@
"delete": "Elimina",
"edit": "Modifica",
"rename": "Rinomina",
"rename_tab": "Rinomina scheda",
"copy": "Copia",
"paste": "Incolla",
"cut": "Taglia",
+1
View File
@@ -73,6 +73,7 @@
"delete": "削除",
"edit": "編集",
"rename": "名前を変更",
"rename_tab": "タブの名前を変更",
"copy": "コピー",
"paste": "貼り付け",
"cut": "切り取り",
+1
View File
@@ -73,6 +73,7 @@
"delete": "삭제",
"edit": "편집",
"rename": "이름 바꾸기",
"rename_tab": "탭 이름 바꾸기",
"copy": "복사",
"paste": "붙여넣기",
"cut": "잘라내기",
+1
View File
@@ -73,6 +73,7 @@
"delete": "Excluir",
"edit": "Editar",
"rename": "Renomear",
"rename_tab": "Renomear aba",
"copy": "Copiar",
"paste": "Colar",
"cut": "Recortar",
+1
View File
@@ -73,6 +73,7 @@
"delete": "Удалить",
"edit": "Изменить",
"rename": "Переименовать",
"rename_tab": "Переименовать вкладку",
"copy": "Копировать",
"paste": "Вставить",
"cut": "Вырезать",
+1
View File
@@ -237,6 +237,7 @@
"delete": "删除",
"edit": "编辑",
"rename": "重命名",
"rename_tab": "重命名标签页",
"copy": "复制",
"paste": "粘贴",
"cut": "剪切",
+1
View File
@@ -97,6 +97,7 @@
"delete": "刪除",
"edit": "編輯",
"rename": "重新命名",
"rename_tab": "重新命名分頁",
"copy": "複製",
"paste": "貼上",
"cut": "剪下",
+3
View File
@@ -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,
+1 -1
View File
@@ -92,7 +92,6 @@ pub fn default_key_bindings() -> Vec<KeyBinding> {
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> {
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;
+14 -5
View File
@@ -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(),
+70 -1
View File
@@ -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::*;
+8 -8
View File
@@ -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 {
+5 -6
View File
@@ -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);
@@ -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()
};
@@ -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) {
@@ -81,8 +81,6 @@ pub(super) fn default_workspace_bindings() -> Vec<KeyBinding> {
// 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> {
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,
}
@@ -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<usize>,
renamed: usize,
mode: TermMode,
input: Vec<Keystroke>,
encoded: Vec<u8>,
@@ -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");
}
});
}
}
}
+4
View File
@@ -357,6 +357,10 @@ impl<T: EventListener> Execute<T> 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,
)),