Skip to content

Commit d67dd38

Browse files
committed
Polish file operation dialogs: readable numbers, stable width
- Format all file/dir counts with `formatNumber()` (thousands separators) in `TransferProgressDialog`, `TransferDialog`, `DeleteDialog`, toast messages, and clipboard notifications - Add `shortenMiddle` utility using `@chenglou/pretext` for pixel-accurate mid-text truncation with binary search, injectable `measureWidth` for testability, and `preferBreakAt` snap-to-delimiter support - Add `useShortenMiddle` Svelte action: async pretext loading with CSS fallback, `ResizeObserver`, font auto-detection - Apply to progress dialog current-file path (`preferBreakAt: "/"`) — no more width jitter - Refactor `SelectionInfo` filename truncation from 85-line DOM-based approach (throwaway `<span>` + `offsetWidth`) to the new action with `preferBreakAt: "."` (preserves extensions) - Fix all three dialog widths from `min-width/max-width` to fixed `width: 500px` - Update pretext from 0.0.3 to 0.0.5 (adds `measureNaturalWidth`) - Add "format large numbers" rule to style guide
1 parent 97c0481 commit d67dd38

17 files changed

Lines changed: 748 additions & 138 deletions

apps/desktop/coverage-allowlist.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@
256256
},
257257
"hmr-recovery.ts": {
258258
"reason": "Dev-only HMR crash handler, runs inside import.meta.hot, depends on window/sessionStorage/location.reload"
259+
},
260+
"utils/shorten-middle-action.ts": {
261+
"reason": "Svelte action, depends on DOM APIs (ResizeObserver, getComputedStyle, clientWidth) and dynamic import"
259262
}
260263
}
261264
}

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"license": "SEE LICENSE IN LICENSE",
3434
"dependencies": {
3535
"@ark-ui/svelte": "^5.19.1",
36-
"@chenglou/pretext": "^0.0.3",
36+
"@chenglou/pretext": "^0.0.5",
3737
"@crabnebula/tauri-plugin-drag": "^2.1.0",
3838
"@leeoniya/ufuzzy": "^1.0.19",
3939
"@logtape/logtape": "^2.0.4",

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
getIsAltHeld,
139139
setAltHeld,
140140
} from '../modifier-key-tracker.svelte'
141+
import { formatNumber } from '$lib/file-explorer/selection/selection-info-utils'
141142
import { addToast } from '$lib/ui/toast'
142143
143144
const log = getAppLogger('fileExplorer')
@@ -1510,7 +1511,7 @@
15101511
state.hasParent,
15111512
showHiddenFiles,
15121513
)
1513-
addToast(`Copied ${String(count)} ${count === 1 ? 'item' : 'items'}`)
1514+
addToast(`Copied ${formatNumber(count)} ${count === 1 ? 'item' : 'items'}`)
15141515
} catch (error) {
15151516
log.error('Clipboard copy failed: {error}', { error })
15161517
}
@@ -1534,7 +1535,7 @@
15341535
state.hasParent,
15351536
showHiddenFiles,
15361537
)
1537-
addToast(`${String(count)} ${count === 1 ? 'item' : 'items'} ready to move. Paste to complete.`)
1538+
addToast(`${formatNumber(count)} ${count === 1 ? 'item' : 'items'} ready to move. Paste to complete.`)
15381539
} catch (error) {
15391540
log.error('Clipboard cut failed: {error}', { error })
15401541
}

