Skip to content

Commit a412e59

Browse files
committed
Shortcuts: serialized saves, typing-safe bare-key dispatch, and macOS system-shortcut warnings
Three hardening fixes for the shortcuts subsystem: - **Saves can't interleave.** Every store mutator fires `void saveToStore()`; two rapid mutations used to run their reconcile/delete/set/save loops concurrently over the shared store. Saves now chain (`saveChain`), so each starts after the previous finished and the last one always persists the final state. Covered by a rapid-mutations round-trip test. - **Typing wins in text inputs.** A bare-key (or shift-only) Tier 1 binding — Tab → switch pane being the built-in case — used to fire mid-typing in any in-pane text input that forgot to `stopPropagation` (a per-component convention; only `NetworkLoginForm` did it). `handleGlobalKeyDown` now bails centrally when focus is in a text-editing element and the combo `isTypingKeyCombo` (new pure helper in `key-capture.ts`: no ⌘/⌃/⌥, not an F-key or Escape). ⌘-combos and F-keys stay live in inputs. Unit-tested red-first. - **Capturing a macOS-owned combo warns honestly.** Binding ⌘Space (Spotlight), ⌃↑ (Mission Control), ⌘⇧4 (screenshots), and ~12 other system defaults used to save silently and then never fire — the OS intercepts first. The capture banner now shows a soft warning (`classifySystemShortcut` + `systemShortcutMessage`, checked only when there's no in-app conflict) with "Use anyway" (the user may have freed the combo in System Settings) and Cancel. macOS-display-form keys mean the list inherently can't misfire on other platforms. - The banner's `ConflictKind` union growing a `command`-less variant forced honest narrowing: the template snapshots `conflictWarning.conflict` into an `{@const}` and branches exhaustively; the unit tests assert via `toMatchObject`.
1 parent 92c5ad4 commit a412e59

10 files changed

Lines changed: 216 additions & 25 deletions

File tree

apps/desktop/src/lib/settings/sections/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Parents: [`../CLAUDE.md`](../CLAUDE.md) (registry, store, applier, search) and
3535
| `license-section-utils.ts` | Pure label/status formatters extracted from `LicenseSection` for testability |
3636
| `ram-gauge-utils.ts` | Pure stacked-bar segment math for `AiLocalSection`'s memory gauge (used → projected → free, plus warning thresholds) |
3737
| `keyboard-shortcuts-grouping.ts` | Pure scope→group logic for `KeyboardShortcutsSection` (one titled group per `CommandScope`, fixed order). Tested by the set-equality regression guard |
38-
| `keyboard-shortcuts-banner.ts` | Pure conflict-banner classification for `KeyboardShortcutsSection` (`classifyConflict` → native vs fixed vs normal; `reservedByMacOsMessage` / `fixedKeyMessage`). Native > fixed > normal in mixed sets. Unit-tested |
38+
| `keyboard-shortcuts-banner.ts` | Pure conflict-banner classification for `KeyboardShortcutsSection` (`classifyConflict` → native vs fixed vs normal, `classifySystemShortcut` → soft macOS-system warning with "Use anyway"; `reservedByMacOsMessage` / `fixedKeyMessage` / `systemShortcutMessage`). Native > fixed > normal in mixed sets; system checked only when no in-app conflict. Unit-tested |
3939

4040
Each section ships with an `*.a11y.test.ts` (axe-core tier-3). `McpServerSection`, `UpdatesSection`, `SearchSection`,
4141
`FileSystemWatchingSection`, and `KeyboardShortcutsSection` also have functional `*.test.ts` / `*.svelte.test.ts` files;

