fix(keymap): make tab rename configurable and release its previous key
This commit is contained in:
1 parent
7c8298d817
commit
26c6af1eb4
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.
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"rename": "Umbenennen",
|
||||
"rename_tab": "Tab umbenennen",
|
||||
"copy": "Kopieren",
|
||||
"paste": "Einfügen",
|
||||
"cut": "Ausschneiden",
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"rename": "Rename",
|
||||
"rename_tab": "Rename tab",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"cut": "Cut",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"rename": "Cambiar nombre",
|
||||
"rename_tab": "Renombrar pestaña",
|
||||
"copy": "Copiar",
|
||||
"paste": "Pegar",
|
||||
"cut": "Cortar",
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"rename": "Renommer",
|
||||
"rename_tab": "Renommer l’onglet",
|
||||
"copy": "Copier",
|
||||
"paste": "Coller",
|
||||
"cut": "Couper",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Elimina",
|
||||
"edit": "Modifica",
|
||||
"rename": "Rinomina",
|
||||
"rename_tab": "Rinomina scheda",
|
||||
"copy": "Copia",
|
||||
"paste": "Incolla",
|
||||
"cut": "Taglia",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"rename": "名前を変更",
|
||||
"rename_tab": "タブの名前を変更",
|
||||
"copy": "コピー",
|
||||
"paste": "貼り付け",
|
||||
"cut": "切り取り",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "삭제",
|
||||
"edit": "편집",
|
||||
"rename": "이름 바꾸기",
|
||||
"rename_tab": "탭 이름 바꾸기",
|
||||
"copy": "복사",
|
||||
"paste": "붙여넣기",
|
||||
"cut": "잘라내기",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Excluir",
|
||||
"edit": "Editar",
|
||||
"rename": "Renomear",
|
||||
"rename_tab": "Renomear aba",
|
||||
"copy": "Copiar",
|
||||
"paste": "Colar",
|
||||
"cut": "Recortar",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"delete": "Удалить",
|
||||
"edit": "Изменить",
|
||||
"rename": "Переименовать",
|
||||
"rename_tab": "Переименовать вкладку",
|
||||
"copy": "Копировать",
|
||||
"paste": "Вставить",
|
||||
"cut": "Вырезать",
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"rename": "重命名",
|
||||
"rename_tab": "重命名标签页",
|
||||
"copy": "复制",
|
||||
"paste": "粘贴",
|
||||
"cut": "剪切",
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"delete": "刪除",
|
||||
"edit": "編輯",
|
||||
"rename": "重新命名",
|
||||
"rename_tab": "重新命名分頁",
|
||||
"copy": "複製",
|
||||
"paste": "貼上",
|
||||
"cut": "剪下",
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)),
|
||||
|
||||
Reference in new issue
Block a user