Skip to content

Commit 13b486a

Browse files
committed
FE: soft-refresh path for directory-diff, no more empty-pane flicker
The list components' single reset effect treated every `totalCount` change as a hard reset: wipe `cachedEntries`, wipe `columnWidths`, refetch from scratch. That fit cold context changes (nav, sort, hidden-files toggle), but `directory-diff` events also bump `totalCount` (and `FilePane` was also bumping `cacheGeneration` on every diff), so a bulk delete fired a destructive wipe per coalesced event. The pane went empty between the wipe and the async IPC response — visible flicker, and in brief mode the columns collapsed to a single name column until the new widths landed. Splits the path into two: - **Hard reset** (`cacheGeneration` bump): cold context only. Still wipes and refetches. `FilePane.refreshView` and `adoptListing` keep their existing `cacheGeneration++`. - **Soft refresh** (new `softRefreshTick` prop, bumped on every `directory-diff`): refetch the visible range in the background with the new `force` flag on `fetchVisibleRange`, then atomically replace `cachedEntries`. Existing rows stay on screen until the new ones land, so the burst is invisible to the user. `totalCount` is no longer in `shouldResetCache` — it changes on diffs and only needs a soft refresh; the `cacheGeneration` / `listingId` / `includeHidden` triad covers true cold resets. The same change applies in both `BriefList` and `FullList`. Brief mode's per-column width refetch also moves behind a 200 ms trailing throttle in `FilePane.scheduleColumnWidthRefetch`. A 10 k-file delete previously fired one `get_brief_column_text_widths` IPC per coalesced event; now it caps at ~5/sec with the final widths landing once the burst settles. Test plan: covered by `file-list-utils.test.ts` (reset is name+hidden+generation, not count) plus visual verification on a large local + MTP delete.
1 parent 5467485 commit 13b486a

5 files changed

Lines changed: 157 additions & 37 deletions

