Skip to content

Commit 8c90428

Browse files
committed
Search + Select: keep your typed text across mode switches and reveal the first selected file
Two small UX wins for the query dialogs, both shared so Search and Select get them: - **Term carry-over on mode switch.** Switching mode (filename / regex / AI) used to blank the bar whenever the destination mode had no prior text, silently dropping whatever you'd typed. Now `switchMode` seeds an EMPTY target buffer with the outgoing term, so your words follow you across the switch (including AI↔non-AI, carried raw: a glob becomes an AI prompt, a prompt becomes a glob). A non-empty target buffer is your own prior text for that mode and is never overwritten. Precedence on an empty target: the AI's structured `aiPatternProbe` pattern wins (the post-AI editing handoff), then the raw carry-over as fallback. - **Cursor jumps to the first selected file after a Select.** Committing a "Select files…" run now moves the focused pane's cursor to the first newly-selected row and scrolls it into view (reusing `setCursorIndex`), so you land looking at your selection instead of wherever the cursor sat. Deselect leaves the cursor put (nothing fresh to reveal). The target goes through `firstSelectedIndex(idxs, hasParent)`, the same `..`-skip `selection.applyIndices` uses, so it can never land on the synthetic parent row. Pure `firstSelectedIndex` helper + colocated tests; `switchMode` carry-over pinned in `query-filter-state.test.ts` (both directions, non-overwrite guard, probe-wins precedence). Existing mode-switch tests updated to the new carry-over behavior. Docs: `query-ui/DETAILS.md` (carry-over rule + precedence), `pane/CLAUDE.md` + `DETAILS.md` (cursor jump, deselect exception, index-space note).
1 parent 600b23c commit 8c90428

11 files changed

