mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 16:02:45 +00:00
refactor(renderer): share path head elision
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user