Skip to content

Commit e6f268c

Browse files
committed
Improve arrow up/down performance in big folders
Debounce/throttle IPC calls during arrow navigation
1 parent 11a8633 commit e6f268c

4 files changed

Lines changed: 243 additions & 13 deletions

File tree

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

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
import ExtensionChangeDialog from '../rename/ExtensionChangeDialog.svelte'
7373
import RenameConflictDialog from '../rename/RenameConflictDialog.svelte'
7474
import { getAppLogger } from '$lib/logging/logger'
75+
import { createDebounce, createThrottle } from '$lib/utils/timing'
7576
7677
const log = getAppLogger('fileExplorer')
7778
import { isMtpVolumeId, getMtpDisplayPath } from '$lib/mtp'
@@ -134,7 +135,11 @@
134135
let cursorIndex = $state(0)
135136
136137
// Selection state (extracted to selection-state.svelte.ts)
137-
const selection = createSelectionState({ onChanged: () => void syncPaneStateToMcp() })
138+
const selection = createSelectionState({
139+
onChanged: () => {
140+
debouncedSyncMcp.call()
141+
},
142+
})
138143
139144
// Rename state (inline rename editor)
140145
const rename = createRenameState()
@@ -212,14 +217,14 @@
212217
return
213218
}
214219
cursorIndex = index
215-
void fetchEntryUnderCursor()
220+
// fetchEntryUnderCursor is handled by the $effect tracking cursorIndex
216221
// Scroll to make cursor visible
217222
const listRef = viewMode === 'brief' ? briefListRef : fullListRef
218223
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
219224
listRef?.scrollToIndex(index)
220225
// Wait for scroll effects to complete before syncing to MCP
221226
await tick()
222-
void syncPaneStateToMcp()
227+
debouncedSyncMcp.call()
223228
}
224229
225230
export function getCursorIndex(): number {
@@ -601,7 +606,7 @@
601606
void fetchListingStats()
602607
603608
// Sync state to MCP
604-
void syncPaneStateToMcp()
609+
debouncedSyncMcp.call()
605610
606611
// Scroll to cursor position
607612
void tick().then(() => {
@@ -773,11 +778,22 @@
773778
}
774779
}
775780
781+
// Debounced/throttled IPC wrappers to avoid flooding the backend during rapid navigation.
782+
// The virtual scroll (cursorIndex → scrollToIndex → DOM) is fully synchronous and unaffected.
783+
const debouncedFetchEntry = createDebounce(() => void fetchEntryUnderCursor(), 16)
784+
const throttledFetchStats = createThrottle(() => void fetchListingStats(), 150)
785+
const debouncedMenuContext = createDebounce(() => {
786+
if (entryUnderCursor && entryUnderCursor.name !== '..') {
787+
void updateMenuContext(entryUnderCursor.path, entryUnderCursor.name)
788+
}
789+
}, 100)
790+
const debouncedSyncMcp = createDebounce(() => void syncPaneStateToMcp(), 300)
791+
776792
/** Handle visible range change from list components */
777793
function handleVisibleRangeChange(start: number, end: number) {
778794
visibleRangeStart = start
779795
visibleRangeEnd = end
780-
void syncPaneStateToMcp()
796+
debouncedSyncMcp.call()
781797
}
782798
783799
// Check if error is a permission denied error
@@ -1037,7 +1053,7 @@
10371053
void fetchListingStats()
10381054
10391055
// Sync state to MCP for context tools
1040-
void syncPaneStateToMcp()
1056+
debouncedSyncMcp.call()
10411057
10421058
// Scroll to cursor after DOM updates
10431059
void tick().then(() => {
@@ -1198,7 +1214,7 @@
11981214
}
11991215
cursorIndex = newIndex
12001216
listRef?.scrollToIndex(newIndex)
1201-
void fetchEntryUnderCursor()
1217+
// fetchEntryUnderCursor is handled by the $effect tracking cursorIndex
12021218
}
12031219
12041220
// Helper: Handle brief mode key navigation
@@ -1427,27 +1443,27 @@
14271443
prevVolumeId = volumeId
14281444
})
14291445
1430-
// Update global menu context when cursor position or focus changes
1446+
// Update global menu context when cursor position or focus changes (debounced — only matters for right-click)
14311447
$effect(() => {
14321448
if (!isFocused) return
14331449
if (entryUnderCursor && entryUnderCursor.name !== '..') {
1434-
void updateMenuContext(entryUnderCursor.path, entryUnderCursor.name)
1450+
debouncedMenuContext.call()
14351451
}
14361452
})
14371453
1438-
// Re-fetch entry under the cursor when cursorIndex changes
1454+
// Re-fetch entry under the cursor when cursorIndex changes (debounced — status bar info can lag one frame)
14391455
$effect(() => {
14401456
void cursorIndex // Track
14411457
if (listingId && !loading) {
1442-
void fetchEntryUnderCursor()
1458+
debouncedFetchEntry.call()
14431459
}
14441460
})
14451461
1446-
// Re-fetch listing stats when selection changes
1462+
// Re-fetch listing stats when selection changes (throttled — shows live count at steady cadence)
14471463
$effect(() => {
14481464
void selection.selectedIndices.size // Track selection changes
14491465
if (listingId && !loading) {
1450-
void fetchListingStats()
1466+
throttledFetchStats.call()
14511467
}
14521468
})
14531469
@@ -1677,6 +1693,10 @@
16771693
}
16781694
clearInterval(syncPollInterval)
16791695
clearInterval(dirExistsPollInterval)
1696+
debouncedFetchEntry.cancel()
1697+
throttledFetchStats.cancel()
1698+
debouncedMenuContext.cancel()
1699+
debouncedSyncMcp.cancel()
16801700
unlistenOpening?.()
16811701
unlistenProgress?.()
16821702
unlistenReadComplete?.()