Lines changed: 235 additions & 23 deletions

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ Full file table, conventions, and decision rationale: [DETAILS.md](DETAILS.md).
3333
- **`capabilitiesFor` / `volumeKindOf` must stay TOTAL** (never return `undefined`): unknown real ids fall to the
3434
`local` default; the two virtual ids short-circuit first. The tint classifier `volumeKindFor` keeps its own body and
3535
output so tint stays byte-stable; never feed the `local` default back into tinting.
36+
- **`FilePane.applyIndices` jumps the cursor on SELECT only.** A committed select moves the cursor to the first
37+
newly-selected row and scrolls it into view (via `setCursorIndex`); a deselect (`'remove'`) leaves the cursor put. The
38+
target is `firstSelectedIndex(idxs, hasParent)`, which applies the SAME `hasParent && i === 0` skip
39+
`selection.applyIndices` uses, so it never lands on `..`. Don't reach for raw `idxs[0]`; it can be the `..` row.
3640
- **Snapshot pane (`volumeId === 'search-results'`) couples two integration points**: `computeHasParent` returns `false`
3741
(no `..` row) AND `isCrossVolumeNavigation` routes any real-path nav through the volume-change machinery. Skipping
3842
either breaks selection (off-by-one) or poisons the pane with a `search-results` volumeId + a real path.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ list).
6767
| `navigate.ts` | `navigate(intent, deps)` transaction: the single coordinator-level pane-nav entry |
6868
| `snapshot-pane-navigation.ts` | `isCrossVolumeNavigation` — snapshot-volume → real-path triggers volume switch |
6969
| `has-parent.ts` | `computeHasParent({ isSearchResultsView, currentPath, effectiveVolumeRoot })` |
70+
| `first-selected-index.ts` | `firstSelectedIndex(idxs, hasParent)` — post-select cursor-jump target, skips the `..` row |
7071
| `volume-capabilities.ts` | `VolumeKind` + frozen per-kind `VolumeCapabilities` table + `volumeKindOf` / `capabilitiesFor` |
7172
| `search-results-keys.ts` | Pure key→action dispatch for the flat snapshot pane |
7273
| `selection-dialog-keys.ts` | Classify `+` / `-` keypresses → open Selection dialog (Total Commander parity) |

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
import { handleNavigationShortcut } from '../navigation/keyboard-shortcuts'
101101
import { computeSearchPaneKeyAction } from './search-results-keys'
102102
import { computeHasParent } from './has-parent'
103+
import { firstSelectedIndex } from './first-selected-index'
103104
import { capabilitiesFor } from './volume-capabilities'
104105
import { openFileViewer } from '$lib/file-viewer/open-viewer'
105106
import { openInEditor } from '$lib/tauri-commands'
@@ -876,9 +877,21 @@
876877
* Bulk-apply indices to the selection (add or remove). Used by the Selection
877878
* dialog at commit time. Skips `..` per `hasParent`. Range anchor/end state
878879
* is untouched so the user's prior keyboard/mouse anchor survives.
880+
*
881+
* On a SELECT (`mode === 'add'`), the cursor jumps to the first newly-selected
882+
* file and scrolls into view, so the user lands looking at their selection
883+
* instead of wherever the cursor happened to sit. We derive the target through
884+
* the SAME `hasParent` skip `selection.applyIndices` uses (`firstSelectedIndex`),
885+
* so the cursor can never land on the synthetic `..` row. On a DESELECT
886+
* (`mode === 'remove'`) we leave the cursor put: there's nothing freshly
887+
* selected to reveal, and yanking the cursor onto a just-deselected row is odd.
879888
*/
880889
export function applyIndices(idxs: number[], mode: 'add' | 'remove'): void {
881890
selection.applyIndices(idxs, mode, hasParent)
891+
if (mode === 'add') {
892+
const target = firstSelectedIndex(idxs, hasParent)
893+
if (target !== null) void setCursorIndex(target)
894+
}
882895
}
883896
884897
/**
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { firstSelectedIndex } from './first-selected-index'
3+
import { createSelectionState } from './selection-state.svelte'
4+
5+
describe('firstSelectedIndex', () => {
6+
it('returns the lowest index when there is no parent row', () => {
7+
expect(firstSelectedIndex([2, 5, 7], false)).toBe(2)
8+
})
9+
10+
it('never lands on the `..` row: skips a leading index 0 when hasParent', () => {
11+
// A `*`-style match can include the synthetic `..` at 0; the cursor must skip it.
12+
expect(firstSelectedIndex([0, 3, 4], true)).toBe(3)
13+
})
14+
15+
it('keeps index 0 when there is NO parent row (0 is a real file)', () => {
16+
expect(firstSelectedIndex([0, 3, 4], false)).toBe(0)
17+
})
18+
19+
it('returns the only selectable index past the parent row', () => {
20+
expect(firstSelectedIndex([0, 9], true)).toBe(9)
21+
})
22+
23+
it('returns null for an empty set', () => {
24+
expect(firstSelectedIndex([], true)).toBeNull()
25+
expect(firstSelectedIndex([], false)).toBeNull()
26+
})
27+
28+
it('returns null when the only entry is the `..` row under hasParent', () => {
29+
expect(firstSelectedIndex([0], true)).toBeNull()
30+
})
31+
})
32+
33+
/**
34+
* Pins `FilePane.applyIndices`'s post-select cursor jump without mounting the component
35+
* (same approach as `has-parent.test.ts` pairing the pure helper with real `selectAll`).
36+
* Replicates the method body: apply to the real selection state, then move the cursor on
37+
* `add` only. `setCursorIndex` is the pane's cursor-move + scroll-into-view primitive, so a
38+
* call on it IS the scroll-into-view request.
39+
*/
40+
function runApplyIndices(
41+
idxs: number[],
42+
mode: 'add' | 'remove',
43+
hasParent: boolean,
44+
setCursorIndex: (index: number) => void,
45+
): void {
46+
const selection = createSelectionState()
47+
selection.applyIndices(idxs, mode, hasParent)
48+
if (mode === 'add') {
49+
const target = firstSelectedIndex(idxs, hasParent)
50+
if (target !== null) setCursorIndex(target)
51+
}
52+
}
53+
54+
describe('applyIndices post-select cursor jump (integration)', () => {
55+
it('moves the cursor to the first selected file on add, scrolling it into view', () => {
56+
const setCursorIndex = vi.fn()
57+
runApplyIndices([2, 5, 7], 'add', false, setCursorIndex)
58+
expect(setCursorIndex).toHaveBeenCalledTimes(1)
59+
expect(setCursorIndex).toHaveBeenCalledWith(2)
60+
})
61+
62+
it('lands on the first real file, never the `..` row, when hasParent', () => {
63+
const setCursorIndex = vi.fn()
64+
// The match included the synthetic `..` at index 0; the cursor must skip it.
65+
runApplyIndices([0, 4, 8], 'add', true, setCursorIndex)
66+
expect(setCursorIndex).toHaveBeenCalledWith(4)
67+
})
68+
69+
it('does NOT move the cursor on remove (deselect)', () => {
70+
const setCursorIndex = vi.fn()
71+
runApplyIndices([2, 5, 7], 'remove', false, setCursorIndex)
72+
expect(setCursorIndex).not.toHaveBeenCalled()
73+
})
74+
75+
it('does not move the cursor when an add selects nothing selectable', () => {
76+
const setCursorIndex = vi.fn()
77+
runApplyIndices([0], 'add', true, setCursorIndex) // only `..`, which is skipped
78+
expect(setCursorIndex).not.toHaveBeenCalled()
79+
})
80+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Pure helper for the post-select cursor jump in `FilePane.applyIndices`.
3+
*
4+
* After the Selection dialog commits an ADD, we move the pane's cursor to the first
5+
* newly-selected file and scroll it into view. The matched `idxs` are in the SAME
6+
* index space as the pane's selection set and cursor (snapshot indices, with the synthetic
7+
* `..` at index 0 when `hasParent`). `selection.applyIndices` skips index 0 under
8+
* `hasParent` (it never selects `..`); the cursor must land on the same first row it
9+
* actually selected, so we apply the identical skip here. Without it, an `idxs` that still
10+
* carried a leading `0` would jump the cursor onto the `..` row.
11+
*
12+
* `idxs` is already in sort order (the dialog's snapshot is sorted), so the first surviving
13+
* entry is the lowest selected file index. Returns `null` when nothing selectable remains.
14+
*/
15+
export function firstSelectedIndex(idxs: number[], hasParent: boolean): number | null {
16+
for (const i of idxs) {
17+
if (hasParent && i === 0) continue
18+
return i
19+
}
20+
return null
21+
}

apps/desktop/src/lib/query-ui/DETAILS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,28 @@ ONLY to the Search-only fields. Search's wrapper calls this right after the core
193193
The Search façade in `lib/search/search-state.svelte.ts` keeps a `recordAiTranslation({pattern, kind, label})`
194194
convenience that calls both methods in sequence.
195195

196+
### `switchMode` carries the term into an empty target buffer
197+
198+
Each mode (`ai` / `filename` / `regex`) owns its own `handTyped` buffer. `switchMode(target)` saves the bar's current
199+
text under the outgoing mode's slot, then restores the target's buffer. When the target buffer is **empty**, it seeds
200+
the bar with the **outgoing term** so the user's words follow them across the switch instead of vanishing. A
201+
**non-empty** target buffer is the user's own prior text for that mode and is never overwritten.
202+
203+
This carries across AI↔non-AI too, raw and unconverted: a glob switched into AI lands as a prompt, a prompt switched
204+
into filename lands as a glob. That's a deliberate semantic oddity (the text isn't re-interpreted), accepted because
205+
losing the user's words is worse than handing them text they may need to tweak.
206+
207+
**Precedence on an empty target buffer** (reconciling the carry-over with the AI-pattern probe):
208+
209+
1. `aiPatternProbe(target)` first. It returns the AI's structured, kind-correct pattern (filename gets the glob, regex
210+
gets the regex) and is the post-AI editing handoff (M6's "tweak what the agent did" loop depends on it). The raw
211+
carry-over must NOT clobber it.
212+
2. The outgoing term second, as the fallback when there's no probed pattern.
213+
214+
Selection wires `aiPatternProbe` to `null` (no Pattern chip), so for Selection the carry-over is the only seeder; Search
215+
wires it to its extras module. Pinned by `query-filter-state.test.ts` § "switchMode term carry-over" (both directions,
216+
the non-overwrite guard, and the probe-wins precedence).
217+
196218
## Shared UI behavior
197219

198220
Small contracts that apply to every consumer of the query UI:

apps/desktop/src/lib/query-ui/query-filter-state.svelte.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,14 +278,25 @@ export function createQueryFilterState(options: CreateQueryFilterStateOptions =
278278
function switchMode(targetMode: SearchMode): void {
279279
if (mode === targetMode) return
280280
// Preserve the user's current typing under the previous mode's slot before swapping.
281-
handTyped[mode] = query
281+
const outgoingTerm = query
282+
handTyped[mode] = outgoingTerm
282283
mode = targetMode
283-
// Restore the target mode's hand-typed value, or fall back to the AI pattern when it
284-
// matches the kind. The "other" mode's buffer stays whatever the user last typed.
284+
// Restore the target mode's hand-typed value. When that buffer is empty, seed it so the
285+
// user's text follows them across the switch instead of vanishing. Precedence on an empty
286+
// target buffer:
287+
// 1. The AI's structured pattern (`aiPatternProbe`) — a deliberate, kind-correct seed
288+
// (filename gets a glob, regex gets a regex). This is the post-AI handoff, so it must
289+
// NOT be clobbered by the raw carry-over below.
290+
// 2. The outgoing term — the bar's current text, carried verbatim (a glob into AI as a
291+
// prompt, a prompt into filename as a glob; raw text, semantic oddity accepted).
292+
// A non-empty target buffer is the user's own prior text for that mode; never overwrite it.
285293
let next = handTyped[targetMode]
286-
if (!next && targetMode !== 'ai') {
287-
const probed = aiPatternProbe(targetMode)
288-
if (probed) next = probed
294+
if (!next) {
295+
if (targetMode !== 'ai') {
296+
const probed = aiPatternProbe(targetMode)
297+
if (probed) next = probed
298+
}
299+
if (!next) next = outgoingTerm
289300
}
290301
query = next
291302
}

apps/desktop/src/lib/query-ui/query-filter-state.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,17 @@ describe('createQueryFilterState: buildBaseSearchQuery', () => {
144144
})
145145

146146
describe('createQueryFilterState: switchMode + per-mode buffers', () => {
147-
it('swaps the bar between mode buffers via switchMode', () => {
147+
it('swaps the bar between mode buffers via switchMode, carrying the term into an empty target', () => {
148148
const s = createQueryFilterState()
149149
s.setMode('filename')
150150
s.setQueryFromUserInput('*.pdf')
151+
// Target (regex) buffer is empty → the outgoing term follows the user across the switch.
151152
s.switchMode('regex')
152153
expect(s.getMode()).toBe('regex')
153-
expect(s.getQuery()).toBe('')
154+
expect(s.getQuery()).toBe('*.pdf')
154155

156+
// Now regex's buffer holds the carried '*.pdf'. Overwrite it, then switch back: filename's
157+
// own buffer ('*.pdf') is non-empty, so it's restored verbatim (no overwrite from regex).
155158
s.setQueryFromUserInput('foo.*bar')
156159
s.switchMode('filename')
157160
expect(s.getQuery()).toBe('*.pdf')
@@ -207,6 +210,59 @@ describe('createQueryFilterState: switchMode + per-mode buffers', () => {
207210
})
208211
})
209212

213+
describe('createQueryFilterState: switchMode term carry-over', () => {
214+
it('carries the outgoing term into an EMPTY target buffer (filename → regex)', () => {
215+
const s = createQueryFilterState()
216+
s.setMode('filename')
217+
s.setQueryFromUserInput('report*')
218+
s.switchMode('regex')
219+
expect(s.getQuery()).toBe('report*')
220+
})
221+
222+
it('carries the outgoing term across AI → filename when the target is empty', () => {
223+
const s = createQueryFilterState()
224+
s.setMode('ai')
225+
s.setQueryFromUserInput('all my invoices')
226+
// No recorded AI translation, no probe: the raw prompt carries into filename verbatim
227+
// (the accepted semantic oddity — a prompt landing in the filename bar as a glob).
228+
s.switchMode('filename')
229+
expect(s.getQuery()).toBe('all my invoices')
230+
})
231+
232+
it('carries the outgoing term across filename → AI when the AI buffer is empty', () => {
233+
const s = createQueryFilterState()
234+
s.setMode('filename')
235+
s.setQueryFromUserInput('*.png')
236+
s.switchMode('ai')
237+
expect(s.getQuery()).toBe('*.png')
238+
})
239+
240+
it('does NOT overwrite a NON-empty target buffer (both directions)', () => {
241+
const s = createQueryFilterState()
242+
// Seed both buffers with distinct hand-typed text. `switchMode` between them so `query`
243+
// stays coherent with the active mode (raw `setMode` would leave `query` stale).
244+
s.setMode('filename')
245+
s.setQueryFromUserInput('*.filename')
246+
s.switchMode('regex')
247+
s.setQueryFromUserInput('regex.*')
248+
// regex → filename: filename buffer is non-empty ('*.filename'), so it survives.
249+
s.switchMode('filename')
250+
expect(s.getQuery()).toBe('*.filename')
251+
// filename → regex: regex buffer is non-empty ('regex.*'), so it survives.
252+
s.switchMode('regex')
253+
expect(s.getQuery()).toBe('regex.*')
254+
})
255+
256+
it('lets the aiPatternProbe win over the raw carry-over on an empty target', () => {
257+
const s = createQueryFilterState()
258+
s.setAiPatternProbe((forMode) => (forMode === 'filename' ? '*.probed' : null))
259+
s.setMode('ai')
260+
s.setQueryFromUserInput('find pngs') // would carry over, but the probe is preferred
261+
s.switchMode('filename')
262+
expect(s.getQuery()).toBe('*.probed')
263+
})
264+
})
265+
210266
describe('createQueryFilterState: history filters round-trip', () => {
211267
it('applies and reads back size and date filters', () => {
212268
const s = createQueryFilterState()

apps/desktop/src/lib/search/SearchDialog.svelte.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -248,19 +248,19 @@ describe('SearchDialog mode shortcuts (AI on)', () => {
248248
cleanup()
249249
})
250250

251-
it("switching mode swaps the bar to the target mode's hand-typed buffer", async () => {
252-
// Each mode owns its own input buffer: switching from AI to filename
253-
// restores filename's last hand-typed
254-
// value (empty here), not the AI-mode contents. The AI bar's prompt stays
255-
// available via `getLastAiPrompt()` for the transparency strip.
251+
it("switching mode swaps the bar to the target mode's hand-typed buffer (carrying into an empty target)", async () => {
252+
// Each mode owns its own input buffer. Switching from AI to filename restores filename's
253+
// last hand-typed value; when that buffer is empty, the outgoing term carries across so
254+
// the user's words don't vanish (M5 carry-over). The AI prompt stays available via
255+
// `getLastAiPrompt()` for the transparency strip regardless.
256256
const { overlay, cleanup } = await mountDialog()
257257
setMode('ai')
258258
setQuery('big files')
259259
dispatchKey(overlay, '2', true)
260260
await tick()
261261
expect(getMode()).toBe('filename')
262-
// Filename's hand-typed buffer was empty, so the bar is empty after switch.
263-
expect(getQuery()).toBe('')
262+
// Filename's buffer was empty, so the outgoing 'big files' carries into the bar.
263+
expect(getQuery()).toBe('big files')
264264
cleanup()
265265
})
266266

apps/desktop/src/lib/search/search-state.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -423,14 +423,15 @@ describe('per-mode buffer', () => {
423423
clearSearchState()
424424
setMode('filename')
425425
setQueryFromUserInput('*.pdf')
426+
// Target (regex) buffer is empty → the outgoing term carries across (M5 carry-over).
426427
switchMode('regex')
427428
expect(getMode()).toBe('regex')
428-
expect(getQuery()).toBe('')
429+
expect(getQuery()).toBe('*.pdf')
429430

430431
setQueryFromUserInput('foo.*bar')
431432
switchMode('filename')
432433
expect(getMode()).toBe('filename')
433-
expect(getQuery()).toBe('*.pdf')
434+
expect(getQuery()).toBe('*.pdf') // filename's own non-empty buffer survives.
434435
switchMode('regex')
435436
expect(getQuery()).toBe('foo.*bar')
436437
})
@@ -446,8 +447,10 @@ describe('per-mode buffer', () => {
446447
expect(getMode()).toBe('filename')
447448
expect(getQuery()).toBe('*.pdf') // Glob → filename input.
448449

450+
// Regex's buffer is empty and the AI pattern is a glob (no regex probe), so the outgoing
451+
// filename term carries across rather than vanishing (M5 carry-over).
449452
switchMode('regex')
450-
expect(getQuery()).toBe('') // Regex's hand-typed buffer is still empty.
453+
expect(getQuery()).toBe('*.pdf')
451454
})
452455

453456
it('switching to AI restores the original prompt the user typed', async () => {

0 commit comments

Comments
 (0)