Skip to content

Commit 36212ed

Browse files
committed
File list: Show current folder's total on the ".." row
- `createParentEntry` accepts optional `DirStats` and populates `recursiveSize` / `recursivePhysicalSize` / `recursiveFileCount` / `recursiveDirCount` on the synthetic `..` entry, so it renders through the same code path as any other directory (size column, size-mismatch warning, stale-index hourglass, and tooltip). - `updateIndexSizesInPlace` takes an optional `currentPath` and returns its stats alongside the in-place updates on cached entries — single batch IPC call. - `BriefList` / `FullList` gain a `currentPath` prop, a `parentDirStats` state, and a `$effect` that fetches the current folder's stats on dir change via `getDirStatsBatch`. `refreshIndexSizes()` also refreshes the parent row. - `FilePane` passes `currentPath` down to both lists. - Updated `createParentEntry` and `updateIndexSizesInPlace` tests for the new params; added `currentPath` to both a11y tests. - Documented the behavior as a gotcha in `views/CLAUDE.md` so future agents understand the ".." row shows the current folder's total, not the parent's.
1 parent 77ea6e8 commit 36212ed

8 files changed

Lines changed: 204 additions & 25 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1873,6 +1873,7 @@
18731873
{sortOrder}
18741874
renameState={rename.active ? rename : null}
18751875
parentPath={hasParent ? currentPath.substring(0, currentPath.lastIndexOf('/')) || '/' : ''}
1876+
{currentPath}
18761877
onSelect={handleSelect}
18771878
onNavigate={handleNavigate}
18781879
onContextMenu={handleContextMenu}
@@ -1905,6 +1906,7 @@
19051906
{sortOrder}
19061907
renameState={rename.active ? rename : null}
19071908
parentPath={hasParent ? currentPath.substring(0, currentPath.lastIndexOf('/')) || '/' : ''}
1909+
{currentPath}
19081910
onSelect={handleSelect}
19091911
onNavigate={handleNavigate}
19101912
onContextMenu={handleContextMenu}

