Skip to content

Commit 73766c9

Browse files
committed
Bugfix: Show all commands in the shortcuts editor
- Group commands by their `CommandScope` (one titled group per scope) so the 51 compound-scope commands (`Main window/File list`, `Main window/Brief mode`, every F-key and Quick Look command) now render and are rebindable. Previously they matched no group and never appeared. - Extract the pure grouping into `keyboard-shortcuts-grouping.ts` with a set-equality regression guard: union of grouped commands === all registry commands. - Deep links to compound-scope rows (`shortcut-file.quickLook` from the Quick Look toast) now land and flash; the deep-link E2E spec targets `file.quickLook` to pin it. - Update the section CLAUDE.md gotcha to describe the new grouping.
1 parent b38f6cf commit 73766c9

5 files changed

Lines changed: 139 additions & 34 deletions

File tree

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Parents: [`../CLAUDE.md`](../CLAUDE.md) (registry, store, applier, search) and
3434
| `ai-secret-error.ts` | Pure mapper from OS secret-store error variants to user-facing strings. Used by `AiCloudSection` |
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) |
37+
| `keyboard-shortcuts-grouping.ts` | Pure scope→group logic for `KeyboardShortcutsSection` (one titled group per `CommandScope`, fixed order). Tested by the set-equality regression guard |
3738

3839
Each section ships with an `*.a11y.test.ts` (axe-core tier-3). `McpServerSection`, `UpdatesSection`, `SearchSection`,
3940
and `FileSystemWatchingSection` also have functional `*.test.ts` / `*.svelte.test.ts` files; the three pure-helper `.ts`
@@ -102,16 +103,19 @@ Settings AI changes hot-apply because `settings-applier.ts` routes `ai.provider`
102103
`ai.cloudProviderConfigs` to `ai-config.ts::pushConfigToBackend()`, which re-reads everything fresh. Sections just call
103104
`setSetting(...)`; don't try to push the AI config from the section component.
104105

105-
### Compound-scope rows don't render in any group
106+
### Every command groups by scope (one group per `CommandScope`)
106107

107-
`KeyboardShortcutsSection` groups commands by **exact** `command.scope` match against a fixed `scopes` list
108-
(`['App', 'Main window', 'File list', …]`). A command whose registry scope is a compound path like
109-
`'Main window/File list'` (e.g. `file.quickLook`, `file.contextMenu`) matches NEITHER `'Main window'` NOR `'File list'`,
110-
so it lands in no group and never renders here. A deep link to such a row (`shortcut-file.quickLook`) still opens the
111-
section, but the scroll silently no-ops because the row element doesn't exist. This is a pre-existing display gap, not
112-
something the deep-link work introduced; fixing it (group compound scopes under their parent, or widen the match) is
113-
separate work. The Quick Look toast deep-links to `file.quickLook` anyway because that's the truthful target — when the
114-
display gap is fixed, it lands on the row with no further change.
108+
`KeyboardShortcutsSection` renders one titled group per `CommandScope`, in a fixed reading order, via the pure
109+
`groupCommandsByScope` (`keyboard-shortcuts-grouping.ts`). Compound scopes (`'Main window/File list'`,
110+
`'Main window/Brief mode'`, `'Main window/Volume chooser'`, …) each become their own group titled by the last segment
111+
("File list", "Brief mode", …). So every registry command lands in exactly one group and is rebindable here, including
112+
`file.quickLook` and the F-key commands. Don't reintroduce an ad-hoc title list matched against scopes: the group set
113+
must stay the scope union, or whole groups of commands silently vanish from the rebinding UI. The
114+
`keyboard-shortcuts-grouping.test.ts` set-equality test (union of grouped commands === all registry commands) is the
115+
guard; it also fails if a new `CommandScope` is added without a `scopeOrder` entry.
116+
117+
Deep links to compound-scope rows now land + flash like any other (`shortcut-file.quickLook` from the Quick Look toast,
118+
F-key chips from the F-bar).
115119

