mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 08:02:31 +00:00
fix(sidebar): host-qualify discovery notice rows on multi-host projects (#15546)
* fix(sidebar): host-qualify discovery notice rows and collapse one checkout's twins A project checked out on several hosts emits one discovery-notice row per checkout, and those rows only named the project. A sidebar with paired remote hosts therefore showed several identical "N hidden worktrees" buttons under one project header, with no way to tell which machine each belonged to — or that one of them was another machine's worktree inbox entirely. Two causes, both fixed here: - Notice rows carried no host context, unlike worktree rows, which have been host-labelled since STA-4343. Both notice rows now take a host label, applied per project (not per rendered section, since a card can land in the pinned fallback) and only when that project spans hosts. The label also lands in the review, expand, and dismiss accessible names, so the actions that write to a specific host's repo record say which host that is. - One machine registered as a direct SSH target *and* paired as a runtime environment gives a single on-disk checkout two repo records with independent hidden-worktree state, so it emitted two rows for one directory. Repos now resolve to a (hostname, path) checkout key, and twins collapse to the record this client persists itself — its visibility state is the user's own and survives the paired runtime going away. The key is deliberately conservative: an unresolved hostname, or a tunnelled environment answering on loopback, yields no key and never collapses anything. Renderer-only; no wire or persistence change. * fix(sidebar): drop the machine-identity collapse, gate notice labels on host ids Replaces this branch's second change after a plan review found it has no precedent and eight concrete failure modes. Deleted: the (hostname, path) checkout key that collapsed two repo records believed to be one machine. Orca models a direct SSH target and a paired runtime environment as different execution hosts everywhere else; that change asserted sameness by resolving strings a user typed in two places. It also dropped rows (a differing count vanished with the shadowed record), flipped with the sidebar host filter, ignored port and user so a host and a container on it could merge, tie-broke on repo-store order, was disabled in the one case Orca can prove (a tunnelled pairing answers on loopback) and fired only on coincidence, and left the visibility dialog showing state the sidebar had hidden. Its module also carried a literal NUL byte, so git classified the file as binary and the diff was unreviewable. Kept, with two corrections: notice rows still carry a host label, but the gate now counts distinct host ids rather than distinct label strings — two hosts sharing one user-facing label is exactly when the rows are hardest to tell apart — and membership is read from the unfiltered repo universe rather than the host-filtered notice candidates, so a label no longer appears and disappears with the filter. Two hosts that share a label still render the same label. Disambiguating that is a shared concern across worktree badges, host headers, and host-filter options, and needs its own design; three verification passes each found a different hole in doing it here. Follow-ups: general host-label collision, and the repo-record duplication that produces the twin rows in the first place. * fix(i18n): catalog notice host scope copy * feat(sidebar): show each notice row's host with the project-on-host glyph Notice rows on a multi-host project already carried a host label, but two hosts can share one user-facing name, and the label truncates first in a narrow sidebar. Each row now also carries its host's glyph. Deliberately the same indicator worktree cards use (worktree-card-header): a Server glyph, ServerOff when a paired runtime has no live status, and a "Project on ..." tooltip naming the host — SSH and runtime keep their distinct tooltip wording. Local hosts draw nothing, as on the cards. The glyph is shrink-0, so unlike the text label it survives the sidebar narrowing, and the row keeps an identifying mark either way. Rows now carry the host id alongside the label, since the label alone cannot select a glyph or its tooltip. Catalog entries for the new copy ship with the change rather than relying on inline fallbacks. * refactor(sidebar): draw notice-row hosts with the shared host glyph Follow-up to the notice-row host indicator: use the one glyph vocabulary the app already has instead of a second copy of it. HostRowIcon — a monitor for this computer, a server for anything remote — was private to the composer's run-target rows. Moved to a shared home and reused, so the sidebar and the composer cannot drift apart. The run-target module re-exports it, leaving its own call sites untouched. Every notice row now gets a glyph, local included, so no row is the odd one out; the tooltip still names the host and says when a paired runtime has no live status. Same size and tone tokens across kinds, so no row reads as decorated relative to its neighbours. * fix(sidebar): make notice host glyphs accessible
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Monitor, Server } from 'lucide-react'
|
||||
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
/** The local machine isn't a server — a monitor glyph reads as "this computer". */
|
||||
export function HostRowIcon({
|
||||
hostId,
|
||||
className
|
||||
}: {
|
||||
hostId: ExecutionHostId
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server
|
||||
return <Icon className={className ?? 'size-3.5 shrink-0 text-muted-foreground'} />
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, ChevronRight, LoaderCircle, Monitor, Server } from 'lucide-react'
|
||||
import { AlertTriangle, ChevronRight, LoaderCircle } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { ProjectOptionDetail } from './ProjectComboboxRow'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { HostRowIcon } from '../host-row-icon'
|
||||
|
||||
/** The local machine isn't a server — a monitor glyph reads as "this computer". */
|
||||
export function HostRowIcon({ hostId }: { hostId: ExecutionHostId }): React.JSX.Element {
|
||||
const Icon = hostId === LOCAL_EXECUTION_HOST_ID ? Monitor : Server
|
||||
return <Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
export { HostRowIcon }
|
||||
|
||||
/**
|
||||
* One run-target row. Shares the Project picker's shape — 32px, label and
|
||||
|
||||
@@ -73,6 +73,26 @@ describe('ImportedWorktreesVisibilityLine', () => {
|
||||
expect(markup).not.toContain('/worktrees/demo-project')
|
||||
})
|
||||
|
||||
it('names the host when the project is checked out on more than one', () => {
|
||||
const markup = renderLine({ hostContextLabel: 'openclaw' })
|
||||
|
||||
expect(markup).toContain('openclaw')
|
||||
expect(markup).toContain('Expand hidden worktrees for orca on openclaw')
|
||||
expect(markup).toContain(
|
||||
'Keep 4 discovered worktrees hidden for orca on openclaw; recover from the project menu'
|
||||
)
|
||||
})
|
||||
|
||||
it('folds the host into pinned fallback copy, which already names the repo', () => {
|
||||
const markup = renderLine({
|
||||
hostContextLabel: 'openclaw',
|
||||
placement: 'pinned-fallback',
|
||||
onKeepHidden: undefined
|
||||
})
|
||||
|
||||
expect(markup).toContain('Hiding 4 discovered worktrees in orca on openclaw')
|
||||
})
|
||||
|
||||
it('scopes pinned fallback copy to the repo name without a dismiss action', () => {
|
||||
const markup = renderLine({ placement: 'pinned-fallback', onKeepHidden: undefined })
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ChevronRight, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import NoticeHostGlyph from './NoticeHostGlyph'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getExternalWorktreeParentPath } from '../../../../shared/external-worktree-visibility'
|
||||
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -19,6 +21,10 @@ export type ImportedWorktreeVisibilityPreview = {
|
||||
|
||||
type ImportedWorktreesVisibilityLineProps = {
|
||||
repoDisplayName: string
|
||||
/** Host this checkout lives on. Set only when the project is checked out on
|
||||
* more than one host, where the line alone cannot identify the row. */
|
||||
hostContextLabel?: string
|
||||
hostContextHostId?: ExecutionHostId
|
||||
hiddenWorktrees: readonly ImportedWorktreeVisibilityPreview[]
|
||||
placement: ImportedWorktreesVisibilityPlacement
|
||||
pending: boolean
|
||||
@@ -74,6 +80,8 @@ export function groupWorktreesByParentPath(
|
||||
|
||||
export default function ImportedWorktreesVisibilityLine({
|
||||
repoDisplayName,
|
||||
hostContextLabel,
|
||||
hostContextHostId,
|
||||
hiddenWorktrees,
|
||||
placement,
|
||||
pending,
|
||||
@@ -89,7 +97,11 @@ export default function ImportedWorktreesVisibilityLine({
|
||||
const worktreeGroups = groupWorktreesByParentPath(hiddenWorktrees)
|
||||
const visibleWorktreeGroups = worktreeGroups.slice(0, GROUP_LIMIT)
|
||||
const remainingGroupCount = Math.max(0, worktreeGroups.length - visibleWorktreeGroups.length)
|
||||
const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoDisplayName}; recover from the project menu`
|
||||
// Why: two hosts checking out one project render two identical lines.
|
||||
const repoScopeLabel = hostContextLabel
|
||||
? `${repoDisplayName} on ${hostContextLabel}`
|
||||
: repoDisplayName
|
||||
const keepHiddenAriaLabel = `Keep ${hiddenCount} discovered ${worktreeNoun} hidden for ${repoScopeLabel}; recover from the project menu`
|
||||
|
||||
if (hiddenCount === 0) {
|
||||
return null
|
||||
@@ -97,7 +109,7 @@ export default function ImportedWorktreesVisibilityLine({
|
||||
|
||||
const lineText =
|
||||
placement === 'pinned-fallback'
|
||||
? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoDisplayName}`
|
||||
? `Hiding ${hiddenCount} discovered ${worktreeNoun} in ${repoScopeLabel}`
|
||||
: `Hiding ${hiddenCount} discovered ${worktreeNoun}`
|
||||
|
||||
const toggleGroupExpanded = (path: string): void => {
|
||||
@@ -133,7 +145,7 @@ export default function ImportedWorktreesVisibilityLine({
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.ImportedWorktreesVisibilityLine.f54f2bec5d',
|
||||
'{{value0}} hidden worktrees for {{value1}}',
|
||||
{ value0: isExpanded ? 'Collapse' : 'Expand', value1: repoDisplayName }
|
||||
{ value0: isExpanded ? 'Collapse' : 'Expand', value1: repoScopeLabel }
|
||||
)}
|
||||
onClick={() => setIsExpanded((value) => !value)}
|
||||
className="shrink-0 rounded-[4px] text-muted-foreground hover:bg-worktree-sidebar-accent hover:text-worktree-sidebar-accent-foreground"
|
||||
@@ -144,6 +156,20 @@ export default function ImportedWorktreesVisibilityLine({
|
||||
/>
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate">{lineText}</span>
|
||||
{hostContextLabel && placement !== 'pinned-fallback' ? (
|
||||
<span className="inline-flex min-w-0 shrink items-center gap-1">
|
||||
{hostContextHostId ? (
|
||||
<NoticeHostGlyph
|
||||
hostId={hostContextHostId}
|
||||
hostLabel={hostContextLabel}
|
||||
keyboardFocusable
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate text-[10px] leading-none text-muted-foreground">
|
||||
{hostContextLabel}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{onKeepHidden ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock('@/components/ui/tooltip', () => ({
|
||||
const roots: Root[] = []
|
||||
|
||||
type RenderOverrides = {
|
||||
hostContextLabel?: string
|
||||
inboxCount?: number
|
||||
pending?: boolean
|
||||
error?: string | null
|
||||
@@ -34,6 +35,7 @@ async function renderLine(overrides: RenderOverrides = {}): Promise<HTMLDivEleme
|
||||
root.render(
|
||||
<NewExternalWorktreesInboxLine
|
||||
repoDisplayName="orca"
|
||||
hostContextLabel={overrides.hostContextLabel}
|
||||
inboxCount={overrides.inboxCount ?? 24}
|
||||
pending={overrides.pending ?? false}
|
||||
error={overrides.error ?? null}
|
||||
@@ -97,6 +99,38 @@ describe('NewExternalWorktreesInboxLine', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('names the host so two checkouts of one project are distinguishable', async () => {
|
||||
// Both rows read "N hidden worktrees"; only the host tells them apart.
|
||||
const local = await renderLine({ hostContextLabel: 'Local Mac', inboxCount: 61 })
|
||||
const remote = await renderLine({ hostContextLabel: 'openclaw', inboxCount: 134 })
|
||||
|
||||
expect(local.textContent).toContain('Local Mac')
|
||||
expect(getReviewButton(local)?.getAttribute('aria-label')).toBe(
|
||||
'Review 61 hidden worktrees in orca on Local Mac'
|
||||
)
|
||||
expect(getReviewButton(remote)?.getAttribute('aria-label')).toBe(
|
||||
'Review 134 hidden worktrees in orca on openclaw'
|
||||
)
|
||||
})
|
||||
|
||||
it('host-qualifies the suppress control, which writes to that host alone', async () => {
|
||||
const container = await renderLine({ hostContextLabel: 'openclaw', onSuppress: vi.fn() })
|
||||
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="Hide external worktrees permanently for orca on openclaw"]'
|
||||
)
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('stays unqualified when the project has a single checkout', async () => {
|
||||
const container = await renderLine()
|
||||
|
||||
expect(getReviewButton(container)?.getAttribute('aria-label')).toBe(
|
||||
'Review 24 hidden worktrees in orca'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps suppress as a hover-revealed control that does not trigger review', async () => {
|
||||
const onReview = vi.fn()
|
||||
const onSuppress = vi.fn()
|
||||
|
||||
@@ -4,10 +4,16 @@ import { ChevronRight, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import NoticeHostGlyph from './NoticeHostGlyph'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type NewExternalWorktreesInboxLineProps = {
|
||||
repoDisplayName: string
|
||||
/** Host this checkout lives on. Set only when the project is checked out on
|
||||
* more than one host, where the count alone cannot identify the row. */
|
||||
hostContextLabel?: string
|
||||
hostContextHostId?: ExecutionHostId
|
||||
inboxCount: number
|
||||
pending: boolean
|
||||
error: string | null
|
||||
@@ -18,6 +24,8 @@ type NewExternalWorktreesInboxLineProps = {
|
||||
|
||||
export default function NewExternalWorktreesInboxLine({
|
||||
repoDisplayName,
|
||||
hostContextLabel,
|
||||
hostContextHostId,
|
||||
inboxCount,
|
||||
pending,
|
||||
error,
|
||||
@@ -29,10 +37,19 @@ export default function NewExternalWorktreesInboxLine({
|
||||
'auto.components.sidebar.NewExternalWorktreesInboxLine.c3e8a1f4b2',
|
||||
"Don't show again"
|
||||
)
|
||||
// Why: the same project on two hosts renders two identical rows, so every
|
||||
// accessible name has to name the host as well as the project.
|
||||
const repoScopeLabel = hostContextLabel
|
||||
? translate(
|
||||
'auto.components.sidebar.NewExternalWorktreesInboxLine.6c07f3a91e',
|
||||
'{{value0}} on {{value1}}',
|
||||
{ value0: repoDisplayName, value1: hostContextLabel }
|
||||
)
|
||||
: repoDisplayName
|
||||
const suppressAriaLabel = translate(
|
||||
'auto.components.sidebar.NewExternalWorktreesInboxLine.9f2d4c8b17',
|
||||
'Hide external worktrees permanently for {{value0}}',
|
||||
{ value0: repoDisplayName }
|
||||
{ value0: repoScopeLabel }
|
||||
)
|
||||
const isSingular = inboxCount === 1
|
||||
const countLabel = isSingular
|
||||
@@ -48,12 +65,12 @@ export default function NewExternalWorktreesInboxLine({
|
||||
? translate(
|
||||
'auto.components.sidebar.NewExternalWorktreesInboxLine.7f18c5b0d3',
|
||||
'Review {{value0}} hidden worktree in {{value1}}',
|
||||
{ value0: inboxCount, value1: repoDisplayName }
|
||||
{ value0: inboxCount, value1: repoScopeLabel }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.NewExternalWorktreesInboxLine.4e2b7a9c05',
|
||||
'Review {{value0}} hidden worktrees in {{value1}}',
|
||||
{ value0: inboxCount, value1: repoDisplayName }
|
||||
{ value0: inboxCount, value1: repoScopeLabel }
|
||||
)
|
||||
|
||||
if (inboxCount === 0) {
|
||||
@@ -83,6 +100,20 @@ export default function NewExternalWorktreesInboxLine({
|
||||
{inboxCount}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-left">{countLabel}</span>
|
||||
{hostContextLabel ? (
|
||||
<span className="inline-flex min-w-0 shrink items-center gap-1">
|
||||
{hostContextHostId ? (
|
||||
<NoticeHostGlyph
|
||||
hostId={hostContextHostId}
|
||||
hostLabel={hostContextLabel}
|
||||
keyboardFocusable={false}
|
||||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate text-[10px] leading-none text-muted-foreground">
|
||||
{hostContextLabel}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
/**
|
||||
* Notice rows reuse one host vocabulary: monitor for local, server for remote,
|
||||
* with the worktree card's "Project on …" tooltip copy.
|
||||
*/
|
||||
import { act, cloneElement, type ReactElement, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import NoticeHostGlyph from './NoticeHostGlyph'
|
||||
import en from '../../i18n/locales/en.json'
|
||||
import es from '../../i18n/locales/es.json'
|
||||
import ja from '../../i18n/locales/ja.json'
|
||||
import ko from '../../i18n/locales/ko.json'
|
||||
import zh from '../../i18n/locales/zh.json'
|
||||
|
||||
const runtimeStatusByEnvironmentId = new Map<string, { status?: unknown }>()
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) => selector({ runtimeStatusByEnvironmentId })
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactElement<{ 'data-testid'?: string }> }) =>
|
||||
cloneElement(children, { 'data-testid': 'tooltip-trigger' }),
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => (
|
||||
<span data-testid="tooltip">{children}</span>
|
||||
)
|
||||
}))
|
||||
|
||||
const roots: Root[] = []
|
||||
|
||||
async function render(
|
||||
hostId: string,
|
||||
hostLabel = 'openclaw',
|
||||
keyboardFocusable = false
|
||||
): Promise<HTMLDivElement> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
roots.push(root)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<NoticeHostGlyph
|
||||
hostId={hostId as never}
|
||||
hostLabel={hostLabel}
|
||||
keyboardFocusable={keyboardFocusable}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
describe('NoticeHostGlyph', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
runtimeStatusByEnvironmentId.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
roots.splice(0).forEach((root) => act(() => root.unmount()))
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('names the SSH host it would act on', async () => {
|
||||
const container = await render('ssh:openclaw-target')
|
||||
|
||||
expect(container.querySelector('[data-notice-host-kind="ssh"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe(
|
||||
'Project on SSH host openclaw'
|
||||
)
|
||||
})
|
||||
|
||||
it('names the paired runtime separately from the SSH host of the same name', async () => {
|
||||
runtimeStatusByEnvironmentId.set('openclaw-env', { status: 'ready' })
|
||||
const container = await render('runtime:openclaw-env')
|
||||
|
||||
expect(container.querySelector('[data-notice-host-kind="runtime"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe(
|
||||
'Project on openclaw'
|
||||
)
|
||||
})
|
||||
|
||||
it('marks a paired runtime with no live status as disconnected', async () => {
|
||||
const container = await render('runtime:openclaw-env')
|
||||
|
||||
expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe(
|
||||
'openclaw disconnected'
|
||||
)
|
||||
})
|
||||
|
||||
it('gives the local host the monitor glyph the run-target rows use', async () => {
|
||||
const container = await render('local', 'Local Mac')
|
||||
|
||||
expect(container.querySelector('[data-notice-host-kind="local"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-testid="tooltip"]')?.textContent).toBe(
|
||||
'Project on this host'
|
||||
)
|
||||
})
|
||||
|
||||
it('makes a passive row glyph keyboard reachable with an accessible name', async () => {
|
||||
const container = await render('ssh:openclaw-target', 'openclaw', true)
|
||||
const trigger = container.querySelector('[data-testid="tooltip-trigger"]')
|
||||
|
||||
expect(trigger?.getAttribute('tabindex')).toBe('0')
|
||||
expect(trigger?.getAttribute('role')).toBe('img')
|
||||
expect(trigger?.getAttribute('aria-label')).toBe('Project on SSH host openclaw')
|
||||
})
|
||||
|
||||
it('does not add a nested tab stop when the glyph is inside a button', async () => {
|
||||
const container = await render('ssh:openclaw-target')
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="tooltip-trigger"]')?.hasAttribute('tabindex')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('draws one glyph vocabulary: a monitor for local, a server for remote', async () => {
|
||||
const local = await render('local', 'Local Mac')
|
||||
const remote = await render('ssh:openclaw-target')
|
||||
|
||||
const glyph = (container: HTMLDivElement): string =>
|
||||
container.querySelector('svg')?.getAttribute('class') ?? ''
|
||||
// The same vocabulary the run-target rows use: this computer vs a server.
|
||||
expect(glyph(local)).toContain('lucide-monitor')
|
||||
expect(glyph(remote)).toContain('lucide-server')
|
||||
// Same size and tone tokens, so neither row reads as decorated.
|
||||
const tokens = (value: string): string[] =>
|
||||
value.split(' ').filter((entry) => !entry.startsWith('lucide'))
|
||||
expect(tokens(glyph(local))).toEqual(tokens(glyph(remote)))
|
||||
})
|
||||
|
||||
it('keeps its copy in the English catalog', async () => {
|
||||
// A key referenced only in the component silently falls back to its inline
|
||||
// default and never reaches translators.
|
||||
expect(en.auto.components.sidebar.NoticeHostGlyph).toMatchObject({
|
||||
hostDisconnected: '{{hostName}} disconnected',
|
||||
sshHostProject: 'Project on SSH host {{hostName}}',
|
||||
localHostProject: 'Project on this host',
|
||||
runtimeHostProject: 'Project on {{hostName}}'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(Object.entries({ es, ja, ko, zh }))(
|
||||
'keeps its copy in the %s catalog',
|
||||
(_locale, catalog) => {
|
||||
expect(catalog.auto.components.sidebar.NoticeHostGlyph).toMatchObject({
|
||||
hostDisconnected: expect.stringContaining('{{hostName}}'),
|
||||
sshHostProject: expect.stringContaining('{{hostName}}'),
|
||||
localHostProject: expect.any(String),
|
||||
runtimeHostProject: expect.stringContaining('{{hostName}}')
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { HostRowIcon } from '../host-row-icon'
|
||||
import { useAppStore } from '@/store'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type NoticeHostGlyphProps = {
|
||||
hostId: ExecutionHostId
|
||||
hostLabel: string
|
||||
keyboardFocusable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The host indicator for a discovery-notice row.
|
||||
*
|
||||
* Deliberately the one host glyph vocabulary the composer's run-target rows
|
||||
* already use (HostRowIcon: a monitor for this computer, a server for anything
|
||||
* remote), plus the worktree card's "Project on …" tooltip. Every row gets one,
|
||||
* including local, so no row is the odd one out.
|
||||
*/
|
||||
export default function NoticeHostGlyph({
|
||||
hostId,
|
||||
hostLabel,
|
||||
keyboardFocusable
|
||||
}: NoticeHostGlyphProps): React.JSX.Element | null {
|
||||
const host = parseExecutionHostId(hostId)
|
||||
const isDisconnected = useAppStore((s) => {
|
||||
if (host?.kind !== 'runtime') {
|
||||
return false
|
||||
}
|
||||
return !s.runtimeStatusByEnvironmentId.get(host.environmentId)?.status
|
||||
})
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tooltip = isDisconnected
|
||||
? translate(
|
||||
'auto.components.sidebar.NoticeHostGlyph.hostDisconnected',
|
||||
'{{hostName}} disconnected',
|
||||
{ hostName: hostLabel }
|
||||
)
|
||||
: host.kind === 'ssh'
|
||||
? translate(
|
||||
'auto.components.sidebar.NoticeHostGlyph.sshHostProject',
|
||||
'Project on SSH host {{hostName}}',
|
||||
{ hostName: hostLabel }
|
||||
)
|
||||
: host.kind === 'local'
|
||||
? translate(
|
||||
'auto.components.sidebar.NoticeHostGlyph.localHostProject',
|
||||
'Project on this host'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.sidebar.NoticeHostGlyph.runtimeHostProject',
|
||||
'Project on {{hostName}}',
|
||||
{ hostName: hostLabel }
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={keyboardFocusable ? tooltip : undefined}
|
||||
className="inline-flex shrink-0 items-center rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring"
|
||||
data-notice-host-kind={host.kind}
|
||||
role={keyboardFocusable ? 'img' : undefined}
|
||||
tabIndex={keyboardFocusable ? 0 : undefined}
|
||||
>
|
||||
<HostRowIcon
|
||||
hostId={hostId}
|
||||
className={`size-3 shrink-0 ${
|
||||
isDisconnected ? 'text-destructive' : 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* A project checked out on several hosts emits one discovery-notice row per
|
||||
* checkout. Those rows only ever named the project, so a sidebar with paired
|
||||
* remote hosts showed identical "N hidden worktrees" buttons with no way to
|
||||
* tell which machine either belonged to.
|
||||
*
|
||||
* Two hosts can also share one user-facing label, which is when the rows are
|
||||
* hardest to tell apart — so the gate counts distinct host ids, and it reads
|
||||
* them from the unfiltered repo universe rather than the host-filtered notice
|
||||
* candidates, or a label would appear and disappear with the sidebar filter.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildRows } from './worktree-list/grouping/build-rows'
|
||||
import { getNoticeHostContextLabels } from './worktree-list/grouping/host-labels'
|
||||
import { buildProjectGroupingIndex } from './worktree-list/grouping/project-grouping'
|
||||
import { repo, worktree, project, projectHostSetups } from './worktree-list-groups-test-fixtures'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { ProjectHostSetup } from '../../../../shared/project-types'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { DetectedWorktree, Worktree } from '../../../../shared/worktree/types'
|
||||
import type { Row } from './worktree-list/grouping/row-types'
|
||||
|
||||
const SSH_HOST_ID: ExecutionHostId = 'ssh:openclaw-target'
|
||||
const ENV_HOST_ID: ExecutionHostId = 'runtime:openclaw-env'
|
||||
|
||||
/** Both twins display the same label: the reporting account's shape. */
|
||||
const HOST_LABELS = new Map([
|
||||
['local', 'Local Mac'],
|
||||
[SSH_HOST_ID, 'openclaw'],
|
||||
[ENV_HOST_ID, 'openclaw']
|
||||
])
|
||||
|
||||
const sshTwin: Repo = {
|
||||
...repo,
|
||||
id: 'repo-ssh-twin',
|
||||
path: '/home/brennan/orca',
|
||||
connectionId: 'openclaw-target'
|
||||
}
|
||||
const envTwin: Repo = {
|
||||
...repo,
|
||||
id: 'repo-env-twin',
|
||||
path: '/home/brennan/orca',
|
||||
connectionId: null,
|
||||
executionHostId: ENV_HOST_ID
|
||||
}
|
||||
|
||||
function setupFor(target: Repo, hostId: ExecutionHostId): ProjectHostSetup {
|
||||
return {
|
||||
...projectHostSetups[0]!,
|
||||
id: target.id,
|
||||
projectId: project.id,
|
||||
hostId,
|
||||
repoId: target.id,
|
||||
path: target.path,
|
||||
displayName: target.displayName
|
||||
}
|
||||
}
|
||||
|
||||
const TWIN_GROUPING = {
|
||||
projects: [{ ...project, sourceRepoIds: [repo.id, sshTwin.id, envTwin.id] }],
|
||||
projectHostSetups: [
|
||||
projectHostSetups[0]!,
|
||||
setupFor(sshTwin, SSH_HOST_ID),
|
||||
setupFor(envTwin, ENV_HOST_ID)
|
||||
]
|
||||
}
|
||||
|
||||
const TWIN_REPO_MAP = new Map([
|
||||
[repo.id, repo],
|
||||
[sshTwin.id, sshTwin],
|
||||
[envTwin.id, envTwin]
|
||||
])
|
||||
|
||||
function detected(path: string): DetectedWorktree {
|
||||
return { path, visible: false } as DetectedWorktree
|
||||
}
|
||||
|
||||
/** Counts differ per record — the reporting account's 61 vs 134. */
|
||||
const INBOX_COUNTS: Record<string, number> = { [sshTwin.id]: 61, [envTwin.id]: 134 }
|
||||
|
||||
function inboxMap(repoIds: readonly string[]): Map<string, unknown> {
|
||||
return new Map(
|
||||
repoIds.map((repoId) => [
|
||||
repoId,
|
||||
{
|
||||
repo: TWIN_REPO_MAP.get(repoId)!,
|
||||
inboxWorktrees: Array.from({ length: INBOX_COUNTS[repoId] ?? 1 }, (_unused, index) =>
|
||||
detected(`/inbox/${repoId}/${index}`)
|
||||
)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function noticeRows(args: {
|
||||
/** Host-filtered: only these records still have notice candidates. */
|
||||
eligibleRepoIds: readonly string[]
|
||||
worktrees?: Worktree[]
|
||||
repoMap?: Map<string, Repo>
|
||||
grouping?: typeof TWIN_GROUPING
|
||||
}): Extract<Row, { type: 'new-external-worktrees-inbox' }>[] {
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
args.worktrees ?? [worktree],
|
||||
args.repoMap ?? TWIN_REPO_MAP,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
[],
|
||||
new Set(),
|
||||
new Map(),
|
||||
inboxMap(args.eligibleRepoIds) as never,
|
||||
[],
|
||||
args.grouping ?? TWIN_GROUPING,
|
||||
[],
|
||||
HOST_LABELS
|
||||
)
|
||||
return rows.filter((row) => row.type === 'new-external-worktrees-inbox')
|
||||
}
|
||||
|
||||
function summarize(
|
||||
rows: Extract<Row, { type: 'new-external-worktrees-inbox' }>[]
|
||||
): { repoId: string; label: string | undefined; hostId: string | undefined; count: number }[] {
|
||||
return rows.map((row) => ({
|
||||
repoId: row.repo.id,
|
||||
label: row.hostContextLabel,
|
||||
// Carried so the row can draw the host glyph; two hosts sharing a label
|
||||
// differ only here.
|
||||
hostId: row.hostContextHostId,
|
||||
count: row.inboxWorktrees.length
|
||||
}))
|
||||
}
|
||||
|
||||
describe('discovery notice rows on a multi-host project', () => {
|
||||
it('labels both rows when two distinct hosts share one label', () => {
|
||||
// Kills the label-counting gate, so the project must contain ONLY the two
|
||||
// same-label hosts: a third host with a different label would supply the
|
||||
// label diversity the old gate needed and the test would pass either way.
|
||||
expect(
|
||||
summarize(
|
||||
noticeRows({
|
||||
eligibleRepoIds: [sshTwin.id, envTwin.id],
|
||||
repoMap: new Map([
|
||||
[sshTwin.id, sshTwin],
|
||||
[envTwin.id, envTwin]
|
||||
]),
|
||||
grouping: {
|
||||
projects: [{ ...project, sourceRepoIds: [sshTwin.id, envTwin.id] }],
|
||||
projectHostSetups: [setupFor(sshTwin, SSH_HOST_ID), setupFor(envTwin, ENV_HOST_ID)]
|
||||
}
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
{ repoId: sshTwin.id, label: 'openclaw', hostId: SSH_HOST_ID, count: 61 },
|
||||
{ repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
|
||||
])
|
||||
})
|
||||
|
||||
it('labels a lone eligible row on a project that spans hosts', () => {
|
||||
// Kills notice-row-scoped membership: only one record emits a row, but the
|
||||
// project still spans hosts, so the row must say which host it is.
|
||||
expect(summarize(noticeRows({ eligibleRepoIds: [envTwin.id] }))).toEqual([
|
||||
{ repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps each row its own label and count under either host filter', () => {
|
||||
// Kills the collapse (both rows survive with distinct counts) and proves the
|
||||
// gate reads the unfiltered universe (each filtered survivor keeps its label).
|
||||
expect(summarize(noticeRows({ eligibleRepoIds: [sshTwin.id] }))).toEqual([
|
||||
{ repoId: sshTwin.id, label: 'openclaw', hostId: SSH_HOST_ID, count: 61 }
|
||||
])
|
||||
expect(summarize(noticeRows({ eligibleRepoIds: [envTwin.id] }))).toEqual([
|
||||
{ repoId: envTwin.id, label: 'openclaw', hostId: ENV_HOST_ID, count: 134 }
|
||||
])
|
||||
})
|
||||
|
||||
it('returns labels for exactly the eligible records, never the filtered-out ones', () => {
|
||||
// A row-level test cannot see an extra ineligible entry, because no row
|
||||
// consumes it; only exact map membership pins the intersection.
|
||||
const index = buildProjectGroupingIndex(TWIN_GROUPING)
|
||||
|
||||
for (const eligible of [[sshTwin.id], [envTwin.id], [sshTwin.id, envTwin.id]]) {
|
||||
const labels = getNoticeHostContextLabels(
|
||||
eligible,
|
||||
TWIN_REPO_MAP.keys(),
|
||||
TWIN_REPO_MAP,
|
||||
index,
|
||||
HOST_LABELS
|
||||
)
|
||||
expect([...(labels?.keys() ?? [])]).toEqual(eligible)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a single-host project unlabelled even with several records on it', () => {
|
||||
// Regression pin: guards a future implementation that counts records
|
||||
// instead of distinct host ids.
|
||||
const secondLocal: Repo = { ...repo, id: 'repo-local-2', path: '/tmp/orca-second' }
|
||||
const rows = noticeRows({
|
||||
eligibleRepoIds: [repo.id],
|
||||
repoMap: new Map([
|
||||
[repo.id, repo],
|
||||
[secondLocal.id, secondLocal]
|
||||
]),
|
||||
grouping: {
|
||||
projects: [{ ...project, sourceRepoIds: [repo.id, secondLocal.id] }],
|
||||
projectHostSetups: [projectHostSetups[0]!, setupFor(secondLocal, 'local')]
|
||||
}
|
||||
})
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).not.toHaveProperty('hostContextLabel')
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,8 @@ import type { SectionAppendContext } from './group-sections'
|
||||
import {
|
||||
getLaneHostWorktreeCounts,
|
||||
getLaneHostWorktreeIds,
|
||||
getMixedWorktreeHostContextLabels
|
||||
getMixedWorktreeHostContextLabels,
|
||||
getNoticeHostContextLabels
|
||||
} from './host-labels'
|
||||
import { buildProjectGroupingIndex } from './project-grouping'
|
||||
import type { ProjectGroupingModel } from './project-grouping'
|
||||
@@ -110,6 +111,17 @@ export function buildRows(
|
||||
hostLabelById,
|
||||
defaultHostId
|
||||
)
|
||||
// Why here and not per section: a notice row can land in the pinned section
|
||||
// instead of its project's own, and the host ambiguity it resolves belongs to
|
||||
// the project either way. repoMap is the unfiltered universe; the candidate
|
||||
// maps are host-filter scoped, so only they gate eligibility.
|
||||
const noticeHostContextLabelByRepoId = getNoticeHostContextLabels(
|
||||
new Set([...importedWorktreesByRepo.keys(), ...newExternalWorktreesInboxByRepo.keys()]),
|
||||
repoMap.keys(),
|
||||
repoMap,
|
||||
projectIndex,
|
||||
hostLabelById
|
||||
)
|
||||
const renderedNaturalAnchorRepoIds = getRenderedNaturalAnchorRepoIds({
|
||||
groupBy,
|
||||
worktrees: naturalWorktrees,
|
||||
@@ -132,7 +144,8 @@ export function buildRows(
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
nestLineage,
|
||||
cyclicLineageIds
|
||||
cyclicLineageIds,
|
||||
noticeHostContextLabelByRepoId
|
||||
)
|
||||
if (groupBy === 'none') {
|
||||
// Why folder workspaces gate this too: an account with only folder
|
||||
@@ -208,6 +221,7 @@ export function buildRows(
|
||||
newExternalWorktreesInboxByRepo,
|
||||
pendingByRepo,
|
||||
mixedWorktreeHostContextLabels,
|
||||
noticeHostContextLabelByRepoId,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
nestLineage,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../workspace-status'
|
||||
import { PROJECT_GROUP_META, PR_GROUP_META } from './group-keys'
|
||||
import type { PRGroupKey } from './group-keys'
|
||||
import type { NoticeHostContext } from './host-labels'
|
||||
import {
|
||||
getLaneHostWorktreeCounts,
|
||||
getLaneHostWorktreeIds,
|
||||
@@ -44,6 +45,7 @@ export type SectionAppendContext = {
|
||||
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
|
||||
pendingByRepo: ReadonlyMap<string, PendingCreationRef[]>
|
||||
mixedWorktreeHostContextLabels: Map<string, string> | undefined
|
||||
noticeHostContextLabelByRepoId: Map<string, NoticeHostContext> | undefined
|
||||
lineageById: Record<string, WorktreeLineage>
|
||||
worktreeMap: Map<string, Worktree>
|
||||
nestLineage: boolean
|
||||
@@ -159,13 +161,24 @@ export function appendOrderedGroups(
|
||||
for (const repoId of repoIds) {
|
||||
const candidate = importedWorktreesByRepo.get(repoId)
|
||||
if (candidate) {
|
||||
result.push(buildImportedWorktreesCardRow(candidate, 'repo-group'))
|
||||
result.push(
|
||||
buildImportedWorktreesCardRow(
|
||||
candidate,
|
||||
'repo-group',
|
||||
ctx.noticeHostContextLabelByRepoId?.get(repoId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const repoId of repoIds) {
|
||||
const candidate = newExternalWorktreesInboxByRepo.get(repoId)
|
||||
if (candidate) {
|
||||
result.push(buildNewExternalWorktreesInboxRow(candidate))
|
||||
result.push(
|
||||
buildNewExternalWorktreesInboxRow(
|
||||
candidate,
|
||||
ctx.noticeHostContextLabelByRepoId?.get(repoId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: surface in-progress creates at the top of their own repo so the
|
||||
|
||||
@@ -7,10 +7,19 @@ import {
|
||||
} from '../../../../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
|
||||
import type { ProjectGroupingIndex, WorktreeGroupEntry } from './project-grouping'
|
||||
import {
|
||||
getProjectGroupingForRepo,
|
||||
type ProjectGroupingIndex,
|
||||
type WorktreeGroupEntry
|
||||
} from './project-grouping'
|
||||
import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
|
||||
import type { RenderableFolderWorkspace } from './folder-workspace-lanes'
|
||||
|
||||
function getRepoHostId(repoId: string, repoMap: Map<string, Repo>): string | null {
|
||||
const repo = repoMap.get(repoId)
|
||||
return repo ? getRepoExecutionHostId(repo) : null
|
||||
}
|
||||
|
||||
function getRepoHostLabel(
|
||||
repoId: string,
|
||||
repoMap: Map<string, Repo>,
|
||||
@@ -48,6 +57,70 @@ export function getMixedHostContextLabels(
|
||||
return uniqueLabels.size > 1 ? labelsByRepoId : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Host labels for the sidebar's notice rows, keyed by repo id.
|
||||
*
|
||||
* Why not getMixedHostContextLabels: a notice row can render outside its
|
||||
* project's own section (pinned fallback), and the ambiguity it resolves
|
||||
* belongs to the project — one project checked out on several hosts emits one
|
||||
* identical-looking row per host. So the mixed test runs per project.
|
||||
*
|
||||
* Two inputs, deliberately: `allRepoIds` is the unfiltered universe that decides
|
||||
* whether a project spans hosts, and `noticeRepoIds` is the host-filtered set
|
||||
* eligible for a label. Deriving both from the filtered set would make a label
|
||||
* appear and disappear with the sidebar's host filter.
|
||||
*/
|
||||
export type NoticeHostContext = {
|
||||
label: string
|
||||
/** Carried so the row can draw the same host glyph and "Project on …"
|
||||
* tooltip worktree cards use, which the label alone cannot select. */
|
||||
hostId: ExecutionHostId
|
||||
}
|
||||
|
||||
export function getNoticeHostContextLabels(
|
||||
noticeRepoIds: Iterable<string>,
|
||||
allRepoIds: Iterable<string>,
|
||||
repoMap: Map<string, Repo>,
|
||||
projectIndex: ProjectGroupingIndex | null,
|
||||
hostLabelById: ReadonlyMap<string, string> | undefined
|
||||
): Map<string, NoticeHostContext> | undefined {
|
||||
const eligible = new Set(noticeRepoIds)
|
||||
if (eligible.size === 0) {
|
||||
return undefined
|
||||
}
|
||||
// Why host ids and not labels: two hosts can share one user-facing label, and
|
||||
// that project spans hosts just the same — counting labels hides exactly the
|
||||
// case where the rows are hardest to tell apart.
|
||||
const hostIdsForProject = new Map<string, Set<string>>()
|
||||
const labelsByRepoId = new Map<string, NoticeHostContext>()
|
||||
const projectKeyByRepoId = new Map<string, string>()
|
||||
for (const repoId of allRepoIds) {
|
||||
const label = getRepoHostLabel(repoId, repoMap, projectIndex, hostLabelById)
|
||||
if (!label) {
|
||||
continue
|
||||
}
|
||||
const projectKey = getProjectGroupingForRepo(repoId, repoMap, projectIndex).projectId ?? repoId
|
||||
const hostId = projectIndex?.setupByRepoId.get(repoId)?.hostId ?? getRepoHostId(repoId, repoMap)
|
||||
if (hostId) {
|
||||
const hostIds = hostIdsForProject.get(projectKey) ?? new Set<string>()
|
||||
hostIds.add(hostId)
|
||||
hostIdsForProject.set(projectKey, hostIds)
|
||||
}
|
||||
if (eligible.has(repoId) && hostId) {
|
||||
labelsByRepoId.set(repoId, { label, hostId: hostId as ExecutionHostId })
|
||||
projectKeyByRepoId.set(repoId, projectKey)
|
||||
}
|
||||
}
|
||||
const mixed = new Map<string, NoticeHostContext>()
|
||||
for (const [repoId, context] of labelsByRepoId) {
|
||||
const projectKey = projectKeyByRepoId.get(repoId)
|
||||
if (projectKey && (hostIdsForProject.get(projectKey)?.size ?? 0) > 1) {
|
||||
mixed.set(repoId, context)
|
||||
}
|
||||
}
|
||||
return mixed.size > 0 ? mixed : undefined
|
||||
}
|
||||
|
||||
/** Keyed by host-qualified identity: two hosts sharing an id need two labels. */
|
||||
export function getMixedWorktreeHostContextLabels(
|
||||
worktrees: readonly Worktree[],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-h
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { PINNED_GROUP_KEY, PINNED_GROUP_META } from './group-keys'
|
||||
import { appendWorktreeRows, buildImportedWorktreesCardRow } from './row-builders'
|
||||
import type { NoticeHostContext } from './host-labels'
|
||||
import type { ImportedWorktreesCardCandidate, Row } from './row-types'
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,8 @@ export function emitPinnedGroup(
|
||||
lineageById: Record<string, WorktreeLineage>,
|
||||
worktreeMap: Map<string, Worktree>,
|
||||
nestLineage: boolean,
|
||||
cyclicLineageIds: ReadonlySet<string>
|
||||
cyclicLineageIds: ReadonlySet<string>,
|
||||
noticeHostContextLabelByRepoId?: ReadonlyMap<string, NoticeHostContext>
|
||||
): void {
|
||||
if (pinnedSectionWorktrees.length === 0) {
|
||||
return
|
||||
@@ -61,7 +63,13 @@ export function emitPinnedGroup(
|
||||
for (const repoId of pinnedRepoOrder) {
|
||||
const candidate = importedWorktreesByRepo.get(repoId)
|
||||
if (allowImportedFallback && candidate && !renderedNaturalAnchorRepoIds.has(repoId)) {
|
||||
result.push(buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
|
||||
result.push(
|
||||
buildImportedWorktreesCardRow(
|
||||
candidate,
|
||||
'pinned-fallback',
|
||||
noticeHostContextLabelByRepoId?.get(repoId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -91,7 +99,15 @@ export function emitPinnedGroup(
|
||||
for (const [repoId, index] of inserts) {
|
||||
const candidate = importedWorktreesByRepo.get(repoId)
|
||||
if (candidate && !renderedNaturalAnchorRepoIds.has(repoId)) {
|
||||
result.splice(index + 1, 0, buildImportedWorktreesCardRow(candidate, 'pinned-fallback'))
|
||||
result.splice(
|
||||
index + 1,
|
||||
0,
|
||||
buildImportedWorktreesCardRow(
|
||||
candidate,
|
||||
'pinned-fallback',
|
||||
noticeHostContextLabelByRepoId?.get(repoId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-
|
||||
import { isValidResolvedWorktreeLineageEdge } from '../../../../../../shared/resolved-worktree-lineage'
|
||||
import { getProjectedWorktreeLineage } from '../../worktree-lineage-projection'
|
||||
import { getWorktreeLineageGroupKey } from './group-keys'
|
||||
import type { NoticeHostContext } from './host-labels'
|
||||
import type { RenderableFolderWorkspace } from './folder-workspace-lanes'
|
||||
import type {
|
||||
FolderWorkspaceRow,
|
||||
@@ -32,25 +33,33 @@ export function buildPendingCreationRow(
|
||||
|
||||
export function buildImportedWorktreesCardRow(
|
||||
candidate: ImportedWorktreesCardCandidate,
|
||||
placement: ImportedWorktreesCardRow['placement']
|
||||
placement: ImportedWorktreesCardRow['placement'],
|
||||
hostContext?: NoticeHostContext
|
||||
): ImportedWorktreesCardRow {
|
||||
return {
|
||||
type: 'imported-worktrees-card',
|
||||
key: `imported-worktrees-card:${placement}:${candidate.repo.id}`,
|
||||
repo: candidate.repo,
|
||||
hiddenWorktrees: candidate.hiddenWorktrees,
|
||||
placement
|
||||
placement,
|
||||
...(hostContext
|
||||
? { hostContextLabel: hostContext.label, hostContextHostId: hostContext.hostId }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNewExternalWorktreesInboxRow(
|
||||
candidate: NewExternalWorktreesInboxCandidate
|
||||
candidate: NewExternalWorktreesInboxCandidate,
|
||||
hostContext?: NoticeHostContext
|
||||
): NewExternalWorktreesInboxRow {
|
||||
return {
|
||||
type: 'new-external-worktrees-inbox',
|
||||
key: `new-external-worktrees-inbox:${candidate.repo.id}`,
|
||||
repo: candidate.repo,
|
||||
inboxWorktrees: candidate.inboxWorktrees
|
||||
inboxWorktrees: candidate.inboxWorktrees,
|
||||
...(hostContext
|
||||
? { hostContextLabel: hostContext.label, hostContextHostId: hostContext.hostId }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ export type ImportedWorktreesCardRow = {
|
||||
repo: Repo
|
||||
hiddenWorktrees: DetectedWorktree[]
|
||||
placement: 'repo-group' | 'pinned-fallback'
|
||||
/** Set only when the row's project is checked out on more than one host. */
|
||||
hostContextLabel?: string
|
||||
hostContextHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type NewExternalWorktreesInboxCandidate = {
|
||||
@@ -69,6 +72,9 @@ export type NewExternalWorktreesInboxRow = {
|
||||
key: string
|
||||
repo: Repo
|
||||
inboxWorktrees: DetectedWorktree[]
|
||||
/** Set only when the row's project is checked out on more than one host. */
|
||||
hostContextLabel?: string
|
||||
hostContextHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
export type PendingCreationRow = {
|
||||
|
||||
@@ -62,6 +62,8 @@ export function renderImportedWorktreesVirtualRow(args: {
|
||||
>
|
||||
<ImportedWorktreesVisibilityLine
|
||||
repoDisplayName={row.repo.displayName}
|
||||
hostContextLabel={row.hostContextLabel}
|
||||
hostContextHostId={row.hostContextHostId}
|
||||
hiddenWorktrees={row.hiddenWorktrees}
|
||||
placement={row.placement}
|
||||
pending={actionState?.pending ?? false}
|
||||
@@ -94,6 +96,8 @@ export function renderNewExternalWorktreesInboxVirtualRow(args: {
|
||||
>
|
||||
<NewExternalWorktreesInboxLine
|
||||
repoDisplayName={row.repo.displayName}
|
||||
hostContextLabel={row.hostContextLabel}
|
||||
hostContextHostId={row.hostContextHostId}
|
||||
inboxCount={row.inboxWorktrees.length}
|
||||
pending={actionState?.pending ?? false}
|
||||
error={actionState?.error ?? null}
|
||||
|
||||
@@ -5727,7 +5727,14 @@
|
||||
"5b90e4a2f6": "hidden worktrees",
|
||||
"7f18c5b0d3": "Review {{value0}} hidden worktree in {{value1}}",
|
||||
"4e2b7a9c05": "Review {{value0}} hidden worktrees in {{value1}}",
|
||||
"c3e8a1f4b2": "Don't show again"
|
||||
"c3e8a1f4b2": "Don't show again",
|
||||
"6c07f3a91e": "{{value0}} on {{value1}}"
|
||||
},
|
||||
"NoticeHostGlyph": {
|
||||
"hostDisconnected": "{{hostName}} disconnected",
|
||||
"sshHostProject": "Project on SSH host {{hostName}}",
|
||||
"localHostProject": "Project on this host",
|
||||
"runtimeHostProject": "Project on {{hostName}}"
|
||||
},
|
||||
"newExternalWorktreesInboxActions": {
|
||||
"a11c2f6d89": "Could not keep external worktrees hidden. Try again.",
|
||||
|
||||
@@ -5059,6 +5059,12 @@
|
||||
"5e1b8d3f62": "Ocultar worktrees externos permanentemente",
|
||||
"c3e8a1f4b2": "No volver a mostrar"
|
||||
},
|
||||
"NoticeHostGlyph": {
|
||||
"hostDisconnected": "{{hostName}} está desconectado",
|
||||
"sshHostProject": "Proyecto en el host SSH {{hostName}}",
|
||||
"localHostProject": "Proyecto en este host",
|
||||
"runtimeHostProject": "Proyecto en {{hostName}}"
|
||||
},
|
||||
"newExternalWorktreesInboxActions": {
|
||||
"a11c2f6d89": "No se pudieron mantener ocultos los worktrees externos. Inténtalo de nuevo.",
|
||||
"b7e4d1a062": "No se pudieron importar los worktrees externos. Inténtalo de nuevo.",
|
||||
|
||||
@@ -5059,6 +5059,12 @@
|
||||
"5e1b8d3f62": "外部ワークツリーを完全に非表示にする",
|
||||
"c3e8a1f4b2": "今後表示しない"
|
||||
},
|
||||
"NoticeHostGlyph": {
|
||||
"hostDisconnected": "{{hostName}} が切断されました",
|
||||
"sshHostProject": "SSH ホスト {{hostName}} 上のプロジェクト",
|
||||
"localHostProject": "このホスト上のプロジェクト",
|
||||
"runtimeHostProject": "{{hostName}} 上のプロジェクト"
|
||||
},
|
||||
"newExternalWorktreesInboxActions": {
|
||||
"a11c2f6d89": "外部ワークツリーを非表示のままにできませんでした。もう一度お試しください。",
|
||||
"b7e4d1a062": "外部ワークツリーをインポートできませんでした。もう一度お試しください。",
|
||||
|
||||
@@ -5061,6 +5061,12 @@
|
||||
"5e1b8d3f62": "외부 워크트리를 영구적으로 숨기기",
|
||||
"c3e8a1f4b2": "다시 표시하지 않기"
|
||||
},
|
||||
"NoticeHostGlyph": {
|
||||
"hostDisconnected": "{{hostName}} 연결 끊김",
|
||||
"sshHostProject": "SSH 호스트 {{hostName}}의 프로젝트",
|
||||
"localHostProject": "이 호스트의 프로젝트",
|
||||
"runtimeHostProject": "{{hostName}}의 프로젝트"
|
||||
},
|
||||
"newExternalWorktreesInboxActions": {
|
||||
"a11c2f6d89": "외부 워크트리를 숨김 상태로 유지할 수 없습니다. 다시 시도하세요.",
|
||||
"b7e4d1a062": "외부 워크트리를 가져올 수 없습니다. 다시 시도하세요.",
|
||||
|
||||
@@ -5071,6 +5071,12 @@
|
||||
"5e1b8d3f62": "永久隐藏外部工作树",
|
||||
"c3e8a1f4b2": "不再显示"
|
||||
},
|
||||
"NoticeHostGlyph": {
|
||||
"hostDisconnected": "{{hostName}} 已断开连接",
|
||||
"sshHostProject": "SSH 主机 {{hostName}} 上的项目",
|
||||
"localHostProject": "此主机上的项目",
|
||||
"runtimeHostProject": "{{hostName}} 上的项目"
|
||||
},
|
||||
"newExternalWorktreesInboxActions": {
|
||||
"a11c2f6d89": "无法保持外部工作树隐藏。请重试。",
|
||||
"b7e4d1a062": "无法导入外部工作树。请重试。",
|
||||
|
||||
@@ -11,6 +11,9 @@ describe('worktree visibility locales', () => {
|
||||
})
|
||||
|
||||
it('keeps current fallback-derived keys in the English catalog', () => {
|
||||
expect(en.auto.components.sidebar.NewExternalWorktreesInboxLine).toMatchObject({
|
||||
'6c07f3a91e': '{{value0}} on {{value1}}'
|
||||
})
|
||||
expect(en.auto.components.sidebar.WorktreeVisibilityHelpPopover).toMatchObject({
|
||||
ec1e6a10fb: 'Other worktrees start hidden to avoid unexpected sidebar clutter.',
|
||||
'1c68c9cf77':
|
||||
|
||||
Reference in New Issue
Block a user