apps/desktop/src/lib/file-explorer/pane/dialog-state.svelte.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { formatBytes, refreshListing } from '$lib/tauri-commands'
22
import { listen, findFileIndex } from '$lib/tauri-commands'
3+
import { formatNumber } from '$lib/file-explorer/selection/selection-info-utils'
34
import { addToast } from '$lib/ui/toast'
45
import { getAppLogger } from '$lib/logging/logger'
56
import { moveCursorToNewFolder } from '$lib/file-operations/mkdir/new-folder-operations'
@@ -333,8 +334,8 @@ export function createDialogState(deps: DialogStateDeps) {
333334
const itemWord = filesProcessed === 1 ? 'file' : 'files'
334335
const toastMessage =
335336
op === 'trash'
336-
? `Moved ${String(filesProcessed)} ${itemWord} to trash`
337-
: `${opLabel} complete: ${String(filesProcessed)} ${itemWord}`
337+
? `Moved ${formatNumber(filesProcessed)} ${itemWord} to trash`
338+
: `${opLabel} complete: ${formatNumber(filesProcessed)} ${itemWord}`
338339
addToast(toastMessage)
339340

340341
refreshPanesAfterTransfer()

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

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ falls back to showing only dir count and percentage.
5050
Stale indicator (UnoCSS/Lucide `i-lucide:hourglass` icon in accent color) appears in `selection-summary` when
5151
`isScanning()` is true and directories are selected, because dir sizes may be incomplete during scanning.
5252

53-
Filename truncation in `file-info` mode uses a ResizeObserver + throwaway `<span>` measurement for middle truncation
54-
(preserves file extension). The truncation runs binary search via `getTruncatedName`, triggered reactively by
55-
`containerWidth` state.
53+
Filename truncation in `file-info` mode uses the `useShortenMiddle` action with `preferBreakAt: '.'` to preserve
54+
file extensions. The action uses pretext for canvas-based measurement and a built-in ResizeObserver.
5655

5756
Date column width is computed via `measureDateColumnWidth(formatDateTime)` to stay in sync with FullList —
5857
`formatDateTime` comes from `reactive-settings.svelte`.
@@ -81,10 +80,11 @@ Handles both `onclick` and `onkeydown` (Enter/Space).
8180
Human-readable values lose precision and make it impossible to compare similarly-sized files. Triads with tier-based CSS
8281
coloring (bytes/KB/MB/GB/TB) give both precision and quick visual scanning. Human-readable is available as a tooltip.
8382

84-
**Decision**: Middle truncation in `file-info` mode uses a throwaway `<span>` + binary search, not CSS
85-
`text-overflow: ellipsis` **Why**: CSS ellipsis truncates from the right, losing the file extension. Middle truncation
86-
preserves both the start of the filename and the extension (e.g. `very-lon...me.txt`). Binary search against measured
87-
pixel width handles variable-width fonts correctly.
83+
**Decision**: Middle truncation in `file-info` mode uses the `useShortenMiddle` Svelte action (from `$lib/utils/`)
84+
with `preferBreakAt: '.'` and `startRatio: 0.7`, not CSS `text-overflow: ellipsis` **Why**: CSS ellipsis truncates from
85+
the right, losing the file extension. Middle truncation with dot-snapping preserves both the start of the filename and
86+
the extension (e.g. `very-lon….txt`). The action uses pretext for pixel-accurate canvas measurement (no DOM reflow)
87+
with a built-in ResizeObserver.
8888

8989
**Decision**: `SelectionInfo` derives display mode from props rather than accepting an explicit `mode` prop **Why**: The
9090
display mode depends on `viewMode`, `selectedCount`, and `stats` together. Letting the component derive it internally
@@ -100,11 +100,6 @@ sizes may be incomplete, so the warning targets that specific case.
100100

101101
## Gotchas
102102

103-
**Gotcha**: `containerWidth` state exists only to trigger reactivity for `truncatedName` **Why**: `ResizeObserver`
104-
callbacks run outside Svelte's reactive graph. Writing to a `$state` variable inside the observer callback bridges the
105-
gap, causing `truncatedName` (which reads `containerWidth` via `void containerWidth`) to recompute when the container
106-
resizes.
107-
108103
**Gotcha**: `sizeTierClasses` CSS rules must be defined in the consuming view, not in `selection-info-utils.ts` **Why**:
109104
The utility file is pure TypeScript with no DOM or style dependencies. The CSS classes it references (`size-bytes`,
110105
`size-kb`, etc.) are defined in the parent list view's stylesheet, keeping style ownership with the view layer.

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

Lines changed: 3 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
} from '../views/full-list-utils'
2121
import { isScanning } from '$lib/indexing/index-state.svelte'
2222
import { tooltip } from '$lib/tooltip/tooltip'
23+
import { useShortenMiddle } from '$lib/utils/shorten-middle-action'
2324
import type { VolumeSpaceInfo } from '$lib/tauri-commands'
2425
import { formatDiskSpaceStatus } from '../disk-space-utils'
2526
@@ -117,96 +118,6 @@
117118
// Calculate date column width using measured text width (same utility as FullList)
118119
const dateColumnWidth = $derived(measureDateColumnWidth(formatDateTime))
119120
120-
// Middle-truncate long filenames
121-
let nameElement: HTMLSpanElement | undefined = $state()
122-
let containerElement: HTMLDivElement | undefined = $state()
123-
124-
// Use a separate state for truncated name, initialized lazily
125-
const getTruncatedName = $derived.by(() => {
126-
// This runs on every displayName change
127-
if (!nameElement || !containerElement || !entry) {
128-
return displayName
129-
}
130-
131-
const containerWidth = containerElement.clientWidth
132-
// Account for size and date widths plus gaps
133-
const sizeEl = containerElement.querySelector('.size')
134-
const dateEl = containerElement.querySelector('.date')
135-
const sizeWidth = sizeEl instanceof HTMLElement ? sizeEl.offsetWidth : 0
136-
const dateWidth = dateEl instanceof HTMLElement ? dateEl.offsetWidth : 0
137-
const diskSpaceEl = containerElement.querySelector('.disk-space-text')
138-
const diskSpaceWidth = diskSpaceEl instanceof HTMLElement ? diskSpaceEl.offsetWidth : 0
139-
const availableWidth = containerWidth - sizeWidth - dateWidth - diskSpaceWidth - 24 // gaps
140-
141-
// Create a temporary span to measure (avoids direct DOM manipulation)
142-
const measureSpan = document.createElement('span')
143-
measureSpan.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;'
144-
measureSpan.style.font = getComputedStyle(nameElement).font
145-
document.body.appendChild(measureSpan)
146-
147-
measureSpan.textContent = displayName
148-
const fullWidth = measureSpan.offsetWidth
149-
150-
if (fullWidth <= availableWidth) {
151-
document.body.removeChild(measureSpan)
152-
return displayName
153-
}
154-
155-
// Binary search for the right truncation point
156-
const extension = displayName.includes('.') ? displayName.slice(displayName.lastIndexOf('.')) : ''
157-
const baseName = displayName.includes('.') ? displayName.slice(0, displayName.lastIndexOf('.')) : displayName
158-
159-
// Keep at least 4 chars of the base name visible
160-
const minPrefix = 4
161-
const ellipsis = ''
162-
163-
let low = minPrefix
164-
let high = baseName.length
165-
let bestFit = minPrefix
166-
167-
while (low <= high) {
168-
const mid = Math.floor((low + high) / 2)
169-
measureSpan.textContent = baseName.slice(0, mid) + ellipsis + extension
170-
171-
if (measureSpan.offsetWidth <= availableWidth) {
172-
bestFit = mid
173-
low = mid + 1
174-
} else {
175-
high = mid - 1
176-
}
177-
}
178-
179-
document.body.removeChild(measureSpan)
180-
return baseName.slice(0, bestFit) + ellipsis + extension
181-
})
182-
183-
// Track container width for reactivity
184-
let containerWidth = $state(0)
185-
186-
// ResizeObserver for responsive truncation
187-
$effect(() => {
188-
if (!containerElement) return
189-
190-
const observer = new ResizeObserver((entries) => {
191-
for (const e of entries) {
192-
containerWidth = e.contentRect.width
193-
}
194-
})
195-
196-
observer.observe(containerElement)
197-
containerWidth = containerElement.clientWidth
198-
199-
return () => {
200-
observer.disconnect()
201-
}
202-
})
203-
204-
// Derive truncated name based on containerWidth (for reactivity)
205-
const truncatedName = $derived.by(() => {
206-
void containerWidth // Dependency trigger for resize
207-
return getTruncatedName
208-
})
209-
210121
// ========================================================================
211122
// No-selection mode (Full mode without selection)
212123
// ========================================================================
@@ -266,15 +177,15 @@
266177
)
267178
</script>
268179

