Skip to content

Commit 123e76b

Browse files
committed
Shortcuts shown in the app now reflect your bindings
Migrate the remaining hardcoded shortcut hints to truthful, binding-aware displays: - F-key bar: each button's key now reads its command's live effective first shortcut (rebinding `file.copy` re-renders F5 instantly), the platform fork collapses, and the aria-label interpolates the same combo. Unbound commands drop the chip but keep the label. The Shift fork stays presentational, each button shows its own command's first binding. Layout survives an absurd custom binding (label truncates, bar can't overflow). Extracted the 9-button map to `function-key-commands.ts` with a test pinning it. - Tab bar "+" tooltip: reads `tab.new` reactively via `$derived`. - Quick Look toast: `⇧Space` is snapshotted at toast creation; `Space` / `Enter` become literal chips (all non-clickable, the toast already links to Settings). - Downloads and go-to-path toasts: snapshot hints now render as literal `ShortcutChip`s. - Transfer `trash_not_supported` suggestion interpolates the live `file.deletePermanently` binding instead of a hardcoded `Shift+F8`. - Onboarding AI step: the wrong `⌘+` becomes the real `selection.selectFiles` binding (bare `+`), reworded to "press the + key" so it doesn't read as a separator. - Updated the touched feature CLAUDE.md docs.
1 parent 73766c9 commit 123e76b

15 files changed

