Skip to content

Commit 719e4f9

Browse files
committed
File list: Insert toggles selection and advances cursor
- Add `selection.toggleAndDown` command on the `Insert` shortcut (Total Commander style). `..` isn't selectable, but the cursor still advances; at the last row it toggles without moving. - Drive-by: wire the existing `selection.toggle` palette entry to the actual handler — clicking "Toggle selection" in the palette previously did nothing. - Bugfix: `selection.toggleAt`, `handleShiftNavigation`, and `selectRange` weren't firing `onChanged`, so Space / Insert / Shift+arrows / range-click silently mutated selection without notifying MCP. `cmdr://state` reported `selected: 0` while the UI showed selected rows. - Bugfix: cursor changes via keyboard nav (arrows, Home/End, PageUp/Down, Insert-advance) bypassed MCP sync — only `setCursorIndex` (the MCP `move_cursor` path) synced. Now a single `$effect` on `cursorIndex` in `FilePane` calls `debouncedSyncMcp.call()` so every mutation surfaces. - Tests: 3 new Insert behavior cases in `selection-consistency.test.ts` (regular file, `..`, last row), plus `onChanged`-fires assertions in `selection-state.test.ts` for `toggleAt`, `selectRange`, and `handleShiftNavigation`. - Stops Space/Insert event propagation past the FilePane handler so the new dispatch case doesn't double-fire on keypress. - Docs: update file-explorer `CLAUDE.md` selection bullet and roadmap copy.
1 parent 432d13f commit 719e4f9

10 files changed

Lines changed: 283 additions & 5 deletions

File tree

apps/desktop/src/lib/commands/command-registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,14 @@ export const commands: Command[] = [
423423
showInPalette: true,
424424
shortcuts: ['Space'],
425425
},
426+
{
427+
id: 'selection.toggleAndDown',
428+
name: 'Toggle selection and move down',
429+
scope: 'Main window/File list',
430+
showInPalette: true,
431+
shortcuts: ['Insert'],
432+
description: 'Selects or deselects the file under the cursor, then moves down (Total Commander style)',
433+
},
426434
{
427435
id: 'selection.selectAll',
428436
name: 'Select all',

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ Dual-pane file explorer with keyboard-driven navigation, file selection, sorting
77
### User interaction
88

99
- **Space**: toggle selection at cursor
10+
- **Insert**: toggle selection at cursor and move cursor down (Total Commander style). `..` isn't selectable, but the
11+
cursor still advances. At the last row the cursor stays put. No physical Insert key on Apple keyboards — users can
12+
remap via Karabiner-Elements, plug in a PC USB keyboard, or rebind in Settings → Shortcuts.
1013
- **Shift+click / Shift+arrow**: range selection with anchor (A) and end (B). If anchor already selected, range
1114
deselects.
1215
- **Cmd+A / Cmd+Shift+A**: select all / deselect all

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2079,7 +2079,7 @@
20792079
20802080
/**
20812081
* Handle selection action from MCP.
2082-
* @param action - The selection action (clear, selectAll, deselectAll, toggleAtCursor, selectRange)
2082+
* @param action - The selection action (clear, selectAll, deselectAll, toggleAtCursor, toggleAtCursorAndMoveDown, selectRange)
20832083
* @param startIndex - Start index for range selection
20842084
* @param endIndex - End index for range selection
20852085
*/
@@ -2098,6 +2098,9 @@
20982098
case 'toggleAtCursor':
20992099
paneRef.toggleSelectionAtCursor()
21002100
break
2101+
case 'toggleAtCursorAndMoveDown':
2102+
paneRef.toggleSelectionAndMoveDownAtCursor()
2103+
break
21012104
case 'selectRange':
21022105
if (startIndex !== undefined && endIndex !== undefined) {
21032106
paneRef.selectRange(startIndex, endIndex)

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,20 @@
611611
selection.toggleAt(cursorIndex, hasParent)
612612
}
613613
614+
/**
615+
* Toggle selection at cursor, then move cursor down by one row. Mirrors
616+
* the Total Commander Insert-key behavior. `toggleAt` no-ops on `..` (the
617+
* parent entry isn't selectable); the cursor still advances. At the last
618+
* row the selection toggles but the cursor stays put (no wrap-around).
619+
*/
620+
export function toggleSelectionAndMoveDownAtCursor(): void {
621+
selection.toggleAt(cursorIndex, hasParent)
622+
if (cursorIndex < effectiveTotalCount - 1) {
623+
const listRef = viewMode === 'brief' ? briefListRef : fullListRef
624+
applyNavigation(cursorIndex + 1, listRef, false)
625+
}
626+
}
627+
614628
export function selectRange(startIndex: number, endIndex: number): void {
615629
selection.selectRange(startIndex, endIndex, hasParent)
616630
}
@@ -1573,10 +1587,22 @@
15731587
// Space - toggle selection at cursor
15741588
if (e.key === ' ') {
15751589
e.preventDefault()
1590+
// Stop propagation so the document-level centralized dispatch doesn't
1591+
// re-fire `selection.toggle` (whose case in command-dispatch.ts exists
1592+
// for palette/MCP triggers).
1593+
e.stopPropagation()
15761594
selection.toggleAt(cursorIndex, hasParent)
15771595
15781596
return true
15791597
}
1598+
// Insert - toggle selection at cursor and move cursor down (Total Commander style)
1599+
if (e.key === 'Insert') {
1600+
e.preventDefault()
1601+
// See Space note above re: stopPropagation.
1602+
e.stopPropagation()
1603+
toggleSelectionAndMoveDownAtCursor()
1604+
return true
1605+
}
15801606
// Cmd+A - select all (Cmd+Shift+A - deselect all)
15811607
if (e.key === 'a' && e.metaKey) {
15821608
e.preventDefault()
@@ -1835,11 +1861,15 @@
18351861
}
18361862
})
18371863
1838-
// Re-fetch entry under the cursor when cursorIndex changes (debounced: status bar info can lag one frame)
1864+
// Re-fetch entry under the cursor when cursorIndex changes (debounced: status bar info can lag one frame).
1865+
// Also sync to MCP so cmdr://state reflects keyboard nav (arrows, Insert, PageUp/Down, Home/End, click-to-position).
1866+
// Previously, only listing changes and visible-range scrolls triggered the sync, so cursor moves within an
1867+
// already-rendered window stayed invisible to MCP-driven agents.
18391868
$effect(() => {
18401869
void cursorIndex // Track
18411870
if (listingId && !loading) {
18421871
debouncedFetchEntry.call()
1872+
debouncedSyncMcp.call()
18431873
}
18441874
})
18451875

