(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