mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix(shortcuts): address review on rebinding (gate holes, re-enable/Reset duplicates, labels, tests)
- isUserBoundShortcutEvent skips toggleSessionSidebar (it has its own gate) and an entry re-captured onto its own default, so ^L still reaches the CLI. - Re-enabling a shortcut and Reset run the duplicate check and refuse when the chord was taken meanwhile; Ctrl/Shift+Enter and Ctrl/Cmd+V are reserved with Alt+1-9, and the Alt-digit check no longer excludes Cmd. - Alt+[ / Alt+] keep their Alt/Option+[ labels via displayBindings. - Behavioural tests (vm harness) replace the source-string test. - Docs: wiki Rebinding / Why-chords-behave-oddly, architecture-invariants note. - Small: _shortcutActions indentation, stale Alt-block comment, typeof check instead of Object.hasOwn, full-width parentheses in zh-CN.
This commit is contained in:
1 parent
1aeabdfd55
commit
3776bbd431
8 files changed
+337
-42
No files matched your search
+35
-25
@@ -532,8 +532,8 @@ const DEFAULT_SHORTCUTS = [
|
||||
group: 'Session',
|
||||
label: 'Toggle Session Sidebar',
|
||||
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
|
||||
// readline backward-char). The Alt block below claims only Digit1-9 and
|
||||
// the brackets, and the registry claims Alt for KeyK and Slash only.
|
||||
// readline backward-char). The Alt block below claims only Digit1-9; the
|
||||
// brackets are registry entries, and the registry claims Alt for KeyK and Slash only.
|
||||
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
||||
action: 'toggleSessionSidebar',
|
||||
},
|
||||
@@ -642,6 +642,7 @@ const DEFAULT_SHORTCUTS = [
|
||||
id: 'previous-session',
|
||||
group: 'Session',
|
||||
label: 'Previous Session',
|
||||
displayBindings: ['Alt/Option+['],
|
||||
bindings: [{ modifiers: ['alt'], code: 'BracketLeft' }],
|
||||
action: 'previousSession',
|
||||
},
|
||||
@@ -649,6 +650,7 @@ const DEFAULT_SHORTCUTS = [
|
||||
id: 'next-session-alt',
|
||||
group: 'Session',
|
||||
label: 'Next Session (alternate)',
|
||||
displayBindings: ['Alt/Option+]'],
|
||||
bindings: [{ modifiers: ['alt'], code: 'BracketRight' }],
|
||||
action: 'nextSession',
|
||||
},
|
||||
@@ -1450,7 +1452,8 @@ class CodemanApp {
|
||||
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
|
||||
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
|
||||
// of layout. Option+1-9 = switch by index. Option+[ / ] (prev / next) live in the shortcut registry, so they can be rebound.
|
||||
// of layout. Option+1-9 = switch by index. Option+[ / ] (prev / next) live in the
|
||||
// shortcut registry, so they can be rebound.
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
|
||||
const code = e.code || '';
|
||||
const digitMatch = code.match(/^Digit([1-9])$/);
|
||||
@@ -10196,39 +10199,46 @@ class CodemanApp {
|
||||
* absent here (copy selection, the palette, the tile chords) have their own dispatch.
|
||||
*/
|
||||
_shortcutActions() {
|
||||
return {
|
||||
showShortcutOverlay: () => this.showShortcutOverlay(),
|
||||
killActiveSession: () => this.killActiveSession(),
|
||||
nextSession: () => this.nextSession(),
|
||||
previousSession: () => this.prevSession(),
|
||||
clearTerminal: () => this.clearTerminal(),
|
||||
restoreTerminalSize: () => this.restoreTerminalSize(),
|
||||
increaseFontSize: () => this.increaseFontSize(),
|
||||
decreaseFontSize: () => this.decreaseFontSize(),
|
||||
toggleVoiceInput: () => VoiceInput.toggle(),
|
||||
moveActiveTabLeft: () => this.moveActiveTabLeft(),
|
||||
moveActiveTabRight: () => this.moveActiveTabRight(),
|
||||
toggleSessionSidebar: () => this.toggleSessionSidebar(),
|
||||
};
|
||||
return {
|
||||
showShortcutOverlay: () => this.showShortcutOverlay(),
|
||||
killActiveSession: () => this.killActiveSession(),
|
||||
nextSession: () => this.nextSession(),
|
||||
previousSession: () => this.prevSession(),
|
||||
clearTerminal: () => this.clearTerminal(),
|
||||
restoreTerminalSize: () => this.restoreTerminalSize(),
|
||||
increaseFontSize: () => this.increaseFontSize(),
|
||||
decreaseFontSize: () => this.decreaseFontSize(),
|
||||
toggleVoiceInput: () => VoiceInput.toggle(),
|
||||
moveActiveTabLeft: () => this.moveActiveTabLeft(),
|
||||
moveActiveTabRight: () => this.moveActiveTabRight(),
|
||||
toggleSessionSidebar: () => this.toggleSessionSidebar(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `e` is a chord the USER rebound to a generically dispatched action.
|
||||
* The document handler has already run the action, but its preventDefault() does not
|
||||
* stop xterm, so without this gate a rebind to e.g. Alt+J would also type ESC j into the
|
||||
* session. Defaults are not covered on purpose: several (Ctrl+L) rely on reaching the PTY.
|
||||
* session. Defaults are not covered on purpose: several (Ctrl+L) rely on reaching the PTY,
|
||||
* and neither is a chord that matches the entry's own default (a re-capture of it).
|
||||
*/
|
||||
isUserBoundShortcutEvent(e) {
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
|
||||
const overrides = this.loadAppSettingsFromStorage().shortcutOverrides || {};
|
||||
const actions = this._shortcutActions();
|
||||
return this.getShortcutRegistry().some(
|
||||
(shortcut) =>
|
||||
!shortcut.disabled &&
|
||||
Array.isArray(overrides[shortcut.id]?.bindings) &&
|
||||
Object.hasOwn(actions, shortcut.action) &&
|
||||
this.matchesShortcutEvent(e, shortcut)
|
||||
);
|
||||
return this.getShortcutRegistry().some((shortcut) => {
|
||||
if (shortcut.disabled || !Array.isArray(overrides[shortcut.id]?.bindings)) return false;
|
||||
// toggleSessionSidebar has its own gate (shouldToggleSessionSidebarFromShortcut) that
|
||||
// lets the chord through when the sidebar layout is off; swallowing it here would make a
|
||||
// rebound sidebar key dead in the header layout.
|
||||
if (shortcut.action === 'toggleSessionSidebar') return false;
|
||||
if (typeof actions[shortcut.action] !== 'function') return false;
|
||||
if (!this.matchesShortcutEvent(e, shortcut)) return false;
|
||||
// Re-capturing the entry's own default (Ctrl+L for Clear Terminal) must keep reaching the
|
||||
// CLI: clearTerminal() relies on ^L for the redraw.
|
||||
const fallback = DEFAULT_SHORTCUTS.find((d) => d.id === shortcut.id);
|
||||
return !this.matchesShortcutEvent(e, fallback);
|
||||
});
|
||||
}
|
||||
|
||||
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
|
||||
|
||||
@@ -1141,7 +1141,7 @@
|
||||
'Previous / Next Session': '上一个 / 下一个会话',
|
||||
'Next Session': '下一个会话',
|
||||
'Previous Session': '上一个会话',
|
||||
'Next Session (alternate)': '下一个会话(备用)',
|
||||
'Next Session (alternate)': '下一个会话(备用)',
|
||||
'Switch to Tab N': '切换到第 N 个标签页',
|
||||
'Move Active Tab Left': '向左移动当前标签页',
|
||||
'Move Active Tab Right': '向右移动当前标签页',
|
||||
|
||||
@@ -4730,10 +4730,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/** Label of the action that already owns the chord in `e`, or '' when it is free. */
|
||||
findShortcutConflict(e, shortcutId) {
|
||||
// Alt+1-9 switch tabs by index; they are fixed, not in the registry.
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && /^Digit[1-9]$/.test(e.code || '')) {
|
||||
// Chords the keydown handlers claim before (or outside) the registry: they are fixed, so a
|
||||
// rebind onto one of them would be dead or would hijack it. Alt+1-9 switch tabs by index
|
||||
// (the document handler does not exclude Cmd); Ctrl/Shift+Enter insert a newline and
|
||||
// Ctrl/Cmd+V pastes, and the user-bound gate sits above those branches in the xterm handler.
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && /^Digit[1-9]$/.test(e.code || '')) {
|
||||
return 'Switch to Tab N';
|
||||
}
|
||||
// (Alt+Shift+Enter is the tile grid's Zoom chord, a registry entry, so Alt stays free.)
|
||||
if (e.key === 'Enter' && !e.altKey && (e.ctrlKey || e.shiftKey)) return 'Insert Newline';
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && String(e.key).toLowerCase() === 'v') return 'Paste';
|
||||
if (typeof this.getShortcutRegistry !== 'function' || typeof this.matchesShortcutEvent !== 'function') return '';
|
||||
const other = this.getShortcutRegistry().find(
|
||||
(shortcut) =>
|
||||
@@ -4742,6 +4748,30 @@ Object.assign(CodemanApp.prototype, {
|
||||
return other ? other.label : '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Label of the action that already owns a chord in `shortcut`'s bindings, or ''. Used when a
|
||||
* binding comes back without a capture (re-enable, Reset), where the chord may have been
|
||||
* given to another action in the meantime.
|
||||
*/
|
||||
findShortcutBindingConflict(shortcut) {
|
||||
for (const binding of shortcut?.bindings || []) {
|
||||
const mods = binding.modifiers || [];
|
||||
const taken = this.findShortcutConflict(
|
||||
{
|
||||
ctrlKey: mods.includes('ctrl'),
|
||||
metaKey: mods.includes('meta'),
|
||||
shiftKey: mods.includes('shift'),
|
||||
altKey: mods.includes('alt'),
|
||||
key: binding.key || '',
|
||||
code: binding.code || '',
|
||||
},
|
||||
shortcut.id
|
||||
);
|
||||
if (taken) return taken;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
describeShortcutEvent(e) {
|
||||
const parts = [];
|
||||
if (e.ctrlKey) parts.push('Ctrl');
|
||||
@@ -4753,6 +4783,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
resetShortcutOverride(shortcutId) {
|
||||
// Reset puts the default chord back (and re-enables the row): refuse when another action
|
||||
// has taken that chord since, instead of leaving two actions on one key.
|
||||
const fallback = typeof DEFAULT_SHORTCUTS !== 'undefined' ? DEFAULT_SHORTCUTS.find((d) => d.id === shortcutId) : null;
|
||||
const taken = fallback ? this.findShortcutBindingConflict(fallback) : '';
|
||||
if (taken) {
|
||||
this.renderShortcutSettingsList();
|
||||
this.showToast?.(`The default chord is already used by ${taken}`, 'error');
|
||||
return;
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||
delete shortcutOverrides[shortcutId];
|
||||
@@ -4762,6 +4801,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
toggleShortcutEnabled(shortcutId, enabled) {
|
||||
if (enabled) {
|
||||
// Re-enabling brings the row's chord back into play: leave it disabled when another action
|
||||
// has been bound to that chord while it was off.
|
||||
const shortcut = this.getShortcutRegistry?.().find((s) => s.id === shortcutId);
|
||||
const taken = this.findShortcutBindingConflict(shortcut);
|
||||
if (taken) {
|
||||
this.renderShortcutSettingsList();
|
||||
this.showToast?.(`Its chord is already used by ${taken}`, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
|
||||
|
||||
Reference in new issue
Block a user