diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx index 69f85ee8d00..29d78fa55a0 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.test.tsx @@ -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() + }) + + expect(container.textContent).toBe(detail) + expect(markedText()).toBe('src') + }) +}) diff --git a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx index 471fd9a42cc..d9568dd5526 100644 --- a/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx +++ b/src/renderer/src/components/new-workspace/ProjectComboboxRow.tsx @@ -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 ( @@ -80,9 +81,14 @@ export function ProjectOptionDetail({ } return ( - {/* Head collapses first; the tail only truncates once the head is gone. */} {split.head} - /{split.tail} + + {split.tailRanges.length > 0 ? ( + range.start)} /> + ) : ( + split.tail + )} + ) } diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts index 6e16dae386d..28c422e6c2b 100644 --- a/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.test.ts @@ -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() - }) -}) diff --git a/src/renderer/src/components/new-workspace/project-combobox-matching.ts b/src/renderer/src/components/new-workspace/project-combobox-matching.ts index 2e3459ac745..6ee21dd2194 100644 --- a/src/renderer/src/components/new-workspace/project-combobox-matching.ts +++ b/src/renderer/src/components/new-workspace/project-combobox-matching.ts @@ -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('/') } -} diff --git a/src/renderer/src/lib/path-head-elision.test.ts b/src/renderer/src/lib/path-head-elision.test.ts new file mode 100644 index 00000000000..3fd110dd27e --- /dev/null +++ b/src/renderer/src/lib/path-head-elision.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/path-head-elision.ts b/src/renderer/src/lib/path-head-elision.ts new file mode 100644 index 00000000000..e21bac240d6 --- /dev/null +++ b/src/renderer/src/lib/path-head-elision.ts @@ -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 + })) + } +}