Skip to content

Commit 3378c8f

Browse files
alicodingclaude
andauthored
feat: keyboard coverage -- register the rest of Atlas's toolbar/board actions as commands (#211)
Registers every previously click-only Atlas action (Auto-arrange, Import, Export, Add from folder, the two space-share actions, the lens, select-all, and the selection tray's Delete/Group) into the shared command registry so the palette and Shortcuts Help overlay stop being incomplete. Two new Command fields make this legal without breaking existing dispatch: - hintOnly: the command's real keydown handling lives in a dedicated listener (Cmd+A select-all, Delete/G over a live selection) rather than the generic dispatcher, which would otherwise preventDefault a native browser shortcut or has no access to the live selection it needs. - paletteHidden: excludes an action needing a live on-screen target (Delete selection, Group into a new area) from the palette while it stays reachable via HotkeyHint chips, ContextMenu items, and the Shortcuts Help overlay. Cmd+A select-all is a new capability (useAtlasSelectAll.ts), gated by app/useKeymapDispatch.ts's new Listener 5 the same way the existing undo-delete listener guards Cmd+Z. The two context-menu items that already existed (Group into new area, Delete) now carry commandId so their rows show the same hint chip the palette/overlay do. Split shared/commands.ts's own settings-deep-link commands out to a new settingsCommands.ts to stay under the file-length cap once the new Command fields' doc comments and the Atlas command spread landed. Claude-Session: https://claude.ai/code/session_01FW5GkkAG8du7tNdYLk2zSd Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8063d4c commit 3378c8f

24 files changed

Lines changed: 630 additions & 73 deletions

frontend/e2e/atlas-select-group.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,71 @@ test('atlas shift-click select: toggle membership, group via member right-click,
330330
rmSync(dir, { recursive: true, force: true })
331331
}
332332
})
333+
334+
// atlas.selectAll's own ⌘A (shared/atlasBoardCommands.ts, app/useKeymapDispatch.ts's
335+
// Listener 5): a dedicated, editable-target-guarded listener, not the
336+
// generic dispatcher -- proves both halves in one flow, native
337+
// select-all-text inside the jump dialog's own input stays untouched,
338+
// and a real board-level press selects every card.
339+
// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture.
340+
test('atlas select-all (Cmd+A): guarded inside an editable field, selects every card on the board otherwise', async ({}, testInfo) => {
341+
const idx = testInfo.parallelIndex
342+
const dir = mkdtempSync(path.join(tmpdir(), `mill-e2e-atlas-select-all-${idx}-`))
343+
const settingsPath = path.join(dir, 'settings.json')
344+
const executionDbPath = path.join(dir, 'execution.db')
345+
const backupDir = path.join(dir, 'backups')
346+
const port = ATLAS_SELECT_GROUP_SERVER_BASE_PORT + idx
347+
const mcpPort = ATLAS_SELECT_GROUP_MCP_BASE_PORT + idx
348+
349+
let server: SpawnedServer | undefined
350+
const browser = await chromium.launch()
351+
try {
352+
server = await spawnMillServer({ port, mcpPort, settingsPath, executionDbPath, backupDir })
353+
const page = await browser.newPage()
354+
await page.goto(`${server.baseURL}/`)
355+
await page.getByRole('link', { name: 'Atlas' }).click()
356+
const board = page.getByTestId('atlas-board')
357+
await expect(board).toBeVisible()
358+
await zoomOutLight(page)
359+
360+
const popover = page.getByTestId('atlas-placement-popover')
361+
await armAndPlaceTopicCard(page, board, popover, 0.25, 0.05, 'ZzA2eSelAllA')
362+
await armAndPlaceTopicCard(page, board, popover, 0.55, 0.05, 'ZzA2eSelAllB')
363+
364+
const selected = page.locator('.react-flow__node.selected')
365+
366+
// Editable-target guard: Cmd+A inside the jump dialog's own search
367+
// input is native browser select-all-text, never board select-all.
368+
await page.keyboard.press('Meta+k')
369+
const jumpInput = page.getByTestId('atlas-jump-input')
370+
await expect(jumpInput).toBeFocused()
371+
await jumpInput.press('Meta+a')
372+
await expect(selected).toHaveCount(0)
373+
await page.keyboard.press('Escape')
374+
375+
// Real dispatch: Cmd+A on the board selects EVERY top-level card at
376+
// this level -- the seeded root ("My space") already carries 3
377+
// (Example area, Getting started, Scratchpad; internal/domain/atlas/builtin.go),
378+
// plus the 2 just placed.
379+
await page.keyboard.press('Meta+a')
380+
await expect(selected).toHaveCount(5)
381+
const selectionTray = page.getByTestId('atlas-selection-tray')
382+
await expect(selectionTray).toBeVisible()
383+
await expect(page.getByTestId('atlas-selection-count')).toHaveText('5 selected')
384+
385+
// Cleanup (testing.md's within-file discipline): quick delete +
386+
// clock-controlled toast expiry, same pattern this file's other
387+
// test already uses.
388+
await page.clock.install()
389+
await page.keyboard.press('Delete')
390+
await expect(noteCard(page, 'ZzA2eSelAllA')).toHaveCount(0)
391+
await expect(noteCard(page, 'ZzA2eSelAllB')).toHaveCount(0)
392+
const undoToast = page.getByTestId('atlas-undo-toast')
393+
await expect(undoToast).toBeVisible()
394+
await page.clock.fastForward('00:11')
395+
await expect(undoToast).toHaveCount(0)
396+
} finally {
397+
await server?.stop()
398+
rmSync(dir, { recursive: true, force: true })
399+
}
400+
})

