Skip to content

Commit c336dbb

Browse files
committed
Brief mode: Variable column widths that fit filenames
- Each column shrink-wraps to its widest visible filename, capped at the existing `maxFilenameWidth` and floored at `MIN_COLUMN_WIDTH`. - Measured via `@chenglou/pretext` in a new `measure-brief-column-widths.ts`, cached per column index in a `SvelteMap`. - `transition: width 300ms ease` animates the changes. Nav snaps via the same 2-rAF `skipTransition` trick as Full mode. Reset on `itemsPerColumn` change (height resize reshuffles column contents). - Tradeoff: virtual-scroll math stays on the cap width, so the scrollbar slightly overestimates total content width when columns are narrow. Documented as a Decision in `views/CLAUDE.md`.
1 parent 7325c8f commit c336dbb

4 files changed

Lines changed: 204 additions & 8 deletions

File tree

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import { buildDirSizeTooltip, hasSizeMismatch } from './full-list-utils'
2424
import { getRowHeight, formatFileSize, getSizeMismatchWarning, getStripedRows } from '$lib/settings/reactive-settings.svelte'
2525
import { getSetting } from '$lib/settings/settings-store'
26+
import { measureWidestFilename } from './measure-brief-column-widths'
27+
import { SvelteMap } from 'svelte/reactivity'
2628
import { formatNumber, pluralize } from '../selection/selection-info-utils'
2729
import { isScanning, isAggregating } from '$lib/indexing/index-state.svelte'
2830
import { iconCacheCleared } from '$lib/icon-cache'
@@ -246,6 +248,45 @@
246248
return columns
247249
})
248250
251+
// ==== Per-column shrink-wrap widths ====
252+
// Each Brief column sizes to its widest visible filename, capped at `maxFilenameWidth`
253+
// and floored at `MIN_COLUMN_WIDTH`. Virtual-scroll math stays on the cap (so the
254+
// scrollbar math doesn't need to know variable widths), while each rendered column
255+
// gets its own measured width — `transition: width 300ms ease` smooths the change.
256+
//
257+
// Reset on nav/listing change (via the cache-reset effect) and when `itemsPerColumn`
258+
// changes (height resize reshuffles which files are in which column).
259+
const columnWidthsMap = new SvelteMap<number, number>()
260+
let skipTransition = $state(false)
261+
let prevItemsPerColumn = 0
262+
263+
$effect(() => {
264+
// Reset when column index semantics change.
265+
if (itemsPerColumn !== prevItemsPerColumn) {
266+
columnWidthsMap.clear()
267+
prevItemsPerColumn = itemsPerColumn
268+
}
269+
})
270+
271+
$effect(() => {
272+
// Re-measure whenever visible columns (or their cached contents) change.
273+
const cols = visibleColumns
274+
const cap = maxFilenameWidth
275+
for (const col of cols) {
276+
const files = col.files.map((f) => f.file)
277+
const widest = measureWidestFilename(files)
278+
if (widest === 0) continue // measurer unavailable (SSR/jsdom)
279+
const width = Math.min(cap, Math.max(MIN_COLUMN_WIDTH, widest + COLUMN_PADDING))
280+
if (columnWidthsMap.get(col.columnIndex) !== width) {
281+
columnWidthsMap.set(col.columnIndex, width)
282+
}
283+
}
284+
})
285+
286+
function getColumnWidth(colIndex: number): number {
287+
return columnWidthsMap.get(colIndex) ?? maxFilenameWidth
288+
}
289+
249290
// Fetch on scroll
250291
function handleScroll() {
251292
cancelClickToRename()
@@ -420,6 +461,15 @@
420461
cachedEntries = []
421462
cachedRange = { start: 0, end: 0 }
422463
prevCacheProps = currentProps
464+
// Drop measured widths so the new listing starts fresh, and snap the
465+
// animation for one paint so the persistent header/columns don't slide.
466+
columnWidthsMap.clear()
467+
skipTransition = true
468+
requestAnimationFrame(() => {
469+
requestAnimationFrame(() => {
470+
skipTransition = false
471+
})
472+
})
423473
}
424474
425475
void fetchVisibleRange()
@@ -563,7 +613,11 @@
563613
<!-- Visible window positioned with translateX -->
564614
<div class="virtual-window" style="transform: translateX({virtualWindow.offset}px);">
565615
{#each visibleColumns as column (column.columnIndex)}
566-
<div class="column" style="width: {maxFilenameWidth}px;">
616+
<div
617+
class="column"
618+
class:no-transition={skipTransition}
619+
style="width: {getColumnWidth(column.columnIndex)}px;"
620+
>
567621
{#each column.files as { file, globalIndex } (file.path)}
568622
{@const syncIcon = getSyncIconPath(syncStatusMap[file.path])}
569623
<!-- svelte-ignore a11y_click_events_have_key_events,a11y_interactive_supports_focus -->
@@ -676,6 +730,17 @@
676730
flex-shrink: 0;
677731
display: flex;
678732
flex-direction: column;
733+
transition: width 300ms ease;
734+
}
735+
736+
.column.no-transition {
737+
transition: none;
738+
}
739+
740+
@media (prefers-reduced-motion: reduce) {
741+
.column {
742+
transition: none;
743+
}
679744
}
680745
681746
.file-entry {

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ without DOM performance issues.
77

88
### Components
99

10-
- **BriefList.svelte** – Horizontal columns, fixed-width items, horizontal scrolling
10+
- **BriefList.svelte** – Horizontal columns, per-column shrink-wrapped widths (capped), horizontal scrolling
1111
- **FullList.svelte** – Vertical rows, full metadata display, vertical scrolling
1212
- **virtual-scroll.ts** – Pure math functions for calculating visible windows
1313
- **file-list-utils.ts** – Shared helpers: entry caching, icon prefetching, sync status
@@ -17,6 +17,8 @@ without DOM performance issues.
1717
- **measure-column-widths.ts**`computeFullListColumnWidths()`: pixel-accurate widths for the Ext / Size / Modified
1818
columns based on the currently loaded entries. Uses `@chenglou/pretext` for canvas-based measurement (no DOM reflow).
1919
FullList transitions `grid-template-columns` over 300ms so widths refine smoothly as more entries stream in.
20+
- **measure-brief-column-widths.ts**`measureWidestFilename()`: widest filename's pixel width in a Brief column,
21+
measured via pretext. Caller adds icon/gap/padding chrome and clamps to the min/max column-width range.
2022
- **FullList.svelte** – Reads `listing.sizeDisplay` (via `getSizeDisplayMode()`) and `listing.sizeMismatchWarning` (via
2123
`getSizeMismatchWarning()`) settings. Uses UnoCSS/Lucide `i-lucide:circle-alert` for size mismatch warnings and
2224
`i-lucide:hourglass` for stale index indicators
@@ -71,6 +73,14 @@ window requires fresh data. Parent bumps `cacheGeneration`, triggering re-fetch.
7173
**Decision**: Icon prefetching only for visible entries **Why**: With 50k files, prefetching all icons = 50k IPC calls.
7274
Virtual scrolling renders only ~50 items, so prefetch only visible. Re-fetch on scroll.
7375

76+
**Decision**: Brief columns shrink-wrap to the widest filename in each column (capped at the existing
77+
`maxFilenameWidth`, floored at `MIN_COLUMN_WIDTH`) **Why**: Long filenames deserve their full width while short ones let
78+
the user scan more columns at once. Widths are measured per visible column via pretext and cached in a
79+
`SvelteMap<columnIndex, width>`; uncached columns fall back to the cap. `transition: width 300ms ease` animates width
80+
changes. **Tradeoff**: the virtual-scroll math stays on the cap width (so the scrollbar still computes
81+
`totalColumns × cap`). When real columns are narrower than the cap, the scrollbar slightly overestimates the total —
82+
users can scroll a few pixels past the last visible column. Considered acceptable for the visual payoff.
83+
7484
**Decision**: Shrink-wrap Ext / Size / Modified columns from the rows **currently on screen**, not the prefetch buffer
7585
or the full directory **Why**: The name column should keep every spare pixel, so columns track live content. Pretext's
7686
canvas measurement is fast enough to recompute on every scroll row-crossing and window resize. The 300ms
@@ -111,9 +121,12 @@ those CSS values or the caret size/markup, update the two constants or column wi
111121
from the live DOM because pretext measurement runs without a reference element — everything is computed from the
112122
pre-known chrome formula.
113123

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.
124+
**Gotcha**: Width transitions would "slide" on dir switches, because the header (FullList) and columns (BriefList)
125+
persist across navs **Why**: When `shouldResetCache` fires, both lists set a `skipTransition` flag and clear it after
126+
two `requestAnimationFrame` ticks (one to paint with `transition: none`, one more before re-enabling). FullList also
127+
holds widths while `cachedEntries` is empty so the brief post-nav gap doesn't collapse to header-only floors. Combined,
128+
nav = snap; within-dir scroll/resize/stream-in = animated.
129+
130+
**Gotcha**: BriefList's `columnWidthsMap` is keyed by `columnIndex`, which depends on `itemsPerColumn` **Why**: A height
131+
resize changes how many files fit in a column, which reshuffles which file lands in which column index. Stale widths
132+
would stick to the wrong columns. A dedicated `$effect` clears the map when `itemsPerColumn` changes — don't remove it.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Tests for measure-brief-column-widths.ts. Replaces pretext's measurer with
3+
* a deterministic `text.length * 7` stand-in for readable assertions.
4+
*/
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
7+
import type { FileEntry } from '../types'
8+
9+
import { _setBriefMeasureForTests, measureWidestFilename } from './measure-brief-column-widths'
10+
11+
const fakeMeasure = (text: string): number => text.length * 7
12+
13+
function entry(name: string): FileEntry {
14+
return {
15+
name,
16+
path: `/x/${name}`,
17+
isDirectory: false,
18+
isSymlink: false,
19+
size: 0,
20+
permissions: 0o644,
21+
owner: 'u',
22+
group: 'g',
23+
iconId: 'text',
24+
extendedMetadataLoaded: false,
25+
}
26+
}
27+
28+
describe('measureWidestFilename', () => {
29+
afterEach(() => {
30+
_setBriefMeasureForTests(null)
31+
})
32+
33+
it('returns 0 when no measurer is available', () => {
34+
_setBriefMeasureForTests(null)
35+
// jsdom has no canvas, so the real measurer will set measureUnavailable=true
36+
// and return 0 — the caller should fall back to the cap width.
37+
expect(measureWidestFilename([entry('anything.txt')])).toBe(0)
38+
})
39+
40+
it('returns the widest name across the column', () => {
41+
_setBriefMeasureForTests(fakeMeasure)
42+
const w = measureWidestFilename([entry('a.txt'), entry('longer.md'), entry('z')])
43+
expect(w).toBe('longer.md'.length * 7)
44+
})
45+
46+
it('returns 0 for an empty column', () => {
47+
_setBriefMeasureForTests(fakeMeasure)
48+
expect(measureWidestFilename([])).toBe(0)
49+
})
50+
51+
it('handles unicode names by character count via the fake measurer', () => {
52+
_setBriefMeasureForTests(fakeMeasure)
53+
const w = measureWidestFilename([entry('ábc'), entry('日本語.txt')])
54+
// "日本語.txt" has 7 code units (3 CJK + 4 ASCII)
55+
expect(w).toBe('日本語.txt'.length * 7)
56+
})
57+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Brief-mode per-column width measurement. Each column shrink-wraps to its
3+
* widest filename; the caller caps the result at whatever max width the
4+
* container + backend font metrics dictate. Uses `@chenglou/pretext` for
5+
* pixel-accurate text measurement without DOM reflow.
6+
*/
7+
8+
import * as pretext from '@chenglou/pretext'
9+
10+
import type { FileEntry } from '../types'
11+
import { createPretextMeasure } from '$lib/utils/shorten-middle'
12+
13+
/**
14+
* CSS `font` shorthand matching `.brief-list` (`var(--font-system)` at 12px).
15+
* Kept in sync with `apps/desktop/src/app.css` — pretext warns `system-ui`
16+
* is unsafe for layout accuracy, so we lead with `-apple-system`.
17+
*/
18+
const FONT = '12px -apple-system, BlinkMacSystemFont, sans-serif'
19+
20+
let measureWidthCached: ((text: string) => number) | null = null
21+
let measureUnavailable = false
22+
23+
function getMeasure(): ((text: string) => number) | null {
24+
if (measureWidthCached) return measureWidthCached
25+
if (measureUnavailable) return null
26+
if (typeof document === 'undefined') return null
27+
try {
28+
const candidate = createPretextMeasure(FONT, pretext)
29+
candidate('probe')
30+
measureWidthCached = candidate
31+
return measureWidthCached
32+
} catch {
33+
measureUnavailable = true
34+
return null
35+
}
36+
}
37+
38+
/** Exposed for tests to inject a fake measurer. */
39+
export function _setBriefMeasureForTests(fn: ((text: string) => number) | null): void {
40+
measureWidthCached = fn
41+
measureUnavailable = false
42+
}
43+
44+
/**
45+
* Returns the widest filename's pixel width across `files`. The caller is
46+
* responsible for adding icon/gap/padding chrome and clamping between min
47+
* and max column widths.
48+
*
49+
* Returns 0 when no measurer is available (SSR or jsdom without canvas) —
50+
* the caller should fall back to the default cap in that case.
51+
*/
52+
export function measureWidestFilename(files: FileEntry[]): number {
53+
const measure = getMeasure()
54+
if (!measure) return 0
55+
let max = 0
56+
for (const f of files) {
57+
const w = measure(f.name)
58+
if (w > max) max = w
59+
}
60+
return max
61+
}

0 commit comments

Comments
 (0)