diff --git a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts index e42e79da9ce..03c35d874c0 100644 --- a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts +++ b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts @@ -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' } ]) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index fcd3cfee1ad..728cb5470fb 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-host-name-collisions.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-host-name-collisions.test.ts new file mode 100644 index 00000000000..14e9955e7f3 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list-groups-host-name-collisions.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts index 3c51d49f213..b854423ec51 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-main-list-model.ts @@ -292,6 +292,7 @@ export function useMainSidebarListModel(args: SidebarListModelArgs): SidebarList externalWorktreeCards, rowModel: { occurrencePlan: planned.plan.occurrencePlan, + hostOptions: planned.orderedHostOptions, effectiveCollapsedGroups, rows: rendered.rows, sectionRows, diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index 91ae37dad27..5537b8e48b9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/ProjectHeaderHostLabel.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/ProjectHeaderHostLabel.test.tsx new file mode 100644 index 00000000000..f9d0c1e52ec --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/ProjectHeaderHostLabel.test.tsx @@ -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( + + +
+ app + + Collapse + + + + + + Project actions + +
+
+
+ ) + }) + 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('[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) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/ProjectHeaderHostLabel.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/ProjectHeaderHostLabel.tsx new file mode 100644 index 00000000000..083fd82b9bf --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/ProjectHeaderHostLabel.tsx @@ -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 ( + + + {context.label} + + ) +} + +export function ProjectHeaderHostTooltip({ + context, + label, + children +}: { + context: ProjectHeaderHostContext | undefined + label: string + children: React.JSX.Element +}): React.JSX.Element { + return context ? ( + + {children} + + ) : ( + 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 ( + setOpen(nextOpen && !actionOwnsTooltip.current)} + > + { + // 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} + + + {label} — {context.label} + {context.detail !== context.label ?
{context.detail}
: null} +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index a9ba7d4c73c..b56235981d3 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -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 collapsedGroups: Set 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) }} > -
ctx.onWorkspaceStatusDragOver(event, headerWorkspaceStatus) + +
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. */} -
- {row.repo ? ( - - ) : ( - - )} -
- -
-
-
{label}
- - - {isRepoHeader ? : null} -
-
-
- - - {showHeaderCollapseAffordance ? (
{ - 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 + )} > - + {row.repo ? ( + + ) : ( + + )}
- ) : null} - {isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? ( - - ) : null} +
+
+
{label}
+ {hostContext ? : null} + + + {isRepoHeader ? : null} +
+
+
- {folderBackedProjectGroup ? ( - - ) : null} + + {showHeaderCollapseAffordance ? ( +
{ + event.preventDefault() + event.stopPropagation() + ctx.toggleGroupWithScrollAnchor(collapseKey) + }} + > + +
+ ) : null} - {row.repo && ctx.groupBy === 'repo' ? ( - - ) : null} + {isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? ( + + ) : null} - {row.repo && ctx.groupBy === 'repo' ? ( - - ) : null} -
-
+ {folderBackedProjectGroup ? ( + + ) : null} + + {row.repo && ctx.groupBy === 'repo' ? ( + + ) : null} + + {row.repo && ctx.groupBy === 'repo' ? ( + + ) : null} + + + ) } diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.test.ts b/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.test.ts new file mode 100644 index 00000000000..466452a7614 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.ts b/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.ts new file mode 100644 index 00000000000..88f4c26d8ba --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.ts @@ -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> + hosts: readonly ExecutionHostRegistryEntry[] + runtimeEnvironments?: readonly ManagedOrcadEnvironmentSummary[] + sshConnectionStates?: ReadonlyMap> + repoMap: Map + projectGrouping?: ProjectGroupingModel +}): ReadonlyMap { + 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>() + 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() + projectHosts.add(hostId) + hostsByProjectKey.set(key, projectHosts) + } + const candidates = new Map() + const visibleHostIds = new Set() + 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() +} diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx index 7a022e795c6..9d284bc3384 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts index 25a0e625449..c087501c076 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts @@ -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 + visibleProjectRepoIdsBySection?: ReadonlyMap> rows: HostSectionRow[] + hostOptions?: readonly ExecutionHostRegistryEntry[] activeWorktreeId: string | null activeWorkspaceExecutionHostId: ExecutionHostId | null currentWorktreeId: string | null diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index b03ba6f1579..0af03d8a4fa 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -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 nativeDrag: ReturnType + projectHeaderHostContexts: ReadonlyMap headerDrag: WorktreeSidebarHeaderDrag getCachedFolderWorkspacePathStatus: ReturnType 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: { diff --git a/src/shared/project-section-parity.test-fixture.ts b/src/shared/project-section-parity.test-fixture.ts index d7295e1460d..07dd52ab2d0 100644 --- a/src/shared/project-section-parity.test-fixture.ts +++ b/src/shared/project-section-parity.test-fixture.ts @@ -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' } diff --git a/src/shared/repo-display-labels.test.ts b/src/shared/repo-display-labels.test.ts index 4935f2da1f8..44cdddf17d8 100644 --- a/src/shared/repo-display-labels.test.ts +++ b/src/shared/repo-display-labels.test.ts @@ -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') + }) }) diff --git a/src/shared/repo-display-labels.ts b/src/shared/repo-display-labels.ts index b6c16ffb923..933206f4260 100644 --- a/src/shared/repo-display-labels.ts +++ b/src/shared/repo-display-labels.ts @@ -40,17 +40,19 @@ export function getRepoDisplayLabelsByPath( items: readonly RepoDisplayLabelItem[] ): Map { const labels = new Map() - const itemsByName = new Map() + const itemsByHostAndName = new Map() 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 }