diff --git a/src/renderer/src/components/changes/ChangesView.tsx b/src/renderer/src/components/changes/ChangesView.tsx index 88bb58e..3724a0b 100644 --- a/src/renderer/src/components/changes/ChangesView.tsx +++ b/src/renderer/src/components/changes/ChangesView.tsx @@ -14,6 +14,7 @@ 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, statusLetter } from '@/lib/format' @@ -111,9 +112,9 @@ function DiscardSummary({ {files.slice(0, DISCARD_LIST_MAX).map((f) => (
{statusLetter(f.status)} - - {f.path} - + {/* Same trimming as the changes list: the directory gives way so the + file name — the part that identifies the file — stays readable. */} +
))} {overflow > 0 && ( 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 7421143..53e96af 100644 --- a/src/renderer/src/styles/features/changes.css +++ b/src/renderer/src/styles/features/changes.css @@ -275,10 +275,9 @@ font-size: 12px; min-width: 0; } +/* Width for the row's TrimmedPath (see primitives.css → .tpath). */ .discard-list__path { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + flex: 1; } .discard-list__more { padding: 4px 10px 2px; @@ -397,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 { @@ -413,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