Skip to content

Commit c569899

Browse files
committed
File list: Cap Ext column width on long extensions
- Clamp per-row Ext text width at `measure('extensionxx')` so a pathological extension can't push the rest of the row off-screen. Cap is measured via pretext, so it scales with font. - `.col-ext` now uses `useShortenMiddle` for middle ellipsis on overflow, with a tooltip showing the full extension only when truncated. - New `tooltipWhenTruncated` option on `useShortenMiddle` (default false; existing callers unchanged). - Note in `docs/architecture.md` that frontend text measurement always uses `@chenglou/pretext` (distinct from the Rust-bound `font-metrics/` per-char module).
1 parent 97d1067 commit c569899

5 files changed

Lines changed: 69 additions & 4 deletions

File tree

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
} from '$lib/settings/reactive-settings.svelte'
5151
import { iconCacheCleared } from '$lib/icon-cache'
5252
import { tooltip } from '$lib/tooltip/tooltip'
53+
import { useShortenMiddle } from '$lib/utils/shorten-middle-action'
5354
import type { RenameState } from '../rename/rename-state.svelte'
5455
5556
interface Props {
@@ -690,7 +691,13 @@
690691
{status ? glyphFor(status) : ''}
691692
</span>
692693
{/if}
693-
<span class="col-ext">{getDisplayExtension(file.name, file.isDirectory)}</span>
694+
<span
695+
class="col-ext"
696+
use:useShortenMiddle={{
697+
text: getDisplayExtension(file.name, file.isDirectory),
698+
tooltipWhenTruncated: true,
699+
}}
700+
></span>
694701
{/if}
695702
<span
696703
class="col-size"

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,23 @@ describe('computeFullListColumnWidths', () => {
8484
expect(long.ext).toBeGreaterThan(short.ext)
8585
})
8686

87+
it('caps the ext column so a pathological extension cannot dominate the row', () => {
88+
_setMeasureForTests(fakeMeasure)
89+
const longExt = 'extension-extension-extension-extension-extension'
90+
// Cap sample is "extensionxx" (11 chars × 7 = 77 px) — text only, no chrome.
91+
const capped = computeFullListColumnWidths({
92+
...baseArgs,
93+
entries: [entry({ name: `a.${longExt}` })],
94+
})
95+
expect(capped.ext).toBe('extensionxx'.length * 7)
96+
// And: the cap doesn't shrink columns below what real shorter extensions deserve.
97+
const normal = computeFullListColumnWidths({
98+
...baseArgs,
99+
entries: [entry({ name: 'a.js' })],
100+
})
101+
expect(capped.ext).toBeGreaterThan(normal.ext)
102+
})
103+
87104
it('widens date column based on longest formatted date', () => {
88105
_setMeasureForTests(fakeMeasure)
89106
const short = computeFullListColumnWidths({

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ const MIN_EXT_WIDTH = 28
5555
const MIN_SIZE_WIDTH = 40
5656
const MIN_DATE_WIDTH = 70
5757

58+
/**
59+
* Cap-width sample for the Ext column. A pathological extension like
60+
* `extension-extension-extension-…` would otherwise stretch the column wide
61+
* enough to push the rest of the row off-screen. The sample is measured with
62+
* pretext at the current font, so the cap scales with font size/family.
63+
* `extensionxx` ≈ 11 chars — generous enough for real-world long extensions
64+
* (`controller`, `component`) without truncating, and just over the literal
65+
* word "extension" so that word itself never gets clipped.
66+
*/
67+
const EXT_CAP_SAMPLE = 'extensionxx'
68+
5869
let measureWidthCached: ((text: string) => number) | null = null
5970
let measureUnavailable = false
6071

@@ -168,14 +179,20 @@ export function computeFullListColumnWidths(args: {
168179
let sizeMax = measure('Size') + chromeFor('size')
169180
let dateMax = measure('Modified') + chromeFor('modified')
170181

182+
// Cap on per-row Ext text width so a single pathological extension can't
183+
// push the rest of the row off-screen. Compared against the row's text-only
184+
// measurement (no chrome), so the header bound — `measure('Ext') + chrome` —
185+
// can still win when no real extension is wider.
186+
const extCap = measure(EXT_CAP_SAMPLE)
187+
171188
// Per-row icons in the size column live to the right of the text; count the
172189
// widest icon suffix we've seen so we can add it to the data width.
173190
let sizeIconSuffixMax = 0
174191

175192
for (const entry of entries) {
176193
const ext = getDisplayExtension(entry.name, entry.isDirectory)
177194
if (ext) {
178-
const w = measure(ext)
195+
const w = Math.min(measure(ext), extCap)
179196
if (w > extMax) extMax = w
180197
}
181198

apps/desktop/src/lib/utils/shorten-middle-action.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ export interface ShortenMiddleParams {
55
text: string
66
preferBreakAt?: string
77
startRatio?: number
8+
/**
9+
* When true, the native `title` tooltip is set only when truncation actually
10+
* happened (so short, fully-visible text doesn't trigger a redundant tooltip).
11+
* Defaults to false — previous callers always show the full text on hover.
12+
*/
13+
tooltipWhenTruncated?: boolean
814
}
915

1016
// Shared across all action instances so the dynamic import runs at most once.
@@ -41,7 +47,16 @@ export function useShortenMiddle(node: HTMLElement, params: ShortenMiddleParams)
4147
node.style.textOverflow = 'ellipsis'
4248
node.style.whiteSpace = 'nowrap'
4349
node.textContent = params.text
44-
node.title = params.text
50+
if (!params.tooltipWhenTruncated) node.title = params.text
51+
52+
function applyTitle(truncated: boolean): void {
53+
if (params.tooltipWhenTruncated) {
54+
if (truncated) node.title = currentText
55+
else node.removeAttribute('title')
56+
} else {
57+
node.title = currentText
58+
}
59+
}
4560

4661
function truncate() {
4762
if (!measureWidth) return
@@ -52,6 +67,7 @@ export function useShortenMiddle(node: HTMLElement, params: ShortenMiddleParams)
5267
startRatio: params.startRatio,
5368
})
5469
node.textContent = result
70+
applyTitle(result !== currentText)
5571
}
5672

5773
// Load pretext, then switch from CSS fallback to pixel-accurate truncation
@@ -76,11 +92,12 @@ export function useShortenMiddle(node: HTMLElement, params: ShortenMiddleParams)
7692
params = newParams
7793
if (!textChanged) return
7894
currentText = newParams.text
79-
node.title = currentText
8095
if (measureWidth) {
8196
truncate()
8297
} else {
8398
node.textContent = currentText
99+
if (!params.tooltipWhenTruncated) node.title = currentText
100+
else node.removeAttribute('title')
84101
}
85102
},
86103
destroy() {

docs/architecture.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ All under `apps/desktop/src/lib/`.
3636
| `utils/` | Filename validation, confirm dialog utilities |
3737
| `font-metrics/` | Character width measurement for accurate Brief mode column sizing |
3838

39+
**Frontend text measurement always uses `@chenglou/pretext`.** When the frontend needs to know the pixel width of a
40+
string (column shrink-wrapping, middle-truncation, viewer line heights, etc.), call
41+
`createPretextMeasure(font, pretext)` from `lib/utils/shorten-middle.ts` rather than rolling a Canvas `measureText` or
42+
DOM-reflow path. Pretext matches the browser's own text shaping and is dynamically imported so it doesn't bloat the
43+
initial bundle. The separate `font-metrics/` module above is a distinct concern — it ships per-character widths to Rust
44+
for backend column sizing.
45+
3946
## Backend (Rust + Tauri 2)
4047

4148
All under `apps/desktop/src-tauri/src/`.

0 commit comments

Comments
 (0)