feat(cmd-j): compact palette location layout

This commit is contained in:
Jinjing
2026-09-10 15:31:00 -07:00
parent fad48ab4ac
commit fee70ee080
5 changed files with 226 additions and 124 deletions
@@ -1,15 +1,14 @@
import type React from 'react'
import { Smartphone } from 'lucide-react'
import { CommandItem } from '@/components/ui/command'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { BrowserPaletteItem, SimulatorPaletteItem } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller'
import {
HighlightedText,
PaletteHostBadgeChip,
PaletteLocationChip,
PaletteOpenTabPrimaryLine,
PaletteRowShortcutBadge
} from './worktree-jump-palette-primitives'
@@ -67,8 +66,6 @@ export function WorktreeJumpPaletteSimulatorRow({
secondaryText={result.secondaryText}
secondaryRanges={result.secondaryRanges}
secondaryMatches={result.secondaryMatches}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
sessionAge={simulatorSessionAge}
leadingBadges={
<>
@@ -94,16 +91,15 @@ export function WorktreeJumpPaletteSimulatorRow({
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex min-w-0 max-w-[40%] items-center justify-end gap-1.5">
<PaletteHostBadgeChip badge={simulatorHostBadge} />
{simulatorRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={simulatorRepo?.badgeColor} />
<span className="truncate">
<HighlightedText text={simulatorRepoName} matchRanges={result.repoRanges} />
</span>
</span>
)}
<PaletteLocationChip
repoName={simulatorRepoName}
repoRanges={result.repoRanges}
repoColor={simulatorRepo?.badgeColor}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
/>
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}
@@ -165,8 +161,6 @@ export function WorktreeJumpPaletteBrowserRow({
secondaryText={result.secondaryText}
secondaryRanges={result.secondaryRanges}
secondaryMatches={result.secondaryMatches}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
sessionAge={browserSessionAge}
leadingBadges={
<>
@@ -187,16 +181,15 @@ export function WorktreeJumpPaletteBrowserRow({
}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex min-w-0 max-w-[40%] items-center justify-end gap-1.5">
<PaletteHostBadgeChip badge={browserHostBadge} />
{browserRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={browserRepo?.badgeColor} />
<span className="truncate">
<HighlightedText text={browserRepoName} matchRanges={result.repoRanges} />
</span>
</span>
)}
<PaletteLocationChip
repoName={browserRepoName}
repoRanges={result.repoRanges}
repoColor={browserRepo?.badgeColor}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
/>
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}
@@ -448,8 +448,13 @@ describe('WorktreeJumpPalette interleaved primary sections', () => {
const title = row?.querySelector('[data-slot="palette-open-tab-title"]')
const worktree = row?.querySelector('[data-slot="palette-open-tab-worktree"]')
expect(title?.textContent).toBe(longTitle)
expect(title?.classList.contains('flex-auto')).toBe(true)
expect(title?.classList.contains('min-w-0')).toBe(true)
expect(title?.classList.contains('shrink-0')).toBe(false)
expect(worktree?.textContent).toBe('user-support')
const locationChip = worktree?.closest('[data-slot="palette-location-chip"]')
expect(locationChip).not.toBeNull()
expect(locationChip?.parentElement?.classList.contains('max-w-[40%]')).toBe(true)
expect(locationChip?.parentElement?.classList.contains('min-w-0')).toBe(true)
expect(worktree?.compareDocumentPosition(title ?? document.createElement('span'))).toBe(
Node.DOCUMENT_POSITION_PRECEDING
)
@@ -3,7 +3,7 @@
import { cleanup, render, type RenderResult, screen } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { PaletteOpenTabPrimaryLine } from './worktree-jump-palette-primitives'
import { PaletteLocationChip, PaletteOpenTabPrimaryLine } from './worktree-jump-palette-primitives'
afterEach(() => cleanup())
@@ -18,8 +18,6 @@ function renderPrimaryLine(
secondaryText="src/app.ts"
secondaryRanges={[]}
secondaryMatches={secondaryMatches}
worktreeName="Workspace"
worktreeRanges={[]}
/>
</TooltipProvider>
)
@@ -45,3 +43,92 @@ it('renders no badge when every secondary match is already shown', () => {
expect(screen.queryByText(/^\+\d+$/)).toBeNull()
})
it('elides a deep path from the head so the matched tail stays visible', () => {
const path = '/Users/me/projects/orca/new-create-button-design/proposals/create-button.html'
const start = path.indexOf('create-butt')
const { container } = render(
<TooltipProvider>
<PaletteOpenTabPrimaryLine
title="Design"
titleRanges={[]}
secondaryText={path}
secondaryRanges={[{ start, end: start + 'create-butt'.length }]}
/>
</TooltipProvider>
)
const secondary = container.querySelector('[data-slot="palette-open-tab-secondary"]')
expect(secondary?.textContent).toBe(path)
const [head, tail] = Array.from(secondary?.children ?? [])
expect(head?.textContent).toBe('/Users/me/projects/orca')
expect(tail?.textContent).toBe('/new-create-button-design/proposals/create-button.html')
expect(tail?.querySelector('.font-semibold')?.textContent).toBe('create-butt')
})
it('folds the worktree into the repo chip and drops it when it repeats the repo name', () => {
const { container, rerender } = render(
<TooltipProvider>
<PaletteLocationChip
repoName="orca"
repoRanges={[]}
worktreeName="new-create-button-design"
worktreeRanges={[]}
/>
</TooltipProvider>
)
expect(container.querySelector('[data-slot="palette-location-chip"]')?.textContent).toBe(
'orca·new-create-button-design'
)
const chip = container.querySelector('[data-slot="palette-location-chip"]')
const repo = container.querySelector('[data-slot="palette-location-repo"]')
const worktree = container.querySelector('[data-slot="palette-open-tab-worktree"]')
expect(chip?.className).toContain('overflow-hidden')
expect(chip?.className).toContain('min-w-0')
expect(repo?.className).toContain('max-w-[55%]')
expect(repo?.className).toContain('min-w-[3ch]')
expect(worktree?.className).toContain('min-w-[3ch]')
expect(worktree?.getAttribute('data-state')).toBeNull()
expect(worktree?.getAttribute('tabindex')).toBeNull()
rerender(
<TooltipProvider>
<PaletteLocationChip
repoName="orca"
repoRanges={[]}
worktreeName="orca"
worktreeRanges={[{ start: 0, end: 4 }]}
/>
</TooltipProvider>
)
expect(container.querySelector('[data-slot="palette-location-chip"]')?.textContent).toBe('orca')
expect(
container.querySelector('[data-slot="palette-location-repo"] .font-semibold')?.textContent
).toBe('orca')
})
it('keeps a short title at its natural width so the session age stays beside it', () => {
const { container } = render(
<TooltipProvider>
<PaletteOpenTabPrimaryLine
title="Terminal 1"
titleRanges={[]}
secondaryText=""
secondaryRanges={[]}
sessionAge="2d"
/>
</TooltipProvider>
)
const title = container.querySelector('[data-slot="palette-open-tab-title"]')
expect(title?.className).toContain('min-w-0')
expect(title?.className).not.toContain('shrink-0')
})
it('caps the title only when it shares the line with secondary text', () => {
const { container } = renderPrimaryLine([])
const title = container.querySelector('[data-slot="palette-open-tab-title"]')
expect(title?.className).toContain('max-w-[62%]')
expect(title?.className).toContain('shrink-0')
})
@@ -1,11 +1,12 @@
import React, { useLayoutEffect, useRef, useState } from 'react'
import React from 'react'
import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo'
import { translate } from '@/i18n/i18n'
import type { PaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import type { MatchRange, PaletteSearchResult } from '@/lib/worktree-palette-search'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { Worktree } from '../../../shared/worktree/types'
import { resolveWorktreeBranchLabel } from '@/lib/worktree-default-display-name'
import { splitPathHeadForElision } from '@/lib/path-head-elision'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { cn } from '@/lib/utils'
const NO_SECONDARY_MATCHES: readonly { text: string; ranges: readonly MatchRange[] }[] = []
@@ -67,14 +68,48 @@ export function HighlightedText({
return <>{parts}</>
}
function PaletteOpenTabSecondaryText({
text,
ranges
}: {
text: string
ranges: readonly MatchRange[]
}): React.JSX.Element {
const split = splitPathHeadForElision(text, ranges)
const content = split ? (
<span
data-slot="palette-open-tab-secondary"
className="flex min-w-0 items-baseline overflow-hidden font-mono text-[11px] text-muted-foreground/80"
>
<span className="min-w-0 shrink-[999] truncate">{split.head}</span>
<span className="min-w-0 shrink truncate">
<HighlightedText text={split.tail} matchRanges={split.tailRanges} />
</span>
</span>
) : (
<span
data-slot="palette-open-tab-secondary"
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/80"
>
<HighlightedText text={text} matchRanges={ranges} />
</span>
)
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-96 break-all">
{text}
</TooltipContent>
</Tooltip>
)
}
export function PaletteOpenTabPrimaryLine({
title,
titleRanges,
secondaryText,
secondaryRanges,
secondaryMatches = NO_SECONDARY_MATCHES,
worktreeName,
worktreeRanges,
sessionAge,
leadingBadges
}: {
@@ -83,13 +118,10 @@ export function PaletteOpenTabPrimaryLine({
secondaryText: string
secondaryRanges: readonly MatchRange[]
secondaryMatches?: readonly { text: string; ranges: readonly MatchRange[] }[]
worktreeName: string
worktreeRanges: readonly MatchRange[]
sessionAge?: string
leadingBadges?: React.ReactNode
}): React.JSX.Element {
const showSecondary = secondaryText.trim().length > 0
const showWorktree = worktreeName.trim().length > 0
const additionalSecondaryMatches = secondaryMatches.filter(
(match) => match.text && match.text !== secondaryText
)
@@ -98,7 +130,10 @@ export function PaletteOpenTabPrimaryLine({
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
<span
data-slot="palette-open-tab-title"
className="min-w-0 flex-auto truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground"
className={cn(
'truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground',
showSecondary ? 'max-w-[62%] shrink-0' : 'min-w-0'
)}
>
<HighlightedText text={title} matchRanges={titleRanges} />
</span>
@@ -116,12 +151,7 @@ export function PaletteOpenTabPrimaryLine({
) : null}
{leadingBadges}
{showSecondary ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92">
<HighlightedText text={secondaryText} matchRanges={secondaryRanges} />
</span>
</>
<PaletteOpenTabSecondaryText text={secondaryText} ranges={secondaryRanges} />
) : null}
{additionalSecondaryMatches.length ? (
<>
@@ -154,85 +184,76 @@ export function PaletteOpenTabPrimaryLine({
</Tooltip>
</>
) : null}
{showWorktree ? (
<>
<span className="shrink-0 text-muted-foreground/45">·</span>
<span
data-slot="palette-open-tab-worktree"
className="min-w-0 truncate text-[12px] font-medium text-muted-foreground/92"
>
<HighlightedText text={worktreeName} matchRanges={worktreeRanges} />
</span>
</>
) : null}
</div>
)
}
function resolveOpenTabWorktreeRailTooltip({
isBranch,
truncated,
name
export function PaletteLocationChip({
repoName,
repoRanges,
repoColor,
worktreeName,
worktreeRanges,
className
}: {
isBranch: boolean
truncated: boolean
name: string
}): string {
if (truncated) {
return name
}
return isBranch
? translate('auto.components.WorktreeJumpPalette.paletteOpenTabBranch', 'Branch name')
: translate('auto.components.WorktreeJumpPalette.paletteOpenTabWorkspace', 'Workspace name')
}
export function PaletteOpenTabWorktreeRailLabel({
name,
matchRanges,
worktree,
className,
slot = 'palette-open-tab-worktree'
}: {
name: string
matchRanges: readonly MatchRange[]
worktree?: Pick<Worktree, 'branch'> | null
repoName: string
repoRanges: readonly MatchRange[]
repoColor?: string
worktreeName: string
worktreeRanges: readonly MatchRange[]
className?: string
slot?: string
}): React.JSX.Element | null {
const [truncated, setTruncated] = useState(false)
const labelRef = useRef<HTMLSpanElement | null>(null)
useLayoutEffect(() => {
const node = labelRef.current
if (!node) {
setTruncated(false)
return
}
const updateTruncated = (): void => {
const next = node.scrollWidth > node.clientWidth
setTruncated((current) => (current === next ? current : next))
}
updateTruncated()
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(updateTruncated)
observer.observe(node)
return () => observer.disconnect()
}, [name])
if (name.trim().length === 0) {
const showRepo = repoName.trim().length > 0
const repeatedName = showRepo && worktreeName === repoName
const showWorktree = worktreeName.trim().length > 0 && !repeatedName
if (!showRepo && !showWorktree) {
return null
}
const isBranch = worktree != null && name === resolveWorktreeBranchLabel(worktree)
const tooltip = resolveOpenTabWorktreeRailTooltip({ isBranch, truncated, name })
const repoMatchRanges = repeatedName
? [...repoRanges, ...worktreeRanges].sort((left, right) => left.start - right.start)
: repoRanges
const label = [showRepo ? repoName : '', showWorktree ? worktreeName : '']
.filter(Boolean)
.join(' · ')
const chip = (
<span
data-slot="palette-location-chip"
className={cn(
'inline-flex min-w-0 max-w-[260px] items-center gap-1.5 overflow-hidden rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground',
className
)}
>
{showRepo ? (
<>
<RepoBadgeMark color={repoColor} />
<span
className={cn('min-w-0 truncate', showWorktree && 'min-w-[3ch] max-w-[55%]')}
data-slot="palette-location-repo"
>
<HighlightedText text={repoName} matchRanges={repoMatchRanges} />
</span>
</>
) : null}
{showRepo && showWorktree ? (
<span aria-hidden className="shrink-0 text-muted-foreground/50">
·
</span>
) : null}
{showWorktree ? (
<span
data-slot="palette-open-tab-worktree"
className="min-w-[3ch] truncate font-medium text-muted-foreground"
>
<HighlightedText text={worktreeName} matchRanges={worktreeRanges} />
</span>
) : null}
</span>
)
return (
<Tooltip>
<TooltipTrigger asChild>
<span ref={labelRef} data-slot={slot} tabIndex={-1} className={className}>
<HighlightedText text={name} matchRanges={matchRanges} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="max-w-80 break-all">
{tooltip}
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" sideOffset={4} className="max-w-96 break-all">
{label}
</TooltipContent>
</Tooltip>
)
@@ -3,15 +3,14 @@ import { FileText, SquareTerminal } from 'lucide-react'
import { AgentIcon } from '@/lib/agent-catalog'
import { CommandItem } from '@/components/ui/command'
import { PaletteRecentTabStatusDot } from '@/components/cmd-j/palette-live-status'
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
import { getPaletteHostBadge } from '@/components/cmd-j/palette-host-badge'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { WorkspaceTabPaletteItem } from './worktree-jump-palette-model'
import type { WorktreeJumpPaletteController } from './use-worktree-jump-palette-controller'
import {
HighlightedText,
PaletteHostBadgeChip,
PaletteLocationChip,
PaletteOpenTabPrimaryLine,
PaletteRowShortcutBadge
} from './worktree-jump-palette-primitives'
@@ -76,8 +75,6 @@ export function WorktreeJumpPaletteWorkspaceTabRow({
secondaryText={result.secondaryText}
secondaryRanges={result.secondaryRanges}
secondaryMatches={result.secondaryMatches}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
sessionAge={sessionAge}
leadingBadges={
<>
@@ -103,16 +100,15 @@ export function WorktreeJumpPaletteWorkspaceTabRow({
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex min-w-0 max-w-[40%] items-center justify-end gap-1.5">
<PaletteHostBadgeChip badge={workspaceTabHostBadge} />
{workspaceTabRepoName && (
<span className="inline-flex max-w-[180px] items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 text-[11px] font-semibold leading-none text-foreground">
<RepoBadgeMark color={workspaceTabRepo?.badgeColor} />
<span className="truncate">
<HighlightedText text={workspaceTabRepoName} matchRanges={result.repoRanges} />
</span>
</span>
)}
<PaletteLocationChip
repoName={workspaceTabRepoName}
repoRanges={result.repoRanges}
repoColor={workspaceTabRepo?.badgeColor}
worktreeName={result.worktreeName}
worktreeRanges={result.worktreeRanges}
/>
<PaletteRowShortcutBadge
index={controller.recentTabShortcutIndexByItem.get(entry)}
modifierKeys={controller.digitShortcutModifiers}