apps/desktop/src/lib/file-explorer/pane/selection-consistency.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest'
99
import { mount, tick } from 'svelte'
1010
import FilePane from './FilePane.svelte'
1111
import { waitForUpdates, useMountTarget } from './integration-test-utils'
12+
import { findFileIndex } from '$lib/tauri-commands'
1213

1314
// ============================================================================
1415
// Mock setup (must be in each test file; Vitest hoists vi.mock calls)
@@ -93,13 +94,15 @@ vi.mock('$lib/tauri-commands', () => ({
9394
notifyDialogOpened: vi.fn().mockResolvedValue(undefined),
9495
notifyDialogClosed: vi.fn().mockResolvedValue(undefined),
9596
watchVolumeSpace: vi.fn().mockResolvedValue(undefined),
97+
getDirStatsBatch: vi.fn().mockResolvedValue({}),
9698
}))
9799

98100
vi.mock('$lib/icon-cache', async () => {
99101
const { writable } = await import('svelte/store')
100102
return {
101103
getCachedIcon: vi.fn().mockReturnValue('/icons/file.png'),
102104
iconCacheVersion: writable(0),
105+
iconCacheCleared: writable(0),
103106
prefetchIcons: vi.fn().mockResolvedValue(undefined),
104107
}
105108
})
@@ -130,6 +133,11 @@ vi.mock('$lib/settings/reactive-settings.svelte', () => ({
130133
getUseAppIconsForDocuments: vi.fn().mockReturnValue(true),
131134
getSizeDisplayMode: vi.fn().mockReturnValue('smart'),
132135
getNetworkEnabled: vi.fn().mockReturnValue(true),
136+
getSizeMismatchWarning: vi.fn().mockReturnValue(false),
137+
getStripedRows: vi.fn().mockReturnValue(false),
138+
getBriefColumnWidthMode: vi.fn().mockReturnValue('auto'),
139+
getBriefColumnWidthMaxPx: vi.fn().mockReturnValue(400),
140+
getIsCmdrGold: vi.fn().mockReturnValue(false),
133141
}))
134142

