fix(sidebar): keep project names short and show their machine (#28250)

Keep same-name projects short across execution hosts and show their machine beside host-owned headers, including when collapsed. Reuse existing host identities and filtered section membership; retain global ownership for shared projects.

Includes tooltip, narrow-sidebar, alias and filtering regression fixes with visual proof in the PR.

Co-authored-by: KAPUIST <thsxornjs12@gmail.com>
This commit is contained in:
Neil
2026-10-20 00:25:15 -07:00
committed by GitHub
co-authored by KAPUIST
parent cf6bbd30da
commit fadcb8a988
16 changed files with 1037 additions and 193 deletions
@@ -84,8 +84,8 @@ describe('Project grouping on the phone', () => {
expect(headers(buildProjectSectionCatalog(null, desktopRepos, null, 'manual'))).toEqual([
{ key: 'repo:m4-orca-two', label: 'orca' },
{ key: 'repo:win-orca', label: 'orca' },
{ key: 'repo:local-workspace', label: 'Users/jinwoo/orca/workspaces/orca/orca' },
{ key: 'repo:local-stably', label: 'Users/jinwoo/stably/orca' },
{ key: 'repo:local-workspace', label: 'orca/orca' },
{ key: 'repo:local-stably', label: 'stably/orca' },
{ key: 'repo:m4-orca', label: 'orca' },
{ key: 'repo:local-assets', label: 'design-assets' }
])
@@ -138,6 +138,10 @@ const WorktreeList = React.memo(function WorktreeList({
prCache,
agentSendTargetWorktreeId
})
const visibleProjectRepoIdsBySection = useMemo(
() => new Map(rowModel.occurrencePlan.sections.map(({ key, group }) => [key, group.repoIds])),
[rowModel.occurrencePlan]
)
const effectiveCollapsedGroups = rowModel.effectiveCollapsedGroups
const selection = useSidebarWorktreeSelection({
sectionRows: rowModel.sectionRows,
@@ -247,7 +251,9 @@ const WorktreeList = React.memo(function WorktreeList({
// Why: status headers move during wake (inactive -> active); key only on grouping mode so row identity survives.
key={`group:${groupBy}:host:${filterState.visibleWorkspaceHostIds?.join(',') ?? 'all'}:lineage`}
rows={rowModel.sectionRows}
hostOptions={rowModel.hostOptions}
occurrencePlan={rowModel.occurrencePlan}
visibleProjectRepoIdsBySection={visibleProjectRepoIdsBySection}
// Why: full-page nav views aren't scoped to a worktree, so no sidebar card should look selected.
activeWorktreeId={
activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { buildRows } from './worktree-list/grouping/build-rows.test-fixture'
import { repo, worktree } from './worktree-list-groups-test-fixtures'
import type { Repo } from '../../../../shared/repo-types'
const local: Repo = {
...repo,
id: 'local-app',
path: '/home/user/projects/app',
displayName: 'app'
}
const paired: Repo = { ...local, id: 'home-app', executionHostId: 'runtime:home' }
describe('project labels across execution hosts', () => {
it('keeps identical local and paired project paths short while preserving host labels', () => {
const worktrees = [
{ ...worktree, id: 'local-workspace', repoId: local.id, hostId: 'local' as const },
{ ...worktree, id: 'home-workspace', repoId: paired.id, hostId: 'runtime:home' as const }
]
const rows = buildRows(
'repo',
worktrees,
new Map([
[local.id, local],
[paired.id, paired]
]),
null,
new Set()
)
expect(rows.filter((row) => row.type === 'header').map((row) => row.label)).toEqual([
'app',
'app'
])
const items = rows.filter((row) => row.type === 'item')
expect(items.map((row) => row.worktree.hostId)).toEqual(['local', 'runtime:home'])
expect(items[0]?.hostContextLabel).toBeTruthy()
expect(items[1]?.hostContextLabel).toBeTruthy()
expect(items[0]?.hostContextLabel).not.toBe(items[1]?.hostContextLabel)
})
})
@@ -292,6 +292,7 @@ export function useMainSidebarListModel(args: SidebarListModelArgs): SidebarList
externalWorktreeCards,
rowModel: {
occurrencePlan: planned.plan.occurrencePlan,
hostOptions: planned.orderedHostOptions,
effectiveCollapsedGroups,
rows: rendered.rows,
sectionRows,
@@ -36,6 +36,7 @@ import {
mergeDraggedHostSectionOrder,
orderHostSectionOptions
} from '../../../../../../shared/sidebar-list/host-section-order'
import type { ExecutionHostRegistryEntry } from '../../../../../../shared/execution-host-registry'
import { buildSidebarHostOptions } from '../../sidebar-host-options'
import { selectPendingWorktreeCreationKeys } from './pending-worktree-creation-keys'
@@ -278,8 +279,10 @@ export function useSidebarSectionRows(args: SectionRowsArgs) {
[sectionRows]
)
const headerHostOptions: readonly ExecutionHostRegistryEntry[] = hostOptions
return {
occurrencePlan: rowPlan.occurrencePlan,
hostOptions: headerHostOptions,
effectiveCollapsedGroups,
rows,
sectionRows,
@@ -0,0 +1,155 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { ProjectHeaderHostTooltip } from './ProjectHeaderHostLabel'
beforeAll(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
})
afterAll(() => {
vi.unstubAllGlobals()
})
const cleanup: (() => void)[] = []
afterEach(() => {
for (const release of cleanup.splice(0)) {
release()
}
})
async function renderHeader(delayDuration = 0) {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
cleanup.push(() => {
act(() => root.unmount())
container.remove()
})
await act(async () => {
root.render(
<TooltipProvider delayDuration={delayDuration}>
<ProjectHeaderHostTooltip
label="app"
context={{ hostId: 'runtime:home', label: 'Home', detail: 'Orca server' }}
>
<div role="button" tabIndex={0}>
<span>app</span>
<span data-repo-header-action="" data-collapse-chevron="">
Collapse
</span>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" data-repo-header-action="">
Menu
</button>
</TooltipTrigger>
<TooltipContent>Project actions</TooltipContent>
</Tooltip>
</div>
</ProjectHeaderHostTooltip>
</TooltipProvider>
)
})
return container
}
describe('project header host tooltip focus', () => {
it('opens the full identity tooltip when the header itself receives keyboard focus', async () => {
const container = await renderHeader()
const header = container.querySelector<HTMLElement>('[role="button"]')
await act(async () => {
header?.focus()
await new Promise((resolve) => setTimeout(resolve, 20))
})
expect(document.querySelector('[data-slot="tooltip-content"]')?.textContent).toContain(
'app — Home'
)
})
it('leaves a nested action tooltip open when the action receives keyboard focus', async () => {
const container = await renderHeader()
const action = container.querySelector('button')
await act(async () => {
action?.focus()
await new Promise((resolve) => setTimeout(resolve, 20))
})
const texts = [...document.querySelectorAll('[data-slot="tooltip-content"]')].map(
(element) => element.textContent
)
expect(texts).toContain('Project actions')
expect(texts.some((text) => text?.includes('app — Home'))).toBe(false)
})
it('leaves a nested action tooltip open when hovering its button', async () => {
const container = await renderHeader()
const action = container.querySelector('button')
await act(async () => {
action?.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' })
)
await new Promise((resolve) => setTimeout(resolve, 20))
})
const texts = [...document.querySelectorAll('[data-slot="tooltip-content"]')].map(
(element) => element.textContent
)
expect(texts).toContain('Project actions')
expect(texts.some((text) => text?.includes('app — Home'))).toBe(false)
})
it('shows the header tooltip when hovering project text inside the header', async () => {
const container = await renderHeader()
const text = container.querySelector('span')
await act(async () => {
text?.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }))
await new Promise((resolve) => setTimeout(resolve, 20))
})
expect(document.querySelector('[data-slot="tooltip-content"]')?.textContent).toContain(
'app — Home'
)
})
it.each(['button', '[data-collapse-chevron]'])(
'cancels the pending identity tooltip when the pointer moves from project text to %s',
async (selector) => {
const container = await renderHeader(40)
const text = container.querySelector('span')
const action = container.querySelector(selector)
await act(async () => {
text?.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' })
)
await new Promise((resolve) => setTimeout(resolve, 10))
action?.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' })
)
await new Promise((resolve) => setTimeout(resolve, 80))
})
const texts = [...document.querySelectorAll('[data-slot="tooltip-content"]')].map(
(element) => element.textContent
)
expect(texts.some((text) => text?.includes('app — Home'))).toBe(false)
if (selector === 'button') {
expect(texts).toContain('Project actions')
}
}
)
it('restores the identity tooltip when the pointer returns from an action to project text', async () => {
const container = await renderHeader(40)
const text = container.querySelector('span')
const action = container.querySelector('button')
await act(async () => {
action?.dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' })
)
await new Promise((resolve) => setTimeout(resolve, 80))
text?.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }))
await new Promise((resolve) => setTimeout(resolve, 80))
})
expect(
[...document.querySelectorAll('[data-slot="tooltip-content"]')].some((element) =>
element.textContent?.includes('app — Home')
)
).toBe(true)
})
})
@@ -0,0 +1,87 @@
import React, { useRef, useState } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { HostRowIcon } from '@/components/host-row-icon'
import { isRepoHeaderActionTarget } from '../../project-header-drag-contract'
import type { ProjectHeaderHostContext } from './project-header-host-context'
export function ProjectHeaderHostLabel({
context
}: {
context: ProjectHeaderHostContext
}): React.JSX.Element {
return (
<span className="flex min-w-0 max-w-1/2 shrink-0 items-center gap-1 text-xs font-normal leading-4 text-muted-foreground">
<HostRowIcon hostId={context.hostId} className="size-3.5 shrink-0" />
<span className="truncate">{context.label}</span>
</span>
)
}
export function ProjectHeaderHostTooltip({
context,
label,
children
}: {
context: ProjectHeaderHostContext | undefined
label: string
children: React.JSX.Element
}): React.JSX.Element {
return context ? (
<ProjectHeaderIdentityTooltip context={context} label={label}>
{children}
</ProjectHeaderIdentityTooltip>
) : (
children
)
}
function ProjectHeaderIdentityTooltip({
context,
label,
children
}: {
context: ProjectHeaderHostContext
label: string
children: React.JSX.Element
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const actionOwnsTooltip = useRef(false)
return (
<Tooltip
open={open}
onOpenChange={(nextOpen) => setOpen(nextOpen && !actionOwnsTooltip.current)}
>
<TooltipTrigger
asChild
onFocus={(event) => {
// Nested actions keep their own tooltip.
actionOwnsTooltip.current = event.target !== event.currentTarget
if (actionOwnsTooltip.current) {
setOpen(false)
event.preventDefault()
}
}}
onPointerMove={(event) => {
const wasOverAction = actionOwnsTooltip.current
actionOwnsTooltip.current = isRepoHeaderActionTarget(event.target, event.currentTarget)
if (actionOwnsTooltip.current) {
setOpen(false)
event.preventDefault()
} else if (wasOverAction) {
setOpen(true)
}
}}
>
{children}
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="max-w-80 whitespace-normal break-words text-left"
>
{label} — {context.label}
{context.detail !== context.label ? <div>{context.detail}</div> : null}
</TooltipContent>
</Tooltip>
)
}
@@ -1,5 +1,7 @@
import React from 'react'
import { ChevronDown } from 'lucide-react'
import { ProjectHeaderHostLabel, ProjectHeaderHostTooltip } from './ProjectHeaderHostLabel'
import type { ProjectHeaderHostContext } from './project-header-host-context'
import type { VirtualItem } from '@tanstack/react-virtual'
import { cn } from '@/lib/utils'
import type { RepoSshGateResolver } from '@/hooks/use-repo-ssh-gate-resolver'
@@ -49,6 +51,7 @@ import { getWorktreeOptionId } from './option-dom'
export type SectionHeaderRowContext = {
groupBy: WorktreeGroupBy
projectHeaderHostContexts?: ReadonlyMap<string, ProjectHeaderHostContext>
collapsedGroups: Set<string>
workspaceStatuses: readonly WorkspaceStatusDefinition[]
projectGroups: readonly ProjectGroup[]
@@ -150,6 +153,8 @@ export function renderWorktreeSectionHeaderRow(args: {
const isPinnedHeader = row.key === PINNED_GROUP_KEY
const headerVisual = getSidebarHeaderVisual(row.icon, ctx.workspaceStatuses)
const label = row.label ?? headerVisual.label ?? ''
const hostContext =
ctx.groupBy === 'repo' ? ctx.projectHeaderHostContexts?.get(row.key) : undefined
const repoHeaderColor = resolveProjectGroupHeaderColor({
groupBy: ctx.groupBy,
headerKey: row.key,
@@ -207,199 +212,204 @@ export function renderWorktreeSectionHeaderRow(args: {
)}
style={isActiveStickyHeader ? undefined : { transform: getVirtualRowTransform(vItem.start) }}
>
<div
id={getWorktreeOptionId(row.key)}
role="button"
tabIndex={0}
aria-expanded={showHeaderCollapseAffordance ? !isHeaderCollapsed : undefined}
data-repo-header-id={projectIdForHeader}
data-repo-header-index={repoHeaderIndex}
data-repo-header-bucket={repoHeaderBucketKey}
data-repo-header-section-end={
projectIdForHeader
? headerDrag.repoHeaderSectionEndByRepoId.get(projectIdForHeader)
: undefined
}
// Why: row keeps handle attrs so indent/padding still arms drag; grab
// cursor lives only on the title surface so … / + never inherit it.
data-repo-header-drag-handle={isDraggableRepoHeader ? '' : undefined}
data-project-group-header-id={projectGroupIdForHeader}
data-project-group-header-index={projectGroupHeaderIndex}
data-project-group-header-bucket={projectGroupHeaderBucketKey}
data-project-group-header-section-end={
projectGroupIdForHeader
? headerDrag.projectGroupHeaderSectionEndByGroupId.get(projectGroupIdForHeader)
: undefined
}
data-project-group-header-drag-handle={isDraggableProjectGroupHeader ? '' : undefined}
data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined}
data-workspace-status={headerWorkspaceStatus ?? undefined}
data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined}
className={cn(
// Why: no row-level grab — only the title surface below shows the hand;
// actions use cursor-pointer so … / + never look reorderable.
'group relative flex h-7 w-full items-center gap-1.5 pr-2 text-left transition-all',
!(isDraggableRepoHeader || isDraggableProjectGroupHeader) && 'cursor-pointer',
ctx.highlightedRevealRowKey === row.key &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
(isDraggingThis || isDraggingThisProjectGroup) &&
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
headerWorkspaceStatus &&
ctx.dragOverStatus === headerWorkspaceStatus &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/40',
isPinnedHeader &&
ctx.pinDragOver &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/40',
row.repo && 'overflow-hidden'
)}
style={{
// Why: non-project headers like "All" are flat-list labels; don't reserve project hierarchy indent.
paddingLeft:
isRepoHeader || isProjectGroupHeader
? getProjectGroupHeaderPaddingLeft(row.projectGroupDepth ?? 0)
: WORKTREE_SECTION_HEADER_PADDING_LEFT
}}
onDragOver={
isPinnedHeader
? ctx.onWorkspacePinDragOver
: headerWorkspaceStatus
? (event) => ctx.onWorkspaceStatusDragOver(event, headerWorkspaceStatus)
<ProjectHeaderHostTooltip label={label} context={hostContext}>
<div
id={getWorktreeOptionId(row.key)}
role="button"
tabIndex={0}
aria-label={hostContext ? `${label} — ${hostContext.label}` : undefined}
aria-expanded={showHeaderCollapseAffordance ? !isHeaderCollapsed : undefined}
data-repo-header-id={projectIdForHeader}
data-repo-header-index={repoHeaderIndex}
data-repo-header-bucket={repoHeaderBucketKey}
data-repo-header-section-end={
projectIdForHeader
? headerDrag.repoHeaderSectionEndByRepoId.get(projectIdForHeader)
: undefined
}
onDragLeave={
isPinnedHeader
? ctx.onWorkspacePinDragLeave
: headerWorkspaceStatus
? ctx.onWorkspaceStatusDragLeave
: undefined
}
onDrop={
headerWorkspaceStatus
? (event) => ctx.onWorkspaceStatusDrop(event, headerWorkspaceStatus)
: undefined
}
onPointerDown={
isDraggableRepoHeader && headerRepo
? (event) => headerDrag.repoDrag.onHandlePointerDown(event, headerRepo)
: isDraggableProjectGroupHeader && projectGroupIdForHeader
? (event) =>
headerDrag.projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader)
: undefined
}
onClick={(event) => {
if (shouldIgnoreRepoHeaderToggle(event)) {
return
}
ctx.toggleGroupWithScrollAnchor(collapseKey)
}}
onKeyDown={(e) => {
if (shouldIgnoreRepoHeaderToggle(e)) {
return
// Why: row keeps handle attrs so indent/padding still arms drag; grab
// cursor lives only on the title surface so … / + never inherit it.
data-repo-header-drag-handle={isDraggableRepoHeader ? '' : undefined}
data-project-group-header-id={projectGroupIdForHeader}
data-project-group-header-index={projectGroupHeaderIndex}
data-project-group-header-bucket={projectGroupHeaderBucketKey}
data-project-group-header-section-end={
projectGroupIdForHeader
? headerDrag.projectGroupHeaderSectionEndByGroupId.get(projectGroupIdForHeader)
: undefined
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
data-project-group-header-drag-handle={isDraggableProjectGroupHeader ? '' : undefined}
data-workspace-status-drop-target={headerWorkspaceStatus ? '' : undefined}
data-workspace-status={headerWorkspaceStatus ?? undefined}
data-workspace-pin-drop-target={isPinnedHeader ? '' : undefined}
className={cn(
// Why: no row-level grab — only the title surface below shows the hand;
// actions use cursor-pointer so … / + never look reorderable.
'group relative flex h-7 w-full items-center gap-1.5 pr-2 text-left transition-all',
!(isDraggableRepoHeader || isDraggableProjectGroupHeader) && 'cursor-pointer',
ctx.highlightedRevealRowKey === row.key &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/50',
(isDraggingThis || isDraggingThisProjectGroup) &&
'bg-accent/80 ring-1 ring-ring/40 shadow-md rounded-md scale-[1.01]',
headerWorkspaceStatus &&
ctx.dragOverStatus === headerWorkspaceStatus &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/40',
isPinnedHeader &&
ctx.pinDragOver &&
'rounded-md bg-worktree-sidebar-accent ring-1 ring-worktree-sidebar-ring/40',
row.repo && 'overflow-hidden'
)}
style={{
// Why: non-project headers like "All" are flat-list labels; don't reserve project hierarchy indent.
paddingLeft:
isRepoHeader || isProjectGroupHeader
? getProjectGroupHeaderPaddingLeft(row.projectGroupDepth ?? 0)
: WORKTREE_SECTION_HEADER_PADDING_LEFT
}}
onDragOver={
isPinnedHeader
? ctx.onWorkspacePinDragOver
: headerWorkspaceStatus
? (event) => ctx.onWorkspaceStatusDragOver(event, headerWorkspaceStatus)
: undefined
}
onDragLeave={
isPinnedHeader
? ctx.onWorkspacePinDragLeave
: headerWorkspaceStatus
? ctx.onWorkspaceStatusDragLeave
: undefined
}
onDrop={
headerWorkspaceStatus
? (event) => ctx.onWorkspaceStatusDrop(event, headerWorkspaceStatus)
: undefined
}
onPointerDown={
isDraggableRepoHeader && headerRepo
? (event) => headerDrag.repoDrag.onHandlePointerDown(event, headerRepo)
: isDraggableProjectGroupHeader && projectGroupIdForHeader
? (event) =>
headerDrag.projectGroupDrag.onHandlePointerDown(event, projectGroupIdForHeader)
: undefined
}
onClick={(event) => {
if (shouldIgnoreRepoHeaderToggle(event)) {
return
}
ctx.toggleGroupWithScrollAnchor(collapseKey)
}
}}
>
{/* Why: grab cursor on icon+title only. Row still has handle attrs so
}}
onKeyDown={(e) => {
if (shouldIgnoreRepoHeaderToggle(e)) {
return
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
ctx.toggleGroupWithScrollAnchor(collapseKey)
}
}}
>
{/* Why: grab cursor on icon+title only. Row still has handle attrs so
indent/padding can arm drag; actions are excluded via data-repo-header-actions.
self-stretch fills h-7 so grab matches the full title column height. */}
<div
data-repo-header-drag-handle={isDraggableRepoHeader ? '' : undefined}
data-project-group-header-drag-handle={isDraggableProjectGroupHeader ? '' : undefined}
className={cn(
'flex min-w-0 flex-1 items-center gap-1.5 self-stretch',
(isDraggableRepoHeader || isDraggableProjectGroupHeader) &&
'cursor-grab active:cursor-grabbing'
)}
>
<div
data-repo-header-drag-handle={isDraggableRepoHeader ? '' : undefined}
data-project-group-header-drag-handle={isDraggableProjectGroupHeader ? '' : undefined}
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
repoHeaderColor ? 'text-muted-foreground' : headerVisual.tone
'flex min-w-0 flex-1 items-center gap-1.5 self-stretch',
hostContext && 'can-hover:pr-18',
(isDraggableRepoHeader || isDraggableProjectGroupHeader) &&
'cursor-grab active:cursor-grabbing'
)}
>
{row.repo ? (
<RepoIconGlyph
repoIcon={row.repo.repoIcon}
color={repoHeaderColor}
className="size-4"
iconClassName="size-3.5"
/>
) : (
<headerVisual.icon className="size-3" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 truncate text-[13px] font-semibold leading-none">{label}</div>
<RepoForkIndicator upstream={row.repo?.upstream} />
<FolderPathStatusIndicator status={projectGroupPathStatus} />
{isRepoHeader ? <RepoScanUnavailableIndicator repo={row.repo!} /> : null}
</div>
</div>
</div>
<ProjectHeaderActions>
{showHeaderCollapseAffordance ? (
<div
className="flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground"
data-repo-header-collapse-affordance=""
aria-hidden
onPointerDown={handleRepoHeaderCollapseAffordancePointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
ctx.toggleGroupWithScrollAnchor(collapseKey)
}}
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
repoHeaderColor ? 'text-muted-foreground' : headerVisual.tone
)}
>
<ChevronDown
className={cn('size-3.5 transition-transform', isHeaderCollapsed && '-rotate-90')}
/>
{row.repo ? (
<RepoIconGlyph
repoIcon={row.repo.repoIcon}
color={repoHeaderColor}
className="size-4"
iconClassName="size-3.5"
/>
) : (
<headerVisual.icon className="size-3" />
)}
</div>
) : null}
{isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? (
<ProjectGroupHeaderMenu
groupId={projectGroupIdForHeader}
hostId={projectGroupHostIdForHeader}
label={label}
onRename={ctx.onRenameProjectGroup}
onDelete={ctx.onDeleteProjectGroup}
/>
) : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 truncate text-[13px] font-semibold leading-4">{label}</div>
{hostContext ? <ProjectHeaderHostLabel context={hostContext} /> : null}
<RepoForkIndicator upstream={row.repo?.upstream} />
<FolderPathStatusIndicator status={projectGroupPathStatus} />
{isRepoHeader ? <RepoScanUnavailableIndicator repo={row.repo!} /> : null}
</div>
</div>
</div>
{folderBackedProjectGroup ? (
<ProjectGroupCreateWorkspaceButton
projectGroup={folderBackedProjectGroup}
label={label}
pathStatus={projectGroupPathStatus}
disabled={isFolderWorkspaceCreateDisabled(projectGroupPathStatus)}
onCreate={ctx.onCreateFolderWorkspace}
/>
) : null}
<ProjectHeaderActions>
{showHeaderCollapseAffordance ? (
<div
className="flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent/70 hover:text-foreground"
data-repo-header-collapse-affordance=""
aria-hidden
onPointerDown={handleRepoHeaderCollapseAffordancePointerDown}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
ctx.toggleGroupWithScrollAnchor(collapseKey)
}}
>
<ChevronDown
className={cn('size-3.5 transition-transform', isHeaderCollapsed && '-rotate-90')}
/>
</div>
) : null}
{row.repo && ctx.groupBy === 'repo' ? (
<RepoHeaderProjectActionsMenu
repo={row.repo}
label={label}
projectGroups={ctx.projectGroups}
actions={ctx.projectActions}
/>
) : null}
{isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? (
<ProjectGroupHeaderMenu
groupId={projectGroupIdForHeader}
hostId={projectGroupHostIdForHeader}
label={label}
onRename={ctx.onRenameProjectGroup}
onDelete={ctx.onDeleteProjectGroup}
/>
) : null}
{row.repo && ctx.groupBy === 'repo' ? (
<RepoHeaderCreateWorkspaceButton
repo={row.repo}
label={label}
createState={createState}
onCreateForRepo={ctx.projectActions.onCreateForRepo}
/>
) : null}
</ProjectHeaderActions>
</div>
{folderBackedProjectGroup ? (
<ProjectGroupCreateWorkspaceButton
projectGroup={folderBackedProjectGroup}
label={label}
pathStatus={projectGroupPathStatus}
disabled={isFolderWorkspaceCreateDisabled(projectGroupPathStatus)}
onCreate={ctx.onCreateFolderWorkspace}
/>
) : null}
{row.repo && ctx.groupBy === 'repo' ? (
<RepoHeaderProjectActionsMenu
repo={row.repo}
label={label}
projectGroups={ctx.projectGroups}
actions={ctx.projectActions}
/>
) : null}
{row.repo && ctx.groupBy === 'repo' ? (
<RepoHeaderCreateWorkspaceButton
repo={row.repo}
label={label}
createState={createState}
onCreateForRepo={ctx.projectActions.onCreateForRepo}
/>
) : null}
</ProjectHeaderActions>
</div>
</ProjectHeaderHostTooltip>
</div>
)
}
@@ -0,0 +1,337 @@
import { describe, expect, it } from 'vitest'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { GroupHeaderRow } from '../../../../../../shared/sidebar-list/row-types'
import type { HostHeaderRow } from '../../../../../../shared/sidebar-list/host-section-rows'
import type { SidebarHostOption } from '../../sidebar-host-options'
import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan'
import { addHostSectionRows } from '../../../../../../shared/sidebar-list/host-section-rows'
import {
project,
projectHostSetups,
repo,
remoteRepo,
remoteWorktree,
worktree
} from '../../worktree-list-groups-test-fixtures'
import { getProjectHeaderHostContexts } from './project-header-host-context'
const local = { ...repo, displayName: 'app' }
const paired: Repo = { ...local, id: 'home-app', executionHostId: 'runtime:home' }
const hosts: SidebarHostOption[] = [
{
id: 'local',
label: 'Local Mac',
detail: 'This computer',
kind: 'local',
health: 'local',
presence: 'local'
},
{
id: 'runtime:home',
label: 'Home',
detail: 'home.example',
kind: 'runtime',
health: 'disconnected',
presence: 'configured'
}
]
function header(value: Repo): GroupHeaderRow {
return {
type: 'header',
key: `repo:${value.id}`,
label: value.displayName,
count: 1,
icon: { kind: 'project' },
repo: value
}
}
const repoMap = new Map([local, paired].map((value) => [value.id, value]))
const rows = [header(local), header(paired)]
const folder: ProjectGroup = {
id: 'folder',
name: 'app',
parentPath: '/projects/app',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: true,
color: null,
createdAt: 0,
updatedAt: 0,
executionHostId: 'runtime:home'
}
const folderHeader: GroupHeaderRow = {
type: 'header',
key: 'project-group:folder',
label: 'app',
count: 0,
icon: { kind: 'project' },
projectGroup: folder
}
const hostHeader: HostHeaderRow = {
type: 'host-header',
key: 'host:local',
hostId: 'local',
kind: 'local',
label: 'Local Mac',
detail: 'This computer',
health: 'local',
collapsed: false,
count: 1
}
describe('project header host context', () => {
it('keeps two collapsed same-name projects distinguishable without any workspace rows', () => {
const contexts = getProjectHeaderHostContexts({ rows, hosts, repoMap })
expect([...contexts.values()].map(({ label }) => label)).toEqual(['Local Mac', 'Home'])
expect(contexts.get(rows[1]!.key)).toEqual({
hostId: 'runtime:home',
label: 'Home',
detail: 'home.example'
})
})
it('keeps single-host sidebars quiet even when another host is configured', () => {
expect(getProjectHeaderHostContexts({ rows: [rows[0]!], hosts, repoMap }).size).toBe(0)
})
it('omits redundant labels under actual enclosing host sections', () => {
expect(getProjectHeaderHostContexts({ rows: [hostHeader, ...rows], hosts, repoMap }).size).toBe(
0
)
})
it('does not conflate different hosts with the same custom display name', () => {
const sameNames = hosts.map((host) => ({ ...host, label: 'My computer' }))
const contexts = getProjectHeaderHostContexts({ rows, hosts: sameNames, repoMap })
expect([...contexts.values()].map(({ hostId }) => hostId)).toEqual(['local', 'runtime:home'])
expect([...contexts.values()].map(({ label }) => label)).toEqual(['My computer', 'My computer'])
expect(new Set([...contexts.values()].map(({ detail }) => detail)).size).toBe(2)
})
it('resolves SSH and runtime aliases through the canonical host registry', () => {
const ssh: Repo = { ...local, id: 'ssh-app', connectionId: 'home' }
const aliases: SidebarHostOption[] = [
...hosts,
{
id: 'ssh:home',
label: 'Old name',
detail: '',
kind: 'ssh',
health: 'disconnected',
presence: 'configured',
mergedIntoHostId: 'runtime:home'
}
]
const contexts = getProjectHeaderHostContexts({
rows: [header(local), header(ssh)],
hosts: aliases,
repoMap: new Map([
[local.id, local],
[ssh.id, ssh]
])
})
expect(contexts.get(`repo:${ssh.id}`)?.label).toBe('Home')
expect(contexts.get(`repo:${ssh.id}`)?.hostId).toBe('runtime:home')
expect(
getProjectHeaderHostContexts({ rows: [header(ssh), header(paired)], hosts: aliases, repoMap })
.size
).toBe(0)
})
it('restores managed aliases from owner configuration when feed host options omit them', () => {
const ssh: Repo = { ...local, id: 'ssh-app', connectionId: 'home' }
const unmerged: SidebarHostOption[] = [
...hosts,
{
id: 'ssh:home',
label: 'My Home',
detail: 'Configured SSH host',
kind: 'ssh',
health: 'disconnected',
presence: 'configured'
}
]
const contexts = getProjectHeaderHostContexts({
rows: [header(local), header(ssh), header(paired)],
hosts: unmerged,
repoMap: new Map([local, ssh, paired].map((value) => [value.id, value])),
runtimeEnvironments: [{ id: 'home', orcadDeployment: { kind: 'ssh', sshTargetId: 'home' } }]
})
expect(contexts.get(`repo:${ssh.id}`)).toEqual({
hostId: 'runtime:home',
label: 'My Home',
detail: 'Configured SSH host'
})
expect(contexts.get(`repo:${paired.id}`)).toEqual(contexts.get(`repo:${ssh.id}`))
})
it('preserves an authoritative alias snapshot while the relay route is changing', () => {
const ssh: Repo = { ...local, id: 'ssh-app', connectionId: 'home' }
const annotated: SidebarHostOption[] = [
hosts[0]!,
{ ...hosts[1]!, aliasHostIds: ['ssh:home'] },
{
id: 'ssh:home',
label: 'Home',
detail: 'SSH',
kind: 'ssh',
health: 'disconnected',
presence: 'configured',
mergedIntoHostId: 'runtime:home'
}
]
const contexts = getProjectHeaderHostContexts({
rows: [header(local), header(ssh), header(paired)],
hosts: annotated,
repoMap: new Map([local, ssh, paired].map((value) => [value.id, value])),
runtimeEnvironments: [{ id: 'home', orcadDeployment: { kind: 'ssh', sshTargetId: 'home' } }],
sshConnectionStates: new Map([
['home', { managedServer: { kind: 'relay', reason: 'failed' } }]
])
})
expect(contexts.get(`repo:${ssh.id}`)?.hostId).toBe('runtime:home')
expect(contexts.get(`repo:${paired.id}`)?.hostId).toBe('runtime:home')
expect(annotated[2]?.mergedIntoHostId).toBe('runtime:home')
expect(annotated[2]?.aliasHostIds).toBeUndefined()
})
it('uses a folder-backed group owner even with no children and a disconnected host', () => {
const contexts = getProjectHeaderHostContexts({
rows: [header(local), folderHeader],
hosts,
repoMap
})
expect(contexts.get(folderHeader.key)?.label).toBe('Home')
})
it('does not assign a singular owner to manual project groups', () => {
const manual = { ...folderHeader, projectGroup: { ...folder, createdFrom: 'manual' as const } }
const contexts = getProjectHeaderHostContexts({ rows: [...rows, manual], hosts, repoMap })
expect(contexts.has(manual.key)).toBe(false)
})
it('never labels a multi-host logical project with its representative repo owner', () => {
const other = { ...paired, id: 'independent' }
const logical: GroupHeaderRow = { ...header(repo), key: `project:${project.id}` }
const contexts = getProjectHeaderHostContexts({
rows: [logical, header(other)],
hosts,
repoMap: new Map([repo, remoteRepo, other].map((value) => [value.id, value])),
projectGrouping: { projects: [project], projectHostSetups }
})
expect(contexts.has(logical.key)).toBe(false)
expect(contexts.get(`repo:${other.id}`)?.hostId).toBe('runtime:home')
})
describe.each([
{ name: 'filtered local checkout', includeRemote: false, empty: false, hostFiltered: false },
{
name: 'visible checkouts on both hosts',
includeRemote: true,
empty: false,
hostFiltered: false
},
{
name: 'filtered empty local checkout',
includeRemote: false,
empty: true,
hostFiltered: false
},
{
name: 'empty checkouts on both hosts',
includeRemote: true,
empty: true,
hostFiltered: false
},
{ name: 'local host scope', includeRemote: false, empty: false, hostFiltered: true },
{ name: 'empty local host scope', includeRemote: false, empty: true, hostFiltered: true }
])('$name', ({ includeRemote, empty, hostFiltered }) => {
it.each([false, true])('counts only listed owners (collapsed: %s)', (collapsed) => {
const other = { ...local, id: 'independent-local' }
const map = new Map([repo, remoteRepo, other].map((value) => [value.id, value]))
const listedRepoIds = [repo.id, ...(includeRemote ? [remoteRepo.id] : []), other.id]
const items = [
...(empty ? [] : [worktree, ...(includeRemote ? [remoteWorktree] : [])]),
{ ...worktree, id: 'independent-workspace', repoId: other.id }
]
const plan = planSidebarRows({
groupBy: 'repo',
worktrees: items,
repoMap: map,
prGroupOf: () => 'in-progress',
workspaceStatuses: [],
projectOrderBy: 'manual',
lineageById: {},
worktreeMap: new Map(items.map((item) => [item.id, item])),
nestLineage: false,
projectGroups: [],
placeholderRepoIds: new Set(empty ? listedRepoIds : []),
importedWorktreesByRepo: new Map(),
newExternalWorktreesInboxByRepo: new Map(),
pendingCreations: [],
projectGrouping: { projects: [project], projectHostSetups },
folderWorkspaces: [],
defaultHostId: 'local',
pinnedDisplayPolicy: 'single-location',
filterRepoIds: hostFiltered ? [] : listedRepoIds
})
const sectionRows = addHostSectionRows({
rows: plan.render(new Set(collapsed ? [`project:${project.id}`] : [])),
hostOptions: hosts,
workspaceHostScope: hostFiltered ? 'local' : 'all',
defaultHostId: 'local',
localHostLabel: 'Local Mac',
preferProjectGrouping: true
})
expect(sectionRows.some((row) => row.type === 'host-header')).toBe(false)
const contexts = getProjectHeaderHostContexts({
rows: sectionRows,
visibleProjectRepoIdsBySection: new Map(
plan.occurrencePlan.sections.map(({ key, group }) => [key, group.repoIds])
),
hosts,
repoMap: map,
projectGrouping: { projects: [project], projectHostSetups }
})
expect(contexts.has(`project:${project.id}`)).toBe(false)
expect(contexts.size).toBe(includeRemote ? 1 : 0)
if (includeRemote) {
expect(contexts.get(`repo:${other.id}`)?.hostId).toBe('local')
}
})
})
it('canonicalizes listed checkout aliases before counting visible hosts', () => {
const logical: GroupHeaderRow = { ...header(remoteRepo), key: `project:${project.id}` }
const contexts = getProjectHeaderHostContexts({
rows: [logical, header(paired)],
visibleProjectRepoIdsBySection: new Map([[logical.key, new Set([remoteRepo.id])]]),
hosts: [
...hosts,
{
id: 'ssh:gpu-vm',
label: 'Home SSH',
detail: '',
kind: 'ssh',
health: 'disconnected',
mergedIntoHostId: 'runtime:home'
}
],
repoMap: new Map([repo, remoteRepo, paired].map((value) => [value.id, value])),
projectGrouping: { projects: [project], projectHostSetups }
})
expect(contexts.size).toBe(0)
})
it('keeps non-project lanes unchanged', () => {
expect(
getProjectHeaderHostContexts({
rows: rows.map((row) => ({ ...row, icon: { kind: 'pinned' } })),
hosts,
repoMap
}).size
).toBe(0)
})
})
@@ -0,0 +1,112 @@
import {
getRepoExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../../../../shared/execution-host'
import {
annotateManagedOrcadExecutionHosts,
indexExecutionHostsById,
type ManagedOrcadEnvironmentSummary
} from '../../../../../../shared/managed-orcad-execution-host'
import {
buildProjectGroupingIndex,
getProjectGroupingForRepo,
type ProjectGroupingModel
} from '../../../../../../shared/project-section-grouping'
import type { Repo } from '../../../../../../shared/repo-types'
import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows'
import { getHostContextLabel } from '../../../../../../shared/worktree/host-context-labels'
import type { ExecutionHostRegistryEntry } from '../../../../../../shared/execution-host-registry'
import type { SshConnectionState } from '../../../../../../shared/ssh-types'
export type ProjectHeaderHostContext = {
hostId: ExecutionHostId
label: string
detail: string
}
export function getProjectHeaderHostContexts(args: {
rows: readonly HostSectionRow[]
visibleProjectRepoIdsBySection?: ReadonlyMap<string, ReadonlySet<string>>
hosts: readonly ExecutionHostRegistryEntry[]
runtimeEnvironments?: readonly ManagedOrcadEnvironmentSummary[]
sshConnectionStates?: ReadonlyMap<string, Pick<SshConnectionState, 'managedServer'>>
repoMap: Map<string, Repo>
projectGrouping?: ProjectGroupingModel
}): ReadonlyMap<string, ProjectHeaderHostContext> {
const hostRegistry = new Map(args.hosts.map((host) => [host.id, host]))
annotateManagedOrcadExecutionHosts({
hosts: hostRegistry,
// Preserve authoritative aliases; feed options need owner configuration to fill them in.
runtimeEnvironments: (args.runtimeEnvironments ?? []).filter((environment) => {
const host = hostRegistry.get(toRuntimeExecutionHostId(environment.id))
return !host?.mergedIntoHostId && !host?.aliasHostIds?.length
}),
sshConnectionStates: args.sshConnectionStates
})
const hostsById = indexExecutionHostsById([...hostRegistry.values()])
const canonicalHostId = (hostId: ExecutionHostId): ExecutionHostId =>
hostsById.get(hostId)?.id ?? hostId
const projectIndex = buildProjectGroupingIndex(args.projectGrouping)
const repoHostId = (repo: Repo): ExecutionHostId =>
canonicalHostId(
projectIndex?.setupByRepoId.get(repo.id)?.hostId ?? getRepoExecutionHostId(repo)
)
const hostsByProjectKey = new Map<string, Set<ExecutionHostId>>()
for (const repo of args.repoMap.values()) {
const key = getProjectGroupingForRepo(repo.id, args.repoMap, projectIndex).key
const hostId = repoHostId(repo)
const projectHosts = hostsByProjectKey.get(key) ?? new Set<ExecutionHostId>()
projectHosts.add(hostId)
hostsByProjectKey.set(key, projectHosts)
}
const candidates = new Map<string, ProjectHeaderHostContext>()
const visibleHostIds = new Set<ExecutionHostId>()
for (const row of args.rows) {
// Host sections already identify every following project header.
if (row.type === 'host-header') {
break
}
if (row.type !== 'header' || row.icon.kind !== 'project') {
continue
}
let ownerHostId: ExecutionHostId
if (row.repo) {
const projectHosts = hostsByProjectKey.get(row.key)
// A logical project can contain checkouts on several machines.
if (projectHosts && projectHosts.size > 1) {
const visibleRepoIds = args.visibleProjectRepoIdsBySection?.get(row.key)
const visibleProjectHosts = visibleRepoIds
? [...visibleRepoIds].flatMap((repoId) => {
const repo = args.repoMap.get(repoId)
return repo ? [repoHostId(repo)] : []
})
: projectHosts
for (const hostId of visibleProjectHosts) {
visibleHostIds.add(hostId)
}
continue
}
ownerHostId = projectHosts?.values().next().value ?? getRepoExecutionHostId(row.repo)
} else if (
row.projectGroup &&
'createdFrom' in row.projectGroup &&
row.projectGroup.createdFrom === 'folder-scan' &&
row.projectGroup.parentPath
) {
ownerHostId = getRepoExecutionHostId(row.projectGroup)
} else {
continue
}
const hostId = canonicalHostId(ownerHostId)
visibleHostIds.add(hostId)
const host = hostsById.get(hostId)
candidates.set(row.key, {
hostId,
label: host?.label ?? getHostContextLabel(hostId),
detail: host?.detail ?? getHostContextLabel(hostId)
})
}
// Keep single-host sidebars quiet, independently of workspace-card preferences.
return visibleHostIds.size > 1 ? candidates : new Map()
}
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { getProjectHeaderHostContexts } from '../rows/project-header-host-context'
import { translate } from '@/i18n/i18n'
import { WorktreeListScrollToTopButton } from '../../WorktreeListScrollToTopButton'
import { renderWorktreeSidebarDropIndicators } from './drop-indicators'
@@ -60,6 +61,28 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const getRepoSshGate = useRepoSshGateResolver()
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
const projectHeaderHostContexts = useMemo(
() =>
getProjectHeaderHostContexts({
rows,
visibleProjectRepoIdsBySection: props.visibleProjectRepoIdsBySection,
hosts: props.hostOptions ?? [],
runtimeEnvironments,
sshConnectionStates,
repoMap,
projectGrouping: props.projectGrouping
}),
[
rows,
props.visibleProjectRepoIdsBySection,
props.hostOptions,
repoMap,
props.projectGrouping,
runtimeEnvironments,
sshConnectionStates
]
)
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
const reveal = useSidebarRevealHighlight()
@@ -315,6 +338,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
statusDrag,
nativeDrag,
headerDrag,
projectHeaderHostContexts,
getCachedFolderWorkspacePathStatus,
getLineageToggleHandler,
toggleGroupWithScrollAnchor,
@@ -17,6 +17,7 @@ import type {
Worktree
} from '../../../../../../shared/worktree/types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { ExecutionHostRegistryEntry } from '../../../../../../shared/execution-host-registry'
import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows'
import type { ImportedWorktreeCardActionState } from '../../imported-worktrees-card-actions'
import type { NewExternalWorktreesInboxActionState } from '../../new-external-worktrees-inbox-actions'
@@ -35,7 +36,9 @@ import type {
export const EMPTY_PROJECT_GROUPS: readonly ProjectGroup[] = []
export type VirtualizedWorktreeViewportProps = {
occurrencePlan?: WorktreeOccurrencePlan<Worktree>
visibleProjectRepoIdsBySection?: ReadonlyMap<string, ReadonlySet<string>>
rows: HostSectionRow[]
hostOptions?: readonly ExecutionHostRegistryEntry[]
activeWorktreeId: string | null
activeWorkspaceExecutionHostId: ExecutionHostId | null
currentWorktreeId: string | null
@@ -17,6 +17,7 @@ import type { VirtualizedWorktreeViewportProps } from './viewport-props'
import type { WorktreeVirtualRowContext } from '../rows/virtual-row-dispatch'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../../store/worktree-visibility-defaults-by-host'
import type { ProjectHeaderHostContext } from '../rows/project-header-host-context'
import type { RepoSshGateResolver } from '@/hooks/use-repo-ssh-gate-resolver'
type BuildArgs = {
@@ -37,6 +38,7 @@ type BuildArgs = {
reveal: SidebarRevealHighlight
statusDrag: ReturnType<typeof useWorkspaceStatusRowDrag>
nativeDrag: ReturnType<typeof useWorktreeNativeDrag>
projectHeaderHostContexts: ReadonlyMap<string, ProjectHeaderHostContext>
headerDrag: WorktreeSidebarHeaderDrag
getCachedFolderWorkspacePathStatus: ReturnType<typeof useFolderWorkspacePathStatusRows>
getLineageToggleHandler: (groupKey: string) => LineageToggleHandler
@@ -85,6 +87,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual
dragOverStatus: runtime.dragOverStatus,
pinDragOver: runtime.pinDragOver,
headerDrag,
projectHeaderHostContexts: args.projectHeaderHostContexts,
getCachedFolderWorkspacePathStatus: args.getCachedFolderWorkspacePathStatus,
toggleGroupWithScrollAnchor: args.toggleGroupWithScrollAnchor,
projectActions: {
@@ -106,20 +106,21 @@ export const PROJECT_SECTION_PARITY_ROWS: readonly {
{ id: 'wt-m4', repoId: 'm4-orca', lastActivityAt: 10 }
]
// Same-host checkouts still need path prefixes; other machines do not.
const STABLY = {
key: 'project:github:stablyai/orca::setup:local-stably',
label: 'jinwoo/stably/orca'
label: 'stably/orca'
}
const WORKSPACE = {
key: 'project:github:stablyai/orca::setup:local-workspace',
label: 'workspaces/orca/orca'
label: 'orca/orca'
}
// Why 'orca', not the server's 'Orca': the later host's derived project name wins the merge.
const WINDOWS = { key: 'project:github:stablyai/orca', label: 'jinwo/orca/orca' }
const M4 = { key: 'project:github:stablyai/orca::setup:m4-orca', label: 'm4air/orca/orca' }
const WINDOWS = { key: 'project:github:stablyai/orca', label: 'orca' }
const M4 = { key: 'project:github:stablyai/orca::setup:m4-orca', label: 'orca/orca' }
const M4_TWO = {
key: 'project:github:stablyai/orca::setup:m4-orca-two',
label: 'm4air/orca-two/orca'
label: 'orca-two/orca'
}
const ASSETS = { key: 'project:repo:local-assets', label: 'design-assets' }
+66 -6
View File
@@ -57,18 +57,14 @@ describe('getRepoDisplayLabelsByPath', () => {
})
it('keeps cross-host repos with identical path AND name as separate entries', () => {
// Hardening: when paths are byte-identical the same-name collision loop runs
// and re-sets each entry, so host scoping must survive that pass too — neither
// host may overwrite the other. Label text can still coincide; that residual is
// disambiguated by the host section/badge, not this map.
const localRepo = { path: '/Users/alice', displayName: 'home' }
const sshRepo = { path: '/Users/alice', displayName: 'home', connectionId: 'prod-ssh' }
const labels = getRepoDisplayLabelsByPath([localRepo, sshRepo])
expect(getRepoDisplayLabelKey(localRepo)).not.toBe(getRepoDisplayLabelKey(sshRepo))
expect(labels.size).toBe(2)
expect(labels.get(getRepoDisplayLabelKey(localRepo))).toBeDefined()
expect(labels.get(getRepoDisplayLabelKey(sshRepo))).toBeDefined()
expect(labels.get(getRepoDisplayLabelKey(localRepo))).toBe('home')
expect(labels.get(getRepoDisplayLabelKey(sshRepo))).toBe('home')
})
it('normalizes Windows separators to slash display labels', () => {
@@ -84,4 +80,68 @@ describe('getRepoDisplayLabelsByPath', () => {
'billing/api'
)
})
it('keeps identical project paths short across local and paired execution hosts', () => {
const local = { path: '/home/user/projects/app', displayName: 'app' }
const paired = { ...local, executionHostId: 'runtime:home' as const }
const labels = getRepoDisplayLabelsByPath([local, paired])
expect(labels.get(getRepoDisplayLabelKey(local))).toBe('app')
expect(labels.get(getRepoDisplayLabelKey(paired))).toBe('app')
expect(labels.size).toBe(2)
})
it('disambiguates collisions only within their owning host, even when paths overlap', () => {
const local = { path: '/workspace/payments/api', displayName: 'api' }
const sibling = { path: '/workspace/billing/api', displayName: 'api' }
const paired = { ...local, executionHostId: 'runtime:home' as const }
const labels = getRepoDisplayLabelsByPath([local, sibling, paired])
expect(labels.get(getRepoDisplayLabelKey(local))).toBe('payments/api')
expect(labels.get(getRepoDisplayLabelKey(sibling))).toBe('billing/api')
expect(labels.get(getRepoDisplayLabelKey(paired))).toBe('api')
expect(labels.size).toBe(3)
})
it('does not add path segments for matching names at different paths on different SSH hosts', () => {
const first = { path: '/work/payments/api', displayName: 'api', connectionId: 'first' }
const second = { path: '/work/billing/api', displayName: 'api', connectionId: 'second' }
const labels = getRepoDisplayLabelsByPath([first, second])
expect(labels.get(getRepoDisplayLabelKey(first))).toBe('api')
expect(labels.get(getRepoDisplayLabelKey(second))).toBe('api')
})
it('resolves explicit execution ownership before legacy connection metadata', () => {
const first = {
path: '/work/payments/api',
displayName: 'api',
connectionId: 'old',
executionHostId: 'runtime:first' as const
}
const second = {
path: '/work/billing/api',
displayName: 'api',
connectionId: 'old',
executionHostId: 'runtime:second' as const
}
const labels = getRepoDisplayLabelsByPath([first, second])
expect(labels.get(getRepoDisplayLabelKey(first))).toBe('api')
expect(labels.get(getRepoDisplayLabelKey(second))).toBe('api')
})
it('keeps Windows labels separate across hosts that share a path', () => {
const first = { path: 'C:\\work\\payments\\api', displayName: 'api' }
const sibling = { path: 'C:\\work\\billing\\api', displayName: 'api' }
const paired = { ...first, executionHostId: 'runtime:windows' as const }
const labels = getRepoDisplayLabelsByPath([first, sibling, paired])
expect(labels.get(getRepoDisplayLabelKey(first))).toBe('payments/api')
expect(labels.get(getRepoDisplayLabelKey(sibling))).toBe('billing/api')
expect(labels.get(getRepoDisplayLabelKey(paired))).toBe('api')
})
it('preserves suffix disambiguation for local WSL distro paths', () => {
const first = { path: '\\\\wsl.localhost\\Ubuntu\\home\\user\\app', displayName: 'app' }
const second = { path: '\\\\wsl.localhost\\Debian\\home\\user\\app', displayName: 'app' }
const labels = getRepoDisplayLabelsByPath([first, second])
expect(labels.get(getRepoDisplayLabelKey(first))).toBe('Ubuntu/home/user/app')
expect(labels.get(getRepoDisplayLabelKey(second))).toBe('Debian/home/user/app')
})
})
+6 -4
View File
@@ -40,17 +40,19 @@ export function getRepoDisplayLabelsByPath(
items: readonly RepoDisplayLabelItem[]
): Map<string, string> {
const labels = new Map<string, string>()
const itemsByName = new Map<string, RepoDisplayLabelItem[]>()
const itemsByHostAndName = new Map<string, RepoDisplayLabelItem[]>()
for (const item of items) {
const displayName = item.displayName || item.path
labels.set(getRepoDisplayLabelKey(item), displayName)
const colliding = itemsByName.get(displayName) ?? []
// Host labels already distinguish projects on different machines.
const collisionKey = JSON.stringify([getRepoExecutionHostId(item), displayName])
const colliding = itemsByHostAndName.get(collisionKey) ?? []
colliding.push({ ...item, displayName })
itemsByName.set(displayName, colliding)
itemsByHostAndName.set(collisionKey, colliding)
}
for (const collidingItems of itemsByName.values()) {
for (const collidingItems of itemsByHostAndName.values()) {
if (collidingItems.length < 2) {
continue
}