Lines changed: 287 additions & 165 deletions

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Backend counterpart: [`src-tauri/src/downloads/CLAUDE.md`](../../../src-tauri/sr
1515
| `LatestDownloadFdaToastContent.svelte` | INFO toast: "Cmdr needs Full Disk Access…" with an "Open System Settings" action. |
1616
| `go-to-latest-ids.ts` | Dedup ids for the go-to-latest INFO toasts. |
1717
| `event-bridge.svelte.ts` | Listener bridge: one `download-detected` subscription, dispatches per the settings enum. |
18-
| `DownloadToastContent.svelte` | In-app toast: title with filename + size, optional subdir line, snapshotted shortcut hint, Jump + Stop-showing actions. |
18+
| `DownloadToastContent.svelte` | In-app toast: title with filename + size, optional subdir line, snapshotted shortcut hint (literal `ShortcutChip`), Jump + Stop-showing actions. |
1919
| `notifications-mode.ts` | Reader, writer, and deep-link helper for `behavior.fileSystemWatching.downloadsNotifications`. |
2020
| `global-shortcut-bridge.svelte.ts` | One `global-shortcut-fired` Tauri event subscription. Calls `goToLatestDownload` plus, on first un-acknowledged trigger, the warn toast. |
2121
| `GlobalShortcutWarnToastContent.svelte` | First-trigger persistent warn toast for ⌃⌥⌘J. "Keep it on" / "Turn it off" buttons. Snapshotted binding prop. |
@@ -40,9 +40,11 @@ Settings whenever; their preference stays put.
4040
## Snapshot-at-creation rule
4141

4242
The shortcut hint shown on each in-app toast is the value of `getEffectiveShortcuts('downloads.goToLatest')[0]` at
43-
toast-creation time, passed as a prop. A remap that happens between this toast appearing and the user clicking does NOT
43+
toast-creation time, passed as the `shortcutHint` prop and rendered as a literal-mode `ShortcutChip`
44+
(`key={shortcutHint}`, non-clickable). A remap that happens between this toast appearing and the user clicking does NOT
4445
change what's displayed — that would be confusing, because the hint would no longer match what the user actually pressed
45-
when the toast first showed up. The next toast picks up the new binding naturally.
46+
when the toast first showed up. The next toast picks up the new binding naturally. (The chip is literal, not `commandId`
47+
mode, precisely to preserve this snapshot semantic; a `commandId` chip would re-render live.)
4648

4749
Pure-prop-driven: the toast component reads `event`, `shortcutHint`, `explorer`, and `toastId` once on mount. No live
4850
subscriptions, no module state. The `ToastItem` host extends the toast store with a `props` field (see `lib/ui/toast/`)

apps/desktop/src/lib/downloads/DownloadToastContent.svelte

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* second copy of the Jump action.
1414
*/
1515
import Size from '$lib/ui/Size.svelte'
16+
import ShortcutChip from '$lib/ui/ShortcutChip.svelte'
1617
import { dismissToast } from '$lib/ui/toast'
1718
import { goToDownload } from './go-to-latest'
1819
import {
@@ -116,7 +117,7 @@
116117
<span class="subdir">in {subdirLabel}</span>
117118
{/if}
118119
{#if shortcutHint}
119-
<span class="hint">Press <kbd>{shortcutHint}</kbd> to jump</span>
120+
<span class="hint">Press <ShortcutChip key={shortcutHint} /> to jump</span>
120121
{/if}
121122
<div class="actions">
122123
<button type="button" class="jump-button" onclick={handleJumpButton}>
@@ -162,14 +163,9 @@
162163
.hint {
163164
color: var(--color-text-tertiary);
164165
font-size: var(--font-size-xs);
165-
}
166-
167-
kbd {
168-
font-family: var(--font-mono);
169-
font-size: var(--font-size-xs);
170-
padding: 0 var(--spacing-xs);
171-
border-radius: var(--radius-xs);
172-
background: var(--color-bg-tertiary);
166+
display: inline-flex;
167+
align-items: center;
168+
gap: var(--spacing-xxs);
173169
}
174170
175171
.actions {

apps/desktop/src/lib/file-explorer/CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ Dual-pane file explorer with keyboard-driven navigation, file selection, sorting
1212
toast keeps reappearing as a gentle reminder until the user clicks "Don't show again" (or flips
1313
`fileExplorer.suppressQuickLookHint` in Settings > Advanced). While the toast is on screen, further Space presses just
1414
toggle selection — the hint module no-ops if the toast is already visible. The X on the toast frame closes the current
15-
instance without suppressing future ones.
15+
instance without suppressing future ones. The toast's keys render as literal-mode `ShortcutChip`s: `Space` / `Enter`
16+
are fixed interaction keys, and the Quick Look key is snapshotted at toast creation
17+
(`getEffectiveShortcuts('file.quickLook')[0]`, default `⇧Space`) so a mid-display rebind doesn't rewrite the visible
18+
toast. The toast also carries a "Settings > Keyboard shortcuts" `LinkButton` that deep-links to the `file.quickLook`
19+
row, so the chips themselves stay non-clickable.
1620
- **Insert**: toggle selection at cursor and move cursor down (Total Commander style). `..` isn't selectable, but the
1721
cursor still advances. At the last row the cursor stays put. No physical Insert key on Apple keyboards — users can
1822
remap via Karabiner-Elements, plug in a PC USB keyboard, or rebind in Settings → Shortcuts.

apps/desktop/src/lib/file-explorer/pane/CLAUDE.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ list).
6767
| `volume-capabilities.ts` | `VolumeKind` + frozen per-kind `VolumeCapabilities` table + `volumeKindOf` / `capabilitiesFor` |
6868
| `search-results-keys.ts` | Pure key→action dispatch for the flat snapshot pane |
6969
| `selection-dialog-keys.ts` | Classify `+` / `-` keypresses → open Selection dialog (Total Commander parity) |
70+
| `function-key-commands.ts` | `fnKeyToCommand`: the F-key bar's 9 button → command-id map (typed; unit-tested) |
7071
| `error-pane-utils.ts` | Tiny helper for `ErrorPane`'s technical-details rendering |
7172
| `integration-test-utils.ts` | Shared test scaffolding for pane integration tests |
7273

@@ -253,15 +254,28 @@ in the chain. Per-pane read only (P1): touch the focused pane's manager, never b
253254

254255
**`FunctionKeyBar` dispatches `file.*` onto the bus.** Each button click calls a single
255256
`onCommand?: (id: CommandId) => void` prop, wired in `+page.svelte` to `handleCommandExecute`. The button-to-command
256-
mapping lives in a typed `fnKeyToCommand` map inside the component (F2/⇧F6 → `file.rename`, F3 → `file.view`, F4 →
257-
`file.edit`, F5 → `file.copy`, F6 → `file.move`, ⇧F4 → `file.newFile`, F7 → `file.newFolder`, F8 → `file.delete`, ⇧F8 →
258-
`file.deletePermanently`). The keys are held in a typed map (not inlined at the call site) so
259-
`cmdr/no-raw-command-dispatch` stays satisfied. Routing F-clicks through the bus means they now get the dispatch
260-
preamble (`log.info` + `record_breadcrumb` breadcrumb + the `blockedByCapabilities` guard) like every other entry path —
261-
a deliberate telemetry gain, not a behavior change. The buttons' visible `disabled` flags (`canRename` / `canMkfile` /
262-
`canMkdir` / `canSourceOps`) win first: a disabled button can't be clicked, so the dispatch capability guard never fires
263-
for an F-click (the guard's blocked set — `file.rename` / `file.newFile` / `file.newFolder` — matches exactly the
264-
buttons the flags disable on a snapshot pane).
257+
mapping lives in a typed `fnKeyToCommand` map (F2/⇧F6 → `file.rename`, F3 → `file.view`, F4 → `file.edit`, F5 →
258+
`file.copy`, F6 → `file.move`, ⇧F4 → `file.newFile`, F7 → `file.newFolder`, F8 → `file.delete`, ⇧F8 →
259+
`file.deletePermanently`). The map is extracted to `function-key-commands.ts` so it's unit-testable
260+
(`function-key-commands.test.ts` pins the 9 mappings); it's a typed constant (not inlined at the call site) so
261+
`cmdr/no-raw-command-dispatch` stays satisfied.
262+
263+
**The F-bar chips read live effective shortcuts, not hardcoded F-keys.** Each visible button shows its command's
264+
`getFirstShortcutReactive(id)` value, so rebinding `file.copy` to `⌘C` re-renders the F5 button's chip immediately — the
265+
bar never lies about what the keys do. The `aria-label` interpolates the same dynamic combo ("Copy (F5)" → "Copy (⌘C)").
266+
When a command has no binding the chip renders nothing (the button keeps its label and stays clickable; an empty `<kbd>`
267+
would read as broken). The chips keep the bar's quiet local `<kbd>` styling rather than the boxed `ShortcutChip` pill —
268+
a boxed pill repeated 8× fights the flat bar; truthfulness is the must, the chip look is the want. The Shift fork stays
269+
**presentational and hardcoded** (which buttons appear on Shift never changes), but each shown button reads ITS
270+
command's effective FIRST binding — so the Shift-revealed "Rename" button shows `file.rename`'s first binding (`F2`),
271+
not `⇧F6`. Slightly odd next to its siblings, but truthful, which is the whole point. The four Shift placeholder slots
272+
(F2/F3/F5/F7, no command) keep their static F-key labels. Layout survives an absurd custom binding: the buttons are
273+
`flex: 1; min-width: 0` and the label truncates before the chip, so a long combo can't push the bar past the window.
274+
Routing F-clicks through the bus means they now get the dispatch preamble (`log.info` + `record_breadcrumb` breadcrumb +
275+
the `blockedByCapabilities` guard) like every other entry path — a deliberate telemetry gain, not a behavior change. The
276+
buttons' visible `disabled` flags (`canRename` / `canMkfile` / `canMkdir` / `canSourceOps`) win first: a disabled button
277+
can't be clicked, so the dispatch capability guard never fires for an F-click (the guard's blocked set — `file.rename` /
278+
`file.newFile` / `file.newFolder` — matches exactly the buttons the flags disable on a snapshot pane).
265279

266280
**Selection-dialog keys dispatch onto the bus.** The `+` / `-` keypresses are classified by `selection-dialog-keys.ts`
267281
and reach the bus through a typed `onCommand?: (commandId: CommandId) => void` prop chain: `FilePane` (the classifier at

apps/desktop/src/lib/file-explorer/pane/FunctionKeyBar.svelte

Lines changed: 80 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import { explorerState } from './explorer-state.svelte'
33
import { getActiveTab } from '../tabs/tab-state-manager.svelte'
44
import { capabilitiesFor } from './volume-capabilities'
5+
import { getFirstShortcutReactive } from '$lib/shortcuts/reactive-shortcuts.svelte'
6+
import { fnKeyToCommand } from './function-key-commands'
57
import type { CommandId } from '$lib/commands'
68
79
interface Props {
@@ -19,22 +21,24 @@
1921
const { visible = true, onCommand }: Props = $props()
2022
2123
/**
22-
* Each F-key button's command id. Held in a typed map (not inlined as a
23-
* string literal at the `onCommand?.(…)` call site) so the `CommandId` type
24-
* is checked and `cmdr/no-raw-command-dispatch` stays satisfied: the call
25-
* site passes a typed value, never a magic string.
24+
* Each visible button's CHIP shows its command's live effective first shortcut
25+
* (`getFirstShortcutReactive`), not the hardcoded F-key. Rebinding `file.copy`
26+
* to `⌘C` in Settings re-renders the F5 button's chip as `⌘C` immediately, so
27+
* the bar never lies about what the keys do. The chip keeps the bar's quiet
28+
* `<kbd>` look (a boxed `ShortcutChip` pill repeated 8× fights the flat bar);
29+
* truthfulness is the must, the chip style is the want (see the migration plan).
30+
*
31+
* The Shift fork stays presentational: WHICH buttons appear on Shift is fixed,
32+
* but each shown button reads ITS command's effective FIRST binding. Both Rename
33+
* buttons (F2 and the Shift-revealed one) therefore show `file.rename`'s first
34+
* binding — slightly odd, but truthful, which is the whole point.
35+
*
36+
* When a command has no binding the chip renders nothing (the button stays
37+
* clickable and keeps its label); an empty `<kbd>` would read as broken.
2638
*/
27-
const fnKeyToCommand = {
28-
view: 'file.view',
29-
edit: 'file.edit',
30-
copy: 'file.copy',
31-
move: 'file.move',
32-
rename: 'file.rename',
33-
newFile: 'file.newFile',
34-
newFolder: 'file.newFolder',
35-
delete: 'file.delete',
36-
deletePermanently: 'file.deletePermanently',
37-
} as const satisfies Record<string, CommandId>
39+
function shortcutFor(id: CommandId): string | undefined {
40+
return getFirstShortcutReactive(id)
41+
}
3842
3943
/**
4044
* Capabilities for the focused pane, read straight off the explorer store.
@@ -82,6 +86,31 @@
8286

8387
<svelte:document onkeydown={handleKeyDown} onkeyup={handleKeyUp} />
8488

89+
<!--
90+
One command button. The chip reads the command's live effective first shortcut;
91+
the aria-label interpolates the same dynamic combo so screen readers hear what
92+
actually triggers the action ("Copy (F5)" → "Copy (⌘C)" after a rebind). When
93+
unbound, both the chip and the parenthetical drop — the label alone, still clickable.
94+
-->
95+
{#snippet commandButton(id: CommandId, label: string, action: string, enabled: boolean)}
96+
{@const shortcut = shortcutFor(id)}
97+
<button
98+
onclick={() => onCommand?.(id)}
99+
disabled={!enabled}
100+
tabindex={-1}
101+
aria-label={shortcut ? `${action} (${shortcut})` : action}
102+
>
103+
{#if shortcut}<kbd>{shortcut}</kbd>{/if}<span>{label}</span>
104+
</button>
105+
{/snippet}
106+
107+
<!-- A fixed F-key slot with no Shift action. Presentational only (not a command). -->
108+
{#snippet emptySlot(fnKey: string)}
109+
<button disabled tabindex={-1} aria-label="{fnKey} (no shift action)">
110+
<kbd>{fnKey}</kbd>
111+
</button>
112+
{/snippet}
113+
85114
{#if visible}
86115
<div
87116
class="function-key-bar"
@@ -91,99 +120,30 @@
91120
e.preventDefault()
92121
}}
93122
>
123+
<!-- eslint-disable @typescript-eslint/no-confusing-void-expression -- Svelte {@render} syntax -->
94124
{#if shiftHeld}
95-
<button disabled tabindex={-1} aria-label="F2 (no shift action)">
96-
<kbd>F2</kbd>
97-
</button>
98-
<button disabled tabindex={-1} aria-label="F3 (no shift action)">
99-
<kbd>F3</kbd>
100-
</button>
101-
<button
102-
onclick={() => onCommand?.(fnKeyToCommand.newFile)}
103-
disabled={!canMkfile}
104-
tabindex={-1}
105-
aria-label="Create new file (Shift+F4)"
106-
>
107-
<kbd>⇧F4</kbd><span>New file</span>
108-
</button>
109-
<button disabled tabindex={-1} aria-label="F5 (no shift action)">
110-
<kbd>F5</kbd>
111-
</button>
112-
<button
113-
onclick={() => onCommand?.(fnKeyToCommand.rename)}
114-
disabled={!canRename}
115-
tabindex={-1}
116-
aria-label="Rename (Shift+F6)"
117-
>
118-
<kbd>⇧F6</kbd><span>Rename</span>
119-
</button>
120-
<button disabled tabindex={-1} aria-label="F7 (no shift action)">
121-
<kbd>F7</kbd>
122-
</button>
123-
<button
124-
onclick={() => onCommand?.(fnKeyToCommand.deletePermanently)}
125-
disabled={!canSourceOps}
126-
tabindex={-1}
127-
aria-label="Delete permanently (Shift+F8)"
128-
>
129-
<kbd>⇧F8</kbd><span>Permanently</span>
130-
</button>
125+
{@render emptySlot('F2')}
126+
{@render emptySlot('F3')}
127+
{@render commandButton(fnKeyToCommand.newFile, 'New file', 'Create new file', canMkfile)}
128+
{@render emptySlot('F5')}
129+
{@render commandButton(fnKeyToCommand.rename, 'Rename', 'Rename', canRename)}
130+
{@render emptySlot('F7')}
131+
{@render commandButton(
132+
fnKeyToCommand.deletePermanently,
133+
'Permanently',
134+
'Delete permanently',
135+
canSourceOps,
136+
)}
131137
{:else}
132-
<button
133-
onclick={() => onCommand?.(fnKeyToCommand.rename)}
134-
disabled={!canRename}
135-
tabindex={-1}
136-
aria-label="Rename (F2)"
137-
>
138-
<kbd>F2</kbd><span>Rename</span>
139-
</button>
140-
<button
141-
onclick={() => onCommand?.(fnKeyToCommand.view)}
142-
tabindex={-1}
143-
aria-label="View file (F3)"
144-
>
145-
<kbd>F3</kbd><span>View</span>
146-
</button>
147-
<button
148-
onclick={() => onCommand?.(fnKeyToCommand.edit)}
149-
tabindex={-1}
150-
aria-label="Edit file (F4)"
151-
>
152-
<kbd>F4</kbd><span>Edit</span>
153-
</button>
154-
<button
155-
onclick={() => onCommand?.(fnKeyToCommand.copy)}
156-
disabled={!canSourceOps}
157-
tabindex={-1}
158-
aria-label="Copy (F5)"
159-
>
160-
<kbd>F5</kbd><span>Copy</span>
161-
</button>
162-
<button
163-
onclick={() => onCommand?.(fnKeyToCommand.move)}
164-
disabled={!canSourceOps}
165-
tabindex={-1}
166-
aria-label="Move (F6)"
167-
>
168-
<kbd>F6</kbd><span>Move</span>
169-
</button>
170-
<button
171-
onclick={() => onCommand?.(fnKeyToCommand.newFolder)}
172-
disabled={!canMkdir}
173-
tabindex={-1}
174-
aria-label="New folder (F7)"
175-
>
176-
<kbd>F7</kbd><span>New folder</span>
177-
</button>
178-
<button
179-
onclick={() => onCommand?.(fnKeyToCommand.delete)}
180-
disabled={!canSourceOps}
181-
tabindex={-1}
182-
aria-label="Delete (F8)"
183-
>
184-
<kbd>F8</kbd><span>Delete</span>
185-
</button>
138+
{@render commandButton(fnKeyToCommand.rename, 'Rename', 'Rename', canRename)}
139+
{@render commandButton(fnKeyToCommand.view, 'View', 'View file', true)}
140+
{@render commandButton(fnKeyToCommand.edit, 'Edit', 'Edit file', true)}
141+
{@render commandButton(fnKeyToCommand.copy, 'Copy', 'Copy', canSourceOps)}
142+
{@render commandButton(fnKeyToCommand.move, 'Move', 'Move', canSourceOps)}
143+
{@render commandButton(fnKeyToCommand.newFolder, 'New folder', 'New folder', canMkdir)}
144+
{@render commandButton(fnKeyToCommand.delete, 'Delete', 'Delete', canSourceOps)}
186145
{/if}
146+
<!-- eslint-enable @typescript-eslint/no-confusing-void-expression -->
187147
</div>
188148
{/if}
189149

@@ -196,6 +156,10 @@
196156
197157
button {
198158
flex: 1;
159+
/* min-width: 0 lets a button shrink below its content size so a long custom
160+
binding (e.g. ⌘⇧⌥K) can't force the bar wider than the window: the label
161+
truncates instead. Without it, flex items refuse to shrink past content. */
162+
min-width: 0;
199163
display: flex;
200164
align-items: center;
201165
justify-content: center;
@@ -210,6 +174,15 @@
210174
transition: background-color var(--transition-fast);
211175
}
212176
177+
/* The label truncates before the chip does: a long binding keeps the key
178+
readable (the chip is the truthful claim) while the word gives way. */
179+
button > span {
180+
overflow: hidden;
181+
text-overflow: ellipsis;
182+
white-space: nowrap;
183+
min-width: 0;
184+
}
185+
213186
button:last-child {
214187
border-right: none;
215188
}
@@ -229,5 +202,7 @@
229202
color: var(--color-text-secondary);
230203
/* stylelint-disable-next-line declaration-property-value-disallowed-list */
231204
padding: 1px var(--spacing-xs);
205+
white-space: nowrap;
206+
flex-shrink: 0;
232207
}
233208
</style>

0 commit comments

Comments
 (0)