135143
vi.mock('$lib/drag-drop', () => ({ startDragTracking: vi.fn() }))
@@ -348,6 +356,186 @@ describe('Selection state consistency', () => {
348356
expect(getSelectedIndices()).toEqual([])
349357
})
350358

359+
it('Insert key toggles selection and moves cursor down', async () => {
360+
const component = mount(FilePane, {
361+
target: getTarget(),
362+
props: {
363+
initialPath: '/',
364+
volumeId: 'root',
365+
volumePath: '/',
366+
isFocused: true,
367+
showHiddenFiles: true,
368+
viewMode: 'brief',
369+
},
370+
})
371+
372+
await waitForUpdates(100)
373+
374+
type Api = {
375+
handleKeyDown: (e: KeyboardEvent) => void
376+
adoptListing: (s: {
377+
currentPath: string
378+
listingId: string
379+
totalCount: number
380+
cursorIndex: number
381+
selectedIndices: number[]
382+
lastSequence: number
383+
}) => void
384+
getSelectedIndices: () => number[]
385+
getCursorIndex: () => number
386+
}
387+
const c = component as unknown as Api
388+
389+
// Test mocks don't drive the `listing-complete` event, so totalCount stays
390+
// at 0 unless we adopt a listing. Seed: 10 rows, no parent, cursor at 0.
391+
c.adoptListing({
392+
currentPath: '/',
393+
listingId: 'mock-listing',
394+
totalCount: 10,
395+
cursorIndex: 0,
396+
selectedIndices: [],
397+
lastSequence: 0,
398+
})
399+
// Let the listingId-driven $effect (refetch totalCount + clamp cursor) settle
400+
// before we exercise the handler — otherwise its async chain races with the keypress.
401+
await waitForUpdates(50)
402+
403+
expect(c.getSelectedIndices()).toEqual([])
404+
expect(c.getCursorIndex()).toBe(0)
405+
406+
const insertEvent = new KeyboardEvent('keydown', { key: 'Insert', bubbles: true })
407+
c.handleKeyDown(insertEvent)
408+
await tick()
409+
410+
// Cursor row 0 is selected, cursor advanced to row 1
411+
expect(c.getSelectedIndices()).toEqual([0])
412+
expect(c.getCursorIndex()).toBe(1)
413+
414+
// Press again at row 1: select row 1, advance to row 2
415+
c.handleKeyDown(insertEvent)
416+
await tick()
417+
expect(c.getSelectedIndices().sort()).toEqual([0, 1])
418+
expect(c.getCursorIndex()).toBe(2)
419+
})
420+
421+
it('Insert on ".." advances cursor without selecting the parent entry', async () => {
422+
// initialPath !== volumePath produces a ".." entry at index 0
423+
const component = mount(FilePane, {
424+
target: getTarget(),
425+
props: {
426+
initialPath: '/test',
427+
volumeId: 'root',
428+
volumePath: '/',
429+
isFocused: true,
430+
showHiddenFiles: true,
431+
viewMode: 'brief',
432+
},
433+
})
434+
435+
await waitForUpdates(100)
436+
437+
type Api = {
438+
handleKeyDown: (e: KeyboardEvent) => void
439+
adoptListing: (s: {
440+
currentPath: string
441+
listingId: string
442+
totalCount: number
443+
cursorIndex: number
444+
selectedIndices: number[]
445+
lastSequence: number
446+
}) => void
447+
getSelectedIndices: () => number[]
448+
getCursorIndex: () => number
449+
}
450+
const c = component as unknown as Api
451+
452+
// 10 backend rows + ".." = 11 frontend rows; cursor on ".." (index 0).
453+
// Same race as the "last row" test: the listingId-driven $effect calls
454+
// findFileIndex (mocked to 0) and would yank the cursor off ".." onto
455+
// the first real row. Return null so the effect leaves cursor alone.
456+
vi.mocked(findFileIndex).mockResolvedValueOnce(null)
457+
c.adoptListing({
458+
currentPath: '/test',
459+
listingId: 'mock-listing',
460+
totalCount: 10,
461+
cursorIndex: 0,
462+
selectedIndices: [],
463+
lastSequence: 0,
464+
})
465+
await waitForUpdates(50)
466+
467+
expect(c.getCursorIndex()).toBe(0) // sitting on ".."
468+
expect(c.getSelectedIndices()).toEqual([])
469+
470+
const insertEvent = new KeyboardEvent('keydown', { key: 'Insert', bubbles: true })
471+
c.handleKeyDown(insertEvent)
472+
await tick()
473+
474+
// ".." stayed unselected; cursor moved down anyway
475+
expect(c.getSelectedIndices()).toEqual([])
476+
expect(c.getCursorIndex()).toBe(1)
477+
})
478+
479+
it('Insert at last row toggles but does not move cursor past end', async () => {
480+
const component = mount(FilePane, {
481+
target: getTarget(),
482+
props: {
483+
initialPath: '/',
484+
volumeId: 'root',
485+
volumePath: '/',
486+
isFocused: true,
487+
showHiddenFiles: true,
488+
viewMode: 'brief',
489+
},
490+
})
491+
492+
await waitForUpdates(100)
493+
494+
type Api = {
495+
handleKeyDown: (e: KeyboardEvent) => void
496+
adoptListing: (s: {
497+
currentPath: string
498+
listingId: string
499+
totalCount: number
500+
cursorIndex: number
501+
selectedIndices: number[]
502+
lastSequence: number
503+
}) => void
504+
getSelectedIndices: () => number[]
505+
getCursorIndex: () => number
506+
}
507+
const c = component as unknown as Api
508+
509+
// 10 rows, no parent, cursor on the last row (index 9).
510+
// The listingId-driven $effect would otherwise call findFileIndex (mocked to 0)
511+
// and yank the cursor back to 0 after we adopt cursor: 9.
512+
vi.mocked(findFileIndex).mockResolvedValueOnce(8)
513+
c.adoptListing({
514+
currentPath: '/',
515+
listingId: 'mock-listing',
516+
totalCount: 10,
517+
cursorIndex: 9,
518+
selectedIndices: [],
519+
lastSequence: 0,
520+
})
521+
await waitForUpdates(50)
522+
// After the effect settles, cursor lands wherever findFileIndex pointed.
523+
// We just need it pinned to the last row before pressing Insert.
524+
const setCursorIndex = (component as unknown as { setCursorIndex: (i: number) => Promise<void> }).setCursorIndex
525+
await setCursorIndex(9)
526+
await tick()
527+
528+
expect(c.getCursorIndex()).toBe(9)
529+
530+
const insertEvent = new KeyboardEvent('keydown', { key: 'Insert', bubbles: true })
531+
c.handleKeyDown(insertEvent)
532+
await tick()
533+
534+
// Last row toggled; cursor stayed put
535+
expect(c.getSelectedIndices()).toEqual([9])
536+
expect(c.getCursorIndex()).toBe(9)
537+
})
538+
351539
it('handleKeyUp export exists and handles Shift release', async () => {
352540
const component = mount(FilePane, {
353541
target: getTarget(),

apps/desktop/src/lib/file-explorer/pane/selection-state.svelte.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,16 @@ export function createSelectionState(options?: { onChanged?: () => void }) {
9090
// Can't select ".." entry
9191
if (hasParent && index === 0) return false
9292

93+
let result: boolean
9394
if (selectedIndices.has(index)) {
9495
selectedIndices.delete(index)
95-
return false
96+
result = false
9697
} else {
9798
selectedIndices.add(index)
98-
return true
99+
result = true
99100
}
101+
onChanged?.()
102+
return result
100103
}
101104

102105
function handleShiftNavigation(newIndex: number, cursorIndex: number, hasParent: boolean) {
@@ -109,6 +112,7 @@ export function createSelectionState(options?: { onChanged?: () => void }) {
109112

110113
// Apply the range selection
111114
applyRangeSelection(newIndex, hasParent)
115+
onChanged?.()
112116
}
113117

114118
function selectAll(hasParent: boolean, effectiveTotalCount: number) {
@@ -133,6 +137,7 @@ export function createSelectionState(options?: { onChanged?: () => void }) {
133137
selectedIndices.add(i)
134138
}
135139
clearRangeState()
140+
onChanged?.()
136141
}
137142

138143
function isAllSelected(hasParent: boolean, effectiveTotalCount: number): boolean {

0 commit comments

Comments
 (0)