116120
## Deep-link arrival into a shortcut row
117121

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

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
} from '$lib/shortcuts'
2828
import { confirmDialog } from '$lib/utils/confirm-dialog'
2929
import GlobalShortcutRow from '$lib/downloads/GlobalShortcutRow.svelte'
30+
import { groupCommandsByScope } from './keyboard-shortcuts-grouping'
3031
import { shortcutAnchorId } from '$lib/settings/settings-window'
3132
import {
3233
getPendingShortcutHighlight,
@@ -102,9 +103,6 @@
102103
}
103104
})
104105
105-
// Group commands by scope
106-
const scopes = ['App', 'Main window', 'File list', 'Navigation', 'Selection', 'Edit', 'View', 'Help']
107-
108106
// Get conflict count for badge
109107
const conflictCount = $derived.by(() => {
110108
// Trigger on shortcut changes
@@ -162,17 +160,9 @@
162160
return 'go to latest download global'.includes(nameSearchQuery.trim().toLowerCase())
163161
})
164162
165-
// Group filtered commands by scope
166-
const groupedCommands = $derived.by(() => {
167-
const groups: Record<string, Command[]> = {}
168-
for (const scope of scopes) {
169-
const scopeCommands = filteredCommands.filter((c) => c.scope === scope)
170-
if (scopeCommands.length > 0) {
171-
groups[scope] = scopeCommands
172-
}
173-
}
174-
return groups
175-
})
163+
// Group filtered commands by scope into the fixed display order. Every command
164+
// renders in exactly one group (keyed by its scope), so all are rebindable here.
165+
const groupedCommands = $derived(groupCommandsByScope(filteredCommands))
176166
177167
function handleKeyCapture(event: KeyboardEvent) {
178168
if (!editingShortcut) return
@@ -501,10 +491,10 @@
501491
{/if}
502492

503493
<div class="commands-list">
504-
{#each Object.entries(groupedCommands) as [scope, scopeCommands] (scope)}
494+
{#each groupedCommands as group (group.scope)}
505495
<div class="scope-group">
506-
<h3 class="scope-title">{scope}</h3>
507-
{#each scopeCommands as command (`${command.id}-${String(shortcutChangeCounter)}`)}
496+
<h3 class="scope-title">{group.title}</h3>
497+
{#each group.commands as command (`${command.id}-${String(shortcutChangeCounter)}`)}
508498
{@const shortcuts = getEffectiveShortcuts(command.id)}
509499
{@const isModified = isShortcutModified(command.id)}
510500
{@const hasConflicts = conflictingIds.has(command.id)}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { commands } from '$lib/commands/command-registry'
3+
import { groupCommandsByScope, groupedScopes } from './keyboard-shortcuts-grouping'
4+
5+
describe('groupCommandsByScope', () => {
6+
it('renders every registry command in exactly one group', () => {
7+
const groups = groupCommandsByScope(commands)
8+
const grouped = groups.flatMap((g) => g.commands)
9+
10+
// No command appears twice.
11+
const ids = grouped.map((c) => c.id)
12+
expect(new Set(ids).size).toBe(ids.length)
13+
14+
// The union of grouped commands === all registry commands. This is the
15+
// regression guard: a compound-scope command (e.g. `file.quickLook` on
16+
// `'Main window/File list'`) that falls into no group would shrink this set
17+
// and become unrebindable through the UI.
18+
expect(new Set(ids)).toEqual(new Set(commands.map((c) => c.id)))
19+
})
20+
21+
it('covers every scope present in the registry (no command silently dropped)', () => {
22+
const registryScopes = new Set(commands.map((c) => c.scope))
23+
for (const scope of registryScopes) {
24+
expect(groupedScopes).toContain(scope)
25+
}
26+
})
27+
28+
it('drops empty groups and preserves the fixed scope order', () => {
29+
const groups = groupCommandsByScope(commands)
30+
expect(groups.every((g) => g.commands.length > 0)).toBe(true)
31+
32+
const orderIndex = (scope: string) => groupedScopes.indexOf(scope as (typeof groupedScopes)[number])
33+
for (let i = 1; i < groups.length; i++) {
34+
expect(orderIndex(groups[i].scope)).toBeGreaterThan(orderIndex(groups[i - 1].scope))
35+
}
36+
})
37+
38+
it('groups file.quickLook (a compound-scope command) so the Quick Look deep link lands', () => {
39+
const groups = groupCommandsByScope(commands)
40+
const quickLook = groups.flatMap((g) => g.commands).find((c) => c.id === 'file.quickLook')
41+
expect(quickLook).toBeDefined()
42+
})
43+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Pure grouping logic for the Keyboard shortcuts section.
3+
*
4+
* Every registry command carries a `CommandScope` (`'App'`, `'Main window'`,
5+
* `'Main window/File list'`, …). The section renders one titled group per scope,
6+
* in a fixed reading order, so EVERY command shows up in exactly one group and is
7+
* rebindable through the UI.
8+
*
9+
* Group straight off `CommandScope` (don't reintroduce an ad-hoc title list that
10+
* has to be matched against scopes): commands on compound scopes like
11+
* `'Main window/File list'` only render because the group set IS the scope union.
12+
* A title list that drifts from the scopes silently hides whole groups of
13+
* commands from the rebinding UI.
14+
*/
15+
import type { Command } from '$lib/commands/types'
16+
import type { CommandScope } from '$lib/commands/types'
17+
18+
/** A titled group of commands sharing one scope, ready to render. */
19+
export interface ShortcutGroup {
20+
/** The scope this group renders (stable key for the `{#each}`). */
21+
scope: CommandScope
22+
/** User-facing group heading (sentence case). */
23+
title: string
24+
commands: Command[]
25+
}
26+
27+
/**
28+
* Display order + heading for each scope. Reads top-down the way a user scans:
29+
* global app commands first, then the main window and its inner contexts (file
30+
* list is the workhorse, so it leads), then the auxiliary windows.
31+
*
32+
* Keep this in sync with `CommandScope` in `lib/commands/types.ts`: the
33+
* exhaustiveness test (`union of grouped commands === all registry commands`)
34+
* fails if a new scope is added without an entry here.
35+
*/
36+
const scopeOrder: readonly { scope: CommandScope; title: string }[] = [
37+
{ scope: 'App', title: 'App' },
38+
{ scope: 'Main window', title: 'Main window' },
39+
{ scope: 'Main window/File list', title: 'File list' },
40+
{ scope: 'Main window/Brief mode', title: 'Brief mode' },
41+
{ scope: 'Main window/Full mode', title: 'Full mode' },
42+
{ scope: 'Main window/Volume chooser', title: 'Volume chooser' },
43+
{ scope: 'Main window/Network', title: 'Network' },
44+
{ scope: 'Main window/Share browser', title: 'Share browser' },
45+
{ scope: 'Command palette', title: 'Command palette' },
46+
{ scope: 'About window', title: 'About window' },
47+
{ scope: 'Onboarding', title: 'Onboarding' },
48+
]
49+
50+
/**
51+
* Group commands by scope into the fixed display order, dropping empty groups.
52+
*
53+
* Every command lands in exactly one group (its `scope`); a command whose scope
54+
* isn't in `scopeOrder` would silently vanish, which the exhaustiveness test
55+
* guards against.
56+
*/
57+
export function groupCommandsByScope(commands: Command[]): ShortcutGroup[] {
58+
const groups: ShortcutGroup[] = []
59+
for (const { scope, title } of scopeOrder) {
60+
const scopeCommands = commands.filter((c) => c.scope === scope)
61+
if (scopeCommands.length > 0) {
62+
groups.push({ scope, title, commands: scopeCommands })
63+
}
64+
}
65+
return groups
66+
}
67+
68+
/** Every scope the grouping renders, for the exhaustiveness test. */
69+
export const groupedScopes: readonly CommandScope[] = scopeOrder.map((s) => s.scope)

apps/desktop/test/e2e-playwright/settings.spec.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -353,14 +353,13 @@ test.describe('Settings keyboard-shortcut deep link', () => {
353353

354354
test('lands on Keyboard shortcuts with the target row scrolled into view', async ({ tauriPage }) => {
355355
const main = tauriPage as TauriPage
356-
// `downloads.goToLatest` is a regular registry row (scope `Main window`) that
357-
// lives far enough down the list to need a scroll, so it exercises the nested
358-
// `.commands-list` scroll path. (We can't target `file.quickLook` — the Quick
359-
// Look toast's deep-link target — because its compound `Main window/File list`
360-
// scope keeps it out of the section's scope groups entirely; that's a
361-
// pre-existing display bug, see `sections/CLAUDE.md` § "Compound-scope rows
362-
// don't render in any group".)
363-
const targetAnchor = 'shortcut-downloads.goToLatest'
356+
// `file.quickLook` is the Quick Look toast's deep-link target. Its scope is the
357+
// compound `Main window/File list`, so this also guards that compound-scope rows
358+
// render and are reachable (the section groups every command by scope; see
359+
// `sections/CLAUDE.md` § "Every command groups by scope"). The row lives far
360+
// enough down the list to need a scroll, exercising the nested `.commands-list`
361+
// scroll path.
362+
const targetAnchor = 'shortcut-file.quickLook'
364363
const section = JSON.stringify(['Keyboard shortcuts'])
365364
const url = `/settings?section=${encodeURIComponent(section)}&anchor=${encodeURIComponent(targetAnchor)}`
366365
const urlJson = JSON.stringify(url)

0 commit comments

Comments
 (0)