apps/desktop/src/lib/settings/sections/KeyboardShortcutsSection.svelte

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@
2828
import { confirmDialog } from '$lib/utils/confirm-dialog'
2929
import GlobalShortcutRow from '$lib/downloads/GlobalShortcutRow.svelte'
3030
import { groupCommandsByScope } from './keyboard-shortcuts-grouping'
31-
import { classifyConflict, fixedKeyMessage, reservedByMacOsMessage, type ConflictKind } from './keyboard-shortcuts-banner'
31+
import {
32+
classifyConflict,
33+
classifySystemShortcut,
34+
fixedKeyMessage,
35+
reservedByMacOsMessage,
36+
systemShortcutMessage,
37+
type ConflictKind,
38+
} from './keyboard-shortcuts-banner'
3239
import { shortcutAnchorId } from '$lib/settings/settings-window'
3340
import {
3441
getPendingShortcutHighlight,
@@ -194,7 +201,9 @@
194201
const command = commands.find((c) => c.id === currentEditCommandId)
195202
if (command) {
196203
const conflicts = findConflictsForShortcut(combo, command.scope, command.id)
197-
const conflict = classifyConflict(conflicts)
204+
// In-app conflicts take priority; with none, still warn when macOS
205+
// itself usually owns the combo (Spotlight, Mission Control, …).
206+
const conflict = classifyConflict(conflicts) ?? classifySystemShortcut(combo)
198207
if (conflict) {
199208
conflictWarning = { shortcut: combo, conflict }
200209
return // Don't auto-save, wait for user decision
@@ -492,30 +501,42 @@
492501
</div>
493502

494503
{#if conflictWarning}
504+
<!-- `{@const}` snapshot so the kind checks narrow the union (a re-read of the
505+
`$state` field doesn't narrow, and `SystemConflict` has no `command`). -->
506+
{@const conflict = conflictWarning.conflict}
495507
<div class="conflict-warning">
496508
<span class="warning-icon">⚠️</span>
497-
{#if conflictWarning.conflict.kind === 'native'}
509+
{#if conflict.kind === 'native'}
498510
<!-- macOS owns this combo: it can never reach Cmdr, so we don't offer
499511
"Remove from other" or "Keep both" (both would be a lie). -->
500512
<span class="warning-text">
501-
{reservedByMacOsMessage(conflictWarning.shortcut, conflictWarning.conflict.command)}
513+
{reservedByMacOsMessage(conflictWarning.shortcut, conflict.command)}
514+
</span>
515+
<div class="warning-actions">
516+
<Button variant="secondary" size="mini" onclick={cancelEdit}>Cancel</Button>
517+
</div>
518+
{:else if conflict.kind === 'system'}
519+
<!-- macOS usually intercepts this combo before Cmdr sees it, but the user
520+
may have disabled the system shortcut — warn and let them decide. -->
521+
<span class="warning-text">
522+
{systemShortcutMessage(conflictWarning.shortcut, conflict.label)}
502523
</span>
503524
<div class="warning-actions">
525+
<Button variant="secondary" size="mini" onclick={handleKeepBoth}>Use anyway</Button>
504526
<Button variant="secondary" size="mini" onclick={cancelEdit}>Cancel</Button>
505527
</div>
506-
{:else if conflictWarning.conflict.kind === 'fixed'}
528+
{:else if conflict.kind === 'fixed'}
507529
<!-- The combo is hardcoded in a component: it can't be removed there and
508530
would keep firing, so we don't offer "Remove from other" or "Keep both". -->
509531
<span class="warning-text">
510-
{fixedKeyMessage(conflictWarning.shortcut, conflictWarning.conflict.command)}
532+
{fixedKeyMessage(conflictWarning.shortcut, conflict.command)}
511533
</span>
512534
<div class="warning-actions">
513535
<Button variant="secondary" size="mini" onclick={cancelEdit}>Cancel</Button>
514536
</div>
515-
{:else}
537+
{:else if conflict.kind === 'normal'}
516538
<span class="warning-text">
517-
<strong>{conflictWarning.shortcut}</strong> is already bound to "{conflictWarning.conflict.command
518-
.name}"
539+
<strong>{conflictWarning.shortcut}</strong> is already bound to "{conflict.command.name}"
519540
</span>
520541
<div class="warning-actions">
521542
<Button variant="secondary" size="mini" onclick={handleRemoveFromOther}>Remove from other</Button>

apps/desktop/src/lib/settings/sections/keyboard-shortcuts-banner.test.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, it, expect } from 'vitest'
22
import type { Command } from '$lib/commands/types'
3-
import { classifyConflict, fixedKeyMessage, reservedByMacOsMessage } from './keyboard-shortcuts-banner'
3+
import {
4+
classifyConflict,
5+
classifySystemShortcut,
6+
fixedKeyMessage,
7+
reservedByMacOsMessage,
8+
systemShortcutMessage,
9+
} from './keyboard-shortcuts-banner'
410

511
function cmd(partial: { id: string; name?: string; nativeShortcut?: true; fixedKey?: true }): Command {
612
const command: Command = {
@@ -22,14 +28,12 @@ describe('classifyConflict', () => {
2228

2329
it('classifies a purely non-native conflict as normal (first command)', () => {
2430
const result = classifyConflict([cmd({ id: 'file.copy' }), cmd({ id: 'file.move' })])
25-
expect(result?.kind).toBe('normal')
26-
expect(result?.command.id).toBe('file.copy')
31+
expect(result).toMatchObject({ kind: 'normal', command: { id: 'file.copy' } })
2732
})
2833

2934
it('classifies a native conflict as native', () => {
3035
const result = classifyConflict([cmd({ id: 'app.hide', name: 'Hide Cmdr', nativeShortcut: true })])
31-
expect(result?.kind).toBe('native')
32-
expect(result?.command.id).toBe('app.hide')
36+
expect(result).toMatchObject({ kind: 'native', command: { id: 'app.hide' } })
3337
})
3438

3539
it('lets the native command win a mixed set (native + normal)', () => {
@@ -39,8 +43,7 @@ describe('classifyConflict', () => {
3943
cmd({ id: 'file.copy' }),
4044
cmd({ id: 'app.hide', name: 'Hide Cmdr', nativeShortcut: true }),
4145
])
42-
expect(result?.kind).toBe('native')
43-
expect(result?.command.id).toBe('app.hide')
46+
expect(result).toMatchObject({ kind: 'native', command: { id: 'app.hide' } })
4447
})
4548
})
4649

@@ -54,8 +57,7 @@ describe('reservedByMacOsMessage', () => {
5457
describe('classifyConflict (fixed-key)', () => {
5558
it('classifies a fixed-key conflict as fixed', () => {
5659
const result = classifyConflict([cmd({ id: 'nav.up', name: 'Select previous file', fixedKey: true })])
57-
expect(result?.kind).toBe('fixed')
58-
expect(result?.command.id).toBe('nav.up')
60+
expect(result).toMatchObject({ kind: 'fixed', command: { id: 'nav.up' } })
5961
})
6062

6163
it('lets the fixed command win a mixed set (fixed + normal)', () => {
@@ -65,8 +67,7 @@ describe('classifyConflict (fixed-key)', () => {
6567
cmd({ id: 'file.copy' }),
6668
cmd({ id: 'nav.up', name: 'Select previous file', fixedKey: true }),
6769
])
68-
expect(result?.kind).toBe('fixed')
69-
expect(result?.command.id).toBe('nav.up')
70+
expect(result).toMatchObject({ kind: 'fixed', command: { id: 'nav.up' } })
7071
})
7172

7273
it('lets a native conflict outrank a fixed one (both in the set)', () => {
@@ -86,3 +87,26 @@ describe('fixedKeyMessage', () => {
8687
)
8788
})
8889
})
90+
91+
describe('classifySystemShortcut', () => {
92+
it('flags well-known macOS system combos with their feature label', () => {
93+
expect(classifySystemShortcut('⌘Space')).toEqual({ kind: 'system', label: 'Spotlight' })
94+
expect(classifySystemShortcut('⌃↑')).toEqual({ kind: 'system', label: 'Mission Control' })
95+
expect(classifySystemShortcut('⌘⇧4')).toEqual({ kind: 'system', label: 'screenshots' })
96+
})
97+
98+
it('returns null for ordinary combos', () => {
99+
expect(classifySystemShortcut('⌘⇧P')).toBeNull()
100+
expect(classifySystemShortcut('F5')).toBeNull()
101+
expect(classifySystemShortcut('Ctrl+Space')).toBeNull() // non-mac display form never matches
102+
})
103+
})
104+
105+
describe('systemShortcutMessage', () => {
106+
it('names the combo and the owning feature and points at System Settings', () => {
107+
const message = systemShortcutMessage('⌘Space', 'Spotlight')
108+
expect(message).toContain('⌘Space')
109+
expect(message).toContain('Spotlight')
110+
expect(message).toContain('System Settings')
111+
})
112+
})

apps/desktop/src/lib/settings/sections/keyboard-shortcuts-banner.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface NormalConflict {
3737
command: Command
3838
}
3939

40-
export type ConflictKind = NativeConflict | FixedConflict | NormalConflict
40+
export type ConflictKind = NativeConflict | FixedConflict | SystemConflict | NormalConflict
4141

4242
/**
4343
* Classify a non-empty conflict set. A native command anywhere in the set makes
@@ -72,3 +72,53 @@ export function reservedByMacOsMessage(combo: string, nativeCommand: Command): s
7272
export function fixedKeyMessage(combo: string, fixedCommand: Command): string {
7373
return `${combo} is a fixed key in Cmdr (${fixedCommand.name}) and can't be reassigned. Pick a different combo.`
7474
}
75+
76+
/** A system conflict: macOS itself usually owns the combo (Spotlight, Mission Control, …). */
77+
export interface SystemConflict {
78+
kind: 'system'
79+
/** The macOS feature that owns the combo (drives the banner copy). */
80+
label: string
81+
}
82+
83+
/**
84+
* Default macOS system-wide shortcuts (display form, as `formatKeyCombo`
85+
* produces them on macOS). Best-effort: users can disable or remap these in
86+
* System Settings, so the banner warns and offers "Use anyway" instead of
87+
* refusing. Keys only ever match on macOS — other platforms capture
88+
* `Ctrl+…`-style strings.
89+
*/
90+
const macSystemShortcutToFeatureMap: Record<string, string> = {
91+
'⌘Space': 'Spotlight',
92+
'⌥⌘Space': 'Finder search window',
93+
'⌃⌘Space': 'Character Viewer',
94+
'⌃Space': 'input source switching',
95+
'⌘Tab': 'the app switcher',
96+
'⌃↑': 'Mission Control',
97+
'⌃↓': 'App windows',
98+
'⌃←': 'Spaces',
99+
'⌃→': 'Spaces',
100+
'⌘⇧3': 'screenshots',
101+
'⌘⇧4': 'screenshots',
102+
'⌘⇧5': 'screen recording',
103+
'⌘⇧Q': 'logging out',
104+
'⌃⌘Q': 'locking the screen',
105+
'⌥⌘⎋': 'Force Quit',
106+
}
107+
108+
/**
109+
* Classify a combo against the macOS system-shortcut list. Checked only when
110+
* there's no in-app conflict (those banners take priority); a match produces a
111+
* soft warning, not a refusal.
112+
*/
113+
export function classifySystemShortcut(combo: string): SystemConflict | null {
114+
const label = macSystemShortcutToFeatureMap[combo]
115+
return label ? { kind: 'system', label } : null
116+
}
117+
118+
/**
119+
* The banner copy for a system-owned combo, like
120+
* `⌘Space is usually taken by macOS (Spotlight), so it may never reach Cmdr.`
121+
*/
122+
export function systemShortcutMessage(combo: string, label: string): string {
123+
return `${combo} is usually taken by macOS (${label}), so it may never reach Cmdr. You can free it up in System Settings > Keyboard.`
124+
}

apps/desktop/src/lib/shortcuts/CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ shortcuts:
2828
- Delta-only storage: only customizations, not defaults
2929
- Empty array means "all shortcuts removed"
3030
- Missing command means "use defaults from registry"
31+
- Saves are serialized: every mutator fires `void saveToStore()`, and the save chains onto the previous one
32+
(`saveChain`) so two rapid mutations can't interleave their reconcile/delete/set/save loops over the shared store.
3133
- `saveToStore` reconciles disk against the in-memory map on every write: it deletes any `shortcut:*` key whose command
3234
no longer has a map entry, then writes the current entries. So when `resetShortcut` / `cleanupIfMatchesDefaults` drops
3335
an entry (e.g. a custom that's been edited back to defaults, or `app.showAll`'s `[]` default after removing an added
@@ -176,6 +178,11 @@ command palette and MCP events. Rebuilds automatically when custom shortcuts cha
176178
Tier 2 commands (arrows, Space, Enter, Backspace, etc.) are not in the dispatch map. Unmatched keypresses propagate
177179
normally to component-level handlers in DualPaneExplorer and FilePane.
178180

181+
Typing wins in text inputs: before the lookup, `handleGlobalKeyDown` bails when focus is in a text-editing element and
182+
the combo `isTypingKeyCombo` (no ⌘/⌃/⌥, not an F-key or Escape — shift-only counts as typing). Without this, a bare-key
183+
Tier 1 binding (Tab → switch pane) fires mid-typing in any in-pane text input that forgets to `stopPropagation`. The
184+
guard is central so new inputs are protected by default; `NetworkLoginForm`'s own Tab shielding remains as before.
185+
179186
## Key decisions
180187

181188
### Why platform-specific storage?

apps/desktop/src/lib/shortcuts/key-capture.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest'
2-
import { formatKeyCombo, toPlatformShortcut } from './key-capture'
2+
import { formatKeyCombo, toPlatformShortcut, isTypingKeyCombo } from './key-capture'
33

44
// Mock navigator to control isMacOS() behavior
55
const navigatorSpy = vi.spyOn(globalThis, 'navigator', 'get')
@@ -143,3 +143,35 @@ describe('formatKeyCombo', () => {
143143
expect(result).toBe('⌘⌥E')
144144
})
145145
})
146+
147+
describe('isTypingKeyCombo', () => {
148+
it('treats bare keys as typing (Tab, letters, Space, Enter)', () => {
149+
expect(isTypingKeyCombo('Tab')).toBe(true)
150+
expect(isTypingKeyCombo('A')).toBe(true)
151+
expect(isTypingKeyCombo('Space')).toBe(true)
152+
expect(isTypingKeyCombo('↩')).toBe(true)
153+
})
154+
155+
it('treats shift-only combos as typing (⇧Tab reverse-tab, ⇧A capital letter)', () => {
156+
expect(isTypingKeyCombo('⇧Tab')).toBe(true)
157+
expect(isTypingKeyCombo('⇧A')).toBe(true)
158+
expect(isTypingKeyCombo('Shift+Tab')).toBe(true)
159+
})
160+
161+
it('keeps command-modifier combos live (⌘, ⌃, ⌥, Ctrl, Alt, Super)', () => {
162+
expect(isTypingKeyCombo('⌘C')).toBe(false)
163+
expect(isTypingKeyCombo('⌃X')).toBe(false)
164+
expect(isTypingKeyCombo('⌥↓')).toBe(false)
165+
expect(isTypingKeyCombo('Ctrl+C')).toBe(false)
166+
expect(isTypingKeyCombo('Alt+Tab')).toBe(false)
167+
expect(isTypingKeyCombo('Super+Space')).toBe(false)
168+
})
169+
170+
it('keeps F-keys and Escape live (never typing)', () => {
171+
expect(isTypingKeyCombo('F5')).toBe(false)
172+
expect(isTypingKeyCombo('F12')).toBe(false)
173+
expect(isTypingKeyCombo('⇧F6')).toBe(false)
174+
expect(isTypingKeyCombo('⎋')).toBe(false)
175+
expect(isTypingKeyCombo('Esc')).toBe(false)
176+
})
177+
})

apps/desktop/src/lib/shortcuts/key-capture.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,22 @@ export function matchesShortcut(event: KeyboardEvent, shortcut: string): boolean
197197
export function isCompleteCombo(event: KeyboardEvent): boolean {
198198
return !isModifierKey(event.key)
199199
}
200+
201+
/** Modifier tokens that signal command intent (Shift alone doesn't — it types capitals and reverse-tabs). */
202+
const commandModifierTokens = ['⌘', '⌃', '⌥', 'Ctrl', 'Alt', 'Super']
203+
204+
/**
205+
* True when the combo is something a user types in a text field rather than a
206+
* command: no command modifier (⌘/⌃/⌥ or Ctrl/Alt/Super — Shift alone still
207+
* counts as typing), and not an F-key or Escape (which never produce text).
208+
* The centralized dispatch uses this to let typing win in focused text inputs:
209+
* a bare-key Tier 1 binding (Tab → switch pane) must not fire mid-typing.
210+
*/
211+
export function isTypingKeyCombo(shortcut: string): boolean {
212+
if (commandModifierTokens.some((token) => shortcut.includes(token))) return false
213+
// Strip the shift prefix (both display forms) to inspect the base key.
214+
const base = shortcut.replace(/^/, '').replace(/^Shift\+/, '')
215+
if (/^F\d+$/.test(base)) return false
216+
if (base === '⎋' || base === 'Esc' || base === 'Escape') return false
217+
return true
218+
}

apps/desktop/src/lib/shortcuts/shortcuts-store.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,3 +541,21 @@ describe('shortcuts-store refuses to customize fixed-key commands', () => {
541541
expect(changedEmits()).toHaveLength(0)
542542
})
543543
})
544+
545+
describe('shortcuts-store save serialization', () => {
546+
it('rapid back-to-back mutations persist the final state of every touched command', async () => {
547+
const store = await loadStore()
548+
await store.initializeShortcuts()
549+
550+
// Three mutations in the same tick: their fire-and-forget saves chain
551+
// instead of interleaving reconcile/set/save loops over the shared store.
552+
store.addShortcut('app.about', 'F9')
553+
store.setShortcut('app.about', 0, 'F10')
554+
store.addShortcut('file.copy', '⌘5')
555+
await flushSave()
556+
await flushSave()
557+
558+
expect(disk.get('shortcut:app.about')).toEqual(['F10'])
559+
expect(store.getEffectiveShortcuts('file.copy')).toContain('⌘5')
560+
})
561+
})

0 commit comments

Comments
 (0)