Skip to content

Commit 4793213

Browse files
committed
File list: Shift+nav uses toggle-and-fill model
- Each Shift+arrow/Page/Home/End independently toggles the cursor's old item, then sets every item the cursor jumps over to that state. The landing item is included only when the jump overflowed (intended distance > actual distance because of a list-boundary clamp). - Mouse Shift+click keeps the prior anchor/end semantics (renamed to `handleShiftMouseNavigation`); keyboard goes through the new `handleShiftKeyboardNavigation(oldCursor, newCursor, overflow, hasParent)`. - `..` is never selected; with cursor on `..`, keyboard fills default to "select" so Shift+End from `..` selects everything else. - `handleNavigationShortcut` and `BriefList.handleKeyNavigation` now return `{ newIndex, overflow }`. Home/End always overflow; Page/Brief Left/Right overflow when clamped. - Full-mode Shift+Left/Right acts as Shift+Home/End (always overflow). - Intentionally asymmetric: Shift+Down 3× then Shift+Up 3× doesn't restore the start — each press independently toggles.
1 parent 640c333 commit 4793213

9 files changed

Lines changed: 330 additions & 100 deletions

File tree

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,17 @@ Dual-pane file explorer with keyboard-driven navigation, file selection, sorting
1010
- **Insert**: toggle selection at cursor and move cursor down (Total Commander style). `..` isn't selectable, but the
1111
cursor still advances. At the last row the cursor stays put. No physical Insert key on Apple keyboards — users can
1212
remap via Karabiner-Elements, plug in a PC USB keyboard, or rebind in Settings → Shortcuts.
13-
- **Shift+click / Shift+arrow**: range selection with anchor (A) and end (B). If anchor already selected, range
13+
- **Shift+click**: mouse range selection with anchor (A) and end (B). If anchor was already selected, the range
1414
deselects.
15+
- **Shift+arrow / Shift+Page / Shift+Home/End / Shift+Left/Right (Brief)**: keyboard toggle-and-fill. Toggles the item
16+
at the cursor's _old_ position, then sets (not toggles) every item the cursor jumps over to that toggled state. The
17+
landing item is included only when the jump **overflowed** (intended distance > actual distance because of a list
18+
boundary clamp). Home/End always overflow; arrows overflow when pressed at a boundary (no movement);
19+
PageUp/PageDown/Brief Left/Right overflow when clamped. Full-mode Shift+Left/Right behave like Shift+Home/End. The
20+
model is intentionally asymmetric: Shift+Down 3× then Shift+Up 3× does NOT restore the start state — each press
21+
independently toggles the cursor's item.
1522
- **Cmd+A / Cmd+Shift+A**: select all / deselect all
16-
- **".." entry can't be selected**
23+
- **".." entry can't be selected**: keyboard fills from `..` default to "select" (so Shift+End from `..` selects).
1724
- **Cleared on navigation**: selection is per-directory
1825

1926
### Implementation
@@ -40,7 +47,8 @@ Dual-pane file explorer with keyboard-driven navigation, file selection, sorting
4047
### Gotchas
4148

4249
- **Parent offset**: when `hasParent`, frontend indices = backend indices + 1
43-
- **Range shrinking**: moving cursor back toward anchor removes items no longer in range
50+
- **Range shrinking (mouse Shift+click only)**: moving cursor back toward anchor removes items no longer in range. The
51+
keyboard path is stateless (toggle-and-fill) and doesn't shrink.
4452
- **Optimization flag**: `allSelected: true` avoids sending 500k indices over IPC
4553
- **`allSelected` + cancel**: calls `selectAll()` for move/delete/trash (source listing changed), leaves untouched for
4654
copy (source listing unchanged)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ Meta+Home/End is intentionally not handled (passes to OS). Returns `null` for un
112112

113113
Brief PageUp/PageDown lands on the **bottom row** of the target column (TUI convention).
114114