apps/desktop/src/lib/file-explorer/views/BriefList.a11y.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe('BriefList a11y', () => {
7171
isFocused: true,
7272
hasParent: false,
7373
parentPath: '',
74+
currentPath: '/root',
7475
sortBy: 'name',
7576
sortOrder: 'ascending',
7677
onSelect: () => {},
@@ -94,6 +95,7 @@ describe('BriefList a11y', () => {
9495
isFocused: true,
9596
hasParent: false,
9697
parentPath: '',
98+
currentPath: '/root',
9799
sortBy: 'name',
98100
sortOrder: 'ascending',
99101
onSelect: () => {},
@@ -135,6 +137,7 @@ describe('BriefList a11y', () => {
135137
isFocused: true,
136138
hasParent: true,
137139
parentPath: '/root/..',
140+
currentPath: '/root',
138141
sortBy: 'name',
139142
sortOrder: 'ascending',
140143
onSelect: () => {},
@@ -160,6 +163,7 @@ describe('BriefList a11y', () => {
160163
isFocused: false,
161164
hasParent: false,
162165
parentPath: '',
166+
currentPath: '/root',
163167
sortBy: 'name',
164168
sortOrder: 'ascending',
165169
onSelect: () => {},

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
shouldResetCache,
1818
refetchIconsForEntries,
1919
updateIndexSizesInPlace,
20+
type DirStats,
2021
} from './file-list-utils'
22+
import { getDirStatsBatch } from '$lib/tauri-commands'
2123
import { buildDirSizeTooltip, hasSizeMismatch } from './full-list-utils'
2224
import { getRowHeight, formatFileSize, getSizeMismatchWarning, getStripedRows } from '$lib/settings/reactive-settings.svelte'
2325
import { getSetting } from '$lib/settings/settings-store'
@@ -38,6 +40,8 @@
3840
selectedIndices?: Set<number>
3941
hasParent: boolean
4042
parentPath: string
43+
/** Path of the directory currently being listed — used to show its total on the ".." row. */
44+
currentPath: string
4145
maxFilenameWidth?: number // From backend font metrics, if available
4246
sortBy: SortColumn
4347
sortOrder: SortOrder
@@ -72,6 +76,7 @@
7276
selectedIndices = new Set<number>(),
7377
hasParent,
7478
parentPath,
79+
currentPath,
7580
maxFilenameWidth: backendMaxWidth,
7681
sortBy,
7782
sortOrder,
@@ -93,6 +98,8 @@
9398
let cachedEntries = $state<FileEntry[]>([])
9499
let cachedRange = $state({ start: 0, end: 0 })
95100
let isFetching = $state(false)
101+
// Recursive stats for the CURRENT directory (shown on the ".." row so that space isn't wasted).
102+
let parentDirStats = $state<DirStats | null>(null)
96103
97104
// Drive index state — show spinner while scanning OR aggregating (sizes aren't ready until aggregation finishes)
98105
const indexing = $derived(isScanning() || isAggregating())
@@ -145,13 +152,22 @@
145152
146153
// Get entry at global index (handling ".." entry)
147154
export function getEntryAt(globalIndex: number): FileEntry | undefined {
148-
return getEntryAtUtil(globalIndex, hasParent, parentPath, cachedEntries, cachedRange)
155+
return getEntryAtUtil(
156+
globalIndex,
157+
hasParent,
158+
parentPath,
159+
cachedEntries,
160+
cachedRange,
161+
parentDirStats ?? undefined,
162+
)
149163
}
150164
151-
/** Updates only index size fields on cached directory entries, in-place. */
165+
/** Updates index size fields on cached directory entries AND on the ".." row. */
152166
export function refreshIndexSizes(): void {
153-
if (cachedEntries.length === 0) return
154-
void updateIndexSizesInPlace(cachedEntries)
167+
if (cachedEntries.length === 0 && !hasParent) return
168+
void updateIndexSizesInPlace(cachedEntries, hasParent ? currentPath : undefined).then((stats) => {
169+
parentDirStats = stats
170+
})
155171
}
156172
157173
// Fetch entries for the visible range
@@ -212,7 +228,7 @@
212228
// Inline getEntryAt logic to use local variables
213229
let entry: FileEntry | undefined
214230
if (hasParent && i === 0) {
215-
entry = createParentEntry(parentPath)
231+
entry = createParentEntry(parentPath, parentDirStats ?? undefined)
216232
} else {
217233
const backendIndex = hasParent ? i - 1 : i
218234
if (backendIndex >= rangeStart && backendIndex < rangeEnd) {
@@ -432,6 +448,23 @@
432448
}
433449
})
434450
451+
// Fetch the current folder's recursive stats so the ".." row can show the total.
452+
// Re-runs when the directory changes; cleared when we're at a volume root.
453+
$effect(() => {
454+
if (!hasParent || !currentPath) {
455+
parentDirStats = null
456+
return
457+
}
458+
void cacheGeneration
459+
void getDirStatsBatch([currentPath])
460+
.then((results) => {
461+
parentDirStats = results[0] ?? null
462+
})
463+
.catch(() => {
464+
// Silently ignore -- indexing may not be initialized yet.
465+
})
466+
})
467+
435468
// Size mismatch warning setting
436469
const showSizeMismatchWarning = $derived(getSizeMismatchWarning())
437470

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ shifts by 20. Must recalculate virtual window when `totalCount` changes.
7979
**Gotcha**: When `hasParent = true`, UI indices are 1-based **Why**: Index 0 is ".." parent entry (not in backend
8080
cache). Real files start at index 1. Adjust: `cache_index = ui_index - 1`.
8181

82+
**Gotcha**: The ".." row shows the CURRENT folder's recursive size, not the parent folder's **Why**: The `..` row's size
83+
column is otherwise wasted space. Showing the total for the folder the user is browsing (sum of everything visible plus
84+
unloaded entries) answers "how much is in here?" — more useful than "how big is the place I'd go if I pressed
85+
Backspace." Implementation: `createParentEntry(parentPath, stats?)` in `file-list-utils.ts` takes optional stats;
86+
`BriefList`/`FullList` fetch them via `getDirStatsBatch([currentPath])` on dir change and via
87+
`updateIndexSizesInPlace(cachedEntries, currentPath)` on index refresh (single batch IPC call).
88+
8289
**Gotcha**: Scroll position must use `transform`, not absolute positioning **Why**: Absolute positioning causes full
8390
layout recalc. `transform` uses GPU compositor for 60fps.
8491

apps/desktop/src/lib/file-explorer/views/FullList.a11y.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ describe('FullList a11y', () => {
6969
isFocused: true,
7070
hasParent: false,
7171
parentPath: '',
72+
currentPath: '/root',
7273
sortBy: 'name',
7374
sortOrder: 'ascending',
7475
onSelect: () => {},
@@ -97,6 +98,7 @@ describe('FullList a11y', () => {
9798
isFocused: true,
9899
hasParent: false,
99100
parentPath: '',
101+
currentPath: '/root',
100102
sortBy: 'name',
101103
sortOrder: 'ascending',
102104
onSelect: () => {},
@@ -138,6 +140,7 @@ describe('FullList a11y', () => {
138140
isFocused: true,
139141
hasParent: true,
140142
parentPath: '/root/..',
143+
currentPath: '/root',
141144
sortBy: 'name',
142145
sortOrder: 'ascending',
143146
onSelect: () => {},
@@ -163,6 +166,7 @@ describe('FullList a11y', () => {
163166
isFocused: false,
164167
hasParent: false,
165168
parentPath: '',
169+
currentPath: '/root',
166170
sortBy: 'name',
167171
sortOrder: 'ascending',
168172
onSelect: () => {},

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

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
shouldResetCache,
1717
refetchIconsForEntries,
1818
updateIndexSizesInPlace,
19+
type DirStats,
1920
} from './file-list-utils'
21+
import { getDirStatsBatch } from '$lib/tauri-commands'
2022
import { formatSizeTriads, formatNumber, pluralize } from '../selection/selection-info-utils'
2123
import { isScanning, isAggregating } from '$lib/indexing/index-state.svelte'
2224
import {
@@ -54,6 +56,8 @@
5456
selectedIndices?: Set<number>
5557
hasParent: boolean
5658
parentPath: string
59+
/** Path of the directory currently being listed — used to show its total on the ".." row. */
60+
currentPath: string
5761
sortBy: SortColumn
5862
sortOrder: SortOrder
5963
/** Rename state for inline editing */
@@ -87,6 +91,7 @@
8791
selectedIndices = new Set<number>(),
8892
hasParent,
8993
parentPath,
94+
currentPath,
9095
sortBy,
9196
sortOrder,
9297
renameState = null,
@@ -107,6 +112,8 @@
107112
let cachedEntries = $state<FileEntry[]>([])
108113
let cachedRange = $state({ start: 0, end: 0 })
109114
let isFetching = $state(false)
115+
// Recursive stats for the CURRENT directory (shown on the ".." row so that space isn't wasted).
116+
let parentDirStats = $state<DirStats | null>(null)
110117
111118
// ==== Virtual scrolling constants ====
112119
// Row height is reactive based on UI density setting
@@ -151,13 +158,22 @@
151158
152159
// Get entry at global index (handling ".." entry)
153160
export function getEntryAt(globalIndex: number): FileEntry | undefined {
154-
return getEntryAtUtil(globalIndex, hasParent, parentPath, cachedEntries, cachedRange)
161+
return getEntryAtUtil(
162+
globalIndex,
163+
hasParent,
164+
parentPath,
165+
cachedEntries,
166+
cachedRange,
167+
parentDirStats ?? undefined,
168+
)
155169
}
156170
157-
/** Updates only index size fields on cached directory entries, in-place. */
171+
/** Updates index size fields on cached directory entries AND on the ".." row. */
158172
export function refreshIndexSizes(): void {
159-
if (cachedEntries.length === 0) return
160-
void updateIndexSizesInPlace(cachedEntries)
173+
if (cachedEntries.length === 0 && !hasParent) return
174+
void updateIndexSizesInPlace(cachedEntries, hasParent ? currentPath : undefined).then((stats) => {
175+
parentDirStats = stats
176+
})
161177
}
162178
163179
// Fetch entries for the visible range
@@ -211,7 +227,7 @@
211227
// Inline getEntryAt logic to use local variables
212228
let entry: FileEntry | undefined
213229
if (hasParent && i === 0) {
214-
entry = createParentEntry(parentPath)
230+
entry = createParentEntry(parentPath, parentDirStats ?? undefined)
215231
} else {
216232
const backendIndex = hasParent ? i - 1 : i
217233
if (backendIndex >= rangeStart && backendIndex < rangeEnd) {
@@ -364,6 +380,24 @@
364380
}
365381
})
366382
383+
// Fetch the current folder's recursive stats so the ".." row can show the total.
384+
// Re-runs when the directory changes; cleared when we're at a volume root.
385+
$effect(() => {
386+
if (!hasParent || !currentPath) {
387+
parentDirStats = null
388+
return
389+
}
390+
// Re-run when cacheGeneration bumps (sort, refresh), currentPath is already tracked above.
391+
void cacheGeneration
392+
void getDirStatsBatch([currentPath])
393+
.then((results) => {
394+
parentDirStats = results[0] ?? null
395+
})
396+
.catch(() => {
397+
// Silently ignore -- indexing may not be initialized yet.
398+
})
399+
})
400+
367401
// Report visible range to parent for MCP state sync
368402
$effect(() => {
369403
const startItem = virtualWindow.startIndex

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

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,28 @@ describe('createParentEntry', () => {
8686
const entry = createParentEntry('/home/user')
8787
expect(entry.extendedMetadataLoaded).toBe(true)
8888
})
89+
90+
it('leaves recursive size fields undefined when no stats are passed', () => {
91+
const entry = createParentEntry('/home/user')
92+
expect(entry.recursiveSize).toBeUndefined()
93+
expect(entry.recursivePhysicalSize).toBeUndefined()
94+
expect(entry.recursiveFileCount).toBeUndefined()
95+
expect(entry.recursiveDirCount).toBeUndefined()
96+
})
97+
98+
it('populates recursive size fields when stats are passed', () => {
99+
const entry = createParentEntry('/home/user', {
100+
path: '/home/user/current',
101+
recursiveSize: 1024,
102+
recursivePhysicalSize: 2048,
103+
recursiveFileCount: 3,
104+
recursiveDirCount: 1,
105+
})
106+
expect(entry.recursiveSize).toBe(1024)
107+
expect(entry.recursivePhysicalSize).toBe(2048)
108+
expect(entry.recursiveFileCount).toBe(3)
109+
expect(entry.recursiveDirCount).toBe(1)
110+
})
89111
})
90112

91113
describe('getEntryAt', () => {
@@ -532,9 +554,62 @@ describe('updateIndexSizesInPlace', () => {
532554
]
533555
vi.mocked(getDirStatsBatch).mockRejectedValue(new Error('indexing not ready'))
534556

535-
await updateIndexSizesInPlace(entries)
557+
const stats = await updateIndexSizesInPlace(entries)
536558

537559
// Should not throw, entries unchanged
538560
expect(entries[0].recursiveSize).toBeUndefined()
561+
expect(stats).toBeNull()
562+
})
563+
564+
it('returns current-dir stats when currentPath is passed', async () => {
565+
const entries: FileEntry[] = [
566+
{
567+
name: 'subdir',
568+
path: '/dir/subdir',
569+
isDirectory: true,
570+
isSymlink: false,
571+
permissions: 0o755,
572+
owner: 'user',
573+
group: 'group',
574+
iconId: 'dir',
575+
extendedMetadataLoaded: true,
576+
},
577+
]
578+
vi.mocked(getDirStatsBatch).mockResolvedValue([
579+
{
580+
path: '/dir/subdir',
581+
recursiveSize: 100,
582+
recursivePhysicalSize: 200,
583+
recursiveFileCount: 1,
584+
recursiveDirCount: 0,
585+
},
586+
{ path: '/dir', recursiveSize: 5000, recursivePhysicalSize: 6000, recursiveFileCount: 42, recursiveDirCount: 3 },
587+
])
588+
589+
const stats = await updateIndexSizesInPlace(entries, '/dir')
590+
591+
expect(getDirStatsBatch).toHaveBeenCalledWith(['/dir/subdir', '/dir'])
592+
expect(entries[0].recursiveSize).toBe(100)
593+
expect(stats?.recursiveSize).toBe(5000)
594+
expect(stats?.recursiveFileCount).toBe(42)
595+
})
596+
597+
it('returns current-dir stats even when there are no cached directories', async () => {
598+
vi.mocked(getDirStatsBatch).mockResolvedValue([
599+
{ path: '/dir', recursiveSize: 999, recursivePhysicalSize: 1000, recursiveFileCount: 7, recursiveDirCount: 1 },
600+
])
601+
602+
const stats = await updateIndexSizesInPlace([], '/dir')
603+
604+
expect(getDirStatsBatch).toHaveBeenCalledWith(['/dir'])
605+
expect(stats?.recursiveSize).toBe(999)
606+
})
607+
608+
it('returns null for current-dir stats when index has no data for it', async () => {
609+
vi.mocked(getDirStatsBatch).mockResolvedValue([null])
610+
611+
const stats = await updateIndexSizesInPlace([], '/dir')
612+
613+
expect(stats).toBeNull()
539614
})
540615
})

0 commit comments

Comments
 (0)