Skip to content

Commit a42eda5

Browse files
committed
Move file cache to back end
Quite a big change that brought a big speed-up! 🚀
1 parent 689c74e commit a42eda5

16 files changed

Lines changed: 1222 additions & 1706 deletions
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
# Implementation plan: Backend-driven virtual scrolling
2+
3+
## Problem statement
4+
5+
When opening a 50k-file directory, we currently serialize all 50k FileEntry objects over IPC. The viewport only shows
6+
~50 files at a time.
7+
8+
**Solution**: Keep all data in Rust, fetch only visible items on demand.
9+
10+
---
11+
12+
## Architecture
13+
14+
```
15+
┌─────────────────────────────────────────────────────────────────┐
16+
│ RUST BACKEND │
17+
├─────────────────────────────────────────────────────────────────┤
18+
│ LISTING_CACHE: HashMap<listing_id, CachedListing> │
19+
│ - entries: Vec<FileEntry> (all files, unfiltered, sorted) │
20+
│ - path: PathBuf │
21+
│ │
22+
│ APIs return filtered data based on include_hidden param: │
23+
│ get_range(listing_id, start, count, include_hidden) │
24+
│ get_total_count(listing_id, include_hidden) │
25+
│ find_index(listing_id, name, include_hidden) │
26+
│ get_at(listing_id, index, include_hidden) │
27+
└─────────────────────────────────────────────────────────────────┘
28+
29+
│ IPC (~100-500 items per request)
30+
31+
┌─────────────────────────────────────────────────────────────────┐
32+
│ SVELTE FRONTEND │
33+
├─────────────────────────────────────────────────────────────────┤
34+
│ FilePane.svelte: │
35+
│ - listingId: string │
36+
│ - totalCount: number │
37+
│ - selectedIndex: number │
38+
│ │
39+
│ BriefList/FullList.svelte: │
40+
│ - Prefetch buffer: ~500 items around current position │
41+
│ - Throttled scroll handling (100ms) │
42+
│ - Renders only visible items from buffer │
43+
└─────────────────────────────────────────────────────────────────┘
44+
```
45+
46+
---
47+
48+
## Phase 1: Backend API changes
49+
50+
### 1.1 Rename session → listing
51+
52+
All "session" references become "listing":
53+
54+
- `SESSION_CACHE``LISTING_CACHE`
55+
- `session_id``listing_id`
56+
- `currentSessionId``currentListingId`
57+
58+
### 1.2 New Tauri commands
59+
60+
```rust
61+
/// Get a range of entries (for virtual scrolling)
62+
/// Returns entries with sync status included.
63+
#[tauri::command]
64+
fn get_file_range(
65+
listing_id: String,
66+
start: usize,
67+
count: usize,
68+
include_hidden: bool,
69+
) -> Result<Vec<FileEntry>, String>
70+
71+
/// Get total count (for scrollbar sizing)
72+
#[tauri::command]
73+
fn get_total_count(listing_id: String, include_hidden: bool) -> Result<usize, String>
74+
75+
/// Find index of a file by name (for parent folder selection)
76+
#[tauri::command]
77+
fn find_file_index(
78+
listing_id: String,
79+
name: String,
80+
include_hidden: bool,
81+
) -> Result<Option<usize>, String>
82+
83+
/// Get a single file at index (for SelectionInfo)
84+
#[tauri::command]
85+
fn get_file_at(
86+
listing_id: String,
87+
index: usize,
88+
include_hidden: bool,
89+
) -> Result<Option<FileEntry>, String>
90+
```
91+
92+
### 1.3 Modify [list_directory_start](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src-tauri/src/file_system/operations.rs#284-334)
93+
94+
**Current return:**
95+
96+
```rust
97+
SessionStartResult { session_id, total_count, entries: Vec<FileEntry>, has_more }
98+
```
99+
100+
**New return:**
101+
102+
```rust
103+
ListingStartResult { listing_id, total_count } // No entries!
104+
```
105+
106+
The `include_hidden` param is passed at start to get correct initial `total_count`.
107+
108+
### 1.4 Sync status in FileEntry
109+
110+
Add sync status to
111+
[FileEntry](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src-tauri/src/file_system/operations.rs#92-113)
112+
struct so it's returned with file data:
113+
114+
```rust
115+
pub struct FileEntry {
116+
// ... existing fields ...
117+
pub sync_status: Option<String>, // "synced", "online_only", etc.
118+
}
119+
```
120+
121+
Fetch sync status when populating range response.
122+
123+
---
124+
125+
## Phase 2: Frontend changes
126+
127+
### 2.1 Delete files
128+
129+
- [src/lib/file-explorer/FileDataStore.ts](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src/lib/file-explorer/FileDataStore.ts)
130+
→ DELETE
131+
- [src/lib/file-explorer/FileDataStore.test.ts](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src/lib/file-explorer/FileDataStore.test.ts)
132+
→ DELETE
133+
134+
### 2.2 Update FilePane.svelte
135+
136+
```typescript
137+
// Remove
138+
const CHUNK_SIZE = 5000
139+
let fileStore = createFileDataStore()
140+
let storeVersion = $state(0)
141+
142+
// Add
143+
let listingId = $state('')
144+
let totalCount = $state(0)
145+
let includeHidden = $derived(showHiddenFiles)
146+
147+
// loadDirectory() now just gets listingId + totalCount
148+
async function loadDirectory(path: string, selectName?: string) {
149+
const result = await listDirectoryStart(path, includeHidden)
150+
listingId = result.listingId
151+
totalCount = result.totalCount
152+
153+
if (selectName) {
154+
const idx = await findFileIndex(listingId, selectName, includeHidden)
155+
selectedIndex = idx ?? 0
156+
}
157+
}
158+
```
159+
160+
### 2.3 Update BriefList/FullList.svelte
161+
162+
**New props:**
163+
164+
```typescript
165+
interface Props {
166+
listingId: string
167+
totalCount: number
168+
selectedIndex: number
169+
includeHidden: boolean
170+
// ... existing callbacks
171+
}
172+
```
173+
174+
**Prefetch buffer (~500 items):**
175+
176+
```typescript
177+
const PREFETCH_BUFFER = 500
178+
179+
let cachedEntries = $state<FileEntry[]>([])
180+
let cachedRange = $state({ start: 0, end: 0 })
181+
182+
async function ensureRange(start: number, end: number) {
183+
// Expand to prefetch buffer
184+
const fetchStart = Math.max(0, start - PREFETCH_BUFFER / 2)
185+
const fetchEnd = Math.min(totalCount, end + PREFETCH_BUFFER / 2)
186+
187+
// Only fetch if needed range isn't cached
188+
if (fetchStart < cachedRange.start || fetchEnd > cachedRange.end) {
189+
const entries = await getFileRange(listingId, fetchStart, fetchEnd - fetchStart, includeHidden)
190+
cachedEntries = entries
191+
cachedRange = { start: fetchStart, end: fetchEnd }
192+
}
193+
}
194+
```
195+
196+
**Throttled scroll (100ms):**
197+
198+
```typescript
199+
let scrollThrottleTimer: ReturnType<typeof setTimeout> | undefined
200+
201+
function onScroll() {
202+
if (!scrollThrottleTimer) {
203+
void updateVisibleRange()
204+
scrollThrottleTimer = setTimeout(() => {
205+
scrollThrottleTimer = undefined
206+
void updateVisibleRange() // Trailing call
207+
}, 100)
208+
}
209+
}
210+
```
211+
212+
---
213+
214+
## Phase 3: Edge cases
215+
216+
### 3.1 Hidden files filtering
217+
218+
**Location**: Rust
219+
220+
**Implementation**: APIs accept `include_hidden: bool`. Rust iterates
221+
[entries](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src-tauri/src/file_system/mock_provider_test.rs#6-50)
222+
and skips hidden files when calculating indices/ranges if `include_hidden = false`.
223+
224+
### 3.2 File watcher with index shifting
225+
226+
When files change _before_ the cursor, the view must shift:
227+
228+
```rust
229+
// In watcher diff handler:
230+
struct DiffResult {
231+
new_total_count: usize,
232+
cursor_shift: i32, // +20 = 20 files added before cursor
233+
affected_visible_range: bool,
234+
}
235+
```
236+
237+
Frontend receives diff event:
238+
239+
```typescript
240+
interface ListingDiffEvent {
241+
listingId: string
242+
newTotalCount: number
243+
cursorShift: number
244+
affectedVisibleRange: boolean
245+
}
246+
247+
// On receiving:
248+
totalCount = event.newTotalCount
249+
selectedIndex = Math.max(0, selectedIndex + event.cursorShift)
250+
if (event.affectedVisibleRange) {
251+
void refetchVisibleRange()
252+
}
253+
```
254+
255+
### 3.3 Parent folder navigation
256+
257+
When navigating up, call:
258+
259+
1. `findFileIndex(listingId, previousFolderName, includeHidden)` → e.g., returns 1000
260+
2. `getFileRange(listingId, 750, 500, includeHidden)` → buffer around index 1000
261+
3. Scroll to position: `index * ROW_HEIGHT`
262+
263+
### 3.4 Sync status
264+
265+
**Location**: Rust
266+
267+
Include `sync_status` in
268+
[FileEntry](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src-tauri/src/file_system/operations.rs#92-113).
269+
Fetch when building range response (Dropbox SDK calls are already async-friendly).
270+
271+
### 3.5 Max filename width
272+
273+
**Postponed** — complex with proportional fonts. Use reasonable default for now.
274+
275+
TODO: Consider char-width lookup table in Rust, or estimate based on extension + length.
276+
277+
---
278+
279+
## Deleted complexity
280+
281+
| Removed | Reason |
282+
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ |
283+
| [FileDataStore.ts](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src/lib/file-explorer/FileDataStore.ts) | Data stays in Rust |
284+
| `CHUNK_SIZE`, chunking logic | Fetch on demand |
285+
| [listDirectoryNextChunk](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src/lib/tauri-commands.ts#25-33) | Not needed |
286+
| `loadingMore` state | Not needed |
287+
| [appendFiles()](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src/lib/file-explorer/FileDataStore.ts#154-161), [mergeExtendedData()](file:///Users/veszelovszki/Library/CloudStorage/Dropbox/projects-git/vdavid/rusty-commander/src/lib/file-explorer/FileDataStore.ts#208-239) | Not needed |
288+
289+
---
290+
291+
## Added complexity
292+
293+
| Added | Purpose |
294+
| --------------------------- | -------------------------- |
295+
| `get_file_range` | Core virtual scroll API |
296+
| `get_file_at` | Selected file info |
297+
| `find_file_index` | Navigation selection |
298+
| Prefetch buffer (500 items) | Smooth scrolling |
299+
| Throttled scroll (100ms) | Avoid IPC spam |
300+
| Cursor shift logic | File watcher index updates |
301+
302+
---
303+
304+
## Test plan
305+
306+
### Rust unit tests
307+
308+
- `get_file_range` returns correct slice
309+
- `get_file_range` clamps out-of-bounds
310+
- `find_file_index` finds correct index with/without hidden
311+
- Hidden file filtering works correctly
312+
- Watcher diff computes correct cursor shift
313+
314+
### TypeScript unit tests
315+
316+
- Prefetch buffer logic
317+
- Throttle behavior
318+
- Index calculation from scroll position
319+
320+
### E2E tests
321+
322+
- 50k folder: first files appear <200ms
323+
- Scroll: smooth, no blank areas
324+
- Toggle hidden: filters correctly
325+
- File watcher: updates + cursor shift work
326+
- Go to parent: previous folder selected
327+
328+
---
329+
330+
## Decisions (resolved)
331+
332+
| Decision | Choice |
333+
| --------------------- | ---------------------------- |
334+
| Hidden file filtering | Rust |
335+
| Extended metadata | Include in response |
336+
| Scroll strategy | Throttle 100ms with trailing |
337+
| Sync status storage | Rust (in FileEntry) |
338+
| Max filename width | Postponed |
339+
| Prefetch buffer | 500 items |
340+
341+
---
342+
343+
## TODO for future
344+
345+
- Sorting: Add `sort_by`, `sort_order` params to APIs (mention in code as TODO)
346+
- Max filename width: Implement char-width lookup table
347+
348+
---
349+
350+
## Estimated effort
351+
352+
| Phase | Effort |
353+
| ----------------------------------- | --------------- |
354+
| Phase 1: Backend APIs | 3-4 hours |
355+
| Phase 2: Frontend refactor | 4-5 hours |
356+
| Phase 3: Edge cases (watcher, etc.) | 3-4 hours |
357+
| Testing + polish | 2-3 hours |
358+
| **Total** | **12-16 hours** |

docs/todo.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
loads immediately
3838
- Split-second "Loading..." state in panes at each dir change, ugly. → In "Loading" state, display empty div for 200 ms,
3939
and just THEN show "Loading..." if needed.
40+
- Clean up "folder" vs "directory" → Decide an standardize, then document it in the style guide.
41+
- Fix calculating the Brief mode widths in Rust!
42+
- Cancel requests in Rust when the dir is closed
4043

4144
## Settings
4245

0 commit comments

Comments
 (0)