mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
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:
@@ -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,
|
||||
|
||||
+155
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+337
@@ -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)
|
||||
})
|
||||
})
|
||||
+112
@@ -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()
|
||||
}
|
||||
+24
@@ -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' }
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user