frontend/e2e/atlas.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,29 @@ test('arrange is an action: dragging persists a position, Auto-arrange re-seats
217217
await expect.poll(async () => adaNode.evaluate((el) => (el as HTMLElement).style.transform), { timeout: 10_000 }).toBe(before)
218218
})
219219

220+
// atlas.arrange (shared/atlasBoardCommands.ts): the palette path runs
221+
// the SAME arrange action the toolbar button above does -- proven by
222+
// the same transform-changes-to-a-translate assertion, not a second
223+
// reload-persistence check (already covered above).
224+
test('Auto-arrange from the command palette runs the same action as the toolbar button', async ({ page }) => {
225+
await page.goto('/')
226+
await page.getByRole('link', { name: 'Atlas' }).click()
227+
await groupCard(page, 'Example area').getByTestId('atlas-group-header').click()
228+
await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area')
229+
230+
const adaNode = page.locator('.react-flow__node').filter({ has: page.locator('[aria-label="Flip Ada Lovelace"]') })
231+
await expect(adaNode).toBeVisible()
232+
233+
await page.keyboard.press('Meta+/')
234+
const palette = page.getByRole('dialog', { name: 'Command palette' })
235+
await expect(palette).toBeVisible()
236+
await palette.getByRole('combobox').fill('Auto-arrange')
237+
await palette.getByRole('option', { name: 'Auto-arrange' }).click()
238+
await expect(palette).toHaveCount(0)
239+
240+
await expect.poll(async () => (await adaNode.evaluate((el) => (el as HTMLElement).style.transform)) ?? '').toContain('translate')
241+
})
242+
220243
test('create a child card, edit + persist it via the flip-then-Open overlay, then delete it', async ({ page }) => {
221244
const title = 'ZzE2eAtlasChildCard'
222245
await page.goto('/')

frontend/e2e/command-palette.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,50 @@ test('a workflow row shows its own hotkey-trigger combo inline; a non-hotkey tri
223223
await clickRowAction(page, workflowRow(page, manualLabel), 'Delete')
224224
await expect(workflowRow(page, manualLabel)).toHaveCount(0)
225225
})
226+
227+
// shared/atlasBoardCommands.ts's new Atlas commands: every palette-
228+
// visible one is actually reachable by searching its exact label.
229+
test('every new Atlas command is reachable from the palette by its label', async ({ page }) => {
230+
await page.goto('/')
231+
await page.getByRole('link', { name: 'Atlas' }).click()
232+
await expect(page.getByTestId('atlas-board')).toBeVisible()
233+
234+
const labels = [
235+
'Select all',
236+
'Auto-arrange',
237+
'Open lens',
238+
'Import atlas',
239+
'Export atlas',
240+
'Add cards from a folder',
241+
'Copy space as context',
242+
'Copy space links',
243+
]
244+
for (const label of labels) {
245+
await page.keyboard.press('Meta+/')
246+
await expect(paletteDialog(page)).toBeVisible()
247+
await paletteDialog(page).getByRole('combobox').fill(label)
248+
await expect(paletteDialog(page).getByRole('option', { name: label })).toBeVisible()
249+
await page.keyboard.press('Escape')
250+
}
251+
})
252+
253+
// atlas.delete.selection/atlas.group.selection are paletteHidden --
254+
// they need a live, on-screen selection the palette can't supply, so
255+
// they're excluded here even though the Shortcuts Help overlay still
256+
// lists their hint chips (help-overlay.spec.ts).
257+
test('Delete selection and Group into a new area are excluded from the palette (paletteHidden)', async ({ page }) => {
258+
await page.goto('/')
259+
await page.getByRole('link', { name: 'Atlas' }).click()
260+
await expect(page.getByTestId('atlas-board')).toBeVisible()
261+
262+
await page.keyboard.press('Meta+/')
263+
await expect(paletteDialog(page)).toBeVisible()
264+
265+
await paletteDialog(page).getByRole('combobox').fill('Delete selection')
266+
await expect(paletteDialog(page).getByRole('option', { name: 'Delete selection' })).toHaveCount(0)
267+
268+
await paletteDialog(page).getByRole('combobox').fill('Group into a new area')
269+
await expect(paletteDialog(page).getByRole('option', { name: 'Group into a new area' })).toHaveCount(0)
270+
271+
await page.keyboard.press('Escape')
272+
})

frontend/e2e/help-overlay.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,31 @@ test('"Rebind in Settings" in the overlay footer navigates to Settings and close
9999
await expect(page.getByTestId('settings-view')).toBeVisible()
100100
await expect(page.locator('[data-testid="keymap-list"]')).toBeVisible()
101101
})
102+
103+
// shared/atlasBoardCommands.ts's new Atlas commands: hintOnly ones
104+
// still render their own real hint chip here (atlas.selectAll's ⌘A,
105+
// atlas.delete.selection's ⌫, atlas.group.selection's G); palette-only
106+
// commands with no default binding (atlas.arrange, atlas.import) are
107+
// deliberately absent -- same "unbound stays out of the overlay"
108+
// behavior atlas.matrix/atlas.coverage already have.
109+
test('the overlay shows hint chips for the new Atlas commands, and omits unbound palette-only ones', async ({ page }) => {
110+
await page.goto('/')
111+
await page.getByRole('link', { name: 'Atlas' }).click()
112+
await expect(page.getByTestId('atlas-view')).toBeVisible()
113+
114+
await page.keyboard.press('?')
115+
const dialog = helpDialog(page)
116+
await expect(dialog).toBeVisible()
117+
118+
const selectAllRow = dialog.locator('[data-command-id="atlas.selectAll"]')
119+
await expect(selectAllRow).toContainText('Select all')
120+
await expect(selectAllRow).toContainText('⌘A')
121+
122+
await expect(dialog.locator('[data-command-id="atlas.delete.selection"]')).toContainText('⌫')
123+
await expect(dialog.locator('[data-command-id="atlas.group.selection"]')).toContainText('G')
124+
125+
await expect(dialog.locator('[data-command-id="atlas.arrange"]')).toHaveCount(0)
126+
await expect(dialog.locator('[data-command-id="atlas.import"]')).toHaveCount(0)
127+
128+
await page.keyboard.press('Escape')
129+
})

