Skip to content

Commit 7325c8f

Browse files
committed
Full mode: Shrink-wrap Ext/Size/Modified columns
- Measure the visible rows via `@chenglou/pretext` to give the name column every spare pixel. Updates continuously on scroll, resize, and as the prefetch buffer fills. - Animate column width changes with a 300ms `grid-template-columns` ease transition. `prefers-reduced-motion` disables it. Dir switches snap (no animation) so the persistent header doesn't slide. - Keep the last good widths across the "empty cache" gap after a nav so we don't collapse to header-only widths and snap back out. - Fold the `..` row's huge recursive size into the measurement only while that row is actually on screen — otherwise the size column stayed oversized as the user scrolled away. - Hide the sort caret with `display: none` on inactive columns instead of `opacity: 0`, so it no longer reserves 12px on every unsorted header.
1 parent 36212ed commit 7325c8f

6 files changed

Lines changed: 437 additions & 11 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ Props: `file: FileEntry`, `syncIcon?: string` (URL for sync overlay badge).
7171
Props: `column`, `label`, `currentSortColumn`, `currentSortOrder`, `onClick`, `align?` (`'left'` default, `'right'` for
7272
numeric columns).
7373

74-
Renders a `<button>` with a sort-direction triangle (▲/▼). Triangle is hidden (opacity 0) when column is not active.
75-
Handles both `onclick` and `onkeydown` (Enter/Space).
74+
Renders a `<button>` with a sort-direction triangle (▲/▼). The triangle is `display: none` on inactive columns so it
75+
doesn't reserve width — `FullList` shrink-wraps column widths and `opacity: 0` would have baked ~12px of dead space into
76+
every unsorted header. Handles both `onclick` and `onkeydown` (Enter/Space).
7677

7778
## Key decisions
7879

apps/desktop/src/lib/file-explorer/selection/SortableHeader.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,6 @@
8383
}
8484
8585
.sort-indicator.invisible {
86-
opacity: 0;
86+
display: none;
8787
}
8888
</style>

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ without DOM performance issues.
1414
- **brief-list-utils.ts** / **full-list-utils.ts** – Mode-specific rendering logic. `full-list-utils.ts` includes
1515
dual-size display helpers: `getDisplaySize()` (picks logical/physical/smart), `hasSizeMismatch()`,
1616
`buildFileSizeTooltip()`, `buildDirSizeTooltip()`, `buildSelectionSizeTooltip()`
17+
- **measure-column-widths.ts**`computeFullListColumnWidths()`: pixel-accurate widths for the Ext / Size / Modified
18+
columns based on the currently loaded entries. Uses `@chenglou/pretext` for canvas-based measurement (no DOM reflow).
19+
FullList transitions `grid-template-columns` over 300ms so widths refine smoothly as more entries stream in.
1720
- **FullList.svelte** – Reads `listing.sizeDisplay` (via `getSizeDisplayMode()`) and `listing.sizeMismatchWarning` (via
1821
`getSizeMismatchWarning()`) settings. Uses UnoCSS/Lucide `i-lucide:circle-alert` for size mismatch warnings and
1922
`i-lucide:hourglass` for stale index indicators
@@ -68,6 +71,15 @@ window requires fresh data. Parent bumps `cacheGeneration`, triggering re-fetch.
6871
**Decision**: Icon prefetching only for visible entries **Why**: With 50k files, prefetching all icons = 50k IPC calls.
6972
Virtual scrolling renders only ~50 items, so prefetch only visible. Re-fetch on scroll.
7073

74+
**Decision**: Shrink-wrap Ext / Size / Modified columns from the rows **currently on screen**, not the prefetch buffer
75+
or the full directory **Why**: The name column should keep every spare pixel, so columns track live content. Pretext's
76+
canvas measurement is fast enough to recompute on every scroll row-crossing and window resize. The 300ms
77+
`grid-template-columns ease` transition (on both `.header-row` and `.file-entry`) smooths the resulting width changes.
78+
Dir switches snap instead of animating (see Gotcha below). The `..` row's (often huge) recursive size only contributes
79+
when that row is actually on screen — otherwise the size column would stay oversized after scrolling past it.
80+
`SelectionInfo` keeps using `measureDateColumnWidth` (worst-case sampling) because it renders a single-entry snapshot
81+
with no "visible set" to measure from.
82+
7183
## Gotchas
7284