269-
<div class="selection-info" bind:this={containerElement}>
180+
<div class="selection-info">
270181
{#if displayMode === 'empty'}
271182
<span class="summary-text">Nothing in here.</span>
272183
{#if volumeSpace}
273184
<span class="disk-space-text">{formatDiskSpaceStatus(volumeSpace, formatFileSize)}</span>
274185
{/if}
275186
{:else if displayMode === 'file-info' && entry}
276187
<!-- Brief mode without selection: show file info -->
277-
<span class="name" bind:this={nameElement} use:tooltip={displayName}>{truncatedName}</span>
188+
<span class="name" use:tooltip={displayName} use:useShortenMiddle={{ text: displayName, preferBreakAt: '.', startRatio: 0.7 }}></span>
278189
<span class="size" use:tooltip={sizeTooltip}>
279190
{#if sizeDisplay === 'DIR'}
280191
DIR
@@ -351,7 +262,6 @@
351262
min-width: 0;
352263
overflow: hidden;
353264
white-space: nowrap;
354-
text-overflow: clip; /* We handle truncation manually */
355265
}
356266
357267
.size {

apps/desktop/src/lib/file-operations/delete/DeleteDialog.svelte

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
type DeleteSourceItem,
2323
} from './delete-dialog-utils'
2424
import { formatFileSize } from '$lib/settings/reactive-settings.svelte'
25+
import { formatNumber } from '$lib/file-explorer/selection/selection-info-utils'
2526
import { getAppLogger } from '$lib/logging/logger'
2627
2728
const log = getAppLogger('deleteDialog')
@@ -184,7 +185,7 @@
184185
const parts: string[] = []
185186
if (size !== undefined) parts.push(formatFileSize(size))
186187
if (fileCount !== undefined) {
187-
parts.push(`${String(fileCount)} ${fileCount === 1 ? 'file' : 'files'}`)
188+
parts.push(`${formatNumber(fileCount)} ${fileCount === 1 ? 'file' : 'files'}`)
188189
}
189190
return parts.length > 0 ? parts.join(' ') : ''
190191
}
@@ -199,7 +200,7 @@
199200
role={dialogRole}
200201
onclose={handleCancel}
201202
ariaDescribedby={isPermanent ? 'delete-warning-text' : undefined}
202-
containerStyle="min-width: 420px; max-width: 500px"
203+
containerStyle="width: 500px"
203204
>
204205
{#snippet title()}{dialogTitle}{/snippet}
205206

@@ -244,7 +245,7 @@
244245
{/each}
245246
{#if overflowCount > 0}
246247
<div class="file-list-overflow" role="listitem">
247-
... and {overflowCount} more {overflowCount === 1 ? 'item' : 'items'}
248+
... and {formatNumber(overflowCount)} more {overflowCount === 1 ? 'item' : 'items'}
248249
</div>
249250
{/if}
250251
</div>
@@ -279,12 +280,12 @@
279280
</div>
280281
<span class="scan-divider">/</span>
281282
<div class="scan-stat">
282-
<span class="scan-value">{filesFound}</span>
283+
<span class="scan-value">{formatNumber(filesFound)}</span>
283284
<span class="scan-label">{filesFound === 1 ? 'file' : 'files'}</span>
284285
</div>
285286
<span class="scan-divider">/</span>
286287
<div class="scan-stat">
287-
<span class="scan-value">{dirsFound}</span>
288+
<span class="scan-value">{formatNumber(dirsFound)}</span>
288289
<span class="scan-label">{dirsFound === 1 ? 'dir' : 'dirs'}</span>
289290
</div>
290291
{#if isScanning}

apps/desktop/src/lib/file-operations/transfer/TransferDialog.svelte

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import Button from '$lib/ui/Button.svelte'
3030
import { generateTitle, toVolumeRelativePath } from './transfer-dialog-utils'
3131
import { getVolumes } from '$lib/stores/volume-store.svelte'
32+
import { formatNumber } from '$lib/file-explorer/selection/selection-info-utils'
3233
import { getAppLogger } from '$lib/logging/logger'
3334
3435
const log = getAppLogger('transferDialog')
@@ -385,7 +386,7 @@
385386
onkeydown={handleKeydown}
386387
dialogId="transfer-confirmation"
387388
onclose={handleCancel}
388-
containerStyle="min-width: 420px; max-width: 500px"
389+
containerStyle="width: 500px"
389390
>
390391
{#snippet title()}{dialogTitle}{/snippet}
391392

@@ -454,12 +455,12 @@
454455
</div>
455456
<span class="scan-divider">/</span>
456457
<div class="scan-stat">
457-
<span class="scan-value">{filesFound}</span>
458+
<span class="scan-value">{formatNumber(filesFound)}</span>
458459
<span class="scan-label">{filesFound === 1 ? 'file' : 'files'}</span>
459460
</div>
460461
<span class="scan-divider">/</span>
461462
<div class="scan-stat">
462-
<span class="scan-value">{dirsFound}</span>
463+
<span class="scan-value">{formatNumber(dirsFound)}</span>
463464
<span class="scan-label">{dirsFound === 1 ? 'dir' : 'dirs'}</span>
464465
</div>
465466
{#if isScanning}

apps/desktop/src/lib/file-operations/transfer/TransferProgressDialog.svelte

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@
3838
ConflictResolution,
3939
} from '$lib/file-explorer/types'
4040
import { getVolumes } from '$lib/stores/volume-store.svelte'
41-
import { formatDate } from '$lib/file-explorer/selection/selection-info-utils'
41+
import { formatDate, formatNumber } from '$lib/file-explorer/selection/selection-info-utils'
4242
import { formatFileSize } from '$lib/settings/reactive-settings.svelte'
4343
import { getSetting } from '$lib/settings'
4444
import DirectionIndicator from './DirectionIndicator.svelte'
4545
import ModalDialog from '$lib/ui/ModalDialog.svelte'
4646
import Button from '$lib/ui/Button.svelte'
4747
import { tooltip } from '$lib/tooltip/tooltip'
48+
import { useShortenMiddle } from '$lib/utils/shorten-middle-action'
4849
import ProgressBar from '$lib/ui/ProgressBar.svelte'
4950
import { getAppLogger } from '$lib/logging/logger'
5051
@@ -736,7 +737,7 @@
736737
onkeydown={handleKeydown}
737738
dialogId="transfer-progress"
738739
onclose={() => void handleCancel(false)}
739-
containerStyle="min-width: 420px; max-width: 500px"
740+
containerStyle="width: 500px"
740741
>
741742
{#snippet title()}
742743
{#if waitingForScan}
@@ -763,12 +764,12 @@
763764
</div>
764765
<span class="scan-divider">/</span>
765766
<div class="scan-stat">
766-
<span class="scan-value">{scanFilesFound}</span>
767+
<span class="scan-value">{formatNumber(scanFilesFound)}</span>
767768
<span class="scan-label">{scanFilesFound === 1 ? 'file' : 'files'}</span>
768769
</div>
769770
<span class="scan-divider">/</span>
770771
<div class="scan-stat">
771-
<span class="scan-value">{scanDirsFound}</span>
772+
<span class="scan-value">{formatNumber(scanDirsFound)}</span>
772773
<span class="scan-label">{scanDirsFound === 1 ? 'dir' : 'dirs'}</span>
773774
</div>
774775
<span class="scan-spinner"></span>
@@ -933,7 +934,7 @@
933934

934935
<span class="progress-label">{operationType === 'trash' ? 'Items' : 'Files'}</span>
935936
<ProgressBar value={filesTotal > 0 ? filesDone / filesTotal : 0} ariaLabel="File progress" />
936-
<span class="progress-detail">{filesDone} / {filesTotal}</span>
937+
<span class="progress-detail">{formatNumber(filesDone)} / {formatNumber(filesTotal)}</span>
937938
<div class="progress-meta">
938939
{#if stats.bytesPerSecond > 0}
939940
<span class="progress-speed">{formatBytes(stats.bytesPerSecond)}/s</span>
@@ -947,8 +948,7 @@
947948

948949
<!-- Current file -->
949950
{#if currentFile}
950-
<div class="current-file" use:tooltip={{ text: currentFile, overflowOnly: true }}>
951-
{currentFile}
951+
<div class="current-file" use:useShortenMiddle={{ text: currentFile, preferBreakAt: '/' }}>
952952
</div>
953953
{/if}
954954

@@ -1126,7 +1126,6 @@
11261126
font-size: var(--font-size-sm);
11271127
color: var(--color-text-tertiary);
11281128
overflow: hidden;
1129-
text-overflow: ellipsis;
11301129
white-space: nowrap;
11311130
background: var(--color-bg-tertiary);
11321131
margin: 0 var(--spacing-lg);

0 commit comments

Comments
 (0)