File tree

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

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -679,9 +679,34 @@
679679
renameFlow.cancelRename()
680680
}
681681
682-
// Cache generation counter - incremented to force list components to re-fetch
682+
// Cache generation counter — bumped on **cold context changes** (sort,
683+
// hidden-files toggle, explicit refresh, listing swap). The List components
684+
// treat this as a hard reset: wipe rendered entries and column widths,
685+
// refetch from scratch.
683686
let cacheGeneration = $state(0)
684687
688+
// Soft-refresh tick — bumped on every `directory-diff` event (bulk delete,
689+
// copy, rename). The List components refetch the visible range in the
690+
// background and atomically replace, keeping existing entries on screen
691+
// until the new ones land. This is what prevents the empty-pane flicker
692+
// that destructive `cacheGeneration` bumps caused mid-bulk-op.
693+
let softRefreshTick = $state(0)
694+
695+
// Throttle the brief-mode column-width refetch during diff bursts. Without
696+
// this, a 10 k-file delete fires one `get_brief_column_text_widths` IPC per
697+
// coalesced event (~20/sec), each forcing a layout reflow. ~200 ms trailing
698+
// means at most ~5 width recomputes/sec, with the final widths always
699+
// landing after the burst ends.
700+
let columnWidthRefetchTimer: ReturnType<typeof setTimeout> | null = null
701+
function scheduleColumnWidthRefetch(): void {
702+
if (viewMode !== 'brief') return
703+
if (columnWidthRefetchTimer !== null) return
704+
columnWidthRefetchTimer = setTimeout(() => {
705+
columnWidthRefetchTimer = null
706+
briefListRef?.refetchColumnWidths?.()
707+
}, 200)
708+
}
709+
685710
// noinspection JSUnusedGlobalSymbols -- Used dynamically
686711
export function refreshView(): void {
687712
cacheGeneration++
@@ -1836,16 +1861,16 @@
18361861
}
18371862
}
18381863
1839-
// Refetch total count and then force the List components to re-fetch
1840-
// their visible range. We always bump cacheGeneration because renames
1841-
// don't change totalCount. Brief mode also refetches its per-column
1842-
// widths since the filename set may have changed (rename / add / remove).
1864+
// Refetch total count, bump the soft-refresh tick (renames don't
1865+
// change totalCount, so the tick is what guarantees a refresh),
1866+
// and schedule a throttled column-width refetch in brief mode.
1867+
// We deliberately DON'T bump `cacheGeneration` here: that'd cause
1868+
// a destructive wipe on every diff event, flickering the source
1869+
// pane empty mid-bulk-op.
18431870
void getTotalCount(listingId, includeHidden).then(async (count) => {
18441871
totalCount = count
1845-
cacheGeneration++
1846-
if (viewMode === 'brief') {
1847-
briefListRef?.refetchColumnWidths?.()
1848-
}
1872+
softRefreshTick++
1873+
scheduleColumnWidthRefetch()
18491874
18501875
// Post-rename cursor tracking: move cursor to the renamed file
18511876
const nameToFind = renameFlow.pendingCursorName
@@ -2247,6 +2272,7 @@
22472272
totalCount={effectiveTotalCount}
22482273
{includeHidden}
22492274
{cacheGeneration}
2275+
{softRefreshTick}
22502276
{cursorIndex}
22512277
{isFocused}
22522278
{syncStatusMap}
@@ -2281,6 +2307,7 @@
22812307
totalCount={effectiveTotalCount}
22822308
{includeHidden}
22832309
{cacheGeneration}
2310+
{softRefreshTick}
22842311
{cursorIndex}
22852312
{isFocused}
22862313
{syncStatusMap}

apps/desktop/src/lib/file-explorer/views/BriefList.svelte

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@
5050
totalCount: number
5151
includeHidden: boolean
5252
cacheGeneration?: number
53+
/**
54+
* Bumped on every `directory-diff` event. Triggers a soft refresh
55+
* (refetch visible range in the background, keep existing entries
56+
* visible until new ones land). Use this instead of `cacheGeneration`
57+
* for diff-driven refreshes — `cacheGeneration` does a destructive
58+
* wipe that causes empty-pane flicker mid-bulk-operation.
59+
*/
60+
softRefreshTick?: number
5361
cursorIndex: number
5462
isFocused?: boolean
5563
syncStatusMap?: Record<string, SyncStatus>
@@ -87,6 +95,7 @@
8795
totalCount,
8896
includeHidden,
8997
cacheGeneration = 0,
98+
softRefreshTick = 0,
9099
cursorIndex,
91100
isFocused = true,
92101
syncStatusMap = {},
@@ -207,8 +216,11 @@
207216
})
208217
}
209218
210-
// Fetch entries for the visible range
211-
async function fetchVisibleRange() {
219+
// Fetch entries for the visible range.
220+
// `force=true` skips the "already cached" short-circuit; used when the
221+
// backing listing changed (file watcher diff) and the cached entries are
222+
// stale even though the range indices may still match.
223+
async function fetchVisibleRange(force = false) {
212224
if (!listingId || isFetching) return
213225
214226
// Calculate which backend indices we need (convert column range to item range)
@@ -220,7 +232,7 @@
220232
// Check if range is already cached BEFORE setting isFetching
221233
// This prevents blocking subsequent fetches when data is already available
222234
const { fetchStart, fetchEnd } = calculateFetchRange({ startItem, endItem, hasParent, totalCount })
223-
if (isRangeCached(fetchStart, fetchEnd, cachedRange)) {
235+
if (!force && isRangeCached(fetchStart, fetchEnd, cachedRange)) {
224236
return // Already cached
225237
}
226238
@@ -235,6 +247,7 @@
235247
includeHidden,
236248
cachedRange,
237249
onSyncStatusRequest,
250+
force,
238251
})
239252
if (result) {
240253
cachedEntries = result.entries
@@ -592,18 +605,29 @@
592605
}
593606
594607
// Track previous values to detect actual changes
595-
let prevCacheProps = { listingId: '', includeHidden: false, totalCount: 0, cacheGeneration: 0 }
596-
597-
// Single effect: fetch when ready, reset cache when listingId/includeHidden/totalCount/cacheGeneration changes
608+
let prevCacheProps = { listingId: '', includeHidden: false, cacheGeneration: 0 }
609+
let prevSoftTick = 0
610+
let prevTotalCount = 0
611+
612+
// Hard reset on cold context changes (nav, sort, hidden toggle, explicit
613+
// refresh): wipe entries and widths, refetch from scratch.
614+
// Soft refresh on totalCount or softRefreshTick changes (caused by
615+
// `directory-diff` events during bulk ops, or renames that don't change
616+
// count): refetch in the background and atomically replace, keeping
617+
// existing entries and widths visible until the new ones land — no
618+
// empty/first-column flicker.
598619
$effect(() => {
599-
const currentProps = { listingId, includeHidden, totalCount, cacheGeneration }
620+
const currentProps = { listingId, includeHidden, cacheGeneration }
621+
const currentTotal = totalCount
622+
const currentTick = softRefreshTick
600623
if (!listingId || containerHeight <= 0) return
601624
602-
// Check if any tracked prop changed (totalCount changes on file add/remove, cacheGeneration on sort)
603625
if (shouldResetCache(currentProps, prevCacheProps)) {
604626
cachedEntries = []
605627
cachedRange = { start: 0, end: 0 }
606628
prevCacheProps = currentProps
629+
prevTotalCount = currentTotal
630+
prevSoftTick = currentTick
607631
// Drop measured widths so the new listing starts fresh. Bumping `widthsGeneration`
608632
// BEFORE the refetch ensures any in-flight response for the previous listing
609633
// is discarded by the `(listingId, generation)` guard.
@@ -616,6 +640,20 @@
616640
})
617641
})
618642
fetchColumnWidths()
643+
void fetchVisibleRange()
644+
return
645+
}
646+
647+
if (currentTotal !== prevTotalCount || currentTick !== prevSoftTick) {
648+
prevTotalCount = currentTotal
649+
prevSoftTick = currentTick
650+
// `force=true` bypasses the cached-range short-circuit so stale
651+
// entries within an unchanged range get replaced. Column widths
652+
// are refreshed by FilePane's throttled `refetchColumnWidths`
653+
// call, not here, so a 10 k-file delete doesn't fire one width
654+
// IPC per coalesced event.
655+
void fetchVisibleRange(true)
656+
return
619657
}
620658
621659
void fetchVisibleRange()

