fix(worktrees): align source visibility across runtimes

This commit is contained in:
Brennan Benson
2026-08-13 08:36:40 -07:00
parent 7831096d40
commit 9ed2068c05
16 changed files with 532 additions and 122 deletions
+8 -2
View File
@@ -1004,14 +1004,20 @@ function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] {
function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] {
const settings = store.getSettings()
return listFolderWorkspaces(store, repo).map((worktree) =>
const worktrees = listFolderWorkspaces(store, repo)
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
return worktrees.map((worktree) =>
toDetectedWorktree({
repo,
worktree,
meta: store.getWorktreeMeta(worktree.id),
settings,
knownOrcaLayouts: [],
isLegacyRepoForVisibility: true
isLegacyRepoForVisibility: true,
worktreeVisibilitySourceMatcher
})
)
}
+102
View File
@@ -35509,6 +35509,108 @@ describe('OrcaRuntimeService', () => {
})
})
it('applies linked-checkout source visibility to mobile summaries', async () => {
const linkedPath = '/tmp/linked'
const scratchPath = `${linkedPath}/.claude/worktrees/review`
vi.mocked(listWorktrees).mockResolvedValue([
{
path: TEST_REPO_PATH,
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
{
path: linkedPath,
head: 'linked',
branch: 'feature/linked',
isBare: false,
isMainWorktree: false
},
{
path: scratchPath,
head: 'scratch',
branch: 'feature/review',
isBare: false,
isMainWorktree: false
}
])
const makeRuntime = (externalWorktreeVisibility: 'hide' | 'show', claude: 'hide' | 'show') => {
const repo = {
...store.getRepos()[0],
externalWorktreeVisibility,
externalWorktreeVisibilityLegacy: false,
worktreeVisibilitySourcePreferences: { builtIn: { claude, gsd: 'hide' as const } }
}
return new OrcaRuntimeService({
...store,
getRepos: () => [repo],
getRepo: () => repo
} as never)
}
const hiddenSource = await makeRuntime('show', 'hide').getWorktreePs()
const shownSource = await makeRuntime('hide', 'show').getWorktreePs()
expect(hiddenSource.worktrees.map((worktree) => worktree.path)).not.toContain(scratchPath)
expect(shownSource.worktrees.map((worktree) => worktree.path)).toContain(scratchPath)
})
it('resolves files through a source-visible linked-checkout worktree', async () => {
const linkedPath = '/tmp/linked'
const scratchPath = `${linkedPath}/.claude/worktrees/review`
vi.mocked(listWorktrees).mockResolvedValue([
{
path: TEST_REPO_PATH,
head: 'main',
branch: 'main',
isBare: false,
isMainWorktree: true
},
{
path: linkedPath,
head: 'linked',
branch: 'feature/linked',
isBare: false,
isMainWorktree: false
},
{
path: scratchPath,
head: 'scratch',
branch: 'feature/review',
isBare: false,
isMainWorktree: false
}
])
const repo = {
...store.getRepos()[0],
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show' as const, gsd: 'hide' as const }
}
}
const runtime = new OrcaRuntimeService({
...store,
getRepos: () => [repo],
getRepo: () => repo
} as never)
const target = await (
runtime as unknown as {
resolveKnownWorkspaceFileTarget: (
path: string,
executionHostId: 'local'
) => Promise<{ worktree: { path: string }; relativePath: string } | null>
}
).resolveKnownWorkspaceFileTarget(`${scratchPath}/src/app.ts`, 'local')
expect(target).toMatchObject({
worktree: { path: scratchPath },
relativePath: 'src/app.ts'
})
})
it('marks saved session tabs with live PTYs as host sidebar activity', async () => {
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal()
+45 -19
View File
@@ -9106,8 +9106,16 @@ export class OrcaRuntimeService {
executionHostId: ExecutionHostId
}
>()
for (const worktree of await this.listResolvedWorktrees()) {
if (!this.isRuntimeWorktreeVisible(worktree)) {
const resolvedWorktrees = await this.listResolvedWorktrees()
const visibilitySourceMatchersByRepoId =
this.buildRuntimeVisibilitySourceMatchersByRepoId(resolvedWorktrees)
for (const worktree of resolvedWorktrees) {
if (
!this.isRuntimeWorktreeVisible(
worktree,
visibilitySourceMatchersByRepoId.get(worktree.repoId)
)
) {
continue
}
const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined
@@ -17642,8 +17650,11 @@ export class OrcaRuntimeService {
throw new Error('invalid_limit')
}
const resolvedWorktreeSnapshot = await this.listResolvedWorktreeSnapshot()
const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId(
resolvedWorktreeSnapshot.worktrees
)
const resolvedWorktrees = resolvedWorktreeSnapshot.worktrees.filter((worktree) =>
this.isRuntimeWorktreeVisible(worktree)
this.isRuntimeWorktreeVisible(worktree, visibilitySourceMatchersByRepoId.get(worktree.repoId))
)
// Why: worktree.ps backs the mobile sidebar, so it must use the same
// host-owned imported-worktree visibility gate as worktree.list/desktop.
@@ -20953,21 +20964,8 @@ export class OrcaRuntimeService {
}
const resolved = await this.listResolvedWorktrees()
const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null
const checkoutPathsByRepoId = new Map<string, string[]>()
for (const worktree of resolved) {
const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? []
checkoutPaths.push(worktree.path)
checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths)
}
const visibilitySourceMatchersByRepoId = new Map(
(this.store?.getRepos() ?? []).map((repo) => [
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
])
)
const visibilitySourceMatchersByRepoId =
this.buildRuntimeVisibilitySourceMatchersByRepoId(resolved)
const worktrees = resolved.filter((worktree) => {
if (repoId && worktree.repoId !== repoId) {
return false
@@ -20999,7 +20997,13 @@ export class OrcaRuntimeService {
const store = this.requireStore()
if (isFolderRepo(repo)) {
const worktrees = listRuntimeFolderWorkspaces(store, repo)
const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree))
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
const detected = worktrees.map((worktree) =>
this.toRuntimeDetectedWorktree(repo, worktree, matcher)
)
return {
repoId: repo.id,
authoritative: true,
@@ -21106,6 +21110,28 @@ export class OrcaRuntimeService {
return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher).visible
}
private buildRuntimeVisibilitySourceMatchersByRepoId(
worktrees: readonly Worktree[]
): Map<string, WorktreeVisibilitySourceMatcher> {
const checkoutPathsByRepoId = new Map<string, string[]>()
for (const worktree of worktrees) {
const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? []
checkoutPaths.push(worktree.path)
checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths)
}
return new Map(
(this.store?.getRepos() ?? [])
.filter((repo) => checkoutPathsByRepoId.has(repo.id))
.map((repo) => [
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
])
)
}
private toRuntimeDetectedWorktree(
repo: Repo,
worktree: Worktree,
@@ -67,7 +67,7 @@ export default function HiddenWorktreeRecoveryList({
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.9b53f7a160',
'auto.components.sidebar.HiddenWorktreeRecoveryList.64e6f53f05',
'Show one without enabling its source.'
)}
</p>
@@ -131,6 +131,11 @@ export default function HiddenWorktreeRecoveryList({
variant="outline"
size="sm"
disabled={busyPath !== null || toggling || listState === 'checking'}
aria-label={translate(
'auto.components.sidebar.HiddenWorktreeRecoveryList.showWorktree',
'Show {{value0}} at {{value1}}',
{ value0: worktree.displayName, value1: displayPath }
)}
onClick={() => void onShow(worktree.path)}
>
{busyPath === worktree.path
@@ -205,6 +205,14 @@ async function click(element: HTMLElement): Promise<void> {
})
}
async function setInputValue(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
})
}
describe('WorktreeVisibilityDialog', () => {
it('lists a hidden agent worktree with a repo-relative path', async () => {
await renderDialog()
@@ -249,6 +257,24 @@ describe('WorktreeVisibilityDialog', () => {
expect(document.body.textContent).not.toContain('scratch-0')
})
it('gives each hidden worktree action a distinct accessible name', async () => {
mocks.state.detectedWorktreesByRepo = {
'repo-1': makeDetected([
makeWorktree({ displayName: 'scratch-a', path: '/repo/.claude/worktrees/scratch-a' }),
makeWorktree({ displayName: 'scratch-b', path: '/repo/.claude/worktrees/scratch-b' })
])
}
await renderDialog()
expect(
document.querySelector('button[aria-label="Show scratch-a at .claude/worktrees/scratch-a"]')
).not.toBeNull()
expect(
document.querySelector('button[aria-label="Show scratch-b at .claude/worktrees/scratch-b"]')
).not.toBeNull()
})
it('recovers a hidden worktree per path through the existing import exception', async () => {
// Why: individual recovery remains available while the repo-wide policy is off.
await renderDialog()
@@ -590,14 +616,7 @@ describe('WorktreeVisibilityDialog', () => {
).toBe(true)
expect(input?.closest('form')?.getAttribute('aria-label')).toBe('Add location')
expect(input?.closest('form')?.textContent).not.toContain('Add location')
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
input,
'/srv/team-worktrees'
)
input!.dispatchEvent(new Event('input', { bubbles: true }))
input!.dispatchEvent(new Event('change', { bubbles: true }))
})
await setInputValue(input!, '/srv/team-worktrees')
await click(buttonWithText('Add'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith(
@@ -622,6 +641,82 @@ describe('WorktreeVisibilityDialog', () => {
).toBe('hide')
})
it('keeps source matching stable while typing in the inline form', async () => {
mocks.state.repos = [
makeRepo({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }]
})
]
await renderDialog()
const normalize = vi.spyOn(String.prototype, 'normalize')
await setInputValue(
document.querySelector<HTMLInputElement>('#custom-worktree-root')!,
'/srv/x'
)
expect(normalize).not.toHaveBeenCalled()
normalize.mockRestore()
})
it('distinguishes invalid, duplicate, limit, and save failures when adding a source', async () => {
const existing = Array.from({ length: 32 }, (_, index) => ({
id: `source-${index}`,
rootPath: `/srv/source-${index}`
}))
mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing })]
await renderDialog()
const input = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(input, '/srv/new')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Remove a custom location before adding another.'
)
expect(mocks.state.updateRepo).not.toHaveBeenCalled()
mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing.slice(0, 1) })]
await act(async () => root.render(null))
await renderDialog()
const duplicateInput = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(duplicateInput, '/srv/source-0')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'This location is already listed.'
)
await setInputValue(duplicateInput, 'relative/path')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Enter an absolute path for this host.'
)
mocks.state.updateRepo.mockResolvedValue(false)
await setInputValue(duplicateInput, '/srv/valid')
await click(buttonWithText('Add'))
const alerts = [...document.querySelectorAll('[role="alert"]')]
expect(alerts).toHaveLength(1)
expect(alerts[0]?.textContent).toContain('Could not update worktree visibility. Try again.')
expect(alerts[0]?.textContent).not.toContain('Enter an absolute path')
})
it('reports only unsupported-host copy when an older host strips an added source', async () => {
mocks.state.updateRepo.mockResolvedValue(true)
await renderDialog()
mocks.state.fetchWorktrees.mockClear()
const input = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(input, '/srv/valid')
await click(buttonWithText('Add'))
const alerts = [...document.querySelectorAll('[role="alert"]')]
expect(alerts).toHaveLength(1)
expect(alerts[0]?.textContent).toContain(
"This host doesn't support source-specific worktree visibility."
)
expect(alerts[0]?.textContent).not.toContain('Enter an absolute path')
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('removes custom locations without changing other source preferences', async () => {
mocks.state.repos = [
makeRepo({
@@ -631,17 +726,61 @@ describe('WorktreeVisibilityDialog', () => {
]
await renderDialog()
expect(sourceSwitch('team-worktrees').getAttribute('aria-checked')).toBe('false')
expect(sourceSwitch('/srv/team-worktrees').getAttribute('aria-checked')).toBe('false')
const remove = document.querySelector<HTMLButtonElement>(
'button[aria-label="Remove team-worktrees"]'
'button[aria-label="Remove /srv/team-worktrees"]'
)
expect(remove).not.toBeNull()
await click(remove!)
expect(mocks.state.updateRepo).toHaveBeenCalledWith(
'repo-1',
expect.objectContaining({ customWorktreeVisibilitySources: [] })
expect.objectContaining({
customWorktreeVisibilitySources: [],
worktreeVisibilitySourcePreferences: expect.not.objectContaining({
custom: expect.objectContaining({ team: expect.anything() })
})
})
)
expect(mocks.state.closeModal).not.toHaveBeenCalled()
})
it('rejects removal success when an older host leaves the custom preference behind', async () => {
const repo = makeRepo({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
worktreeVisibilitySourcePreferences: { custom: { team: 'show' } }
})
mocks.state.repos = [repo]
mocks.state.updateRepo.mockImplementation(async () => {
repo.customWorktreeVisibilitySources = []
return true
})
await renderDialog()
mocks.state.fetchWorktrees.mockClear()
await click(document.querySelector('button[aria-label="Remove /srv/team-worktrees"]')!)
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Could not update worktree visibility. Try again.'
)
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('uses full paths to distinguish custom source controls with the same basename', async () => {
mocks.state.repos = [
makeRepo({
customWorktreeVisibilitySources: [
{ id: 'alpha', rootPath: '/srv/alpha/team' },
{ id: 'beta', rootPath: '/srv/beta/team' }
]
})
]
await renderDialog()
expect(sourceSwitch('/srv/alpha/team')).not.toBeNull()
expect(sourceSwitch('/srv/beta/team')).not.toBeNull()
expect(document.querySelector('button[aria-label="Remove /srv/alpha/team"]')).not.toBeNull()
expect(document.querySelector('button[aria-label="Remove /srv/beta/team"]')).not.toBeNull()
})
})
@@ -32,6 +32,7 @@ import {
useVisibilityMutationFence
} from './worktree-visibility-mutation-fence'
import WorktreeVisibilitySourceList, {
type WorktreeVisibilitySourceAddResult,
type WorktreeVisibilitySourceRow
} from './WorktreeVisibilitySourceList'
import type { CustomWorktreeVisibilitySource, Repo } from '../../../../shared/types'
@@ -41,6 +42,7 @@ import {
effectiveBuiltInWorktreeSourceVisibility,
effectiveCustomWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences,
removeCustomWorktreeSourcePreference
} from '../../../../shared/worktree-visibility-sources'
import HiddenWorktreeRecoveryList from './HiddenWorktreeRecoveryList'
@@ -262,22 +264,26 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
)
const handleAddSource = useCallback(
async (rootPath: string): Promise<boolean> => {
async (rootPath: string): Promise<WorktreeVisibilitySourceAddResult> => {
if (!repo) {
return false
return 'save-failed'
}
const existing = normalizeCustomWorktreeVisibilitySources(
repo.customWorktreeVisibilitySources
)
if ((existing?.length ?? 0) >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) {
return false
return 'limit'
}
const id = crypto.randomUUID().replaceAll('-', '')
const next = normalizeCustomWorktreeVisibilitySources([...(existing ?? []), { id, rootPath }])
if (!next || next.length !== (existing?.length ?? 0) + 1) {
return false
const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0]
if (!candidate) {
return 'invalid-path'
}
return commitSourceUpdate(
const next = normalizeCustomWorktreeVisibilitySources([...(existing ?? []), candidate])
if (!next || next.length !== (existing?.length ?? 0) + 1) {
return 'duplicate-path'
}
const saved = await commitSourceUpdate(
{
customWorktreeVisibilitySources: next,
worktreeVisibilitySourcePreferences: buildWorktreeSourcePreferenceUpdate(
@@ -292,6 +298,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
)?.some((source) => source.id === id) === true &&
effectiveCustomWorktreeSourceVisibility(latestRepo, id) === 'hide'
)
return saved ? 'added' : 'save-failed'
},
[commitSourceUpdate, repo]
)
@@ -312,7 +319,10 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
(latestRepo) =>
!normalizeCustomWorktreeVisibilitySources(
latestRepo.customWorktreeVisibilitySources
)?.some((candidate) => candidate.id === source.id)
)?.some((candidate) => candidate.id === source.id) &&
normalizeWorktreeVisibilitySourcePreferences(
latestRepo.worktreeVisibilitySourcePreferences
)?.custom?.[source.id] === undefined
)
},
[commitSourceUpdate, repo]
@@ -49,13 +49,13 @@ export default function WorktreeVisibilityHelpPopover(): React.JSX.Element {
</li>
<li>
{translate(
'auto.components.sidebar.WorktreeVisibilityHelpPopover.54a7c2f183',
'auto.components.sidebar.WorktreeVisibilityHelpPopover.ec1e6a10fb',
'Other worktrees start hidden to avoid unexpected sidebar clutter.'
)}
</li>
<li>
{translate(
'auto.components.sidebar.WorktreeVisibilityHelpPopover.b9e0d43675',
'auto.components.sidebar.WorktreeVisibilityHelpPopover.1c68c9cf77',
'Enable a source for all current and future worktrees, or show individual worktrees below.'
)}
</li>
@@ -34,11 +34,18 @@ type Props = {
repo: Repo
worktrees: readonly DetectedWorktree[]
disabled: boolean
onAdd: (rootPath: string) => Promise<boolean>
onAdd: (rootPath: string) => Promise<WorktreeVisibilitySourceAddResult>
onRemove: (source: CustomWorktreeVisibilitySource) => Promise<void>
onToggle: (source: WorktreeVisibilitySourceRow, enabled: boolean) => Promise<void>
}
export type WorktreeVisibilitySourceAddResult =
| 'added'
| 'invalid-path'
| 'duplicate-path'
| 'limit'
| 'save-failed'
function getSourceLabel(source: WorktreeVisibilitySourceRow): string {
if (source.kind === 'built-in') {
return source.id === 'claude'
@@ -83,17 +90,20 @@ function isSourceEnabled(repo: Repo, source: WorktreeVisibilitySourceRow): boole
)
}
function matchesRow(
source: WorktreeVisibilitySourceRow,
match: WorktreeVisibilitySourceMatch | null
): boolean {
if (source.kind === 'other') {
return match === null
}
if (source.kind === 'built-in') {
return match?.kind === 'built-in' && match.id === source.id
}
return match?.kind === 'custom' && match.id === source.source.id
function sourceMatchKey(match: WorktreeVisibilitySourceMatch | null): string {
return match ? `${match.kind}:${match.id}` : 'other'
}
function sourceRowKey(source: WorktreeVisibilitySourceRow): string {
return source.kind === 'custom'
? `custom:${source.source.id}`
: source.kind === 'built-in'
? `built-in:${source.id}`
: 'other'
}
function getAccessibleSourceLabel(source: WorktreeVisibilitySourceRow, label: string): string {
return source.kind === 'custom' ? source.source.rootPath : label
}
export default function WorktreeVisibilitySourceList({
@@ -106,37 +116,65 @@ export default function WorktreeVisibilitySourceList({
}: Props): React.JSX.Element {
const [rootPath, setRootPath] = useState('')
const [inputError, setInputError] = useState<string | null>(null)
const customSources = normalizeCustomWorktreeVisibilitySources(
repo.customWorktreeVisibilitySources
const customSources = useMemo(
() => normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [],
[repo.customWorktreeVisibilitySources]
)
const sources = useMemo<WorktreeVisibilitySourceRow[]>(
() => [
{ kind: 'built-in', id: 'claude' },
{ kind: 'built-in', id: 'gsd' },
...customSources.map((source) => ({ kind: 'custom' as const, source })),
{ kind: 'other' }
],
[customSources]
)
const sources: WorktreeVisibilitySourceRow[] = [
{ kind: 'built-in', id: 'claude' },
{ kind: 'built-in', id: 'gsd' },
...(customSources ?? []).map((source) => ({ kind: 'custom' as const, source })),
{ kind: 'other' }
]
const classify = useMemo(
() =>
createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
customSources ?? []
customSources
),
[customSources, repo.path, worktrees]
)
const sourceCounts = useMemo(() => {
const counts = new Map<string, number>()
for (const worktree of worktrees) {
if (worktree.selectedCheckout || worktree.ownership === 'orca-managed') {
continue
}
const key = sourceMatchKey(worktree.visibilitySource ?? classify(worktree.path))
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
}, [classify, worktrees])
const handleAdd = async (event: React.FormEvent) => {
event.preventDefault()
setInputError(null)
const added = await onAdd(rootPath)
if (added) {
const result = await onAdd(rootPath)
if (result === 'added') {
setRootPath('')
return
}
if (result === 'save-failed') {
return
}
setInputError(
translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.invalidPath',
'Enter a unique absolute path for this host.'
)
result === 'limit'
? translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.limit',
'Remove a custom location before adding another.'
)
: result === 'duplicate-path'
? translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.duplicatePath',
'This location is already listed.'
)
: translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.invalidPath',
'Enter an absolute path for this host.'
)
)
}
@@ -156,16 +194,9 @@ export default function WorktreeVisibilitySourceList({
<div className="overflow-hidden rounded-lg border border-border bg-muted/30">
{sources.map((source, index) => {
const label = getSourceLabel(source)
const count = worktrees.filter(
(worktree) =>
!worktree.selectedCheckout &&
worktree.ownership !== 'orca-managed' &&
matchesRow(source, worktree.visibilitySource ?? classify(worktree.path))
).length
const key =
source.kind === 'custom'
? `custom:${source.source.id}`
: `${source.kind}:${source.kind === 'built-in' ? source.id : ''}`
const key = sourceRowKey(source)
const count = sourceCounts.get(key) ?? 0
const accessibleLabel = getAccessibleSourceLabel(source, label)
return (
<div
key={key}
@@ -210,7 +241,7 @@ export default function WorktreeVisibilitySourceList({
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.remove',
'Remove {{value0}}',
{ value0: label }
{ value0: accessibleLabel }
)}
onClick={() => void onRemove(source.source)}
>
@@ -231,7 +262,7 @@ export default function WorktreeVisibilitySourceList({
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.toggle',
'Show current and future worktrees from {{value0}}',
{ value0: label }
{ value0: accessibleLabel }
)}
onCheckedChange={(checked) => void onToggle(source, checked)}
/>
+9 -4
View File
@@ -4903,8 +4903,12 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "Which worktrees are hidden by default?",
"8db4e19a26": "This setting never hides worktrees created through Orca.",
"54a7c2f183": "Other worktrees start hidden to avoid unexpected sidebar clutter.",
"b9e0d43675": "Enable a source for all current and future worktrees, or show individual worktrees below."
"ec1e6a10fb": "Other worktrees start hidden to avoid unexpected sidebar clutter.",
"1c68c9cf77": "Enable a source for all current and future worktrees, or show individual worktrees below."
},
"HiddenWorktreeRecoveryList": {
"64e6f53f05": "Show one without enabling its source.",
"showWorktree": "Show {{value0}} at {{value1}}"
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "Non-Orca worktrees",
@@ -4918,7 +4922,6 @@
"b8d24e61f5": "Could not list this repo's worktrees.",
"c5e70a93b1": "Try again",
"7d21c5e848": "Hidden worktrees ({{value0}})",
"9b53f7a160": "Show one without enabling its source.",
"2f80cd4b97": "Showing…",
"e64b81d3a9": "Show",
"d40d436fc2": "Could not update worktree visibility. Try again.",
@@ -5435,7 +5438,9 @@
"other": "Other locations",
"custom": "Custom location",
"otherPath": "Outside listed sources",
"invalidPath": "Enter a unique absolute path for this host.",
"invalidPath": "Enter an absolute path for this host.",
"duplicatePath": "This location is already listed.",
"limit": "Remove a custom location before adding another.",
"sources": "Sources",
"sourcesDescription": "Enabled sources include current and future worktrees in the sidebar.",
"found": "{{value0}} found",
+10 -5
View File
@@ -4700,8 +4700,12 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "¿Qué worktrees se ocultan de forma predeterminada?",
"8db4e19a26": "Este ajuste nunca oculta los worktrees creados mediante Orca.",
"54a7c2f183": "Los demás worktrees empiezan ocultos para evitar saturar inesperadamente la barra lateral.",
"b9e0d43675": "Activa una fuente para mostrar todos sus worktrees actuales y futuros, o muestra worktrees individuales abajo."
"ec1e6a10fb": "Los demás worktrees empiezan ocultos para evitar saturar inesperadamente la barra lateral.",
"1c68c9cf77": "Activa una fuente para mostrar todos sus worktrees actuales y futuros, o muestra worktrees individuales abajo."
},
"HiddenWorktreeRecoveryList": {
"64e6f53f05": "Muestra uno sin activar su fuente.",
"showWorktree": "Mostrar {{value0}} en {{value1}}"
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "Worktrees que no son de Orca",
@@ -4715,7 +4719,6 @@
"b8d24e61f5": "No se pudieron listar los worktrees de este repositorio.",
"c5e70a93b1": "Reintentar",
"7d21c5e848": "Worktrees ocultos ({{value0}})",
"9b53f7a160": "Muestra uno sin activar su fuente.",
"2f80cd4b97": "Mostrando…",
"e64b81d3a9": "Mostrar",
"d40d436fc2": "No se pudo actualizar la visibilidad de los worktrees. Inténtalo de nuevo.",
@@ -4735,10 +4738,12 @@
"other": "Otras ubicaciones",
"custom": "Ubicación personalizada",
"otherPath": "Fuera de las fuentes listadas",
"invalidPath": "Introduce una ruta absoluta única para este host.",
"invalidPath": "Introduce una ruta absoluta para este host.",
"duplicatePath": "Esta ubicación ya está en la lista.",
"limit": "Elimina una ubicación personalizada antes de añadir otra.",
"sources": "Fuentes",
"sourcesDescription": "Las fuentes activadas incluyen los worktrees actuales y futuros en la barra lateral.",
"found": "{{value0}} encontrados",
"found": "Encontrados: {{value0}}",
"remove": "Eliminar {{value0}}",
"removeLocation": "Eliminar ubicación personalizada",
"toggle": "Mostrar los worktrees actuales y futuros de {{value0}}",
+9 -4
View File
@@ -4681,8 +4681,12 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "デフォルトで非表示になるワークツリー",
"8db4e19a26": "この設定では、Orca で作成されたワークツリーが非表示になることはありません。",
"54a7c2f183": "その他のワークツリーは、サイドバーが意図せず煩雑にならないようデフォルトで非表示です。",
"b9e0d43675": "ソースを有効にすると現在および今後のワークツリーをすべて表示できます。下から個別に表示することもできます。"
"ec1e6a10fb": "その他のワークツリーは、サイドバーが意図せず煩雑にならないようデフォルトで非表示です。",
"1c68c9cf77": "ソースを有効にすると現在および今後のワークツリーをすべて表示できます。下から個別に表示することもできます。"
},
"HiddenWorktreeRecoveryList": {
"64e6f53f05": "ソースを有効にせずに個別に表示します。",
"showWorktree": "{{value1}} の {{value0}} を表示"
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "Orca 以外のワークツリー",
@@ -4696,7 +4700,6 @@
"b8d24e61f5": "このリポジトリのワークツリーを一覧できませんでした。",
"c5e70a93b1": "再試行",
"7d21c5e848": "非表示のワークツリー({{value0}})",
"9b53f7a160": "ソースを有効にせずに個別に表示します。",
"2f80cd4b97": "表示中…",
"e64b81d3a9": "表示",
"d40d436fc2": "ワークツリーの表示設定を更新できませんでした。もう一度お試しください。",
@@ -4716,7 +4719,9 @@
"other": "その他の場所",
"custom": "カスタムの場所",
"otherPath": "一覧にないソース",
"invalidPath": "このホストの一意な絶対パスを入力してください。",
"invalidPath": "このホストの絶対パスを入力してください。",
"duplicatePath": "この場所は既に一覧にあります。",
"limit": "別の場所を追加する前にカスタムの場所を削除してください。",
"sources": "ソース",
"sourcesDescription": "有効なソースの現在および今後のワークツリーがサイドバーに表示されます。",
"found": "{{value0}} 件検出",
+9 -4
View File
@@ -4681,8 +4681,12 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "기본적으로 숨겨지는 워크트리",
"8db4e19a26": "이 설정은 Orca에서 만든 워크트리를 숨기지 않습니다.",
"54a7c2f183": "다른 워크트리는 사이드바가 예기치 않게 복잡해지지 않도록 기본적으로 숨겨집니다.",
"b9e0d43675": "소스를 활성화해 현재와 앞으로 생성되는 모든 워크트리를 표시하거나 아래에서 개별 워크트리를 표시하세요."
"ec1e6a10fb": "다른 워크트리는 사이드바가 예기치 않게 복잡해지지 않도록 기본적으로 숨겨집니다.",
"1c68c9cf77": "소스를 활성화해 현재와 앞으로 생성되는 모든 워크트리를 표시하거나 아래에서 개별 워크트리를 표시하세요."
},
"HiddenWorktreeRecoveryList": {
"64e6f53f05": "소스를 활성화하지 않고 하나만 표시합니다.",
"showWorktree": "{{value1}}의 {{value0}} 표시"
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "Orca 외부 워크트리",
@@ -4696,7 +4700,6 @@
"b8d24e61f5": "이 저장소의 워크트리를 나열할 수 없습니다.",
"c5e70a93b1": "다시 시도",
"7d21c5e848": "숨겨진 워크트리({{value0}})",
"9b53f7a160": "소스를 활성화하지 않고 하나만 표시합니다.",
"2f80cd4b97": "표시 중…",
"e64b81d3a9": "표시",
"d40d436fc2": "워크트리 표시 설정을 업데이트하지 못했습니다. 다시 시도하세요.",
@@ -4716,7 +4719,9 @@
"other": "기타 위치",
"custom": "사용자 지정 위치",
"otherPath": "목록에 없는 소스",
"invalidPath": "이 호스트의 고유한 절대 경로를 입력하세요.",
"invalidPath": "이 호스트의 절대 경로를 입력하세요.",
"duplicatePath": "이 위치는 이미 목록에 있습니다.",
"limit": "다른 위치를 추가하기 전에 사용자 지정 위치를 제거하세요.",
"sources": "소스",
"sourcesDescription": "활성화된 소스의 현재와 앞으로 생성되는 워크트리가 사이드바에 표시됩니다.",
"found": "{{value0}}개 발견",
+9 -4
View File
@@ -4693,8 +4693,12 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "哪些工作树默认隐藏?",
"8db4e19a26": "此设置不会隐藏通过 Orca 创建的工作树。",
"54a7c2f183": "其他工作树默认隐藏,以免侧边栏意外变得杂乱。",
"b9e0d43675": "启用来源可显示其当前及未来的所有工作树,也可以在下方单独显示工作树。"
"ec1e6a10fb": "其他工作树默认隐藏,以免侧边栏意外变得杂乱。",
"1c68c9cf77": "启用来源可显示其当前及未来的所有工作树,也可以在下方单独显示工作树。"
},
"HiddenWorktreeRecoveryList": {
"64e6f53f05": "无需启用来源即可单独显示一个工作树。",
"showWorktree": "显示位于 {{value1}} 的 {{value0}}"
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "非 Orca 工作树",
@@ -4708,7 +4712,6 @@
"b8d24e61f5": "无法列出此仓库的工作树。",
"c5e70a93b1": "重试",
"7d21c5e848": "已隐藏的工作树({{value0}})",
"9b53f7a160": "无需启用来源即可单独显示一个工作树。",
"2f80cd4b97": "显示中…",
"e64b81d3a9": "显示",
"d40d436fc2": "无法更新工作树可见性。请重试。",
@@ -4728,7 +4731,9 @@
"other": "其他位置",
"custom": "自定义位置",
"otherPath": "所列来源之外",
"invalidPath": "请输入此主机上唯一的绝对路径。",
"invalidPath": "请输入此主机上的绝对路径。",
"duplicatePath": "此位置已在列表中。",
"limit": "请先移除一个自定义位置再添加新位置。",
"sources": "来源",
"sourcesDescription": "已启用来源的当前及未来工作树会显示在侧边栏中。",
"found": "找到 {{value0}} 个",
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import es from './locales/es.json'
describe('worktree visibility locales', () => {
it('uses number-neutral Spanish source counts', () => {
const found = es.auto.components.sidebar.WorktreeVisibilitySourceList.found
expect(found.replace('{{value0}}', '1')).toBe('Encontrados: 1')
expect(found.replace('{{value0}}', '2')).toBe('Encontrados: 2')
})
it('keeps current fallback-derived keys in the English catalog', () => {
expect(en.auto.components.sidebar.WorktreeVisibilityHelpPopover).toMatchObject({
ec1e6a10fb: 'Other worktrees start hidden to avoid unexpected sidebar clutter.',
'1c68c9cf77':
'Enable a source for all current and future worktrees, or show individual worktrees below.'
})
expect(en.auto.components.sidebar.HiddenWorktreeRecoveryList).toMatchObject({
'64e6f53f05': 'Show one without enabling its source.'
})
})
})
+37 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from './types'
import {
buildWorktreeSourcePreferenceUpdate,
@@ -65,6 +65,22 @@ describe('worktree visibility sources', () => {
})
})
it('normalizes each candidate once regardless of checkout count', () => {
const classify = createWorktreeVisibilitySourceMatcher(
Array.from({ length: 200 }, (_, index) => `/repo/worktree-${index}`),
Array.from({ length: 32 }, (_, index) => ({
id: `custom-${index}`,
rootPath: `/custom/${index}`
}))
)
const normalize = vi.spyOn(String.prototype, 'normalize')
classify('/unmatched/worktree')
expect(normalize).toHaveBeenCalledTimes(1)
normalize.mockRestore()
})
it('migrates the optional legacy agent policy lazily for both built-ins', () => {
const legacy = repo({ agentWorktreeVisibility: 'show' })
expect(effectiveBuiltInWorktreeSourceVisibility(legacy, 'claude')).toBe('show')
@@ -90,4 +106,24 @@ describe('worktree visibility sources', () => {
})
).toEqual({ builtIn: { claude: 'show' }, custom: { team: 'hide' } })
})
it('rejects drive-relative Windows roots and caps after validation', () => {
const invalid = Array.from({ length: 32 }, (_, index) => ({
id: `bad-${index}`,
rootPath: 'relative'
}))
expect(
normalizeCustomWorktreeVisibilitySources([...invalid, { id: 'team', rootPath: '/srv/team' }])
).toEqual([{ id: 'team', rootPath: '/srv/team' }])
expect(
normalizeCustomWorktreeVisibilitySources([
{ id: 'drive-relative', rootPath: '\\Users\\dev\\team' },
{ id: 'drive', rootPath: 'C:\\Users\\dev\\team' },
{ id: 'unc', rootPath: '\\\\server\\share\\team' }
])
).toEqual([
{ id: 'drive', rootPath: 'C:\\Users\\dev\\team' },
{ id: 'unc', rootPath: '\\\\server\\share\\team' }
])
})
})
+25 -18
View File
@@ -41,6 +41,7 @@ function normalizeSourceRootPath(value: string): string | null {
!trimmed ||
trimmed.length > MAX_SOURCE_PATH_LENGTH ||
trimmed.includes('\0') ||
(trimmed.startsWith('\\') && !trimmed.startsWith('\\\\')) ||
!isRuntimePathAbsolute(trimmed)
) {
return null
@@ -57,7 +58,10 @@ export function normalizeCustomWorktreeVisibilitySources(
const ids = new Set<string>()
const roots = new Set<string>()
const normalized: CustomWorktreeVisibilitySource[] = []
for (const candidate of value.slice(0, MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES)) {
for (const candidate of value) {
if (normalized.length >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) {
break
}
if (!candidate || typeof candidate !== 'object') {
continue
}
@@ -116,39 +120,42 @@ export function normalizeWorktreeVisibilitySourcePreferences(
}
}
function createDescendantMatcher(rootPath: string): (candidatePath: string) => boolean {
function createDescendantMatcher(rootPath: string): (normalizedCandidate: string) => boolean {
const matchesInsideOrEqual = createNormalizedPathInsideOrEqualMatcher(rootPath)
const normalizedRoot = normalizeRuntimePathForComparison(rootPath)
return (candidatePath) => {
const normalizedCandidate = normalizeRuntimePathForComparison(candidatePath)
return normalizedCandidate !== normalizedRoot && matchesInsideOrEqual(normalizedCandidate)
}
return (normalizedCandidate) =>
normalizedCandidate !== normalizedRoot && matchesInsideOrEqual(normalizedCandidate)
}
export function createWorktreeVisibilitySourceMatcher(
checkoutPaths: readonly string[],
customSources: readonly CustomWorktreeVisibilitySource[] = []
): WorktreeVisibilitySourceMatcher {
const builtInMatchers = checkoutPaths.flatMap((checkoutPath) =>
BUILT_IN_WORKTREE_VISIBILITY_SOURCES.map(({ id, relativeRootSegments }) => ({
id,
matches: createDescendantMatcher(
`${checkoutPath.replace(/[\\/]+$/, '')}/${relativeRootSegments.join('/')}`
)
}))
)
const checkoutPathKeys = new Set(checkoutPaths.map(normalizeRuntimePathForComparison))
const customMatchers = customSources.map(({ id, rootPath }) => ({
id,
matches: createDescendantMatcher(rootPath)
}))
return (worktreePath) => {
for (const source of builtInMatchers) {
if (source.matches(worktreePath)) {
return { kind: 'built-in', id: source.id }
const normalizedCandidate = normalizeRuntimePathForComparison(worktreePath)
const segments = normalizedCandidate.split('/')
for (const source of BUILT_IN_WORKTREE_VISIBILITY_SOURCES) {
const prefix = source.relativeRootSegments
for (let index = 0; index + prefix.length < segments.length; index += 1) {
if (!prefix.every((segment, offset) => segments[index + offset] === segment)) {
continue
}
const checkoutPath = segments.slice(0, index).join('/')
const checkoutPathKey = /^[a-z]:$/i.test(checkoutPath)
? `${checkoutPath}/`
: checkoutPath || '/'
if (checkoutPathKeys.has(checkoutPathKey)) {
return { kind: 'built-in', id: source.id }
}
}
}
for (const source of customMatchers) {
if (source.matches(worktreePath)) {
if (source.matches(normalizedCandidate)) {
return { kind: 'custom', id: source.id }
}
}