feat(ui): numbered context-panel surface switching with configurable prefix
- Add switch_context_surface shortcut (default Cmd/Ctrl + 1..9, 0 for the 10th surface) that opens/closes/switches context panel rail surfaces by their visible order, configurable and persisted in Settings -> Shortcuts. - Show order-number badges on rail icons while the modifier is held >500ms; dismiss on release, blur, or a number press until the next press-and-hold. - Remove the legacy mod+2/3/4 (diff/terminal/git) and switch_tab_1..9 bindings so numbered surface switching goes only through the new mechanism. - Replace the help-dialog 'Switch Project' row with the surface-switch row and update the shortcuts footer/header icons to the command icon.
This commit is contained in:
@@ -1043,6 +1043,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
|
||||
|
||||
@@ -1558,7 +1558,7 @@ export const dict = {
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten',
|
||||
'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)',
|
||||
'helpDialog.item.switchProject': 'Projekt wechseln',
|
||||
'helpDialog.item.switchContextSurface': 'Kontextpanel-Oberfläche wechseln (Zahlentaste)',
|
||||
'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten',
|
||||
'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen',
|
||||
'helpDialog.item.openSettings': 'Einstellungen öffnen',
|
||||
|
||||
@@ -1108,6 +1108,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
|
||||
|
||||
@@ -1710,8 +1710,8 @@ export const dict = {
|
||||
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
|
||||
'helpDialog.item.switchContextSurface': 'Switch Context Panel Surface (number key)',
|
||||
'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)',
|
||||
'helpDialog.item.switchProject': 'Switch Project',
|
||||
'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu',
|
||||
'helpDialog.item.cycleServicesTab': 'Cycle Services Tab',
|
||||
'helpDialog.item.openSettings': 'Open Settings',
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
|
||||
|
||||
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
|
||||
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
|
||||
"helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)",
|
||||
"helpDialog.item.switchProject": "Cambiar proyecto",
|
||||
"helpDialog.item.switchContextSurface": "Cambiar superficie del panel de contexto (tecla numérica)",
|
||||
"helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios",
|
||||
"helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios",
|
||||
"helpDialog.item.openSettings": "Abrir configuración",
|
||||
|
||||
@@ -996,6 +996,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
|
||||
|
||||
@@ -1524,7 +1524,7 @@ export const dict = {
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
|
||||
'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)',
|
||||
'helpDialog.item.switchProject': 'Changer de projet',
|
||||
'helpDialog.item.switchContextSurface': 'Basculer la surface du panneau contextuel (touche numérique)',
|
||||
'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services',
|
||||
'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo',
|
||||
'helpDialog.item.openSettings': 'Ouvrir les paramètres',
|
||||
|
||||
@@ -1108,6 +1108,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
|
||||
|
||||
@@ -1707,7 +1707,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
|
||||
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
|
||||
'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)',
|
||||
'helpDialog.item.switchProject': 'プロジェクトを切り替え',
|
||||
'helpDialog.item.switchContextSurface': 'コンテキストパネルのサーフェスを切り替え(数字キー)',
|
||||
'helpDialog.item.toggleServicesMenu': 'サービスの切り替え',
|
||||
'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え',
|
||||
'helpDialog.item.openSettings': '設定を開く',
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
|
||||
|
||||
@@ -1713,7 +1713,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
|
||||
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
|
||||
'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)',
|
||||
'helpDialog.item.switchProject': '프로젝트 전환',
|
||||
'helpDialog.item.switchContextSurface': '컨텍스트 패널 서피스 전환(숫자 키)',
|
||||
'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환',
|
||||
'helpDialog.item.cycleServicesTab': '서비스 탭 순환',
|
||||
'helpDialog.item.openSettings': '설정 열기',
|
||||
|
||||
@@ -820,6 +820,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Przełącz powierzchnię panelu kontekstu',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
|
||||
|
||||
@@ -2333,7 +2333,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
|
||||
'helpDialog.item.openSettings': 'Otwórz ustawienia',
|
||||
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
|
||||
'helpDialog.item.switchProject': 'Przełącz projekt',
|
||||
'helpDialog.item.switchContextSurface': 'Przełącz powierzchnię panelu kontekstu (klawisz liczbowy)',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
|
||||
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
|
||||
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
|
||||
|
||||
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
|
||||
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
|
||||
"helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)",
|
||||
"helpDialog.item.switchProject": "Alternar projeto",
|
||||
"helpDialog.item.switchContextSurface": "Alternar superfície do painel de contexto (tecla numérica)",
|
||||
"helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços",
|
||||
"helpDialog.item.cycleServicesTab": "Alternar aba de serviços",
|
||||
"helpDialog.item.openSettings": "Abrir configurações",
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
|
||||
|
||||
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
|
||||
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
|
||||
"helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)",
|
||||
"helpDialog.item.switchProject": "Перемкнути проєкт",
|
||||
"helpDialog.item.switchContextSurface": "Перемкнути поверхню панелі контексту (цифрова клавіша)",
|
||||
"helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів",
|
||||
"helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів",
|
||||
"helpDialog.item.openSettings": "Відкрити налаштування",
|
||||
|
||||
@@ -1075,6 +1075,8 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开文件界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口',
|
||||
|
||||
@@ -1677,7 +1677,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.toggleTerminalExpanded': '切换终端展开状态',
|
||||
'helpDialog.item.togglePlanContextPanel': '切换计划上下文面板',
|
||||
'helpDialog.item.cycleTheme': '循环切换主题(浅色 → 深色 → 跟随系统)',
|
||||
'helpDialog.item.switchProject': '切换项目',
|
||||
'helpDialog.item.switchContextSurface': '切换上下文面板界面(数字键)',
|
||||
'helpDialog.item.toggleServicesMenu': '切换服务菜单',
|
||||
'helpDialog.item.cycleServicesTab': '循环服务标签',
|
||||
'helpDialog.item.openSettings': '打开设置',
|
||||
|
||||
@@ -982,6 +982,8 @@
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟檔案介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗',
|
||||
|
||||
@@ -1681,7 +1681,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.toggleTerminalExpanded': '切換終端機展開狀態',
|
||||
'helpDialog.item.togglePlanContextPanel': '切換計畫上下文面板',
|
||||
'helpDialog.item.cycleTheme': '循環切換主題(淺色 → 深色 → 跟隨系統)',
|
||||
'helpDialog.item.switchProject': '切換專案',
|
||||
'helpDialog.item.switchContextSurface': '切換上下文面板介面(數字鍵)',
|
||||
'helpDialog.item.toggleServicesMenu': '切換服務選單',
|
||||
'helpDialog.item.cycleServicesTab': '循環服務標籤',
|
||||
'helpDialog.item.openSettings': '開啟設定',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
eventMatchesShortcutPrefix,
|
||||
getEffectiveShortcutPrefix,
|
||||
isShortcutPrefixHeld,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
} from './shortcuts';
|
||||
|
||||
describe('getEffectiveShortcutPrefix', () => {
|
||||
test('falls back to the action default (bare mod) when unset', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod');
|
||||
});
|
||||
|
||||
test('honors modifier + key overrides', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p');
|
||||
});
|
||||
|
||||
test('honors modifier-only overrides', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift');
|
||||
});
|
||||
|
||||
test('returns UNASSIGNED for an explicit unassignment', () => {
|
||||
expect(
|
||||
getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }),
|
||||
).toBe(UNASSIGNED_SHORTCUT);
|
||||
});
|
||||
|
||||
test('returns empty string for an unknown action', () => {
|
||||
expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isShortcutPrefixHeld', () => {
|
||||
test('false for an unassigned prefix', () => {
|
||||
expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false);
|
||||
});
|
||||
|
||||
test('requires the prefix primary key to be held', () => {
|
||||
expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false);
|
||||
expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true);
|
||||
});
|
||||
|
||||
test('requires every prefix modifier to be held', () => {
|
||||
expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false);
|
||||
expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent =>
|
||||
({
|
||||
key,
|
||||
metaKey: mods.meta ?? false,
|
||||
ctrlKey: mods.ctrl ?? false,
|
||||
shiftKey: mods.shift ?? false,
|
||||
altKey: mods.alt ?? false,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
describe('eventMatchesShortcutPrefix', () => {
|
||||
test('matches a bare mod prefix when the primary modifier is held', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a bare mod prefix without the primary modifier', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects when the event carries modifiers the prefix does not expect', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false);
|
||||
});
|
||||
|
||||
test('requires the prefix primary key to be held at match time', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false);
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true);
|
||||
});
|
||||
|
||||
test('false for an unassigned prefix', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,17 @@ const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
|
||||
'ctrl': '⌃',
|
||||
};
|
||||
|
||||
// Physical `event.key` values (lowercased) that satisfy each modifier while a
|
||||
// chord is being held. `mod` maps to the platform primary key; on web macOS it
|
||||
// accepts either Meta or Ctrl, matching eventMatchesShortcut.
|
||||
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
|
||||
'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
|
||||
'shift': ['shift'],
|
||||
'alt': ['alt'],
|
||||
'option': ['alt'],
|
||||
'ctrl': ['control'],
|
||||
};
|
||||
|
||||
const KEY_LABEL_MAP: Record<string, string> = {
|
||||
'comma': ',',
|
||||
'period': '.',
|
||||
@@ -207,6 +218,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
description: 'Open right sidebar and select Files',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'switch_context_surface',
|
||||
defaultCombo: 'mod',
|
||||
label: 'Switch context panel surface',
|
||||
description: 'Hold the modifier and press a number to open or close the matching rail icon',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'new_chat',
|
||||
defaultCombo: 'mod+n',
|
||||
@@ -240,24 +258,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
label: 'Clear input',
|
||||
description: 'Clear the input field',
|
||||
},
|
||||
{
|
||||
id: 'open_diff_panel',
|
||||
defaultCombo: 'mod+2',
|
||||
label: 'Open diff panel',
|
||||
description: 'Switch to the diff panel',
|
||||
},
|
||||
{
|
||||
id: 'open_terminal_panel',
|
||||
defaultCombo: 'mod+3',
|
||||
label: 'Open terminal panel',
|
||||
description: 'Switch to the terminal panel',
|
||||
},
|
||||
{
|
||||
id: 'open_git_panel',
|
||||
defaultCombo: 'mod+4',
|
||||
label: 'Open git panel',
|
||||
description: 'Switch to the git panel',
|
||||
},
|
||||
{
|
||||
id: 'open_help',
|
||||
defaultCombo: 'mod+.',
|
||||
@@ -347,60 +347,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
label: 'Abort active run',
|
||||
description: 'Abort the currently running task (double press)',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_1',
|
||||
defaultCombo: 'mod+1',
|
||||
label: 'Switch to tab 1',
|
||||
description: 'Switch to the first tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_2',
|
||||
defaultCombo: 'mod+2',
|
||||
label: 'Switch to tab 2',
|
||||
description: 'Switch to the second tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_3',
|
||||
defaultCombo: 'mod+3',
|
||||
label: 'Switch to tab 3',
|
||||
description: 'Switch to the third tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_4',
|
||||
defaultCombo: 'mod+4',
|
||||
label: 'Switch to tab 4',
|
||||
description: 'Switch to the fourth tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_5',
|
||||
defaultCombo: 'mod+5',
|
||||
label: 'Switch to tab 5',
|
||||
description: 'Switch to the fifth tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_6',
|
||||
defaultCombo: 'mod+6',
|
||||
label: 'Switch to tab 6',
|
||||
description: 'Switch to the sixth tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_7',
|
||||
defaultCombo: 'mod+7',
|
||||
label: 'Switch to tab 7',
|
||||
description: 'Switch to the seventh tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_8',
|
||||
defaultCombo: 'mod+8',
|
||||
label: 'Switch to tab 8',
|
||||
description: 'Switch to the eighth tab or project',
|
||||
},
|
||||
{
|
||||
id: 'switch_tab_9',
|
||||
defaultCombo: 'mod+9',
|
||||
label: 'Switch to tab 9',
|
||||
description: 'Switch to the ninth tab or project',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
|
||||
@@ -610,3 +556,126 @@ export function eventMatchesShortcut(
|
||||
export function getModifierLabel(): string {
|
||||
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the configurable prefix for chord-style shortcuts such as
|
||||
* "switch context panel surface", where a trailing digit key completes the
|
||||
* combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the
|
||||
* bare `mod` primary key) are honored so the prefix can omit a primary key.
|
||||
* Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix.
|
||||
*/
|
||||
export function getEffectiveShortcutPrefix(
|
||||
actionId: string,
|
||||
overrides?: Record<string, ShortcutCombo>,
|
||||
): ShortcutCombo {
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const override = overrides?.[actionId];
|
||||
if (typeof override === 'string' && override.trim() !== '') {
|
||||
const normalized = normalizeCombo(override);
|
||||
if (normalized === UNASSIGNED_SHORTCUT) {
|
||||
return UNASSIGNED_SHORTCUT;
|
||||
}
|
||||
if (normalized) {
|
||||
const parsed = parseShortcut(normalized);
|
||||
if (parsed.modifiers.size > 0 || parsed.key) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return action.defaultCombo;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the physical keys required to "arm" a prefix combo are currently
|
||||
* held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at
|
||||
* least one alias must be held.
|
||||
*/
|
||||
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
|
||||
if (isUnassignedShortcut(prefixCombo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(prefixCombo);
|
||||
|
||||
for (const modifier of parsed.modifiers) {
|
||||
const aliases = MODIFIER_KEY_ALIASES[modifier];
|
||||
if (!aliases.some((alias) => heldKeys.has(alias))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches an activating keydown (the caller checks the event's own key, e.g. a
|
||||
* digit) against a chord prefix: the event's modifier state must match the
|
||||
* prefix's modifiers, and when the prefix has a primary key that key must
|
||||
* currently be held.
|
||||
*/
|
||||
export function eventMatchesShortcutPrefix(
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
prefixCombo: ShortcutCombo,
|
||||
heldKeys?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (isUnassignedShortcut(prefixCombo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(prefixCombo);
|
||||
|
||||
const expectedMod = parsed.modifiers.has('mod');
|
||||
const expectedShift = parsed.modifiers.has('shift');
|
||||
const expectedAlt = parsed.modifiers.has('alt');
|
||||
const expectedCtrl = parsed.modifiers.has('ctrl');
|
||||
const isDesktopMac = isMacOS() && isDesktopShell();
|
||||
const isMac = isMacOS();
|
||||
|
||||
const modMatches = isDesktopMac
|
||||
? event.metaKey
|
||||
: isMac
|
||||
? (event.metaKey || event.ctrlKey)
|
||||
: event.ctrlKey;
|
||||
|
||||
if (expectedMod && !modMatches) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!expectedMod && event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedShift !== event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedAlt !== event.altKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedCtrl) {
|
||||
if (!event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
|
||||
if (event.ctrlKey && !ctrlUsedAsMod) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
|
||||
- Rail order is user-reorderable and persisted globally in
|
||||
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
|
||||
registry's default order and appends any missing surfaces.
|
||||
- `getVisibleContextRailSurfaces` is the single visibility filter shared by the
|
||||
rail and the global surface-switch shortcut (`switch_context_surface` in
|
||||
`lib/shortcuts.ts`): it drops the plan surface unless plan mode is enabled,
|
||||
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides
|
||||
`has-content` surfaces until a tab of their mode exists. Both consumers use
|
||||
it so the digit shown on a rail badge always maps to the same surface the
|
||||
shortcut opens.
|
||||
|
||||
## Adding a surface
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
CONTEXT_SURFACES,
|
||||
getVisibleContextRailSurfaces,
|
||||
WALKTHROUGH_MIN_WIDTH,
|
||||
} from './registry';
|
||||
|
||||
const baseOptions = {
|
||||
railOrder: [],
|
||||
planModeEnabled: true,
|
||||
isVSCode: false,
|
||||
screenWidth: 1200,
|
||||
tabs: [],
|
||||
} as const;
|
||||
|
||||
describe('getVisibleContextRailSurfaces', () => {
|
||||
test('hides the plan surface while plan mode is disabled', () => {
|
||||
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, planModeEnabled: false });
|
||||
expect(surfaces.some((surface) => surface.id === 'plan')).toBe(false);
|
||||
expect(surfaces.some((surface) => surface.id === 'context')).toBe(true);
|
||||
});
|
||||
|
||||
test('shows the plan surface while plan mode is enabled', () => {
|
||||
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, planModeEnabled: true });
|
||||
expect(surfaces.some((surface) => surface.id === 'plan')).toBe(true);
|
||||
});
|
||||
|
||||
test('hides the walkthrough on VS Code and below the min width', () => {
|
||||
expect(getVisibleContextRailSurfaces({ ...baseOptions, isVSCode: true }).some((s) => s.id === 'walkthrough')).toBe(false);
|
||||
expect(
|
||||
getVisibleContextRailSurfaces({ ...baseOptions, screenWidth: WALKTHROUGH_MIN_WIDTH - 1 }).some((s) => s.id === 'walkthrough'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getVisibleContextRailSurfaces({ ...baseOptions, screenWidth: WALKTHROUGH_MIN_WIDTH }).some((s) => s.id === 'walkthrough'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('hides content-driven surfaces until a matching tab exists', () => {
|
||||
const preview = CONTEXT_SURFACES.find((surface) => surface.id === 'preview');
|
||||
if (!preview) {
|
||||
throw new Error('preview surface missing from registry');
|
||||
}
|
||||
expect(preview.availability).toBe('has-content');
|
||||
expect(getVisibleContextRailSurfaces(baseOptions).some((s) => s.id === 'preview')).toBe(false);
|
||||
expect(getVisibleContextRailSurfaces({ ...baseOptions, tabs: [{ mode: preview.mode }] }).some((s) => s.id === 'preview')).toBe(true);
|
||||
});
|
||||
|
||||
test('respects the persisted user rail order', () => {
|
||||
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, railOrder: ['git', 'context'] });
|
||||
expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']);
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,10 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
const SURFACE_BY_ID = new Map(CONTEXT_SURFACES.map((surface) => [surface.id, surface]));
|
||||
const FRACTION_BY_MODE = new Map(CONTEXT_SURFACES.map((surface) => [surface.mode, surface.defaultWidthFraction]));
|
||||
|
||||
// Tablet width and up: below this the walkthrough cannot show a stop and its
|
||||
// code side by side, which is the whole point of the surface.
|
||||
export const WALKTHROUGH_MIN_WIDTH = 768;
|
||||
|
||||
export const getContextSurfaceWidthFraction = (mode: ContextPanelMode): number => {
|
||||
return FRACTION_BY_MODE.get(mode) ?? 1 / 2;
|
||||
};
|
||||
@@ -187,3 +191,36 @@ export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfac
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
type VisibleRailSurfacesOptions = {
|
||||
railOrder: readonly string[];
|
||||
planModeEnabled: boolean;
|
||||
isVSCode: boolean;
|
||||
screenWidth: number;
|
||||
tabs: readonly { mode: ContextPanelMode }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The context panel rail's visible, user-ordered surfaces. Shared by the rail
|
||||
* (for rendering and number badges) and the global surface-switch shortcut so
|
||||
* both agree on which surface each digit maps to.
|
||||
*
|
||||
* Content-driven surfaces are hidden (not disabled) until content exists; an
|
||||
* existing tab keeps them visible even if the content source went away.
|
||||
*/
|
||||
export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOptions): ContextSurfaceDescriptor[] => {
|
||||
return sortContextSurfaces(options.railOrder).filter((surface) => {
|
||||
if (surface.id === 'plan' && !options.planModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
// The walkthrough needs room for a stop list beside real code, and its
|
||||
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
|
||||
if (surface.id === 'walkthrough' && (options.isVSCode || options.screenWidth < WALKTHROUGH_MIN_WIDTH)) {
|
||||
return false;
|
||||
}
|
||||
if (surface.availability === 'has-content') {
|
||||
return options.tabs.some((tab) => tab.mode === surface.mode);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user