Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/renderer/src/components/changes/ChangesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -111,9 +112,9 @@ function DiscardSummary({
{files.slice(0, DISCARD_LIST_MAX).map((f) => (
<div key={f.path} className="discard-list__row" role="listitem">
<span className={`wfl__status st-${f.status}`}>{statusLetter(f.status)}</span>
<span className="discard-list__path" title={f.path}>
{f.path}
</span>
{/* Same trimming as the changes list: the directory gives way so the
file name — the part that identifies the file — stays readable. */}
<TrimmedPath className="discard-list__path" path={f.path} title={f.path} />
</div>
))}
{overflow > 0 && (
Expand Down
84 changes: 84 additions & 0 deletions src/renderer/src/components/common/TrimmedPath.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement> {
/** 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<HTMLSpanElement>(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 (
<span
ref={ref}
className={className ? `tpath ${className}` : 'tpath'}
// The tooltip layer's overflow gate detects CSS clipping, which never
// happens once the text itself is cut to size — so drop the gate while
// trimmed (the tip always shows) and keep it while the path fits.
data-tip-overflow={trimmed ? undefined : tipOverflow}
{...rest}
>
{dirText && <span className="tpath__dir">{highlightMatch(dirText, highlight)}</span>}
<span className="tpath__name">{highlightMatch(name, highlight)}</span>
</span>
)
}
16 changes: 9 additions & 7 deletions src/renderer/src/components/common/WorkingFileList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -90,7 +90,6 @@ const Row = memo(function Row({
onMenu
}: RowProps) {
const conflicted = file.status === 'conflicted'
const { dir, name } = splitPath(file.path)
return (
<div
role="option"
Expand Down Expand Up @@ -127,10 +126,13 @@ const Row = memo(function Row({
<span className={`wfl__status st-${file.status}`} data-tip={statusLabel(file.status)}>
{statusLetter(file.status)}
</span>
<span className="wfl__path" data-tip={file.path} data-tip-overflow="">
{dir && <span className="wfl__dir">{highlightMatch(dir, highlight)}</span>}
<span className="wfl__name">{highlightMatch(name, highlight)}</span>
</span>
<TrimmedPath
className="wfl__path"
path={file.path}
highlight={highlight}
data-tip={file.path}
data-tip-overflow=""
/>
{file.submodule && (
<span className="wfl__module" data-tip="Submodule">
<Icon.Module size={13} />
Expand Down
63 changes: 63 additions & 0 deletions src/renderer/src/components/common/pathTrim.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
33 changes: 33 additions & 0 deletions src/renderer/src/components/common/pathTrim.ts
Original file line number Diff line number Diff line change
@@ -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
}
21 changes: 4 additions & 17 deletions src/renderer/src/styles/features/changes.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/src/styles/primitives.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading