refactor(renderer): share path head elision

This commit is contained in:
Jinjing
2026-09-10 15:30:59 -07:00
parent fb85f88d64
commit fad48ab4ac
6 changed files with 145 additions and 32 deletions
@@ -3,7 +3,7 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { MatchedText } from './ProjectComboboxRow'
import { MatchedText, ProjectOptionDetail } from './ProjectComboboxRow'
import { rankProjectOptions } from './project-combobox-matching'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
@@ -69,3 +69,17 @@ describe('MatchedText', () => {
expect(container.textContent).toBe('🚀 orca')
})
})
describe('ProjectOptionDetail', () => {
it('keeps Windows path matches highlighted in the preserved tail', () => {
const detail = 'C:\\Users\\ada\\projects\\orca\\src\\renderer\\app.ts'
const start = detail.indexOf('src')
act(() => {
root.render(<ProjectOptionDetail detail={detail} hits={[start, start + 1, start + 2]} />)
})
expect(container.textContent).toBe(detail)
expect(markedText()).toBe('src')
})
})
@@ -3,7 +3,7 @@ import { FolderOpen } from 'lucide-react'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import { splitDetailForElision } from './project-combobox-matching'
import { splitPathHeadForElision } from '@/lib/path-head-elision'
/** Identity mark shared by the field and every row, so a project reads the same in both. */
export function ProjectOptionMark({
@@ -70,7 +70,8 @@ export function ProjectOptionDetail({
hits?: readonly number[]
className?: string
}): React.JSX.Element {
const split = splitDetailForElision(detail)
const ranges = hits?.map((index) => ({ start: index, end: index + 1 }))
const split = splitPathHeadForElision(detail, ranges)
if (!split) {
return (
<span className={cn('min-w-0 truncate', className)} title={detail}>
@@ -80,9 +81,14 @@ export function ProjectOptionDetail({
}
return (
<span className={cn('flex min-w-0 items-baseline overflow-hidden', className)} title={detail}>
{/* Head collapses first; the tail only truncates once the head is gone. */}
<span className="min-w-0 shrink-[999] truncate">{split.head}</span>
<span className="min-w-0 shrink truncate">/{split.tail}</span>
<span className="min-w-0 shrink truncate">
{split.tailRanges.length > 0 ? (
<MatchedText text={split.tail} hits={split.tailRanges.map((range) => range.start)} />
) : (
split.tail
)}
</span>
</span>
)
}
@@ -3,8 +3,7 @@ import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-opti
import {
getAmbiguousProjectOptionIds,
rankProjectOptions,
sectionProjectOptions,
splitDetailForElision
sectionProjectOptions
} from './project-combobox-matching'
function project(id: string, displayName: string, detail: string): NewWorkspaceProjectOption {
@@ -76,15 +75,3 @@ describe('getAmbiguousProjectOptionIds', () => {
expect(ids).toEqual(new Set(['a', 'b']))
})
})
describe('splitDetailForElision', () => {
it('keeps the last two segments so sibling paths stay distinguishable', () => {
const split = splitDetailForElision('~/Developer/work/acme/monorepo/services/checkout-api')
expect(split?.tail).toBe('services/checkout-api')
})
it('leaves short or shallow details alone', () => {
expect(splitDetailForElision('stablyai/orca')).toBeNull()
expect(splitDetailForElision('3 hosts configured')).toBeNull()
})
})
@@ -143,16 +143,3 @@ export function getAmbiguousProjectOptionIds(
options.filter((o) => (counts.get(o.displayName) ?? 0) > 1).map((option) => option.id)
)
}
/**
* A deep path's identity lives in its tail (`…/services/checkout-api`), which is
* exactly what a plain truncate throws away — two sibling paths then render
* identically. Split so the head can elide while the tail keeps its width.
*/
export function splitDetailForElision(detail: string): { head: string; tail: string } | null {
const segments = detail.split('/')
if (segments.length <= 3 || detail.length <= 28) {
return null
}
return { head: segments.slice(0, -2).join('/'), tail: segments.slice(-2).join('/') }
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { splitPathHeadForElision } from './path-head-elision'
describe('splitPathHeadForElision', () => {
it('keeps the last two segments as the tail', () => {
expect(splitPathHeadForElision('/Users/me/projects/orca/proposals/create-button.html')).toEqual(
{
head: '/Users/me/projects/orca',
tail: '/proposals/create-button.html',
tailRanges: []
}
)
})
it('supports Windows paths without changing their separators', () => {
const path = 'C:\\Users\\me\\projects\\orca\\src\\renderer\\app.ts'
const start = path.indexOf('src')
expect(splitPathHeadForElision(path, [{ start, end: start + 3 }])).toEqual({
head: 'C:\\Users\\me\\projects\\orca',
tail: '\\src\\renderer\\app.ts',
tailRanges: [{ start: 1, end: 4 }]
})
})
it('keeps backslashes inside POSIX segment names', () => {
expect(splitPathHeadForElision('/tmp/project/src/name\\with\\slashes.ts')).toEqual({
head: '/tmp/project',
tail: '/src/name\\with\\slashes.ts',
tailRanges: []
})
})
it('leaves short or shallow paths whole', () => {
expect(splitPathHeadForElision('src/app.ts')).toBeNull()
expect(splitPathHeadForElision('a/b/c')).toBeNull()
expect(splitPathHeadForElision('/tmp/orca-create-button/create-button.html')).toEqual({
head: '/tmp',
tail: '/orca-create-button/create-button.html',
tailRanges: []
})
})
it('extends the tail back to the first matched segment and re-bases ranges', () => {
const path = '/Users/me/projects/orca/new-create-button-design/proposals/create-button.html'
const start = path.indexOf('create-butt')
const split = splitPathHeadForElision(path, [{ start, end: start + 'create-butt'.length }])
expect(split).toEqual({
head: '/Users/me/projects/orca',
tail: '/new-create-button-design/proposals/create-button.html',
tailRanges: [{ start: 5, end: 16 }]
})
})
it('pulls a segment the match starts in fully into the tail', () => {
const path = '/Users/me/projects/orca/deep/nested/file.ts'
const split = splitPathHeadForElision(path, [{ start: 20, end: 30 }])
expect(split?.head).toBe('/Users/me/projects')
expect(split?.tail).toBe('/orca/deep/nested/file.ts')
expect(split?.tailRanges).toEqual([{ start: 2, end: 12 }])
})
it('returns null when the match sits in the first segment', () => {
const path = '/Users-long-prefix/me/projects/orca/deep/file.ts'
expect(splitPathHeadForElision(path, [{ start: 1, end: 6 }])).toBeNull()
})
})
+52
View File
@@ -0,0 +1,52 @@
import type { MatchRange } from './palette-match/normalized-text'
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
export type PathHeadElisionSplit = {
head: string
tail: string
tailRanges: readonly MatchRange[]
}
const MIN_ELISION_LENGTH = 28
const MIN_SEGMENTS = 4
const TAIL_SEGMENTS = 2
export function splitPathHeadForElision(
path: string,
ranges: readonly MatchRange[] = []
): PathHeadElisionSplit | null {
if (path.length <= MIN_ELISION_LENGTH) {
return null
}
const windowsPath = isWindowsAbsolutePathLike(path)
const separators: number[] = []
for (let index = 0; index < path.length; index += 1) {
if (path[index] === '/' || (windowsPath && path[index] === '\\')) {
separators.push(index)
}
}
if (separators.length < MIN_SEGMENTS - 1) {
return null
}
let tailStart = separators[separators.length - TAIL_SEGMENTS]!
const firstMatchStart = ranges.reduce(
(earliest, range) => (range.start < range.end ? Math.min(earliest, range.start) : earliest),
Number.POSITIVE_INFINITY
)
if (firstMatchStart < tailStart) {
const segmentSeparator = separators.findLast((separator) => separator < firstMatchStart)
tailStart = segmentSeparator ?? 0
}
const head = path.slice(0, tailStart)
if (!(windowsPath ? /[^/\\]/ : /[^/]/).test(head)) {
return null
}
return {
head,
tail: path.slice(tailStart),
tailRanges: ranges.map((range) => ({
start: range.start - tailStart,
end: range.end - tailStart
}))
}
}