apps/desktop/src/lib/utils/CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Small stateless utility functions. Pure, no Svelte state, safe to import from pl
99
| `filename-validation.ts` | Pure client-side filename validation for instant keystroke feedback |
1010
| `filename-validation.test.ts` | Vitest tests covering all validators |
1111
| `confirm-dialog.ts` | Wrapper around Tauri's native dialog API |
12+
| `timing.ts` | `createDebounce` and `createThrottle` for rate-limiting IPC calls |
13+
| `timing.test.ts` | Vitest tests for debounce and throttle |
1214

1315
## filename-validation.ts
1416

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { createDebounce, createThrottle } from './timing'
3+
4+
beforeEach(() => {
5+
vi.useFakeTimers()
6+
})
7+
afterEach(() => {
8+
vi.useRealTimers()
9+
})
10+
11+
describe('createDebounce', () => {
12+
it('fires after delay when called once', () => {
13+
const fn = vi.fn()
14+
const debounced = createDebounce(fn, 100)
15+
16+
debounced.call()
17+
expect(fn).not.toHaveBeenCalled()
18+
19+
vi.advanceTimersByTime(100)
20+
expect(fn).toHaveBeenCalledOnce()
21+
})
22+
23+
it('resets timer on repeated calls — only the last one fires', () => {
24+
const fn = vi.fn()
25+
const debounced = createDebounce(fn, 100)
26+
27+
debounced.call()
28+
vi.advanceTimersByTime(50)
29+
debounced.call()
30+
vi.advanceTimersByTime(50)
31+
debounced.call()
32+
vi.advanceTimersByTime(50)
33+
34+
expect(fn).not.toHaveBeenCalled()
35+
36+
vi.advanceTimersByTime(50)
37+
expect(fn).toHaveBeenCalledOnce()
38+
})
39+
40+
it('cancel prevents pending call', () => {
41+
const fn = vi.fn()
42+
const debounced = createDebounce(fn, 100)
43+
44+
debounced.call()
45+
vi.advanceTimersByTime(50)
46+
debounced.cancel()
47+
48+
vi.advanceTimersByTime(200)
49+
expect(fn).not.toHaveBeenCalled()
50+
})
51+
52+
it('flush fires immediately and clears timer', () => {
53+
const fn = vi.fn()
54+
const debounced = createDebounce(fn, 100)
55+
56+
debounced.call()
57+
debounced.flush()
58+
expect(fn).toHaveBeenCalledOnce()
59+
60+
// No double-fire after the original delay
61+
vi.advanceTimersByTime(200)
62+
expect(fn).toHaveBeenCalledOnce()
63+
})
64+
65+
it('flush is a no-op when nothing is pending', () => {
66+
const fn = vi.fn()
67+
const debounced = createDebounce(fn, 100)
68+
69+
debounced.flush()
70+
expect(fn).not.toHaveBeenCalled()
71+
})
72+
})
73+
74+
describe('createThrottle', () => {
75+
it('fires immediately on first call', () => {
76+
const fn = vi.fn()
77+
const throttled = createThrottle(fn, 100)
78+
79+
throttled.call()
80+
expect(fn).toHaveBeenCalledOnce()
81+
})
82+
83+
it('suppresses calls within the delay window, fires trailing', () => {
84+
const fn = vi.fn()
85+
const throttled = createThrottle(fn, 100)
86+
87+
throttled.call() // fires immediately
88+
throttled.call() // suppressed, schedules trailing
89+
throttled.call() // suppressed, trailing already scheduled
90+
91+
expect(fn).toHaveBeenCalledOnce()
92+
93+
vi.advanceTimersByTime(100)
94+
expect(fn).toHaveBeenCalledTimes(2) // trailing fires
95+
})
96+
97+
it('allows another immediate call after delay has passed', () => {
98+
const fn = vi.fn()
99+
const throttled = createThrottle(fn, 100)
100+
101+
throttled.call() // immediate
102+
vi.advanceTimersByTime(100)
103+
104+
throttled.call() // immediate again (enough time passed)
105+
expect(fn).toHaveBeenCalledTimes(2)
106+
})
107+
108+
it('cancel prevents the trailing call', () => {
109+
const fn = vi.fn()
110+
const throttled = createThrottle(fn, 100)
111+
112+
throttled.call() // immediate
113+
throttled.call() // schedules trailing
114+
throttled.cancel()
115+
116+
vi.advanceTimersByTime(200)
117+
expect(fn).toHaveBeenCalledOnce() // only the immediate one
118+
})
119+
120+
it('handles rapid bursts with correct cadence', () => {
121+
const fn = vi.fn()
122+
const throttled = createThrottle(fn, 100)
123+
124+
// Simulate 10 calls at 20ms intervals (burst over 200ms)
125+
for (let i = 0; i < 10; i++) {
126+
throttled.call()
127+
vi.advanceTimersByTime(20)
128+
}
129+
130+
// Flush any remaining trailing
131+
vi.advanceTimersByTime(100)
132+
133+
// Should fire at most ~3-4 times (immediate + trailing calls), not 10
134+
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(2)
135+
expect(fn.mock.calls.length).toBeLessThanOrEqual(5)
136+
})
137+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Debounce: delays execution until `delayMs` after the last call.
3+
* Only the final call in a burst fires. Good for "I only care about the end state."
4+
*/
5+
export function createDebounce(fn: () => void, delayMs: number) {
6+
let timer: ReturnType<typeof setTimeout> | null = null
7+
8+
function call() {
9+
if (timer !== null) clearTimeout(timer)
10+
timer = setTimeout(() => {
11+
timer = null
12+
fn()
13+
}, delayMs)
14+
}
15+
16+
function cancel() {
17+
if (timer !== null) {
18+
clearTimeout(timer)
19+
timer = null
20+
}
21+
}
22+
23+
/** Cancel pending timer and fire immediately. */
24+
function flush() {
25+
if (timer !== null) {
26+
clearTimeout(timer)
27+
timer = null
28+
fn()
29+
}
30+
}
31+
32+
return { call, cancel, flush }
33+
}
34+
35+
/**
36+
* Throttle: fires immediately on first call, then at most once per `delayMs`.
37+
* Trailing call guaranteed (last call always fires). Good for "show live progress at a steady cadence."
38+
*/
39+
export function createThrottle(fn: () => void, delayMs: number) {
40+
let timer: ReturnType<typeof setTimeout> | null = null
41+
let lastFireTime = 0
42+
43+
function call() {
44+
const now = Date.now()
45+
const elapsed = now - lastFireTime
46+
47+
if (elapsed >= delayMs) {
48+
lastFireTime = now
49+
if (timer !== null) {
50+
clearTimeout(timer)
51+
timer = null
52+
}
53+
fn()
54+
} else if (timer === null) {
55+
timer = setTimeout(() => {
56+
timer = null
57+
lastFireTime = Date.now()
58+
fn()
59+
}, delayMs - elapsed)
60+
}
61+
}
62+
63+
function cancel() {
64+
if (timer !== null) {
65+
clearTimeout(timer)
66+
timer = null
67+
}
68+
}
69+
70+
return { call, cancel }
71+
}

0 commit comments

Comments
 (0)