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
+ }))
+ }
+}