From e97ae645a62d95e05103f31ab50cb613149f70d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 23 Jul 2026 11:12:59 +0200 Subject: [PATCH 1/2] Apply middle path trimming to discard changes popup --- .../src/components/changes/ChangesView.tsx | 25 ++++++++++++------- src/renderer/src/styles/features/changes.css | 14 ++++++++++- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/changes/ChangesView.tsx b/src/renderer/src/components/changes/ChangesView.tsx index 88bb58e..7737ab0 100644 --- a/src/renderer/src/components/changes/ChangesView.tsx +++ b/src/renderer/src/components/changes/ChangesView.tsx @@ -16,7 +16,7 @@ import { Popover } from '@/components/common/Popover' import { Resizer } from '@/components/common/Resizer' import { WorkingFileList } from '@/components/common/WorkingFileList' import type { FileSelection } from '@/lib/commit-selection' -import { pluralize, statusLetter } from '@/lib/format' +import { pluralize, splitPath, statusLetter } from '@/lib/format' import { Icon } from '@/lib/icons' import { ignoreOptionsFor, ignoreSelectionOption } from '@/lib/ignore' import { conflictActionLabels, mergeSourceFromDetail } from '@/lib/merge' @@ -108,14 +108,21 @@ function DiscardSummary({ This will discard the {all ? 'unstaged ' : ''}changes in{' '} {files.length === 1 ? {files[0].path} : pluralize(files.length, 'file')}.
- {files.slice(0, DISCARD_LIST_MAX).map((f) => ( -
- {statusLetter(f.status)} - - {f.path} - -
- ))} + {files.slice(0, DISCARD_LIST_MAX).map((f) => { + // Same trimming as the changes list: the directory shrinks away + // first so the file name — the part that identifies the file — + // stays readable. + const { dir, name } = splitPath(f.path) + return ( +
+ {statusLetter(f.status)} + + {dir && {dir}} + {name} + +
+ ) + })} {overflow > 0 && (
…and {pluralize(overflow, 'more file')}
)} diff --git a/src/renderer/src/styles/features/changes.css b/src/renderer/src/styles/features/changes.css index 7421143..76c5a3c 100644 --- a/src/renderer/src/styles/features/changes.css +++ b/src/renderer/src/styles/features/changes.css @@ -275,10 +275,22 @@ font-size: 12px; min-width: 0; } +/* Same trimming as .wfl__path: the directory prefix gives way first (ellipsis) + so the file name always stays visible. */ .discard-list__path { + display: flex; + min-width: 0; + white-space: nowrap; + overflow: hidden; +} +.discard-list__dir { + color: var(--fg-path-dir); overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; + flex: 0 1 auto; +} +.discard-list__name { + flex: 0 0 auto; } .discard-list__more { padding: 4px 10px 2px; From af3adfd93c4507ca031a259310904b9781b99bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Thu, 23 Jul 2026 11:21:22 +0200 Subject: [PATCH 2/2] Cut trimmed paths by measurement so the name sits flush after the ellipsis CSS text-overflow drops the whole partially-clipped character and draws the ellipsis where the last full one ended, leaving a ragged, row-varying gap between the dimmed directory and the file name. The new TrimmedPath primitive measures the text (canvas measureText, binary-searched in pathTrim.ts) and cuts the directory string itself, so the name always sits directly after the ellipsis. Adopted by the changes-list rows (WorkingFileList, also serving History/Graph/stash lists) and the discard dialog's file list. --- .../src/components/changes/ChangesView.tsx | 26 +++--- .../src/components/common/TrimmedPath.tsx | 84 +++++++++++++++++++ .../src/components/common/WorkingFileList.tsx | 16 ++-- .../src/components/common/pathTrim.test.ts | 63 ++++++++++++++ .../src/components/common/pathTrim.ts | 33 ++++++++ src/renderer/src/styles/features/changes.css | 33 +------- src/renderer/src/styles/primitives.css | 22 +++++ 7 files changed, 225 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/components/common/TrimmedPath.tsx create mode 100644 src/renderer/src/components/common/pathTrim.test.ts create mode 100644 src/renderer/src/components/common/pathTrim.ts diff --git a/src/renderer/src/components/changes/ChangesView.tsx b/src/renderer/src/components/changes/ChangesView.tsx index 7737ab0..3724a0b 100644 --- a/src/renderer/src/components/changes/ChangesView.tsx +++ b/src/renderer/src/components/changes/ChangesView.tsx @@ -14,9 +14,10 @@ import { DEFAULT_FILTER_TYPES, useFileFilter } from '@/components/common/FileFil import { type FileHistoryMode, fileHistoryItems } from '@/components/common/fileHistoryItems' import { Popover } from '@/components/common/Popover' import { Resizer } from '@/components/common/Resizer' +import { TrimmedPath } from '@/components/common/TrimmedPath' import { WorkingFileList } from '@/components/common/WorkingFileList' import type { FileSelection } from '@/lib/commit-selection' -import { pluralize, splitPath, statusLetter } from '@/lib/format' +import { pluralize, statusLetter } from '@/lib/format' import { Icon } from '@/lib/icons' import { ignoreOptionsFor, ignoreSelectionOption } from '@/lib/ignore' import { conflictActionLabels, mergeSourceFromDetail } from '@/lib/merge' @@ -108,21 +109,14 @@ function DiscardSummary({ This will discard the {all ? 'unstaged ' : ''}changes in{' '} {files.length === 1 ? {files[0].path} : pluralize(files.length, 'file')}.
- {files.slice(0, DISCARD_LIST_MAX).map((f) => { - // Same trimming as the changes list: the directory shrinks away - // first so the file name — the part that identifies the file — - // stays readable. - const { dir, name } = splitPath(f.path) - return ( -
- {statusLetter(f.status)} - - {dir && {dir}} - {name} - -
- ) - })} + {files.slice(0, DISCARD_LIST_MAX).map((f) => ( +
+ {statusLetter(f.status)} + {/* Same trimming as the changes list: the directory gives way so the + file name — the part that identifies the file — stays readable. */} + +
+ ))} {overflow > 0 && (
…and {pluralize(overflow, 'more file')}
)} diff --git a/src/renderer/src/components/common/TrimmedPath.tsx b/src/renderer/src/components/common/TrimmedPath.tsx new file mode 100644 index 0000000..f71a955 --- /dev/null +++ b/src/renderer/src/components/common/TrimmedPath.tsx @@ -0,0 +1,84 @@ +// A file path rendered as dim directory prefix + strong basename, with the +// prefix middle-trimmed **by measurement** so the basename always stays fully +// visible and sits flush after the ellipsis. CSS `text-overflow` can't do +// this: the browser drops the whole partially-clipped character and draws "…" +// where the last full one ended, leaving a ragged, row-varying gap between the +// ellipsis and the basename. Cutting the text itself (canvas-measured, binary +// search in pathTrim.ts) removes the gap entirely. +// +// The container's width must not depend on its content (e.g. `flex: 1`), +// otherwise trimming would shrink the container and re-trigger the observer. +// +// styles: primitives.css (.tpath) + +import { type HTMLAttributes, useLayoutEffect, useRef, useState } from 'react' +import { splitPath } from '@/lib/format' +import { highlightMatch } from '@/lib/highlight' +import { trimDirToFit } from './pathTrim' + +let sharedCtx: CanvasRenderingContext2D | null = null + +/** measureText bound to the element's computed font and letter-spacing. */ +function measurerFor(el: HTMLElement): (text: string) => number { + sharedCtx ??= document.createElement('canvas').getContext('2d') + const ctx = sharedCtx + if (!ctx) return (text) => text.length * 8 // canvas unavailable: rough guess + const cs = getComputedStyle(el) + ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}` + ctx.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing + return (text) => ctx.measureText(text).width +} + +interface Props extends HTMLAttributes { + /** Repo-relative path to display. */ + path: string + /** Filter query to highlight inside both segments (see lib/highlight). */ + highlight?: string + 'data-tip'?: string + 'data-tip-overflow'?: string +} + +export function TrimmedPath({ + path, + highlight = '', + className, + 'data-tip-overflow': tipOverflow, + ...rest +}: Props) { + const { dir, name } = splitPath(path) + const ref = useRef(null) + const [dirText, setDirText] = useState(dir) + + // Fit before paint whenever the path changes (no flash while the virtual + // list recycles rows), then re-fit on every container resize (panel drags). + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const fit = () => { + const measure = measurerFor(el) + // clientWidth rounds to whole px — the 1px slack keeps a rounded-up + // width from pushing the fitted text a fraction over and clipping it. + setDirText(trimDirToFit(dir, el.clientWidth - measure(name) - 1, measure)) + } + fit() + const ro = new ResizeObserver(fit) + ro.observe(el) + return () => ro.disconnect() + }, [dir, name]) + + const trimmed = dirText !== dir + return ( + + {dirText && {highlightMatch(dirText, highlight)}} + {highlightMatch(name, highlight)} + + ) +} diff --git a/src/renderer/src/components/common/WorkingFileList.tsx b/src/renderer/src/components/common/WorkingFileList.tsx index add1cb8..639ee9a 100644 --- a/src/renderer/src/components/common/WorkingFileList.tsx +++ b/src/renderer/src/components/common/WorkingFileList.tsx @@ -22,12 +22,12 @@ import type { ChangedFile } from '@shared/types' import { memo, useEffect, useMemo, useRef, useState } from 'react' import type { FileSelection } from '@/lib/commit-selection' -import { splitPath, statusLabel, statusLetter } from '@/lib/format' -import { highlightMatch } from '@/lib/highlight' +import { statusLabel, statusLetter } from '@/lib/format' import { Icon } from '@/lib/icons' import { isCmdOrCtrl } from '@/lib/platform' import { useEvent } from '@/lib/useEvent' import { ContextMenu, type ContextMenuItem } from './ContextMenu' +import { TrimmedPath } from './TrimmedPath' import { useVirtualScroll, VScrollbar } from './VirtualScroll' interface Props { @@ -90,7 +90,6 @@ const Row = memo(function Row({ onMenu }: RowProps) { const conflicted = file.status === 'conflicted' - const { dir, name } = splitPath(file.path) return (
{statusLetter(file.status)} - - {dir && {highlightMatch(dir, highlight)}} - {highlightMatch(name, highlight)} - + {file.submodule && ( diff --git a/src/renderer/src/components/common/pathTrim.test.ts b/src/renderer/src/components/common/pathTrim.test.ts new file mode 100644 index 0000000..5a2d62a --- /dev/null +++ b/src/renderer/src/components/common/pathTrim.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from 'bun:test' +import { ELLIPSIS, trimDirToFit } from './pathTrim' + +// 10px per code point, so widths are trivial to reason about in the cases. +const measure = (text: string) => Array.from(text).length * 10 + +describe('trimDirToFit', () => { + test('returns the dir untouched when it already fits', () => { + expect(trimDirToFit('src/lib/', 80, measure)).toBe('src/lib/') + expect(trimDirToFit('src/lib/', 200, measure)).toBe('src/lib/') + }) + + test('cuts to the longest prefix that fits with the ellipsis', () => { + // 5 slots: 4 chars + the ellipsis. + expect(trimDirToFit('src/lib/deep/', 50, measure)).toBe(`src/${ELLIPSIS}`) + }) + + test('an exact fit is not trimmed', () => { + expect(trimDirToFit('src/lib/', 80, measure)).toBe('src/lib/') + }) + + test('one pixel short of an exact fit gives up the ellipsis room too', () => { + // 79px fits 7 slots: 6 characters plus the ellipsis. + expect(trimDirToFit('src/lib/', 79, measure)).toBe(`src/li${ELLIPSIS}`) + }) + + test('returns just the ellipsis when only it fits', () => { + expect(trimDirToFit('src/', 10, measure)).toBe(ELLIPSIS) + }) + + test('returns nothing when not even the ellipsis fits', () => { + expect(trimDirToFit('src/', 9, measure)).toBe('') + expect(trimDirToFit('src/', -5, measure)).toBe('') + }) + + test('empty dir stays empty', () => { + expect(trimDirToFit('', 100, measure)).toBe('') + expect(trimDirToFit('', 0, measure)).toBe('') + }) + + test('never splits a surrogate pair', () => { + // "📁" is two UTF-16 units but one code point (one 10px slot here). With + // room for two slots the cut lands after the emoji, never inside it. + const trimmed = trimDirToFit('a📁b/', 30, measure) + expect(trimmed).toBe(`a📁${ELLIPSIS}`) + }) + + test('matches a linear search for every width', () => { + const dir = 'Modules/AssetBundle/Tests/' + for (let max = 0; max <= measure(dir) + 10; max++) { + const fast = trimDirToFit(dir, max, measure) + let slow = '' + if (measure(dir) <= max) slow = dir + else if (measure(ELLIPSIS) <= max) { + const chars = Array.from(dir) + let n = 0 + while (n < chars.length && measure(chars.slice(0, n + 1).join('') + ELLIPSIS) <= max) n++ + slow = chars.slice(0, n).join('') + ELLIPSIS + } + expect(fast).toBe(slow) + } + }) +}) diff --git a/src/renderer/src/components/common/pathTrim.ts b/src/renderer/src/components/common/pathTrim.ts new file mode 100644 index 0000000..6a40b87 --- /dev/null +++ b/src/renderer/src/components/common/pathTrim.ts @@ -0,0 +1,33 @@ +// The middle-trim maths behind TrimmedPath.tsx: cut a path's directory prefix +// so the rendered "prefix…" + basename exactly fills the available width. Pure +// — the caller injects the text measurer — so the cut is testable without a +// DOM. + +export const ELLIPSIS = '…' + +/** + * The longest prefix of `dir` that, followed by the ellipsis, fits in + * `maxWidth`: `dir` untouched when the whole thing already fits, '' when not + * even the bare ellipsis does. `measure` maps text to its rendered width and + * must grow with its input. The search bisects over code points, so surrogate + * pairs (an emoji in a folder name) are never cut in half. + */ +export function trimDirToFit( + dir: string, + maxWidth: number, + measure: (text: string) => number +): string { + if (measure(dir) <= maxWidth) return dir + if (measure(ELLIPSIS) > maxWidth) return '' + const chars = Array.from(dir) + // Invariant: prefix of length `lo` fits (with the ellipsis), `hi` doesn't — + // `hi` starts at the full length, which the check above proved too wide. + let lo = 0 + let hi = chars.length + while (hi - lo > 1) { + const mid = (lo + hi) >> 1 + if (measure(chars.slice(0, mid).join('') + ELLIPSIS) <= maxWidth) lo = mid + else hi = mid + } + return chars.slice(0, lo).join('') + ELLIPSIS +} diff --git a/src/renderer/src/styles/features/changes.css b/src/renderer/src/styles/features/changes.css index 76c5a3c..53e96af 100644 --- a/src/renderer/src/styles/features/changes.css +++ b/src/renderer/src/styles/features/changes.css @@ -275,22 +275,9 @@ font-size: 12px; min-width: 0; } -/* Same trimming as .wfl__path: the directory prefix gives way first (ellipsis) - so the file name always stays visible. */ +/* Width for the row's TrimmedPath (see primitives.css → .tpath). */ .discard-list__path { - display: flex; - min-width: 0; - white-space: nowrap; - overflow: hidden; -} -.discard-list__dir { - color: var(--fg-path-dir); - overflow: hidden; - text-overflow: ellipsis; - flex: 0 1 auto; -} -.discard-list__name { - flex: 0 0 auto; + flex: 1; } .discard-list__more { padding: 4px 10px 2px; @@ -409,13 +396,11 @@ .wfl__status.st-conflicted { color: var(--st-conflicted); } +/* Width/typography for the row's TrimmedPath (the dir/name split and the + measured middle-trim live in primitives.css → .tpath). */ .wfl__path { flex: 1; - min-width: 0; - display: flex; font-size: 12px; - white-space: nowrap; - overflow: hidden; } /* Trailing submodule marker on a changes-list row. */ .wfl__module { @@ -425,16 +410,6 @@ margin-right: 8px; color: var(--fg-faint); } -.wfl__dir { - color: var(--fg-path-dir); - overflow: hidden; - text-overflow: ellipsis; - flex: 0 1 auto; -} -.wfl__name { - color: var(--fg); - flex: 0 0 auto; -} /* ── Hunk staging bar (annotation row inside the working diff) ───────────── */ .stage-bar { display: flex; diff --git a/src/renderer/src/styles/primitives.css b/src/renderer/src/styles/primitives.css index 53364d1..5857122 100644 --- a/src/renderer/src/styles/primitives.css +++ b/src/renderer/src/styles/primitives.css @@ -795,6 +795,28 @@ color: inherit; border-radius: 2px; } +/* ── Trimmed path (TrimmedPath.tsx) ───────────────────────────────────────── */ +/* A path as dim directory + strong basename, the directory middle-trimmed by + measurement (not text-overflow) so the basename sits flush after the "…" — + see TrimmedPath.tsx for why. Sites add their own class for width/typography; + the container's width must not depend on its content (e.g. flex: 1). The + overflow rules are only a safety net for the frames between a resize and the + observer's re-fit. */ +.tpath { + display: flex; + min-width: 0; + white-space: nowrap; + overflow: hidden; +} +.tpath__dir { + color: var(--fg-path-dir); + flex: 0 1 auto; + overflow: hidden; +} +.tpath__name { + color: var(--fg); + flex: 0 0 auto; +} /* ── List filter (plain text bar) ─────────────────────────────────────────── */ /* The chip-less sibling of the Changes file filter (.wfl-filter): a single text input that filters a list. Used by FilterInput.tsx (History + File History