apps/desktop/src/lib/file-explorer/views/FullList.svelte

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@
6565
totalCount: number
6666
includeHidden: boolean
6767
cacheGeneration?: number
68+
/**
69+
* Bumped on every `directory-diff` event. Triggers a soft refresh
70+
* (refetch visible range in the background, keep existing entries
71+
* visible until new ones land). Use this instead of `cacheGeneration`
72+
* for diff-driven refreshes — `cacheGeneration` does a destructive
73+
* wipe that causes empty-pane flicker mid-bulk-operation.
74+
*/
75+
softRefreshTick?: number
6876
cursorIndex: number
6977
isFocused?: boolean
7078
syncStatusMap?: Record<string, SyncStatus>
@@ -109,6 +117,7 @@
109117
totalCount,
110118
includeHidden,
111119
cacheGeneration = 0,
120+
softRefreshTick = 0,
112121
cursorIndex,
113122
isFocused = true,
114123
syncStatusMap = {},
@@ -315,7 +324,8 @@
315324
}
316325
317326
// Fetch entries for the visible range
318-
async function fetchVisibleRange() {
327+
// `force=true` skips the "already cached" short-circuit; see BriefList for the rationale.
328+
async function fetchVisibleRange(force = false) {
319329
if (!listingId || isFetching) return
320330
321331
const startItem = virtualWindow.startIndex
@@ -324,7 +334,7 @@
324334
// Check if range is already cached BEFORE setting isFetching
325335
// This prevents blocking subsequent fetches when data is already available
326336
const { fetchStart, fetchEnd } = calculateFetchRange({ startItem, endItem, hasParent, totalCount })
327-
if (isRangeCached(fetchStart, fetchEnd, cachedRange)) {
337+
if (!force && isRangeCached(fetchStart, fetchEnd, cachedRange)) {
328338
return // Already cached
329339
}
330340
@@ -339,6 +349,7 @@
339349
includeHidden,
340350
cachedRange,
341351
onSyncStatusRequest,
352+
force,
342353
})
343354
if (result) {
344355
cachedEntries = result.entries
@@ -488,18 +499,27 @@
488499
}
489500
490501
// Track previous values to detect actual changes
491-
let prevCacheProps = { listingId: '', includeHidden: false, totalCount: 0, cacheGeneration: 0 }
492-
493-
// Single effect: fetch when ready, reset cache when listingId/includeHidden/totalCount/cacheGeneration changes
502+
let prevCacheProps = { listingId: '', includeHidden: false, cacheGeneration: 0 }
503+
let prevTotalCount = 0
504+
let prevSoftTick = 0
505+
506+
// Hard reset on cold context changes (nav, sort, hidden toggle): wipe
507+
// entries, refetch from scratch.
508+
// Soft refresh on totalCount or softRefreshTick changes (`directory-diff`
509+
// bursts, in-place renames): refetch in background and atomically replace,
510+
// keeping existing rows visible — no empty-pane flicker mid-bulk-op.
494511
$effect(() => {
495-
const currentProps = { listingId, includeHidden, totalCount, cacheGeneration }
512+
const currentProps = { listingId, includeHidden, cacheGeneration }
513+
const currentTotal = totalCount
514+
const currentTick = softRefreshTick
496515
if (!listingId || containerHeight <= 0) return
497516
498-
// Check if any tracked prop changed (totalCount changes on file add/remove, cacheGeneration on sort)
499517
if (shouldResetCache(currentProps, prevCacheProps)) {
500518
cachedEntries = []
501519
cachedRange = { start: 0, end: 0 }
502520
prevCacheProps = currentProps
521+
prevTotalCount = currentTotal
522+
prevSoftTick = currentTick
503523
// Suppress the grid-template-columns transition for the first paint after
504524
// a dir switch; otherwise the header (which persists across navs) slides
505525
// from the previous dir's widths to the new ones.
@@ -509,6 +529,15 @@
509529
skipTransition = false
510530
})
511531
})
532+
void fetchVisibleRange()
533+
return
534+
}
535+
536+
if (currentTotal !== prevTotalCount || currentTick !== prevSoftTick) {
537+
prevTotalCount = currentTotal
538+
prevSoftTick = currentTick
539+
void fetchVisibleRange(true)
540+
return
512541
}
513542
514543
void fetchVisibleRange()

