Skip to content

Commit 474f741

Browse files
committed
File list: Align date+time across rows via | split in date format
A date format may contain a single optional `|` that splits each formatted cell into a left half (typically the date) and a right half (typically the time). The Modified column then shrink-wraps both halves separately so the right halves line up across rows even when date widths vary. - `formatDateTimePartsWithFormat` returns `{ left, right | null }`. `formatDateTimeWithFormat` keeps returning a single string (parts joined with a space) for tooltips, MCP, and the status bar. - Built-in `iso` and `short` formats include `|` internally; default custom format updated to `YYYY-MM-DD | HH:mm`. - `computeFullListColumnWidths` exposes a `dateLeft` width; `FullList` renders `.date-left` (inline-block, fixed width, right-aligned) and `.date-right` (margin-left `--spacing-xs`). - Whitespace around `|` is trimmed; a degenerate `format |` with empty right side is treated as no-split. - Test mocks updated to include `formatDateTimeParts`.
1 parent 88f5636 commit 474f741

12 files changed

Lines changed: 271 additions & 44 deletions

apps/desktop/src/lib/file-explorer/pane/file-pane-keyboard.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ vi.mock('$lib/icon-cache', async () => {
105105
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
106106
getRowHeight: vi.fn().mockReturnValue(24),
107107
formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'),
108+
formatDateTimeParts: vi.fn().mockReturnValue({ left: '2025-01-01', right: '00:00' }),
108109
formatFileSize: vi.fn().mockReturnValue('1.0 KB'),
109110
getUseAppIconsForDocuments: vi.fn().mockReturnValue(true),
110111
getSizeDisplayMode: vi.fn().mockReturnValue('smart'),

apps/desktop/src/lib/file-explorer/pane/selection-consistency.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ vi.mock('$lib/icon-cache', async () => {
107107
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
108108
getRowHeight: vi.fn().mockReturnValue(24),
109109
formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'),
110+
formatDateTimeParts: vi.fn().mockReturnValue({ left: '2025-01-01', right: '00:00' }),
110111
formatFileSize: vi.fn().mockReturnValue('1.0 KB'),
111112
getUseAppIconsForDocuments: vi.fn().mockReturnValue(true),
112113
getSizeDisplayMode: vi.fn().mockReturnValue('smart'),

apps/desktop/src/lib/file-explorer/pane/volume-breadcrumb.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ vi.mock('$lib/icon-cache', async () => {
103103
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
104104
getRowHeight: vi.fn().mockReturnValue(24),
105105
formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'),
106+
formatDateTimeParts: vi.fn().mockReturnValue({ left: '2025-01-01', right: '00:00' }),
106107
formatFileSize: vi.fn().mockReturnValue('1.0 KB'),
107108
getUseAppIconsForDocuments: vi.fn().mockReturnValue(true),
108109
getSizeDisplayMode: vi.fn().mockReturnValue('smart'),

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ vi.mock('$lib/settings/reactive-settings.svelte', () => ({
3838
getIsCompactDensity: () => false,
3939
getUseAppIconsForDocuments: () => true,
4040
formatDateTime: (t: number | undefined) => (t ? '2025-03-14 10:30' : ''),
41+
formatDateTimeParts: (t: number | undefined) =>
42+
t ? { left: '2025-03-14', right: '10:30' } : { left: '', right: null },
4143
formatFileSize: (n: number) => `${String(n)} B`,
4244
getSizeDisplayMode: () => 'smart',
4345
getSizeMismatchWarning: () => false,

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
import {
4141
getRowHeight,
4242
getIsCompactDensity,
43-
formatDateTime,
43+
formatDateTimeParts,
4444
formatFileSize,
4545
getSizeDisplayMode,
4646
getSizeMismatchWarning,
@@ -159,7 +159,7 @@
159159
const indexing = $derived(isScanning() || isAggregating())
160160
161161
// Column widths are declared after the virtual window, which gates parent-row inclusion.
162-
let columnWidths = $state({ ext: 60, size: 115, date: 80 })
162+
let columnWidths = $state({ ext: 60, size: 115, date: 80, dateLeft: 0 })
163163
let skipTransition = $state(false)
164164
165165
/**
@@ -241,7 +241,7 @@
241241
columnWidths = computeFullListColumnWidths({
242242
entries: visible,
243243
parentDirStats: parentStats,
244-
formatDateTime,
244+
formatDateTimeParts,
245245
sizeDisplayMode,
246246
indexing,
247247
showSizeMismatchWarning,
@@ -630,6 +630,7 @@
630630
? getDisplaySize(file.size, file.physicalSize, sizeDisplayMode)
631631
: undefined}
632632
{@const sizeOverride = pickSizeDisplay(file)}
633+
{@const dateParts = formatDateTimeParts(file.modifiedAt)}
633634
<!-- svelte-ignore a11y_interactive_supports_focus -->
634635
<div
635636
id={`file-${String(globalIndex)}`}
@@ -768,7 +769,15 @@
768769
>
769770
{/if}
770771
</span>
771-
<span class="col-date">{formatDateTime(file.modifiedAt)}</span>
772+
<span class="col-date">
773+
{#if dateParts.right !== null && columnWidths.dateLeft > 0}
774+
<span class="date-left" style="width: {columnWidths.dateLeft}px"
775+
>{dateParts.left}</span
776+
><span class="date-right">{dateParts.right}</span>
777+
{:else}
778+
{dateParts.left}
779+
{/if}
780+
</span>
772781
</div>
773782
{/each}
774783
</div>
@@ -989,6 +998,24 @@
989998
text-overflow: ellipsis;
990999
font-size: var(--font-size-sm);
9911000
color: var(--color-text-secondary);
1001+
white-space: nowrap;
1002+
}
1003+
1004+
/* Split date cells: `.date-left` is fixed-width (set inline from the
1005+
column-widths measurer) so the right halves align across rows. The 4px
1006+
margin on `.date-right` is mirrored as `DATE_PARTS_GAP` in
1007+
`measure-column-widths.ts` — keep them in sync. */
1008+
.date-left {
1009+
display: inline-block;
1010+
text-align: right;
1011+
overflow: hidden;
1012+
text-overflow: ellipsis;
1013+
white-space: nowrap;
1014+
vertical-align: bottom;
1015+
}
1016+
1017+
.date-right {
1018+
margin-left: var(--spacing-xs);
9921019
}
9931020
9941021
.file-entry.is-selected .col-name {

apps/desktop/src/lib/file-explorer/views/measure-column-widths.test.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ function entry(overrides: Partial<FileEntry>): FileEntry {
3030

3131
const baseArgs = {
3232
parentDirStats: null,
33-
formatDateTime: (t: number) => new Date(t * 1000).toISOString().slice(0, 19).replace('T', ' '),
33+
formatDateTimeParts: (t: number) => ({
34+
left: new Date(t * 1000).toISOString().slice(0, 19).replace('T', ' '),
35+
right: null,
36+
}),
3437
sizeDisplayMode: 'smart' as const,
3538
indexing: false,
3639
showSizeMismatchWarning: false,
@@ -107,17 +110,58 @@ describe('computeFullListColumnWidths', () => {
107110
_setMeasureForTests(fakeMeasure)
108111
const short = computeFullListColumnWidths({
109112
...baseArgs,
110-
formatDateTime: () => 'today',
113+
formatDateTimeParts: () => ({ left: 'today', right: null }),
111114
entries: [entry({ name: 'a', modifiedAt: 1 })],
112115
})
113116
const long = computeFullListColumnWidths({
114117
...baseArgs,
115-
formatDateTime: () => '2026-12-31 23:59:59',
118+
formatDateTimeParts: () => ({ left: '2026-12-31 23:59:59', right: null }),
116119
entries: [entry({ name: 'a', modifiedAt: 1 })],
117120
})
118121
expect(long.date).toBeGreaterThan(short.date)
119122
})
120123

124+
it('reports dateLeft and total width when rows have split dates', () => {
125+
_setMeasureForTests(fakeMeasure)
126+
const w = computeFullListColumnWidths({
127+
...baseArgs,
128+
formatDateTimeParts: () => ({ left: '2026-12-31', right: '23:59' }),
129+
entries: [entry({ name: 'a', modifiedAt: 1 })],
130+
})
131+
// left "2026-12-31" = 10 × 7 = 70; right "23:59" = 5 × 7 = 35; gap = 4.
132+
// Total 70 + 4 + 35 = 109, which beats MIN_DATE_WIDTH (70).
133+
expect(w.dateLeft).toBe(70)
134+
expect(w.date).toBe(109)
135+
})
136+
137+
it('uses the widest left half across all rows when splits are uneven', () => {
138+
_setMeasureForTests(fakeMeasure)
139+
let i = 0
140+
const formatDateTimeParts = () => {
141+
const lefts = ['short', '2026-01-30']
142+
const left = lefts[i % 2]
143+
i++
144+
return { left, right: '14:30' }
145+
}
146+
const w = computeFullListColumnWidths({
147+
...baseArgs,
148+
formatDateTimeParts,
149+
entries: [entry({ name: 'a', modifiedAt: 1 }), entry({ name: 'b', modifiedAt: 2 })],
150+
})
151+
// dateLeft = max("short" = 35, "2026-01-30" = 70) = 70.
152+
expect(w.dateLeft).toBe(70)
153+
})
154+
155+
it('keeps dateLeft at zero when no row produces a split', () => {
156+
_setMeasureForTests(fakeMeasure)
157+
const w = computeFullListColumnWidths({
158+
...baseArgs,
159+
formatDateTimeParts: () => ({ left: '2026-12-31 23:59', right: null }),
160+
entries: [entry({ name: 'a', modifiedAt: 1 })],
161+
})
162+
expect(w.dateLeft).toBe(0)
163+
})
164+
121165
it('reserves icon width when a directory has a stale size during indexing', () => {
122166
_setMeasureForTests(fakeMeasure)
123167
const idle = computeFullListColumnWidths({

apps/desktop/src/lib/file-explorer/views/measure-column-widths.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import * as pretext from '@chenglou/pretext'
1212
import { formatSizeForDisplay } from '../selection/selection-info-utils'
1313
import type { FileEntry, SortColumn } from '../types'
1414
import type { FileSizeFormat } from '$lib/settings/types'
15+
import type { DateTimeParts } from '$lib/settings/format-utils'
1516
import { createPretextMeasure } from '$lib/utils/shorten-middle'
1617

1718
import type { DirStats } from './file-list-utils'
@@ -20,7 +21,14 @@ import { getDisplayExtension, getDisplaySize, hasSizeMismatch } from './full-lis
2021
export interface ColumnWidths {
2122
ext: number
2223
size: number
24+
/** Total date column width (including the inter-half gap when split). */
2325
date: number
26+
/**
27+
* Pixel width of the left half of split date cells. Used as the inline-block
28+
* width of `.date-left` so the right halves (typically the time) line up
29+
* across rows. Zero when no visible row produces a `|` split.
30+
*/
31+
dateLeft: number
2432
}
2533

2634
/**
@@ -59,6 +67,14 @@ const MIN_EXT_WIDTH = 28
5967
const MIN_SIZE_WIDTH = 40
6068
const MIN_DATE_WIDTH = 70
6169

70+
/**
71+
* Visual gap between the date and time halves of a split date cell.
72+
* `var(--spacing-xs)` (4px) — set as `margin-left` on `.date-right` in
73+
* `FullList.svelte`. Mirror this value if the CSS changes, or split-date
74+
* columns will be one or two pixels off.
75+
*/
76+
const DATE_PARTS_GAP = 4
77+
6278
/**
6379
* Cap-width sample for the Ext column. A pathological extension like
6480
* `extension-extension-extension-…` would otherwise stretch the column wide
@@ -133,6 +149,36 @@ function sizeTextForEntry(
133149
return s !== undefined ? sizeCellText(s, sizeFormatOpts) : ''
134150
}
135151

152+
/**
153+
* Running maxima for the date column. `total` is the row width when the date
154+
* is not split; `splitLeft` and `splitRight` accumulate when at least one row
155+
* has a `|` split. Pulled into its own helper to keep
156+
* `computeFullListColumnWidths` under the lint complexity cap.
157+
*/
158+
interface DateMaxima {
159+
total: number
160+
splitLeft: number
161+
splitRight: number
162+
}
163+
164+
function foldDate(
165+
current: DateMaxima,
166+
parts: { left: string; right: string | null },
167+
measure: (text: string) => number,
168+
): DateMaxima {
169+
if (parts.right === null) {
170+
const w = measure(parts.left)
171+
return w > current.total ? { ...current, total: w } : current
172+
}
173+
const lw = measure(parts.left)
174+
const rw = measure(parts.right)
175+
return {
176+
total: current.total,
177+
splitLeft: lw > current.splitLeft ? lw : current.splitLeft,
178+
splitRight: rw > current.splitRight ? rw : current.splitRight,
179+
}
180+
}
181+
136182
/** Pixel width of the size-column icons that follow the text for this row. */
137183
function sizeIconSuffixForEntry(entry: FileEntry, indexing: boolean, showSizeMismatchWarning: boolean): number {
138184
let suffix = 0
@@ -153,7 +199,7 @@ function sizeIconSuffixForEntry(entry: FileEntry, indexing: boolean, showSizeMis
153199
export function computeFullListColumnWidths(args: {
154200
entries: FileEntry[]
155201
parentDirStats?: DirStats | null
156-
formatDateTime: (timestamp: number) => string
202+
formatDateTimeParts: (timestamp: number) => DateTimeParts
157203
sizeDisplayMode: 'smart' | 'logical' | 'physical'
158204
indexing: boolean
159205
showSizeMismatchWarning: boolean
@@ -163,7 +209,7 @@ export function computeFullListColumnWidths(args: {
163209
const {
164210
entries,
165211
parentDirStats,
166-
formatDateTime,
212+
formatDateTimeParts,
167213
sizeDisplayMode,
168214
indexing,
169215
showSizeMismatchWarning,
@@ -173,7 +219,7 @@ export function computeFullListColumnWidths(args: {
173219

174220
const measure = getMeasure()
175221
if (!measure) {
176-
return { ext: MIN_EXT_WIDTH, size: MIN_SIZE_WIDTH, date: MIN_DATE_WIDTH }
222+
return { ext: MIN_EXT_WIDTH, size: MIN_SIZE_WIDTH, date: MIN_DATE_WIDTH, dateLeft: 0 }
177223
}
178224

179225
const chromeFor = (column: SortColumn): number => (sortBy === column ? HEADER_CHROME_ACTIVE : HEADER_CHROME_INACTIVE)
@@ -193,6 +239,11 @@ export function computeFullListColumnWidths(args: {
193239
// widest icon suffix we've seen so we can add it to the data width.
194240
let sizeIconSuffixMax = 0
195241

242+
// Track the two halves of split date cells separately so the renderer can
243+
// line up the right halves across rows. `splitLeft`/`splitRight` stay at 0
244+
// unless at least one row has a `|` split.
245+
let date: DateMaxima = { total: dateMax, splitLeft: 0, splitRight: 0 }
246+
196247
for (const entry of entries) {
197248
const ext = getDisplayExtension(entry.name, entry.isDirectory)
198249
if (ext) {
@@ -207,10 +258,10 @@ export function computeFullListColumnWidths(args: {
207258
if (iconSuffix > sizeIconSuffixMax) sizeIconSuffixMax = iconSuffix
208259

209260
if (entry.modifiedAt !== undefined) {
210-
const w = measure(formatDateTime(entry.modifiedAt))
211-
if (w > dateMax) dateMax = w
261+
date = foldDate(date, formatDateTimeParts(entry.modifiedAt), measure)
212262
}
213263
}
264+
dateMax = date.total
214265

215266
// The ".." row borrows the current folder's recursive size — often the largest
216267
// number in the listing, so fold it in or the column snaps wider the moment it loads.
@@ -222,9 +273,18 @@ export function computeFullListColumnWidths(args: {
222273
}
223274
}
224275

276+
// If any row had a split date, fold the two halves into the total. Header
277+
// overhead (caret allowance for sortBy='modified') is already baked into
278+
// the initial dateMax; we just take the larger of header and split-data.
279+
if (date.splitLeft > 0 || date.splitRight > 0) {
280+
const splitTotal = date.splitLeft + DATE_PARTS_GAP + date.splitRight
281+
if (splitTotal > dateMax) dateMax = splitTotal
282+
}
283+
225284
return {
226285
ext: Math.max(MIN_EXT_WIDTH, Math.ceil(extMax + WIDTH_PADDING)),
227286
size: Math.max(MIN_SIZE_WIDTH, Math.ceil(sizeMax + WIDTH_PADDING)),
228287
date: Math.max(MIN_DATE_WIDTH, Math.ceil(dateMax + WIDTH_PADDING)),
288+
dateLeft: Math.ceil(date.splitLeft),
229289
}
230290
}

apps/desktop/src/lib/file-explorer/views/view-modes.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ vi.mock('$lib/icon-cache', () => ({
2424
vi.mock('$lib/settings/reactive-settings.svelte', () => ({
2525
getRowHeight: vi.fn().mockReturnValue(24),
2626
formatDateTime: vi.fn().mockReturnValue('2025-01-01 00:00'),
27+
formatDateTimeParts: vi.fn().mockReturnValue({ left: '2025-01-01', right: '00:00' }),
2728
formatFileSize: vi.fn().mockReturnValue('1.0 KB'),
2829
getUseAppIconsForDocuments: vi.fn().mockReturnValue(true),
2930
}))

apps/desktop/src/lib/settings/format-utils.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { formatDateTimeWithFormat, formatFileSizeWithFormat } from './format-utils'
2+
import { formatDateTimeWithFormat, formatDateTimePartsWithFormat, formatFileSizeWithFormat } from './format-utils'
33

44
// Fixed timestamp: 2024-03-15 14:30:45 UTC
55
// We use a local date to avoid timezone flakiness
@@ -45,6 +45,49 @@ describe('formatDateTimeWithFormat', () => {
4545
})
4646
})
4747

48+
describe('formatDateTimePartsWithFormat', () => {
49+
it('returns empty parts for missing timestamp', () => {
50+
expect(formatDateTimePartsWithFormat(null, 'iso', '')).toEqual({ left: '', right: null })
51+
expect(formatDateTimePartsWithFormat(undefined, 'iso', '')).toEqual({ left: '', right: null })
52+
expect(formatDateTimePartsWithFormat(0, 'iso', '')).toEqual({ left: '', right: null })
53+
})
54+
55+
it('splits ISO into date and time halves', () => {
56+
expect(formatDateTimePartsWithFormat(timestamp, 'iso', '')).toEqual({ left: '2024-03-15', right: '14:30' })
57+
})
58+
59+
it('splits short into date and time halves', () => {
60+
expect(formatDateTimePartsWithFormat(timestamp, 'short', '')).toEqual({ left: '03/15', right: '14:30' })
61+
})
62+
63+
it('splits a custom format on `|` and trims whitespace around it', () => {
64+
expect(formatDateTimePartsWithFormat(timestamp, 'custom', 'YYYY/MM/DD | HH:mm:ss')).toEqual({
65+
left: '2024/03/15',
66+
right: '14:30:45',
67+
})
68+
})
69+
70+
it('returns no right half for a custom format without `|`', () => {
71+
expect(formatDateTimePartsWithFormat(timestamp, 'custom', 'YYYY/MM/DD HH:mm')).toEqual({
72+
left: '2024/03/15 14:30',
73+
right: null,
74+
})
75+
})
76+
77+
it('treats a degenerate `format |` (empty right side) as no split', () => {
78+
expect(formatDateTimePartsWithFormat(timestamp, 'custom', 'YYYY-MM-DD |')).toEqual({
79+
left: '2024-03-15',
80+
right: null,
81+
})
82+
})
83+
84+
it('keeps system-locale output unsplit', () => {
85+
const parts = formatDateTimePartsWithFormat(timestamp, 'system', '')
86+
expect(parts.right).toBeNull()
87+
expect(parts.left.length).toBeGreaterThan(0)
88+
})
89+
})
90+
4891
describe('formatFileSizeWithFormat', () => {
4992
describe('binary (base 1024)', () => {
5093
it('formats 0 bytes', () => {

0 commit comments

Comments
 (0)