Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ const rendererCrossFileGlobals = {
SYNC_BUFFER_TIMEOUT: 'readonly',
updatePtyTitle: 'readonly',
_shellProfiles: 'writable',

// Configurable keyboard shortcuts (public/shortcuts.js + grid-view.js)
DEFAULT_SHORTCUTS: 'readonly',
SHORTCUT_DEFS: 'readonly',
normalizeShortcuts: 'readonly',
keyFamily: 'readonly',
matchShortcut: 'readonly',
isSessionNavShortcut: 'readonly',
formatBinding: 'readonly',
captureBinding: 'readonly',
appShortcuts: 'writable',
setAppShortcuts: 'readonly',
};

module.exports = [
Expand Down Expand Up @@ -237,6 +249,26 @@ module.exports = [
},
},

// Dual-mode helper: classic <script> in the renderer AND require()-d in tests.
// Same browser globals as the rest of public/, plus `module` for the CJS footer.
{
files: ['public/shortcuts.js'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'script',
globals: {
...globals.browser,
...rendererCrossFileGlobals,
module: 'writable',
},
},
rules: {
'no-undef': 'error',
'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }],
'no-redeclare': 'warn',
},
},

// CodeMirror setup file uses ESM-style imports/closure that don't lint well
// as a classic script — keep no-undef on but be permissive about unused.
{
Expand Down
9 changes: 6 additions & 3 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1072,9 +1072,8 @@ initGridObservers();
// e._handled to prevent the document listener from double-firing the same action.
document.addEventListener('keydown', (e) => {
if (e._handled) return;
// Cmd/Ctrl+Shift+G → toggle grid view
const mod = isMac ? e.metaKey : e.ctrlKey;
if (e.key === 'g' && mod && e.shiftKey && !e.altKey) {
// Toggle grid view (default Cmd/Ctrl+Shift+G)
if (matchShortcut('gridToggle', e, isMac, appShortcuts)) {
e.preventDefault();
toggleGridView();
return;
Expand Down Expand Up @@ -1118,9 +1117,13 @@ setTimeout(() => {
currentThemeName = global.terminalTheme;
TERMINAL_THEME = getTerminalTheme();
}
if (global.shortcuts) setAppShortcuts(global.shortcuts);
}
})();

// Let the settings panel push updated key bindings live (no restart needed).
window._applyShortcuts = (stored) => setAppShortcuts(stored);

loadProjects().then(() => {
// Restore grid view preference before opening sessions so they enter grid mode
if (localStorage.getItem('gridViewActive') === '1') {
Expand Down
25 changes: 12 additions & 13 deletions public/grid-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,29 +486,28 @@ function navigateGrid(direction) {
}
}

// Live session-navigation key bindings (re-bindable via global settings).
// Defaults until the stored `global.shortcuts` setting is applied at startup.
let appShortcuts = normalizeShortcuts(null);
function setAppShortcuts(stored) {
appShortcuts = normalizeShortcuts(stored);
}

// Returns true if the key combo is a session nav shortcut (used by xterm to block without acting)
function isSessionNavKey(e) {
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod || e.altKey) return false;
if (e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) return true;
if (!e.shiftKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return true;
return false;
return isSessionNavShortcut(e, isMac, appShortcuts);
}

function handleSessionNavKey(e) {
const mod = isMac ? e.metaKey : e.ctrlKey;
if (!mod || e.altKey) return false;

// Cmd+Shift+[ or Cmd+Shift+] — prev/next session
// On macOS, Shift changes e.key to { / }, so check code for reliable matching
if (e.shiftKey && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
// Prev/next session (default Cmd/Ctrl+Shift+[ / ])
if (matchShortcut('sessionNavBrackets', e, isMac, appShortcuts)) {
e.preventDefault();
if (e.type === 'keydown') navigateSession(e.code === 'BracketLeft' ? -1 : 1);
return true;
}

// Cmd+Arrow — in grid view: 2D grid navigation; in single view: left/right cycle sessions
if (!e.shiftKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
// Arrow nav (default Cmd/Ctrl+Shift+Arrow) — grid view: 2D navigation; single view: cycle sessions
if (matchShortcut('sessionNavArrows', e, isMac, appShortcuts)) {
e.preventDefault();
if (e.type === 'keydown') {
if (gridViewActive) {
Expand Down
3 changes: 3 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@
<script src="file-panel.js"></script>
<script src="settings-panel.js"></script>
<script src="utils.js"></script>
<!-- shortcuts.js defines matchShortcut/appShortcuts helpers used by terminal-manager,
grid-view, app and settings-panel — must load before those consumers. -->
<script src="shortcuts.js"></script>
<script src="terminal-themes.js"></script>
<script src="terminal-manager.js"></script>
<script src="grid-view.js"></script>
Expand Down
73 changes: 73 additions & 0 deletions public/settings-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
const mcpEmulationValue = fieldValue('mcpEmulation', true);
const shellProfileValue = fieldValue('shellProfile', 'auto');

// Working copy of the (global-only) re-bindable keyboard shortcuts.
let scShortcuts = normalizeShortcuts(isProject ? null : current.shortcuts);
const scIsMac = typeof isMac !== 'undefined' ? isMac : /Mac|iPhone|iPad/.test(navigator.platform);

// Discover available shell profiles
let shellProfiles = [];
try { shellProfiles = await window.api.getShellProfiles(); } catch {};
Expand Down Expand Up @@ -236,6 +240,21 @@
</div>
</div>` : ''}

${!isProject ? `<div class="settings-section">
<div class="settings-section-title">Keyboard Shortcuts</div>
${SHORTCUT_DEFS.map(def => `
<div class="settings-field">
<div class="settings-field-info">
<span class="settings-label">${escapeHtml(def.label)}</span>
<div class="settings-description">${escapeHtml(def.description)}</div>
</div>
<div class="settings-field-control">
<button class="settings-shortcut-btn" id="sv-sc-${def.id}" data-sc-id="${def.id}">${escapeHtml(formatBinding(def.id, scIsMac, scShortcuts))}</button>
</div>
</div>`).join('')}
<div class="settings-hint">Click a shortcut, then press the new combination. At least one modifier (${scIsMac ? 'Cmd' : 'Ctrl'}, ${scIsMac ? 'Option' : 'Alt'} or Shift) is required. Press Esc to cancel, or click again to reset to defaults.</div>
</div>` : ''}

${!isProject ? `<div class="settings-section">
<div class="settings-section-title">Updates</div>
<div class="settings-field">
Expand Down Expand Up @@ -274,6 +293,54 @@
});
});

// --- Keyboard shortcut rebinding (global only) ---
// Capture listeners live on the button element itself (not on document), so
// they can never leak app-wide: losing focus (incl. the settings viewer being
// dismissed by ANY path) fires `blur` → stops capture, and re-opening the
// viewer replaces settingsViewerBody, discarding the old listeners with it.
let capturingBtn = null;
function stopShortcutCapture() {
if (capturingBtn) {
capturingBtn.classList.remove('capturing');
capturingBtn.textContent = formatBinding(capturingBtn.dataset.scId, scIsMac, scShortcuts);
capturingBtn = null;
}
}
settingsViewerBody.querySelectorAll('.settings-shortcut-btn').forEach(btn => {
const id = btn.dataset.scId;
const def = SHORTCUT_DEFS.find(d => d.id === id);
btn.addEventListener('click', () => {
// Clicking the button that is already capturing resets it to default.
if (capturingBtn === btn) {
scShortcuts = { ...scShortcuts, [id]: normalizeShortcuts(null)[id] };
stopShortcutCapture();
btn.blur();
return;
}
stopShortcutCapture();
capturingBtn = btn;
btn.classList.add('capturing');
btn.textContent = 'Press keys…';
btn.focus();
});
// keydown only acts while THIS button is the one capturing.
btn.addEventListener('keydown', (e) => {
if (capturingBtn !== btn) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') { stopShortcutCapture(); btn.blur(); return; }
const binding = captureBinding(e, def, scIsMac);
if (!binding) return; // chord incomplete — keep listening
scShortcuts = { ...scShortcuts, [id]: binding };
stopShortcutCapture();
btn.blur();
});
// Losing focus (click elsewhere, panel dismissed, tab switch) cancels capture.
btn.addEventListener('blur', () => {
if (capturingBtn === btn) stopShortcutCapture();
});
});

// Save button
settingsViewerBody.querySelector('#sv-save-btn').addEventListener('click', async () => {
let settings = {};
Expand Down Expand Up @@ -306,7 +373,9 @@
settings.terminalTheme = settingsViewerBody.querySelector('#sv-terminal-theme').value || 'switchboard';
settings.mcpEmulation = settingsViewerBody.querySelector('#sv-mcp-emulation').checked;
settings.shellProfile = settingsViewerBody.querySelector('#sv-shell-profile').value || 'auto';
settings.shortcuts = scShortcuts;
}
stopShortcutCapture();

// Merge form values into existing settings to preserve keys not managed by the form
if (!isProject) {
Expand All @@ -327,6 +396,9 @@
if (settings.terminalTheme && typeof window._applyTerminalTheme === 'function') {
window._applyTerminalTheme(settings.terminalTheme);
}
if (settings.shortcuts && typeof window._applyShortcuts === 'function') {
window._applyShortcuts(settings.shortcuts);
}
if (typeof refreshSidebar === 'function') refreshSidebar();
}

Expand All @@ -349,6 +421,7 @@

// Cancel button
settingsViewerBody.querySelector('#sv-cancel-btn').addEventListener('click', () => {
stopShortcutCapture();
closeSettingsViewer();
});

Expand Down
Loading
Loading