apps/desktop/src/lib/file-explorer/views/file-list-utils.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,6 @@ describe('shouldResetCache', () => {
316316
const base = {
317317
listingId: 'listing-1',
318318
includeHidden: false,
319-
totalCount: 100,
320319
cacheGeneration: 1,
321320
}
322321

@@ -332,13 +331,16 @@ describe('shouldResetCache', () => {
332331
expect(shouldResetCache({ ...base, includeHidden: true }, base)).toBe(true)
333332
})
334333

335-
it('returns true when totalCount changes', () => {
336-
expect(shouldResetCache({ ...base, totalCount: 200 }, base)).toBe(true)
337-
})
338-
339334
it('returns true when cacheGeneration changes', () => {
340335
expect(shouldResetCache({ ...base, cacheGeneration: 2 }, base)).toBe(true)
341336
})
337+
338+
it('does NOT reset when only totalCount changes (soft-refresh path for diff events)', () => {
339+
// totalCount changes — caused by directory-diff events during bulk ops —
340+
// must not trigger a hard reset; the lists handle these via soft refresh
341+
// (refetch in background, keep entries visible until new ones land).
342+
expect(shouldResetCache(base, base)).toBe(false)
343+
})
342344
})
343345

344346
describe('fetchVisibleRange', () => {

apps/desktop/src/lib/file-explorer/views/file-list-utils.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ export interface FetchRangeParams {
9292
includeHidden: boolean
9393
cachedRange: { start: number; end: number }
9494
onSyncStatusRequest?: (paths: string[]) => void
95+
/**
96+
* Bypass the "already cached" short-circuit. Set when the backing listing
97+
* changed (e.g. a `directory-diff` event added/removed entries within the
98+
* cached range) so the cached entries are stale even though the range
99+
* indices haven't moved.
100+
*/
101+
force?: boolean
95102
}
96103

97104
/** Result of fetchVisibleRange */
@@ -136,13 +143,22 @@ export function isRangeCached(
136143

137144
/** Fetches entries for a visible range with prefetch buffer */
138145
export async function fetchVisibleRange(params: FetchRangeParams): Promise<FetchRangeResult | null> {
139-
const { listingId, startItem, endItem, hasParent, totalCount, includeHidden, cachedRange, onSyncStatusRequest } =
140-
params
146+
const {
147+
listingId,
148+
startItem,
149+
endItem,
150+
hasParent,
151+
totalCount,
152+
includeHidden,
153+
cachedRange,
154+
onSyncStatusRequest,
155+
force,
156+
} = params
141157

142158
const { fetchStart, fetchEnd } = calculateFetchRange({ startItem, endItem, hasParent, totalCount })
143159

144-
// Only fetch if needed range isn't cached
145-
if (isRangeCached(fetchStart, fetchEnd, cachedRange)) {
160+
// Only fetch if needed range isn't cached (unless `force` says the cache is stale)
161+
if (!force && isRangeCached(fetchStart, fetchEnd, cachedRange)) {
146162
return null // Already cached
147163
}
148164

@@ -163,15 +179,23 @@ export async function fetchVisibleRange(params: FetchRangeParams): Promise<Fetch
163179
}
164180
}
165181

166-
/** Checks if cache props changed and returns whether reset is needed */
182+
/**
183+
* Checks if cache props changed in a way that warrants a hard reset (wipe
184+
* cached entries and column widths, refetch from scratch).
185+
*
186+
* Hard resets are for cold context changes: navigation, hidden-files toggle,
187+
* sort, explicit refresh. `totalCount` changes alone (caused by `directory-diff`
188+
* events during bulk ops) trigger a *soft* refresh instead — the visible range
189+
* refetches in the background and atomically replaces, so the user never sees
190+
* an empty pane mid-burst.
191+
*/
167192
export function shouldResetCache(
168-
current: { listingId: string; includeHidden: boolean; totalCount: number; cacheGeneration: number },
169-
previous: { listingId: string; includeHidden: boolean; totalCount: number; cacheGeneration: number },
193+
current: { listingId: string; includeHidden: boolean; cacheGeneration: number },
194+
previous: { listingId: string; includeHidden: boolean; cacheGeneration: number },
170195
): boolean {
171196
return (
172197
current.listingId !== previous.listingId ||
173198
current.includeHidden !== previous.includeHidden ||
174-
current.totalCount !== previous.totalCount ||
175199
current.cacheGeneration !== previous.cacheGeneration
176200
)
177201
}

0 commit comments

Comments
 (0)