7385
**Gotcha**: `$state()` cannot live in `.ts` files **Why**: `virtual-scroll.ts` is pure functions. Reactive state must be
@@ -92,3 +104,16 @@ layout recalc. `transform` uses GPU compositor for 60fps.
92104
**Gotcha**: Cache re-fetch during scroll uses range expansion **Why**: If visible range is [100, 150] but cached is [0,
93105
200], don't re-fetch. If scrolled to [250, 300], expand fetch to [0, 550] to include buffer. `shouldResetCache()`
94106
handles this.
107+
108+
**Gotcha**: `HEADER_CHROME_ACTIVE/INACTIVE` in `measure-column-widths.ts` are tied to `SortableHeader`'s padding + flex
109+
gap + caret glyph (`--spacing-xs` = 4px × 3 + 8px caret = 20px active, 4px × 2 = 8px inactive) **Why**: If you change
110+
those CSS values or the caret size/markup, update the two constants or column widths drift. The values aren't derived
111+
from the live DOM because pretext measurement runs without a reference element — everything is computed from the
112+
pre-known chrome formula.
113+
114+
**Gotcha**: FullList's `grid-template-columns` transition would "slide" the header on dir switches, because the header
115+
lives outside the virtual scroll and persists across navs **Why**: When `shouldResetCache` fires, a `skipTransition`
116+
flag is set and cleared after two `requestAnimationFrame` ticks (one to paint with `transition: none`, one more before
117+
re-enabling). Widths also don't update while `cachedEntries` is empty AND `parentDirStats` is null, so the brief
118+
post-nav gap doesn't collapse them to header-only floors. Combined, nav = snap; within-dir scroll/resize/stream-in =
119+
animated.

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

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@
2424
import {
2525
getVisibleItemsCount as getVisibleItemsCountUtil,
2626
getVirtualizationBufferRows,
27-
measureDateColumnWidth,
2827
buildDirSizeTooltip,
2928
buildFileSizeTooltip,
3029
getDisplaySize,
3130
hasSizeMismatch,
3231
getDisplayExtension,
3332
getDisplayName,
3433
} from './full-list-utils'
34+
import { computeFullListColumnWidths } from './measure-column-widths'
3535
import {
3636
getRowHeight,
3737
getIsCompactDensity,
@@ -123,10 +123,6 @@
123123
// UI density for compact mode detection (uses reactive state from reactive-settings)
124124
const isCompact = $derived(getIsCompactDensity())
125125
126-
// Dynamic date column width based on measured text width using the actual font.
127-
// Measures multiple sample dates to find the maximum width needed.
128-
const dateColumnWidth = $derived(measureDateColumnWidth(formatDateTime))
129-
130126
// Size display mode (smart/logical/physical)
131127
const sizeDisplayMode = $derived(getSizeDisplayMode())
132128
@@ -139,6 +135,14 @@
139135
// Drive index state — show spinner while scanning OR aggregating (sizes aren't ready until aggregation finishes)
140136
const indexing = $derived(isScanning() || isAggregating())
141137
138+
// Column widths are declared after the virtual window, which gates parent-row inclusion.
139+
let columnWidths = $state({ ext: 60, size: 115, date: 80 })
140+
let skipTransition = $state(false)
141+
142+
const gridTemplate = $derived(
143+
`16px 1fr ${String(columnWidths.ext)}px ${String(columnWidths.size)}px ${String(columnWidths.date)}px`,
144+
)
145+
142146
// ==== Virtual scrolling state ====
143147
let scrollContainer: HTMLDivElement | undefined = $state()
144148
let containerHeight = $state(0)
@@ -156,6 +160,52 @@
156160
}),
157161
)
158162
163+
// Shrink-wrapped column widths, measured strictly from the rows currently on
164+
// screen so the name column keeps every spare pixel. Widths refresh smoothly
165+
// (300ms CSS transition) as the user scrolls, resizes the window, or when new
166+
// entries stream into the prefetch buffer.
167+
//
168+
// Held across the "empty cache" window right after a dir switch so we don't
169+
// collapse to header-only widths and then snap outward again — `skipTransition`
170+
// handles the actual nav by suppressing the CSS transition for one paint.
171+
//
172+
// The ".." row's (often huge) recursive size only factors in when that row is
173+
// actually on screen — otherwise the size column stays oversized after scrolling.
174+
const firstVisibleGlobalIndex = $derived(rowHeight > 0 ? Math.floor(scrollTop / rowHeight) : 0)
175+
const lastVisibleGlobalIndex = $derived(
176+
rowHeight > 0 && containerHeight > 0
177+
? Math.min(totalCount - 1, Math.floor((scrollTop + containerHeight - 1) / rowHeight))
178+
: -1,
179+
)
180+
const isParentRowVisible = $derived(hasParent && firstVisibleGlobalIndex === 0)
181+
182+
$effect(() => {
183+
const first = firstVisibleGlobalIndex
184+
const last = lastVisibleGlobalIndex
185+
const parentOffset = hasParent ? 1 : 0
186+
const firstBackend = Math.max(0, first - parentOffset)
187+
const lastBackend = last - parentOffset
188+
189+
const visible: FileEntry[] = []
190+
for (let i = firstBackend; i <= lastBackend; i++) {
191+
if (i >= cachedRange.start && i < cachedRange.end) {
192+
visible.push(cachedEntries[i - cachedRange.start])
193+
}
194+
}
195+
196+
const parentStats = isParentRowVisible ? parentDirStats : null
197+
if (visible.length === 0 && !parentStats) return
198+
columnWidths = computeFullListColumnWidths({
199+
entries: visible,
200+
parentDirStats: parentStats,
201+
formatDateTime,
202+
sizeDisplayMode,
203+
indexing,
204+
showSizeMismatchWarning,
205+
sortBy,
206+
})
207+
})
208+
159209
// Get entry at global index (handling ".." entry)
160210
export function getEntryAt(globalIndex: number): FileEntry | undefined {
161211
return getEntryAtUtil(
@@ -361,6 +411,15 @@
361411
cachedEntries = []
362412
cachedRange = { start: 0, end: 0 }
363413
prevCacheProps = currentProps
414+
// Suppress the grid-template-columns transition for the first paint after
415+
// a dir switch — otherwise the header (which persists across navs) slides
416+
// from the previous dir's widths to the new ones.
417+
skipTransition = true
418+
requestAnimationFrame(() => {
419+
requestAnimationFrame(() => {
420+
skipTransition = false
421+
})
422+
})
364423
}
365424
366425
void fetchVisibleRange()
@@ -410,9 +469,10 @@
410469
<!-- Header row with sortable columns (outside scroll container for correct height calculation) -->
411470
<div
412471
class="header-row"
472+
class:no-transition={skipTransition}
413473
role="toolbar"
414474
aria-label="Sort columns"
415-
style="grid-template-columns: 16px 1fr 60px 115px {dateColumnWidth}px;"
475+
style="grid-template-columns: {gridTemplate};"
416476
>
417477
<span class="header-icon"></span>
418478
<SortableHeader
@@ -475,9 +535,10 @@
475535
class:is-under-cursor={globalIndex === cursorIndex}
476536
class:is-selected={selectedIndices.has(globalIndex)}
477537
class:is-striped={stripedRows && globalIndex % 2 === 1}
538+
class:no-transition={skipTransition}
478539
data-filename={file.name}
479540
data-drop-target-path={file.isDirectory ? file.path : undefined}
480-
style="height: {rowHeight}px; grid-template-columns: 16px 1fr 60px 115px {dateColumnWidth}px;"
541+
style="height: {rowHeight}px; grid-template-columns: {gridTemplate};"
481542
onmousedown={(e: MouseEvent) => {
482543
handleMouseDown(e, globalIndex)
483544
}}
@@ -607,13 +668,14 @@
607668
608669
.header-row {
609670
display: grid;
610-
/* grid-template-columns set via inline style for dynamic date column width */
671+
/* grid-template-columns set via inline style for shrink-wrapped column widths */
611672
gap: var(--spacing-sm);
612673
padding: var(--spacing-xxs) var(--spacing-sm);
613674
background: var(--color-bg-header);
614675
border-bottom: 1px solid var(--color-border);
615676
height: 22px;
616677
flex-shrink: 0;
678+
transition: grid-template-columns 300ms ease;
617679
}
618680
619681
.header-icon {
@@ -636,6 +698,19 @@
636698
align-items: center;
637699
/* Guarantee one visual line per row regardless of cell content length */
638700
white-space: nowrap;
701+
transition: grid-template-columns 300ms ease;
702+
}
703+
704+
.header-row.no-transition,
705+
.file-entry.no-transition {
706+
transition: none;
707+
}
708+
709+
@media (prefers-reduced-motion: reduce) {
710+
.header-row,
711+
.file-entry {
712+
transition: none;
713+
}
639714
}
640715
641716
/* In compact mode, use symmetric padding to match BriefList alignment */
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Tests for measure-column-widths.ts. We replace the real pretext-backed measurer
3+
* with a deterministic `text.length * 7` stand-in so assertions stay readable.
4+
*/
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
7+
import type { FileEntry } from '../types'
8+
9+
import { _setMeasureForTests, computeFullListColumnWidths } from './measure-column-widths'
10+
11+
const fakeMeasure = (text: string): number => text.length * 7
12+
13+
function entry(overrides: Partial<FileEntry>): FileEntry {
14+
return {
15+
name: 'file.txt',
16+
path: '/x/file.txt',
17+
isDirectory: false,
18+
isSymlink: false,
19+
size: 0,
20+
physicalSize: 0,
21+
modifiedAt: undefined,
22+
permissions: 0o644,
23+
owner: 'u',
24+
group: 'g',
25+
iconId: 'text',
26+
extendedMetadataLoaded: false,
27+
...overrides,
28+
}
29+
}
30+
31+
const baseArgs = {
32+
parentDirStats: null,
33+
formatDateTime: (t: number) => new Date(t * 1000).toISOString().slice(0, 19).replace('T', ' '),
34+
sizeDisplayMode: 'smart' as const,
35+
indexing: false,
36+
showSizeMismatchWarning: false,
37+
sortBy: 'name' as const,
38+
}
39+
40+
describe('computeFullListColumnWidths', () => {
41+
afterEach(() => {
42+
_setMeasureForTests(null)
43+
})
44+
45+
it('falls back to header-only widths when entries is empty', () => {
46+
_setMeasureForTests(fakeMeasure)
47+
const w = computeFullListColumnWidths({ ...baseArgs, entries: [] })
48+
// With sortBy='name', none of Ext/Size/Modified are active, so each gets
49+
// HEADER_CHROME_INACTIVE (8). "Ext" = 21 + 8 = 29; "Size" = 28 + 8 = 36
50+
// → clamped to MIN_SIZE_WIDTH (40); "Modified" = 56 + 8 = 64 → clamped to
51+
// MIN_DATE_WIDTH (70).
52+
expect(w.ext).toBe(29)
53+
expect(w.size).toBe(40)
54+
expect(w.date).toBe(70)
55+
})
56+
57+
it('widens the active sort column to reserve room for the caret', () => {
58+
_setMeasureForTests(fakeMeasure)
59+
const nameSorted = computeFullListColumnWidths({ ...baseArgs, entries: [] })
60+
const sizeSorted = computeFullListColumnWidths({ ...baseArgs, entries: [], sortBy: 'size' })
61+
// size col picks up the caret (+12 chrome) when sortBy==='size'; ext stays inactive.
62+
expect(sizeSorted.size).toBeGreaterThan(nameSorted.size)
63+
expect(sizeSorted.ext).toBe(nameSorted.ext)
64+
})
65+
66+
it('widens size column when a large file is present', () => {
67+
_setMeasureForTests(fakeMeasure)
68+
const small = computeFullListColumnWidths({
69+
...baseArgs,
70+
entries: [entry({ name: 'a.txt', size: 0, physicalSize: 0 })],
71+
})
72+
const big = computeFullListColumnWidths({
73+
...baseArgs,
74+
entries: [entry({ name: 'z.bin', size: 100_000_000, physicalSize: 100_000_000 })],
75+
})
76+
expect(big.size).toBeGreaterThan(small.size)
77+
})
78+
79+
it('widens ext column based on actual extensions', () => {
80+
_setMeasureForTests(fakeMeasure)
81+
const short = computeFullListColumnWidths({ ...baseArgs, entries: [entry({ name: 'a.js' })] })
82+
const long = computeFullListColumnWidths({ ...baseArgs, entries: [entry({ name: 'a.verylongext' })] })
83+
expect(long.ext).toBeGreaterThan(short.ext)
84+
})
85+
86+
it('widens date column based on longest formatted date', () => {
87+
_setMeasureForTests(fakeMeasure)
88+
const short = computeFullListColumnWidths({
89+
...baseArgs,
90+
formatDateTime: () => 'today',
91+
entries: [entry({ name: 'a', modifiedAt: 1 })],
92+
})
93+
const long = computeFullListColumnWidths({
94+
...baseArgs,
95+
formatDateTime: () => '2026-12-31 23:59:59',
96+
entries: [entry({ name: 'a', modifiedAt: 1 })],
97+
})
98+
expect(long.date).toBeGreaterThan(short.date)
99+
})
100+
101+
it('reserves icon width when a directory has a stale size during indexing', () => {
102+
_setMeasureForTests(fakeMeasure)
103+
const idle = computeFullListColumnWidths({
104+
...baseArgs,
105+
entries: [entry({ name: 'd', isDirectory: true, recursiveSize: 12345 })],
106+
})
107+
const busy = computeFullListColumnWidths({
108+
...baseArgs,
109+
indexing: true,
110+
entries: [entry({ name: 'd', isDirectory: true, recursiveSize: 12345 })],
111+
})
112+
expect(busy.size).toBeGreaterThanOrEqual(idle.size)
113+
})
114+
115+
it('includes parentDirStats size when provided', () => {
116+
_setMeasureForTests(fakeMeasure)
117+
const without = computeFullListColumnWidths({
118+
...baseArgs,
119+
entries: [entry({ name: 'a', size: 1 })],
120+
})
121+
const withParent = computeFullListColumnWidths({
122+
...baseArgs,
123+
entries: [entry({ name: 'a', size: 1 })],
124+
parentDirStats: {
125+
path: '/x',
126+
recursiveSize: 999_999_999_999,
127+
recursivePhysicalSize: 999_999_999_999,
128+
recursiveFileCount: 1,
129+
recursiveDirCount: 0,
130+
},
131+
})
132+
expect(withParent.size).toBeGreaterThan(without.size)
133+
})
134+
135+
it('never returns widths below the floor', () => {
136+
_setMeasureForTests(() => 0) // pathological: everything measures to zero
137+
const w = computeFullListColumnWidths({ ...baseArgs, entries: [entry({ name: 'a' })] })
138+
expect(w.ext).toBeGreaterThanOrEqual(28)
139+
expect(w.size).toBeGreaterThanOrEqual(40)
140+
expect(w.date).toBeGreaterThanOrEqual(70)
141+
})
142+
})

0 commit comments

Comments
 (0)