115+
`NavigationResult` also carries an `overflow: boolean` field: `true` when the requested step was clamped at a list
116+
boundary (intended distance > actual distance). Home/End are always overflow. PageUp/PageDown are overflow when the page
117+
step would cross 0 or `totalCount - 1`. Callers wiring keyboard Shift+nav use this to decide whether to include the
118+
landing item in the toggle-and-fill range — see `file-explorer/CLAUDE.md` § Selection.
119+
115120
## `VolumeBreadcrumb.svelte`
116121

117122
Pure presentational component. Reads the volume list from the shared `volume-store.svelte.ts` (no fetching, no event

apps/desktop/src/lib/file-explorer/navigation/keyboard-shortcuts.test.ts

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ describe('handleNavigationShortcut', () => {
2222
totalCount: 100,
2323
}
2424

25-
it('handles Option+ArrowUp as Home', () => {
25+
it('handles Option+ArrowUp as Home (always overflow)', () => {
2626
const event = createKeyboardEvent('ArrowUp', { altKey: true })
2727
const result = handleNavigationShortcut(event, context)
28-
expect(result).toEqual({ newIndex: 0, handled: true })
28+
expect(result).toEqual({ newIndex: 0, handled: true, overflow: true })
2929
})
3030

31-
it('handles Home key', () => {
31+
it('handles Home key (always overflow)', () => {
3232
const event = createKeyboardEvent('Home')
3333
const result = handleNavigationShortcut(event, context)
34-
expect(result).toEqual({ newIndex: 0, handled: true })
34+
expect(result).toEqual({ newIndex: 0, handled: true, overflow: true })
3535
})
3636

3737
it('does not handle Home with metaKey', () => {
@@ -47,16 +47,16 @@ describe('handleNavigationShortcut', () => {
4747
totalCount: 100,
4848
}
4949

50-
it('handles Option+ArrowDown as End', () => {
50+
it('handles Option+ArrowDown as End (always overflow)', () => {
5151
const event = createKeyboardEvent('ArrowDown', { altKey: true })
5252
const result = handleNavigationShortcut(event, context)
53-
expect(result).toEqual({ newIndex: 99, handled: true })
53+
expect(result).toEqual({ newIndex: 99, handled: true, overflow: true })
5454
})
5555

56-
it('handles End key', () => {
56+
it('handles End key (always overflow)', () => {
5757
const event = createKeyboardEvent('End')
5858
const result = handleNavigationShortcut(event, context)
59-
expect(result).toEqual({ newIndex: 99, handled: true })
59+
expect(result).toEqual({ newIndex: 99, handled: true, overflow: true })
6060
})
6161

6262
it('does not handle End with metaKey', () => {
@@ -72,12 +72,12 @@ describe('handleNavigationShortcut', () => {
7272
}
7373
const event = createKeyboardEvent('End')
7474
const result = handleNavigationShortcut(event, emptyContext)
75-
expect(result).toEqual({ newIndex: 0, handled: true })
75+
expect(result).toEqual({ newIndex: 0, handled: true, overflow: true })
7676
})
7777
})
7878

7979
describe('PageUp in Full mode', () => {
80-
it('moves up by visible items', () => {
80+
it('moves up by visible items (no overflow)', () => {
8181
const context: NavigationContext = {
8282
currentIndex: 50,
8383
totalCount: 100,
@@ -86,7 +86,7 @@ describe('handleNavigationShortcut', () => {
8686
const event = createKeyboardEvent('PageUp')
8787
const result = handleNavigationShortcut(event, context)
8888
// pageSize = max(1, 20 - 1) = 19
89-
expect(result).toEqual({ newIndex: 31, handled: true })
89+
expect(result).toEqual({ newIndex: 31, handled: true, overflow: false })
9090
})
9191

9292
it('uses default page size when visibleItems not provided', () => {
@@ -97,23 +97,35 @@ describe('handleNavigationShortcut', () => {
9797
const event = createKeyboardEvent('PageUp')
9898
const result = handleNavigationShortcut(event, context)
9999
// pageSize defaults to 20
100-
expect(result).toEqual({ newIndex: 30, handled: true })
100+
expect(result).toEqual({ newIndex: 30, handled: true, overflow: false })
101101
})
102102

103-
it('clamps to 0', () => {
103+
it('clamps to 0 (overflow=true)', () => {
104104
const context: NavigationContext = {
105105
currentIndex: 5,
106106
totalCount: 100,
107107
visibleItems: 20,
108108
}
109109
const event = createKeyboardEvent('PageUp')
110110
const result = handleNavigationShortcut(event, context)
111-
expect(result).toEqual({ newIndex: 0, handled: true })
111+
expect(result).toEqual({ newIndex: 0, handled: true, overflow: true })
112+
})
113+
114+
it('exact boundary (raw = 0) is not overflow', () => {
115+
const context: NavigationContext = {
116+
currentIndex: 19,
117+
totalCount: 100,
118+
visibleItems: 20,
119+
}
120+
// pageSize = 19, raw = 19 - 19 = 0 → not clamped
121+
const event = createKeyboardEvent('PageUp')
122+
const result = handleNavigationShortcut(event, context)
123+
expect(result).toEqual({ newIndex: 0, handled: true, overflow: false })
112124
})
113125
})
114126

115127
describe('PageDown in Full mode', () => {
116-
it('moves down by visible items', () => {
128+
it('moves down by visible items (no overflow)', () => {
117129
const context: NavigationContext = {
118130
currentIndex: 50,
119131
totalCount: 100,
@@ -122,7 +134,7 @@ describe('handleNavigationShortcut', () => {
122134
const event = createKeyboardEvent('PageDown')
123135
const result = handleNavigationShortcut(event, context)
124136
// pageSize = max(1, 20 - 1) = 19
125-
expect(result).toEqual({ newIndex: 69, handled: true })
137+
expect(result).toEqual({ newIndex: 69, handled: true, overflow: false })
126138
})
127139

128140
it('uses default page size when visibleItems not provided', () => {
@@ -132,19 +144,30 @@ describe('handleNavigationShortcut', () => {
132144
}
133145
const event = createKeyboardEvent('PageDown')
134146
const result = handleNavigationShortcut(event, context)
135-
// pageSize defaults to 20
136-
expect(result).toEqual({ newIndex: 70, handled: true })
147+
expect(result).toEqual({ newIndex: 70, handled: true, overflow: false })
137148
})
138149

139-
it('clamps to totalCount - 1', () => {
150+
it('clamps to totalCount - 1 (overflow=true)', () => {
140151
const context: NavigationContext = {
141152
currentIndex: 95,
142153
totalCount: 100,
143154
visibleItems: 20,
144155
}
145156
const event = createKeyboardEvent('PageDown')
146157
const result = handleNavigationShortcut(event, context)
147-
expect(result).toEqual({ newIndex: 99, handled: true })
158+
expect(result).toEqual({ newIndex: 99, handled: true, overflow: true })
159+
})
160+
161+
it('exact boundary (raw = totalCount-1) is not overflow', () => {
162+
const context: NavigationContext = {
163+
currentIndex: 80,
164+
totalCount: 100,
165+
visibleItems: 20,
166+
}
167+
// pageSize = 19, raw = 80 + 19 = 99 = lastIndex → not clamped
168+
const event = createKeyboardEvent('PageDown')
169+
const result = handleNavigationShortcut(event, context)
170+
expect(result).toEqual({ newIndex: 99, handled: true, overflow: false })
148171
})
149172
})
150173

@@ -153,7 +176,7 @@ describe('handleNavigationShortcut', () => {
153176
// itemsPerColumn = 10, visibleColumns = 3
154177
// Layout: Col0[0-9], Col1[10-19], Col2[20-29], Col3[30-39], etc.
155178

156-
it('moves left by visible columns minus 1', () => {
179+
it('moves left by visible columns minus 1 (no overflow)', () => {
157180
const context: NavigationContext = {
158181
currentIndex: 35, // Column 3, row 5
159182
totalCount: 50,
@@ -165,10 +188,10 @@ describe('handleNavigationShortcut', () => {
165188
// columnsToMove = max(1, 3 - 1) = 2
166189
// currentColumn = 3, targetColumn = 1
167190
// targetColumnStart = 10, bottommost = min(49, 10 + 10 - 1) = 19
168-
expect(result).toEqual({ newIndex: 19, handled: true })
191+
expect(result).toEqual({ newIndex: 19, handled: true, overflow: false })
169192
})
170193

171-
it('jumps to first item when near start', () => {
194+
it('jumps to first item when near start (overflow=true)', () => {
172195
const context: NavigationContext = {
173196
currentIndex: 5, // Column 0, row 5
174197
totalCount: 50,
@@ -178,10 +201,10 @@ describe('handleNavigationShortcut', () => {
178201
const event = createKeyboardEvent('PageUp')
179202
const result = handleNavigationShortcut(event, context)
180203
// targetColumn would be -2, which is <= 0, so jump to 0
181-
expect(result).toEqual({ newIndex: 0, handled: true })
204+
expect(result).toEqual({ newIndex: 0, handled: true, overflow: true })
182205
})
183206

184-
it('jumps to first item from column 1', () => {
207+
it('jumps to first item from column 1 (overflow=true)', () => {
185208
const context: NavigationContext = {
186209
currentIndex: 15, // Column 1, row 5
187210
totalCount: 50,
@@ -191,12 +214,12 @@ describe('handleNavigationShortcut', () => {
191214
const event = createKeyboardEvent('PageUp')
192215
const result = handleNavigationShortcut(event, context)
193216
// targetColumn = 1 - 2 = -1, which is <= 0
194-
expect(result).toEqual({ newIndex: 0, handled: true })
217+
expect(result).toEqual({ newIndex: 0, handled: true, overflow: true })
195218
})
196219
})
197220

198221
describe('PageDown in Brief mode', () => {
199-
it('moves right by visible columns minus 1', () => {
222+
it('moves right by visible columns minus 1 (no overflow)', () => {
200223
const context: NavigationContext = {
201224
currentIndex: 15, // Column 1, row 5
202225
totalCount: 50,
@@ -209,10 +232,10 @@ describe('handleNavigationShortcut', () => {
209232
// currentColumn = 1, targetColumn = 3
210233
// totalColumns = ceil(50/10) = 5, so 3 < 4 (last column index)
211234
// targetColumnStart = 30, bottommost = min(49, 30 + 10 - 1) = 39
212-
expect(result).toEqual({ newIndex: 39, handled: true })
235+
expect(result).toEqual({ newIndex: 39, handled: true, overflow: false })
213236
})
214237

215-
it('jumps to last item when near end', () => {
238+
it('jumps to last item when near end (overflow=true)', () => {
216239
const context: NavigationContext = {
217240
currentIndex: 35, // Column 3, row 5
218241
totalCount: 50,
@@ -222,10 +245,10 @@ describe('handleNavigationShortcut', () => {
222245
const event = createKeyboardEvent('PageDown')
223246
const result = handleNavigationShortcut(event, context)
224247
// totalColumns = 5, targetColumn = 3 + 2 = 5 >= 4 (last column index)
225-
expect(result).toEqual({ newIndex: 49, handled: true })
248+
expect(result).toEqual({ newIndex: 49, handled: true, overflow: true })
226249
})
227250

228-
it('handles partial last column', () => {
251+
it('handles partial last column (overflow=true)', () => {
229252
const context: NavigationContext = {
230253
currentIndex: 25, // Column 2, row 5
231254
totalCount: 45, // Last column only has 5 items
@@ -236,7 +259,7 @@ describe('handleNavigationShortcut', () => {
236259
const result = handleNavigationShortcut(event, context)
237260
// totalColumns = ceil(45/10) = 5, targetColumn = 2 + 2 = 4 (last column)
238261
// 4 >= 4, so jump to last item
239-
expect(result).toEqual({ newIndex: 44, handled: true })
262+
expect(result).toEqual({ newIndex: 44, handled: true, overflow: true })
240263
})
241264
})
242265

0 commit comments

Comments
 (0)