Skip to content

Commit 38ebdc8

Browse files
committed
Bugfix: cursor lands on new folder, not the row below
- After F7/Shift+F4, `create_directory`/`create_file` queue a synthetic `directory-diff` with 50 ms trailing-window coalesce (diff_emitter). The optimistic `setCursorIndex` in `moveCursorToNewFolder` landed correctly, but when the deferred diff fired, `FilePane`'s diff handler ran the new entry's index through `adjustSelectionIndices` and shifted the cursor +1 (an `add` at the cursor's index pushes the cursor down). - Fix: `moveCursorToNewFolder` now also calls `paneRef.setPendingCursorName(name)`. The diff handler already reads `pendingCursorName` for the rename flow — when the diff lands, it re-pins the cursor by name and `return`s before the structural shift runs. Reuses the existing channel rather than adding a parallel one. - New `FilePaneAPI.setPendingCursorName(name)` exposes the field; current call site is `moveCursorToNewFolder`, also used by mkfile via the shared helper. - E2E regression guard: `file-operations.spec.ts › Create folder round-trip › cursor lands on the newly created folder` — picks a name that sorts between two existing dirs (`bulk` < `mid-cursor-…` < `sub-dir`) so an off-by-one shift surfaces as a different filename. Asserts cursor name 5× at 80 ms intervals. - `CLAUDE.md` gotcha added so the next agent doesn't re-introduce the race.
1 parent 91962d2 commit 38ebdc8

5 files changed

Lines changed: 71 additions & 0 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,17 @@
553553
return cursorIndex
554554
}
555555
556+
/**
557+
* Sets the "land the cursor on this name when the next diff applies" marker.
558+
* The diff handler already reads `renameFlow.pendingCursorName` for the rename
559+
* flow; mkdir/mkfile reuse the same channel so a freshly-created entry can
560+
* dodge the structural cursor shift `adjustSelectionIndices` would otherwise
561+
* apply when an `add` lands at or above the cursor's index.
562+
*/
563+
export function setPendingCursorName(name: string | null): void {
564+
renameFlow.pendingCursorName = name
565+
}
566+
556567
/**
557568
* Handles one keystroke for the type-to-jump feature. Appends to the buffer,
558569
* fires the IPC match, and (on the response) moves the cursor.

apps/desktop/src/lib/file-explorer/pane/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export interface FilePaneAPI {
2929
getNetworkCursorEntry(): NetworkCursorEntry | null
3030
setCursorIndex(index: number): Promise<void>
3131
getCursorIndex(): number
32+
/**
33+
* Queues a "land the cursor on this filename once the next directory-diff
34+
* applies" intent. Used by mkdir/mkfile/rename to defeat the structural
35+
* cursor-shift the diff handler would otherwise apply when an entry is
36+
* inserted at or above the cursor's index.
37+
*/
38+
setPendingCursorName(name: string | null): void
3239
isInNetworkView(): boolean
3340
hasParentEntry(): boolean
3441
getCurrentPath(): string

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,11 @@ When directory has parent entry shown at index 0, frontend indices are offset by
186186
`await`. Both paths converge on a local `kickOff()` helper guarded by a `started` flag, so `startOperation()`
187187
dispatches exactly once. The scan-error and scan-cancelled listeners also flip `started = true` as a terminal signal,
188188
so a late `scan-preview-complete` event can't dispatch an operation after we've errored or cancelled.
189+
- **mkdir/mkfile must set `paneRef.setPendingCursorName(name)` before the optimistic `setCursorIndex`**:
190+
`create_directory` / `create_file` queue a synthetic `directory-diff` through `diff_emitter::enqueue_diff` (50 ms
191+
trailing-window coalesce). The optimistic `setCursorIndex` in `moveCursorToNewFolder` lands the cursor correctly at
192+
the moment, but when the deferred diff fires, `FilePane`'s diff handler runs the new entry's index through
193+
`adjustSelectionIndices` and shifts the cursor +1 (an `add` at the cursor's index always pushes the cursor down).
194+
`setPendingCursorName` writes to the same `pendingCursorName` field the diff handler already checks for the rename
195+
flow: when the diff lands, it re-pins the cursor by name and `return`s before the structural shift runs. Regression
196+
guard: `file-operations.spec.ts › Create folder round-trip › cursor lands on the newly created folder`.

apps/desktop/src/lib/file-operations/mkdir/new-folder-operations.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export async function moveCursorToNewFolder(
3838
listen: ListenFn,
3939
findFileIndex: FindFileIndexFn,
4040
): Promise<void> {
41+
// Mark the new entry as the cursor target for the next directory-diff. When
42+
// the diff lands (~50 ms later via diff_emitter's trailing-window coalesce),
43+
// FilePane re-pins the cursor by name and skips the structural shift that
44+
// `adjustSelectionIndices` would otherwise apply for an `add` at the cursor's
45+
// index. Without this, the optimistic setCursorIndex below ends up shifted
46+
// one row down by the time the diff arrives.
47+
paneRef?.setPendingCursorName(folderName)
48+
4149
// Try to find the folder immediately: the directory-diff event often fires
4250
// before this listener is set up (the folder is created before onCreated runs).
4351
const tryMoveCursor = async (): Promise<boolean> => {

apps/desktop/test/e2e-playwright/file-operations.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,43 @@ test.describe('Create folder round-trip', () => {
211211
expect(fs.existsSync(folderPath)).toBe(true)
212212
expect(fs.statSync(folderPath).isDirectory()).toBe(true)
213213
})
214+
215+
test('cursor lands on the newly created folder', async ({ tauriPage }) => {
216+
// The synthetic directory-diff for the new entry is emitted with a 50 ms
217+
// trailing-window coalesce (see listing/diff_emitter.rs). After mkdir, the
218+
// optimistic setCursorIndex landed the cursor correctly, but the deferred
219+
// diff then ran through the structural cursor-adjustment path and shifted
220+
// it one row down. This assertion is the regression guard for that race.
221+
await ensureAppReady(tauriPage)
222+
223+
// Pick a name that sorts in the middle of the existing dirs so an off-by-one
224+
// cursor shift produces a different filename. Fixture dirs sorted Asc:
225+
// bulk, sub-dir. "mid-..." sorts between them.
226+
const folderName = `mid-cursor-folder-${String(Date.now())}`
227+
228+
await tauriPage.keyboard.press('F7')
229+
await tauriPage.waitForSelector(MKDIR_DIALOG, 5000)
230+
await tauriPage.waitForSelector(`${MKDIR_DIALOG} .name-input`, 3000)
231+
await tauriPage.fill(`${MKDIR_DIALOG} .name-input`, folderName)
232+
await pollUntil(tauriPage, async () => tauriPage.isEnabled(`${MKDIR_DIALOG} .btn-primary`), 2000)
233+
await tauriPage.click(`${MKDIR_DIALOG} .btn-primary`)
234+
235+
// Dialog closes and the listing renders the new folder. fileExistsInFocusedPane
236+
// polls the DOM, so by the time it returns true the diff has been applied.
237+
await pollUntil(tauriPage, async () => !(await tauriPage.isVisible('.modal-overlay')), 5000)
238+
await pollUntil(tauriPage, async () => fileExistsInFocusedPane(tauriPage, folderName), 5000)
239+
240+
// Cursor must be on the new folder and stay there. Five checks at 80 ms
241+
// intervals cover both the immediate-post-diff window and any later
242+
// re-render that might shift the cursor.
243+
for (let i = 0; i < 5; i++) {
244+
const cursorName = await tauriPage.evaluate<string>(
245+
`document.querySelector('.file-pane.is-focused .file-entry.is-under-cursor')?.getAttribute('data-filename') || ''`,
246+
)
247+
expect(cursorName, `cursor moved off ${folderName} on iteration ${String(i)}`).toBe(folderName)
248+
if (i < 4) await new Promise((r) => setTimeout(r, 80))
249+
}
250+
})
214251
})
215252

216253
test.describe('View mode toggle', () => {

0 commit comments

Comments
 (0)