frontend/src/app/CommandPalette.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,13 +305,13 @@ export function CommandPalette() {
305305

306306
const allEntries = useMemo<PaletteEntry[]>(() => {
307307
if (restState) {
308-
const surfaceCommands = COMMANDS.filter((c) => c.surface?.includes(viewKind)).map(commandEntry)
308+
const surfaceCommands = COMMANDS.filter((c) => c.surface?.includes(viewKind) && !c.paletteHidden).map(commandEntry)
309309
const navCommands = COMMANDS.filter((c) => isNavCommandId(c.id)).map(commandEntry)
310310
const topWorkflows = sortWorkflowsByPinnedAndFrecency(workflows ?? [], mostUsedRank, pinnedWorkflowIds).slice(0, REST_STATE_WORKFLOW_LIMIT)
311311
return [...surfaceCommands, ...navCommands, ...topWorkflows.flatMap(workflowEntries), ...workTabs.flatMap(tabEntries)]
312312
}
313313
return [
314-
...COMMANDS.filter((c) => !c.surface || c.surface.includes(viewKind)).map(commandEntry),
314+
...COMMANDS.filter((c) => (!c.surface || c.surface.includes(viewKind)) && !c.paletteHidden).map(commandEntry),
315315
...sortWorkflowsByPinnedAndFrecency(workflows ?? [], mostUsedRank, pinnedWorkflowIds).flatMap(workflowEntries),
316316
...workTabs.flatMap(tabEntries),
317317
]

frontend/src/app/useKeymapDispatch.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,24 @@ export function useKeymapDispatch(): void {
112112
window.addEventListener('keydown', onKeyDown)
113113
return () => window.removeEventListener('keydown', onKeyDown)
114114
}, [])
115+
116+
// Listener 5, atlas.selectAll's own ⌘A (shared/atlasBoardCommands.ts):
117+
// same reasoning as Listener 4 above -- ⌘A is ALSO the native
118+
// select-all-text combo, so a generic dispatchCommandForEvent match
119+
// (which preventDefaults unconditionally) would break it inside any
120+
// Atlas input. Gated the same way Listener 3 gates bare C/N/A: atlas
121+
// surface only, no open dialog, never an editable target.
122+
useEffect(() => {
123+
const onKeyDown = (e: KeyboardEvent) => {
124+
if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return
125+
if (e.key.toUpperCase() !== 'A') return
126+
if (useAppStore.getState().view.kind !== 'atlas') return
127+
if (isEditableTarget(e.target)) return
128+
if (document.querySelector('[role="dialog"]')) return
129+
e.preventDefault()
130+
findCommand('atlas.selectAll')?.run()
131+
}
132+
window.addEventListener('keydown', onKeyDown)
133+
return () => window.removeEventListener('keydown', onKeyDown)
134+
}, [])
115135
}

frontend/src/atlas/AtlasBoard.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useAtlasCreation, type AtlasGroupRequest, type AtlasPlacementRequest, t
2323
import { useAtlasAreaDraw } from './useAtlasAreaDraw'
2424
import { useAtlasDragFiling, type FrameBox } from './useAtlasDragFiling'
2525
import { useAtlasSelection } from './useAtlasSelection'
26+
import { useAtlasSelectAll } from './useAtlasSelectAll'
2627
import { useAtlasSelectionTray } from './useAtlasSelectionTray'
2728
import { useAtlasSlotDrag } from './useAtlasSlotDrag'
2829
import { AtlasSlotDragLine } from './AtlasSlotDragLine'
@@ -309,6 +310,8 @@ function AtlasBoardInner({ cards, allCards, kinds, links, linkKinds, notes, pare
309310
setNodes(sel.length > 0 ? allNodes.map((n) => (sel.includes(n.id) ? { ...n, selected: true } : n)) : allNodes)
310311
}, [allNodes, setNodes, selection.selectedIDsRef])
311312

313+
useAtlasSelectAll({ cards, notes, setNodes })
314+
312315
const { trayRef, hasSelection: haveSelection, onGroup: onTrayGroup, onDelete: onTrayDelete } = useAtlasSelectionTray({ selectedCards: selection.selectedCards, selectedNotes: selection.selectedNotes, clearSelection: selection.clearSelection, setNodes, onDeleteSelection, onGroupSelection, onUnflip: () => setFlippedID(null) })
313316

314317
// Every re-root (drill in, breadcrumb out, jump) settles the new

frontend/src/atlas/AtlasFolderImport.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { FolderScanEntry, FolderScanResult } from '../shared/bindings'
99
import { refreshAtlas } from './atlasStore'
1010
import { folderScanEntryDepth, groupFolderScanEntries, type FolderScanGroup } from './atlasFolderScanGrouping'
1111
import { useAtlasFolderImportRequestStore } from './atlasFolderImportRequest'
12+
import { useUISignalStore } from '../shared/uiSignalStore'
1213
import runbookStyles from '../shared/ListCard.module.css'
1314
import styles from './AtlasFolderImport.module.css'
1415

@@ -94,6 +95,18 @@ export function AtlasFolderImport({ viewedID, kinds }: { viewedID: string; kinds
9495
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the request's own token, scanRoot reads current kinds/viewedID via closure at fire time same as every other request-token effect in atlas/
9596
}, [folderImportRequest])
9697

98+
// atlas.addFromFolder's own signal (shared/atlasBoardCommands.ts): a
99+
// palette/keyboard invocation runs the SAME PickFolder flow the
100+
// toolbar's own "Add cards from a folder" button does.
101+
const addFromFolderRequest = useUISignalStore((s) => s.atlasAddFromFolderRequest)
102+
const lastAddFromFolderRequest = useRef(addFromFolderRequest)
103+
useEffect(() => {
104+
if (addFromFolderRequest === lastAddFromFolderRequest.current) return
105+
lastAddFromFolderRequest.current = addFromFolderRequest
106+
void startPick()
107+
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the signal tick alone, startPick reads current viewedID via closure at fire time same as every other request-token effect in atlas/
108+
}, [addFromFolderRequest])
109+
97110
const toggleEntry = (relPath: string, checked: boolean) => {
98111
setAccepted((prev) => {
99112
const next = new Set(prev)

frontend/src/atlas/AtlasLensControl.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useState } from 'react'
1+
import { useEffect, useRef, useState } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { Button, Checkbox, CheckboxGroup, Dialog, FormControl, SegmentedControl } from '@primer/react'
44
import { FilterIcon } from '@primer/octicons-react'
55
import type { Kind } from '../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
6+
import { useUISignalStore } from '../shared/uiSignalStore'
67

78
// The per-space lens (docs/goals/0061): which Kinds stay visible among
89
// the viewed space's children, and the depth toggle (this level only vs
@@ -22,6 +23,17 @@ export function AtlasLensControl({ presentKinds, hiddenKindIDs, onChangeHidden,
2223
const [open, setOpen] = useState(false)
2324
const visibleIDs = presentKinds.map((k) => k.ID).filter((id) => !hiddenKindIDs.includes(id))
2425

26+
// atlas.lens's own signal (shared/atlasBoardCommands.ts): a palette/
27+
// keyboard invocation opens the SAME dialog the toolbar's own lens
28+
// button does.
29+
const lensOpenRequest = useUISignalStore((s) => s.atlasLensOpenRequest)
30+
const lastLensOpenRequest = useRef(lensOpenRequest)
31+
useEffect(() => {
32+
if (lensOpenRequest === lastLensOpenRequest.current) return
33+
lastLensOpenRequest.current = lensOpenRequest
34+
setOpen(true)
35+
}, [lensOpenRequest])
36+
2537
return (
2638
<>
2739
<Button leadingVisual={FilterIcon} size="small" variant="invisible" data-testid="atlas-lens-open" onClick={() => setOpen(true)}>

frontend/src/atlas/AtlasSpaceShareMenu.tsx

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useTranslation } from 'react-i18next'
22
import { ActionList, ActionMenu } from '@primer/react'
33
import { ShareIcon } from '@primer/octicons-react'
4-
import { AtlasService } from '../shared/bindings'
4+
import { atlasSpaceShareActions } from './atlasSpaceShareActions'
55

66
// The space toolbar's own share affordance (goal 0063, ADR-0038): the
77
// viewed space's mirror folder (reveal turns it into a STANDING
@@ -19,20 +19,7 @@ export function AtlasSpaceShareMenu({ spaceID, onError }: {
1919
onError: (message: string) => void
2020
}) {
2121
const { t } = useTranslation('atlas')
22-
23-
const revealFolder = () => {
24-
AtlasService.RevealSpaceFolder(spaceID).catch((err) => onError(String(err)))
25-
}
26-
const bundleContext = (withAttachments: boolean) => {
27-
AtlasService.SpaceBundleContext(spaceID, withAttachments)
28-
.then((text) => navigator.clipboard.writeText(text))
29-
.catch((err) => onError(String(err)))
30-
}
31-
const copyLinks = () => {
32-
AtlasService.SpaceLinksList(spaceID)
33-
.then((text) => navigator.clipboard.writeText(text))
34-
.catch((err) => onError(String(err)))
35-
}
22+
const { revealFolder, bundleContext, copyLinks } = atlasSpaceShareActions(spaceID, onError)
3623

3724
return (
3825
<ActionMenu>

0 commit comments

Comments
 (0)