mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #617 from opticon454/feat/custom-keybindings
feat(shortcuts): rebindable previous/next session, duplicate-chord refusal, rebinds stay out of the PTY Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d080004436
11 files changed
+416
-39
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'aicodeman': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Shortcut rebinding (App Settings → Shortcuts) now covers Previous Session and the alternate Next Session, which were fixed at Alt/Option+[ and Alt/Option+]. Capturing a chord another action already owns is refused with a message naming that action (Alt+1-9 are reserved for tab switching, Ctrl/Shift+Enter for newline and Ctrl/Cmd+V for paste); re-enabling a disabled shortcut and Reset run the same check. A chord you rebound to an app action no longer also types into the terminal: a rebind to Alt+J used to switch tabs and send ESC j to the session as well.
|
||||||
@@ -683,7 +683,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
|
|||||||
|
|
||||||
### Command palette and shortcut registry
|
### Command palette and shortcut registry
|
||||||
|
|
||||||
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
|
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ The global Escape handler (app.js) calls every overlay close method on every Escape, in the capture phase and so before the focused element's own Escape handler, which means a close method with side effects beyond hiding must return early when its overlay is not open: `closeCommandPalette()` and `closeSessionManager()` hand focus back on close (`_restoreOverlayFocus()`, panels-ui.js), and run unconditionally that restore stole focus from split Pane B and from any text field, and turned the inline tab rename's Escape into a commit. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ Every xterm key handler (Pane A in terminal-ui.js, Pane B in terminal-tile.js) must call `isUserBoundShortcutEvent(ev)`, which is true only for a chord the user REBOUND a generically dispatched action to: the document capture handler's `preventDefault()` does not stop xterm, so a rebind to e.g. Alt+J would otherwise also type `ESC j` into the PTY. Defaults are deliberately not gated (Ctrl+L must reach the CLI for its redraw), so the gate also skips an entry re-captured onto its own default, `toggleSessionSidebar` (it has its own gate, `shouldToggleSessionSidebarFromShortcut`) and copy-selection. Capturing, re-enabling and Reset all go through `findShortcutConflict`/`findShortcutBindingConflict` (settings-ui.js); Alt+1-9, Ctrl/Shift+Enter and Ctrl/Cmd+V are reserved there.
|
||||||
|
|
||||||
**A key gate on a chord that can carry Shift alone must swallow the KEYPRESS too** (#520). xterm calls the custom handler from `attachCustomKeyEventHandler` for `keydown`, `keypress` AND `keyup`, and its own `_keyPress` drops a keypress carrying Ctrl, Alt or Meta but not one whose only modifier is Shift. So a gate that returns `false` for `ev.type === 'keydown'` alone is safe for a Ctrl or Alt chord (the palette's Ctrl+K, Alt+B, Ctrl+V, Ctrl+Z and the keydown-only smart copy below) and leaks for a Shift-only one. That is how Shift+Enter submitted the prompt from April to #520: its keypress fell through to `onData` as a bare `\r`, which goes out synchronously over the WebSocket ahead of the async `send-key` fetch, so the CR reached the PTY before the newline did. Ctrl+Enter worked all along only because Chrome fires no keypress for it. ⚠️ The Shift/Ctrl+Enter gate in terminal-ui.js and its twin in Pane B (terminal-split.js) return `false` for EVERY event type and key only the `send-key` POST on `keydown`; never narrow either back to keydown, and give any new Shift-only chord the same shape. Swallowing the keyup as well is harmless (xterm only skips a focus and cursor-style refresh for that one event). Tests: `test/shift-enter-keypress-swallowed.test.ts` (CI gate, a source guard on both files) and `test/shift-enter-keypress.browser.test.ts` (browser suite, which the gate does not run: real keys against both real handlers in the real app). Settings → Terminal & Input → **Key tester** shows what a device reports for each of the three events.
|
**A key gate on a chord that can carry Shift alone must swallow the KEYPRESS too** (#520). xterm calls the custom handler from `attachCustomKeyEventHandler` for `keydown`, `keypress` AND `keyup`, and its own `_keyPress` drops a keypress carrying Ctrl, Alt or Meta but not one whose only modifier is Shift. So a gate that returns `false` for `ev.type === 'keydown'` alone is safe for a Ctrl or Alt chord (the palette's Ctrl+K, Alt+B, Ctrl+V, Ctrl+Z and the keydown-only smart copy below) and leaks for a Shift-only one. That is how Shift+Enter submitted the prompt from April to #520: its keypress fell through to `onData` as a bare `\r`, which goes out synchronously over the WebSocket ahead of the async `send-key` fetch, so the CR reached the PTY before the newline did. Ctrl+Enter worked all along only because Chrome fires no keypress for it. ⚠️ The Shift/Ctrl+Enter gate in terminal-ui.js and its twin in Pane B (terminal-split.js) return `false` for EVERY event type and key only the `send-key` POST on `keydown`; never narrow either back to keydown, and give any new Shift-only chord the same shape. Swallowing the keyup as well is harmless (xterm only skips a focus and cursor-style refresh for that one event). Tests: `test/shift-enter-keypress-swallowed.test.ts` (CI gate, a source guard on both files) and `test/shift-enter-keypress.browser.test.ts` (browser suite, which the gate does not run: real keys against both real handlers in the real app). Settings → Terminal & Input → **Key tester** shows what a device reports for each of the three events.
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ default. See [Tile Grid](Tile-Grid).
|
|||||||
|
|
||||||
**App Settings → Shortcuts.** Bindings live in a registry with per-user overrides, so a
|
**App Settings → Shortcuts.** Bindings live in a registry with per-user overrides, so a
|
||||||
rebind is stored as an override on top of the default rather than replacing the table.
|
rebind is stored as an override on top of the default rather than replacing the table.
|
||||||
|
Previous Session (`Alt+[`) and Next Session (alternate, `Alt+]`) are rows like any other,
|
||||||
|
so they can be rebound too.
|
||||||
|
|
||||||
|
A chord another action already uses is refused, and the message names that action. The same
|
||||||
|
check runs when you re-enable a disabled shortcut and when you press Reset: if its chord was
|
||||||
|
given to something else in the meantime, it stays as it was. `Alt+1` to `Alt+9`,
|
||||||
|
`Shift+Enter`/`Ctrl+Enter` and `Ctrl+V`/`Cmd+V` are reserved.
|
||||||
|
|
||||||
Two things are deliberately not rebindable:
|
Two things are deliberately not rebindable:
|
||||||
|
|
||||||
@@ -79,8 +86,11 @@ Two things are deliberately not rebindable:
|
|||||||
|
|
||||||
The terminal sees keystrokes before the app does. Any chord the app claims has to also be
|
The terminal sees keystrokes before the app does. Any chord the app claims has to also be
|
||||||
swallowed at the terminal layer, or xterm writes the control byte into the session as well
|
swallowed at the terminal layer, or xterm writes the control byte into the session as well
|
||||||
as triggering the action. If you rebind something to a chord the terminal cares about
|
as triggering the action. A chord **you** rebind an action to is kept out of the terminal
|
||||||
(`Ctrl+D`, say), expect the CLI to see it too.
|
too, so rebinding Previous Session to `Alt+J` switches the session and sends nothing to the
|
||||||
|
CLI. The built-in defaults are deliberately not held back this way: `Ctrl+L` clears the
|
||||||
|
screen and also reaches the CLI, which redraws on it. Binding an action back onto its own
|
||||||
|
default behaves like the default.
|
||||||
|
|
||||||
`Alt+1` through `Alt+9` are matched on **physical key position** rather than the character
|
`Alt+1` through `Alt+9` are matched on **physical key position** rather than the character
|
||||||
produced, so macOS Option layouts that produce `¡™£` still switch tabs.
|
produced, so macOS Option layouts that produce `¡™£` still switch tabs.
|
||||||
|
|||||||
+67
-29
@@ -533,8 +533,8 @@ const DEFAULT_SHORTCUTS = [
|
|||||||
group: 'Session',
|
group: 'Session',
|
||||||
label: 'Toggle Session Sidebar',
|
label: 'Toggle Session Sidebar',
|
||||||
// Alt+B, not Ctrl+B: Ctrl+B must reach the terminal (tmux prefix,
|
// 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
|
// readline backward-char). The Alt block below claims only Digit1-9; the
|
||||||
// the brackets, and the registry claims Alt for KeyK and Slash only.
|
// brackets are registry entries, and the registry claims Alt for KeyK and Slash only.
|
||||||
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
bindings: [{ modifiers: ['alt'], key: 'b', code: 'KeyB' }],
|
||||||
action: 'toggleSessionSidebar',
|
action: 'toggleSessionSidebar',
|
||||||
},
|
},
|
||||||
@@ -638,11 +638,22 @@ const DEFAULT_SHORTCUTS = [
|
|||||||
bindings: [],
|
bindings: [],
|
||||||
action: 'removeTile',
|
action: 'removeTile',
|
||||||
},
|
},
|
||||||
|
// Physical key codes, so macOS Option layouts that print "“" for Option+[ still match.
|
||||||
{
|
{
|
||||||
id: 'previous-next-session',
|
id: 'previous-session',
|
||||||
group: 'Session',
|
group: 'Session',
|
||||||
label: 'Previous / Next Session',
|
label: 'Previous Session',
|
||||||
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
|
displayBindings: ['Alt/Option+['],
|
||||||
|
bindings: [{ modifiers: ['alt'], code: 'BracketLeft' }],
|
||||||
|
action: 'previousSession',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'next-session-alt',
|
||||||
|
group: 'Session',
|
||||||
|
label: 'Next Session (alternate)',
|
||||||
|
displayBindings: ['Alt/Option+]'],
|
||||||
|
bindings: [{ modifiers: ['alt'], code: 'BracketRight' }],
|
||||||
|
action: 'nextSession',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'switch-tab-n',
|
id: 'switch-tab-n',
|
||||||
@@ -1388,19 +1399,7 @@ class CodemanApp {
|
|||||||
// getShortcutRegistry()). The command palette chord is deliberately NOT in
|
// getShortcutRegistry()). The command palette chord is deliberately NOT in
|
||||||
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
|
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
|
||||||
// focus-target awareness (it must fire from the terminal but not from inputs).
|
// focus-target awareness (it must fire from the terminal but not from inputs).
|
||||||
const SHORTCUT_ACTIONS = {
|
const SHORTCUT_ACTIONS = this._shortcutActions();
|
||||||
showShortcutOverlay: () => this.showShortcutOverlay(),
|
|
||||||
killActiveSession: () => this.killActiveSession(),
|
|
||||||
nextSession: () => this.nextSession(),
|
|
||||||
clearTerminal: () => this.clearTerminal(),
|
|
||||||
restoreTerminalSize: () => this.restoreTerminalSize(),
|
|
||||||
increaseFontSize: () => this.increaseFontSize(),
|
|
||||||
decreaseFontSize: () => this.decreaseFontSize(),
|
|
||||||
toggleVoiceInput: () => VoiceInput.toggle(),
|
|
||||||
moveActiveTabLeft: () => this.moveActiveTabLeft(),
|
|
||||||
moveActiveTabRight: () => this.moveActiveTabRight(),
|
|
||||||
toggleSessionSidebar: () => this.toggleSessionSidebar(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use capture to handle before terminal
|
// Use capture to handle before terminal
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
@@ -1463,7 +1462,8 @@ class CodemanApp {
|
|||||||
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
|
||||||
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
|
// keyboard layouts that emit special characters under Option (Option+1 -> ¡,
|
||||||
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
|
// Option+[ -> "“") still switch sessions. e.code is the physical key regardless
|
||||||
// of layout. Option+1-9 = switch by index; Option+[ / Option+] = prev / next.
|
// 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) {
|
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
|
||||||
const code = e.code || '';
|
const code = e.code || '';
|
||||||
const digitMatch = code.match(/^Digit([1-9])$/);
|
const digitMatch = code.match(/^Digit([1-9])$/);
|
||||||
@@ -1489,16 +1489,6 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.code === 'BracketLeft') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.prevSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.code === 'BracketRight') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.nextSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
|
// Tile grid chords: only where they apply (tile-grid.js tileShortcutFor),
|
||||||
@@ -10698,6 +10688,54 @@ class CodemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Shortcut Registry ───────────────────────────────────────────────────────
|
// ─── Shortcut Registry ───────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Action name -> handler for the shortcut registry entries the document keydown
|
||||||
|
* handler dispatches generically (DEFAULT_SHORTCUTS + the user's rebinds). Entries
|
||||||
|
* 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
* 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) => {
|
||||||
|
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
|
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
|
||||||
// overrides from settings.shortcutOverrides applied on top.
|
// overrides from settings.shortcutOverrides applied on top.
|
||||||
|
|
||||||
|
|||||||
@@ -1179,6 +1179,8 @@
|
|||||||
'Close Panels': '关闭面板',
|
'Close Panels': '关闭面板',
|
||||||
'Previous / Next Session': '上一个 / 下一个会话',
|
'Previous / Next Session': '上一个 / 下一个会话',
|
||||||
'Next Session': '下一个会话',
|
'Next Session': '下一个会话',
|
||||||
|
'Previous Session': '上一个会话',
|
||||||
|
'Next Session (alternate)': '下一个会话(备用)',
|
||||||
'Switch to Tab N': '切换到第 N 个标签页',
|
'Switch to Tab N': '切换到第 N 个标签页',
|
||||||
'Move Active Tab Left': '向左移动当前标签页',
|
'Move Active Tab Left': '向左移动当前标签页',
|
||||||
'Move Active Tab Right': '向右移动当前标签页',
|
'Move Active Tab Right': '向右移动当前标签页',
|
||||||
|
|||||||
@@ -4719,6 +4719,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Refuse a chord something else already owns: the registry dispatches the first match,
|
||||||
|
// so a duplicate would silently take over (or lose to) the other action.
|
||||||
|
const taken = this.findShortcutConflict(e, shortcutId);
|
||||||
|
if (taken) {
|
||||||
|
this.renderShortcutSettingsList();
|
||||||
|
this.showToast?.(`${this.describeShortcutEvent(e)} is already used by ${taken}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const modifiers = [];
|
const modifiers = [];
|
||||||
if (e.ctrlKey) modifiers.push('ctrl');
|
if (e.ctrlKey) modifiers.push('ctrl');
|
||||||
if (e.metaKey) modifiers.push('meta');
|
if (e.metaKey) modifiers.push('meta');
|
||||||
@@ -4735,7 +4743,70 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.renderShortcutSettingsList();
|
this.renderShortcutSettingsList();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Label of the action that already owns the chord in `e`, or '' when it is free. */
|
||||||
|
findShortcutConflict(e, shortcutId) {
|
||||||
|
// 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) =>
|
||||||
|
shortcut.id !== shortcutId && !shortcut.disabled && Array.isArray(shortcut.bindings) && this.matchesShortcutEvent(e, shortcut)
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
if (e.metaKey) parts.push('Meta');
|
||||||
|
if (e.shiftKey) parts.push('Shift');
|
||||||
|
if (e.altKey) parts.push('Alt');
|
||||||
|
parts.push(e.key && e.key.length === 1 ? e.key.toUpperCase() : e.key || e.code);
|
||||||
|
return parts.join('+');
|
||||||
|
},
|
||||||
|
|
||||||
resetShortcutOverride(shortcutId) {
|
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 settings = this.loadAppSettingsFromStorage();
|
||||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||||
delete shortcutOverrides[shortcutId];
|
delete shortcutOverrides[shortcutId];
|
||||||
@@ -4745,6 +4816,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggleShortcutEnabled(shortcutId, enabled) {
|
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 settings = this.loadAppSettingsFromStorage();
|
||||||
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
|
||||||
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
|
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
|
||||||
|
|||||||
@@ -327,6 +327,7 @@
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (ev.type === 'keydown' && global.app?.isUserBoundShortcutEvent?.(ev)) return false;
|
||||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -749,6 +749,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A chord the user rebound to an app action (Settings -> Shortcuts) must not also type into the PTY.
|
||||||
|
if (ev.type === 'keydown' && this.isUserBoundShortcutEvent?.(ev)) return false;
|
||||||
|
|
||||||
// Command palette chord (COD-153): keep it out of the PTY. The document
|
// Command palette chord (COD-153): keep it out of the PTY. The document
|
||||||
// CAPTURE handler has already opened the palette by the time xterm sees
|
// CAPTURE handler has already opened the palette by the time xterm sees
|
||||||
// this keydown, but its preventDefault() does NOT stop xterm — without
|
// this keydown, but its preventDefault() does NOT stop xterm — without
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ describe('keyboard shortcuts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('provides Option+bracket shortcuts for previous and next session', () => {
|
it('provides Option+bracket shortcuts for previous and next session', () => {
|
||||||
expect(appSource).toContain("e.code === 'BracketLeft'");
|
// Registry entries (so they can be rebound), matched by physical key code.
|
||||||
expect(appSource).toContain("e.code === 'BracketRight'");
|
expect(appSource).toMatch(/id: 'previous-session'[\s\S]*?code: 'BracketLeft'[\s\S]*?action: 'previousSession'/);
|
||||||
expect(appSource).toContain('this.prevSession()');
|
expect(appSource).toMatch(/id: 'next-session-alt'[\s\S]*?code: 'BracketRight'[\s\S]*?action: 'nextSession'/);
|
||||||
expect(appSource).toContain('this.nextSession()');
|
expect(appSource).toContain('previousSession: () => this.prevSession()');
|
||||||
|
expect(appSource).toContain('nextSession: () => this.nextSession()');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('suppresses xterm PTY injection for the same physical Alt nav codes (no ESC leak)', () => {
|
it('suppresses xterm PTY injection for the same physical Alt nav codes (no ESC leak)', () => {
|
||||||
@@ -53,7 +54,7 @@ describe('keyboard shortcuts', () => {
|
|||||||
// getShortcutRegistry() + matchesShortcutEvent() so overrides and per-shortcut
|
// getShortcutRegistry() + matchesShortcutEvent() so overrides and per-shortcut
|
||||||
// disables (App Settings → Shortcuts) actually take effect.
|
// disables (App Settings → Shortcuts) actually take effect.
|
||||||
expect(appSource).not.toContain('const SHORTCUTS = [');
|
expect(appSource).not.toContain('const SHORTCUTS = [');
|
||||||
expect(appSource).toContain('const SHORTCUT_ACTIONS = {');
|
expect(appSource).toContain('const SHORTCUT_ACTIONS = this._shortcutActions();');
|
||||||
expect(appSource).toContain('for (const shortcut of this.getShortcutRegistry())');
|
expect(appSource).toContain('for (const shortcut of this.getShortcutRegistry())');
|
||||||
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
|
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
|
||||||
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
|
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Behaviour of shortcut rebinding: Alt+[ / Alt+] as registry entries, the
|
||||||
|
* user-bound gate that keeps a rebound chord out of the PTY, and refusal of duplicate chords.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + app.js + settings-ui.js in one `vm` context (the
|
||||||
|
* makeApp() harness of ctrl-w-never-closes.test.ts, with settings-ui.js loaded after app.js).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
|
||||||
|
type Shortcut = { id: string; label: string; action?: string; bindings?: unknown[]; disabled?: boolean };
|
||||||
|
type App = {
|
||||||
|
getShortcutRegistry(): Shortcut[];
|
||||||
|
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
|
||||||
|
isUserBoundShortcutEvent(e: Record<string, unknown>): boolean;
|
||||||
|
findShortcutConflict(e: Record<string, unknown>, id: string): string;
|
||||||
|
onShortcutCaptureKeydown(e: Record<string, unknown>, id: string): void;
|
||||||
|
toggleShortcutEnabled(id: string, enabled: boolean): void;
|
||||||
|
resetShortcutOverride(id: string): void;
|
||||||
|
loadAppSettingsFromStorage: () => Record<string, any>;
|
||||||
|
saveAppSettingsToStorage: (s: Record<string, any>) => void;
|
||||||
|
renderShortcutSettingsList: () => void;
|
||||||
|
showToast: (msg: string, type?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(initial: Record<string, any> = {}) {
|
||||||
|
const settings: Record<string, any> = structuredClone(initial);
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: vi.fn(),
|
||||||
|
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
MobileDetection: { getDeviceType: () => 'desktop', isMobile: () => false, isTouchDevice: () => false },
|
||||||
|
});
|
||||||
|
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||||
|
vm.runInContext(read('settings-ui.js'), context, { filename: 'settings-ui.js' });
|
||||||
|
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
|
||||||
|
const app = Object.create(CodemanApp.prototype) as App;
|
||||||
|
app.loadAppSettingsFromStorage = () => settings;
|
||||||
|
app.saveAppSettingsToStorage = (s) => {
|
||||||
|
const next = { ...s };
|
||||||
|
Object.keys(settings).forEach((k) => delete settings[k]);
|
||||||
|
Object.assign(settings, next);
|
||||||
|
};
|
||||||
|
app.renderShortcutSettingsList = vi.fn();
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
return { app, settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (o: Record<string, unknown>) => ({
|
||||||
|
type: 'keydown',
|
||||||
|
key: '',
|
||||||
|
code: '',
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
altKey: false,
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
stopPropagation: vi.fn(),
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerOf = (app: App, e: Record<string, unknown>) =>
|
||||||
|
app.getShortcutRegistry().find((s) => !s.disabled && s.action && app.matchesShortcutEvent(e, s))?.action;
|
||||||
|
|
||||||
|
describe('Alt+[ and Alt+] are registry entries', () => {
|
||||||
|
it('resolve to previousSession and nextSession by physical key', () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
expect(ownerOf(app, key({ altKey: true, key: '[', code: 'BracketLeft' }))).toBe('previousSession');
|
||||||
|
expect(ownerOf(app, key({ altKey: true, key: ']', code: 'BracketRight' }))).toBe('nextSession');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('match the macOS Option layout that prints a curly quote for Option+[', () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
expect(ownerOf(app, key({ altKey: true, key: '“', code: 'BracketLeft' }))).toBe('previousSession');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keep their Alt/Option+[ labels until rebound', () => {
|
||||||
|
const { app } = makeApp({
|
||||||
|
shortcutOverrides: { 'next-session-alt': { bindings: [{ modifiers: ['alt'], key: 'j' }] } },
|
||||||
|
});
|
||||||
|
const byId = (id: string) =>
|
||||||
|
app.getShortcutRegistry().find((s) => s.id === id) as Shortcut & { displayBindings?: string[] };
|
||||||
|
expect(byId('previous-session').displayBindings).toEqual(['Alt/Option+[']);
|
||||||
|
expect(byId('next-session-alt').displayBindings).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isUserBoundShortcutEvent', () => {
|
||||||
|
const altJ = key({ altKey: true, key: 'j', code: 'KeyJ' });
|
||||||
|
|
||||||
|
it('is false with no overrides, even for a default chord', () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
expect(app.isUserBoundShortcutEvent(key({ altKey: true, key: '[', code: 'BracketLeft' }))).toBe(false);
|
||||||
|
expect(app.isUserBoundShortcutEvent(key({ ctrlKey: true, key: 'l', code: 'KeyL' }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true for a chord the user rebound an action to', () => {
|
||||||
|
const { app } = makeApp({
|
||||||
|
shortcutOverrides: { 'previous-session': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }] } },
|
||||||
|
});
|
||||||
|
expect(app.isUserBoundShortcutEvent(altJ)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for the copy-selection rebind, which has its own dispatch', () => {
|
||||||
|
const { app } = makeApp({
|
||||||
|
shortcutOverrides: { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }] } },
|
||||||
|
});
|
||||||
|
expect(app.isUserBoundShortcutEvent(altJ)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for a rebound sidebar toggle, whose own gate lets the chord through', () => {
|
||||||
|
const { app } = makeApp({
|
||||||
|
shortcutOverrides: { 'toggle-session-sidebar': { bindings: [{ modifiers: ['alt'], key: 's', code: 'KeyS' }] } },
|
||||||
|
});
|
||||||
|
expect(app.isUserBoundShortcutEvent(key({ altKey: true, key: 's', code: 'KeyS' }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when the entry was re-captured onto its own default (Ctrl+L still reaches the CLI)', () => {
|
||||||
|
const { app } = makeApp({
|
||||||
|
shortcutOverrides: { 'clear-terminal': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] } },
|
||||||
|
});
|
||||||
|
expect(app.isUserBoundShortcutEvent(key({ ctrlKey: true, key: 'l', code: 'KeyL' }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for a disabled rebound entry', () => {
|
||||||
|
const { app } = makeApp({
|
||||||
|
shortcutOverrides: {
|
||||||
|
'previous-session': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }], disabled: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(app.isUserBoundShortcutEvent(altJ)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('duplicate chords are refused', () => {
|
||||||
|
const ctrlL = key({ ctrlKey: true, key: 'l', code: 'KeyL' });
|
||||||
|
|
||||||
|
it("a capture onto another action's chord names the owner and stores nothing", () => {
|
||||||
|
const { app, settings } = makeApp();
|
||||||
|
app.onShortcutCaptureKeydown(ctrlL, 'next-session-alt');
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('already used by Clear Terminal'), 'error');
|
||||||
|
expect(settings.shortcutOverrides).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a shortcut's own id and a disabled owner are free", () => {
|
||||||
|
const { app } = makeApp({ shortcutOverrides: { 'previous-session': { disabled: true } } });
|
||||||
|
expect(app.findShortcutConflict(ctrlL, 'clear-terminal')).toBe('');
|
||||||
|
expect(app.findShortcutConflict(key({ altKey: true, key: '[', code: 'BracketLeft' }), 'next-session-alt')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['Alt+1', { altKey: true, key: '¡', code: 'Digit1' }],
|
||||||
|
['Cmd+Alt+1', { altKey: true, metaKey: true, key: '¡', code: 'Digit1' }],
|
||||||
|
['Ctrl+Enter', { ctrlKey: true, key: 'Enter', code: 'Enter' }],
|
||||||
|
['Shift+Enter', { shiftKey: true, key: 'Enter', code: 'Enter' }],
|
||||||
|
['Ctrl+V', { ctrlKey: true, key: 'v', code: 'KeyV' }],
|
||||||
|
['Cmd+V', { metaKey: true, key: 'v', code: 'KeyV' }],
|
||||||
|
])('%s is reserved', (_n, ev) => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
expect(app.findShortcutConflict(key(ev), 'previous-session')).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no two default shortcuts share a chord', () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
for (const s of app.getShortcutRegistry()) {
|
||||||
|
for (const b of (s.bindings || []) as { modifiers?: string[]; key?: string; code?: string }[]) {
|
||||||
|
const m = b.modifiers || [];
|
||||||
|
const ev = key({
|
||||||
|
ctrlKey: m.includes('ctrl'),
|
||||||
|
metaKey: m.includes('meta'),
|
||||||
|
shiftKey: m.includes('shift'),
|
||||||
|
altKey: m.includes('alt'),
|
||||||
|
key: b.key || '',
|
||||||
|
code: b.code || '',
|
||||||
|
});
|
||||||
|
expect({ id: s.id, owner: app.findShortcutConflict(ev, s.id) }).toEqual({ id: s.id, owner: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-enabling a disabled action whose chord was taken is refused and leaves it disabled', () => {
|
||||||
|
const { app, settings } = makeApp({
|
||||||
|
shortcutOverrides: {
|
||||||
|
'clear-terminal': { disabled: true },
|
||||||
|
'next-session-alt': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.toggleShortcutEnabled('clear-terminal', true);
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('already used by Next Session (alternate)'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
expect(settings.shortcutOverrides['clear-terminal'].disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-enabling with no clash enables it', () => {
|
||||||
|
const { app, settings } = makeApp({ shortcutOverrides: { 'clear-terminal': { disabled: true } } });
|
||||||
|
app.toggleShortcutEnabled('clear-terminal', true);
|
||||||
|
expect(settings.shortcutOverrides['clear-terminal'].disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reset is refused while another action owns the default chord, and works once it is free', () => {
|
||||||
|
const { app, settings } = makeApp({
|
||||||
|
shortcutOverrides: {
|
||||||
|
'clear-terminal': { bindings: [{ modifiers: ['ctrl'], key: 'j', code: 'KeyJ' }] },
|
||||||
|
'next-session-alt': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.resetShortcutOverride('clear-terminal');
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('already used by Next Session (alternate)'),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
expect(settings.shortcutOverrides['clear-terminal']).toBeDefined();
|
||||||
|
|
||||||
|
app.resetShortcutOverride('next-session-alt');
|
||||||
|
app.resetShortcutOverride('clear-terminal');
|
||||||
|
expect(settings.shortcutOverrides['clear-terminal']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -138,8 +138,8 @@ describe('smart-copy wiring invariants', () => {
|
|||||||
// the copy action were reachable from there, Ctrl+C would be swallowed with
|
// the copy action were reachable from there, Ctrl+C would be swallowed with
|
||||||
// no selection and the user would lose the interrupt key.
|
// no selection and the user would lose the interrupt key.
|
||||||
const actionsBlock = APP_SOURCE.slice(
|
const actionsBlock = APP_SOURCE.slice(
|
||||||
APP_SOURCE.indexOf('const SHORTCUT_ACTIONS = {'),
|
APP_SOURCE.indexOf('_shortcutActions() {'),
|
||||||
APP_SOURCE.indexOf('// Use capture to handle before terminal')
|
APP_SOURCE.indexOf('isUserBoundShortcutEvent(e) {')
|
||||||
);
|
);
|
||||||
expect(actionsBlock.length).toBeGreaterThan(0);
|
expect(actionsBlock.length).toBeGreaterThan(0);
|
||||||
expect(actionsBlock).not.toContain('copyTerminalSelection');
|
expect(actionsBlock).not.toContain('copyTerminalSelection');
|
||||||
|
|||||||
Reference in new issue
Block a user