feat(worktrees): add per-source visibility controls

This commit is contained in:
Brennan Benson
2026-08-12 21:03:44 -07:00
parent d9b390f4fd
commit 66c0f18cf7
27 changed files with 1645 additions and 307 deletions
+33
View File
@@ -2189,6 +2189,39 @@ describe('repos:add + repos:clone', () => {
})
})
it('validates source definitions and preferences through local repos:update', () => {
const updated = {
id: 'repo-source-visibility',
path: '/tmp/repo-source-visibility',
displayName: 'repo-source-visibility',
kind: 'git',
badgeColor: '#22c55e'
}
mockStore.updateRepo.mockReturnValue(updated)
handlers.get('repos:update')!(null, {
repoId: updated.id,
updates: {
customWorktreeVisibilitySources: [
{ id: 'team', rootPath: ' /srv/team ' },
{ id: 'bad id', rootPath: '/srv/other' }
],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'invalid' },
custom: { team: 'hide' }
}
}
})
expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, {
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team' }],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show' },
custom: { team: 'hide' }
}
})
})
it('prepares and invalidates roots when project host setup update changes worktree base path', () => {
const repo = {
id: 'repo-setup-update-root',
+26
View File
@@ -118,6 +118,10 @@ import {
} from '../project-groups/folder-workspace-path-status'
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../../shared/worktree-visibility-sources'
import { runWithGitReadCacheInvalidation } from '../git/status'
import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission'
import { isCurrentSshProviderAuthority } from '../ssh/ssh-provider-authority'
@@ -2127,6 +2131,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
| 'projectGroupId'
| 'projectGroupOrder'
>
@@ -2205,6 +2211,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
) {
delete updates.agentWorktreeVisibility
}
if ('customWorktreeVisibilitySources' in updates) {
const normalized = normalizeCustomWorktreeVisibilitySources(
updates.customWorktreeVisibilitySources
)
if (!normalized) {
delete updates.customWorktreeVisibilitySources
} else {
updates.customWorktreeVisibilitySources = normalized
}
}
if ('worktreeVisibilitySourcePreferences' in updates) {
const normalized = normalizeWorktreeVisibilitySourcePreferences(
updates.worktreeVisibilitySourcePreferences
)
if (!normalized) {
delete updates.worktreeVisibilitySourcePreferences
} else {
updates.worktreeVisibilitySourcePreferences = normalized
}
}
if (
'externalWorktreeVisibilityPromptDismissedAt' in updates &&
updates.externalWorktreeVisibilityPromptDismissedAt !== undefined &&
+15 -12
View File
@@ -66,7 +66,10 @@ import {
isLegacyRepoForExternalWorktreeVisibility,
toDetectedWorktree
} from '../../shared/worktree-ownership'
import { createAgentScratchWorktreePathMatcher } from '../../shared/agent-scratch-worktrees'
import {
createWorktreeVisibilitySourceMatcher,
normalizeCustomWorktreeVisibilitySources
} from '../../shared/worktree-visibility-sources'
import {
assertWorktreeCleanForRemoval,
forceDeleteLocalBranch,
@@ -850,10 +853,10 @@ function buildDetectedGitWorktrees(
const liveWorktrees = dedupeWorktreesByPath(
gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable)
)
const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([
repo.path,
...liveWorktrees.map((worktree) => worktree.path)
])
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...liveWorktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
const detected = liveWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
let meta = store.getWorktreeMeta(worktreeId)
@@ -865,7 +868,7 @@ function buildDetectedGitWorktrees(
settings,
knownOrcaLayouts,
isLegacyRepoForVisibility,
agentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher
})
if (!detected.visible) {
return detected
@@ -879,7 +882,7 @@ function buildDetectedGitWorktrees(
settings,
knownOrcaLayouts,
isLegacyRepoForVisibility,
agentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher
})
})
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
@@ -1081,10 +1084,10 @@ function buildDisconnectedDetectedWorktrees(
worktrees: Worktree[]
): DetectedWorktree[] {
const settings = store.getSettings()
const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([
repo.path,
...worktrees.map((worktree) => worktree.path)
])
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
const detected = worktrees.map((worktree) => {
const meta = store.getWorktreeMeta(worktree.id)
const detected = toDetectedWorktree({
@@ -1094,7 +1097,7 @@ function buildDisconnectedDetectedWorktrees(
settings,
knownOrcaLayouts: [],
isLegacyRepoForVisibility: true,
agentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher
})
return applyMetadataFallbackVisibility(detected)
})
+39 -3
View File
@@ -4738,7 +4738,7 @@ describe('Store', () => {
expect(updated!.externalWorktreeVisibilityLegacy).toBe(true)
})
it('persists agent worktree visibility independently from external visibility', async () => {
it('keeps rollback agent visibility writes authoritative for both built-in sources', async () => {
const store = await createStore()
store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' }))
@@ -4746,17 +4746,53 @@ describe('Store', () => {
expect(updated).toMatchObject({
externalWorktreeVisibility: 'hide',
agentWorktreeVisibility: 'show'
agentWorktreeVisibility: 'show',
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' }
}
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')).toMatchObject({
externalWorktreeVisibility: 'hide',
agentWorktreeVisibility: 'show'
agentWorktreeVisibility: 'show',
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' }
}
})
})
it('persists bounded custom worktree sources separately from their preferences', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const updated = store.updateRepo('r1', {
customWorktreeVisibilitySources: [
{ id: 'team', rootPath: ' /srv/team-worktrees ' },
{ id: 'invalid', rootPath: '../relative' }
],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' },
custom: { team: 'show' }
}
})
expect(updated).toMatchObject({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' },
custom: { team: 'show' }
}
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')?.customWorktreeVisibilitySources).toEqual([
{ id: 'team', rootPath: '/srv/team-worktrees' }
])
})
it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => {
const store = await createStore()
store.addRepo(
+54
View File
@@ -137,6 +137,10 @@ import {
import { parseWorkspaceSessionSalvaging } from '../shared/workspace-session-salvage'
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
import { normalizeStatusBarUsageMode } from '../shared/status-bar-usage-mode'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../shared/worktree-visibility-sources'
import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice'
import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice'
import { normalizePRBotAuthorOverrides } from '../shared/pr-bot-author-overrides'
@@ -1463,6 +1467,8 @@ function sanitizeRepoUpdatesForPersistence<
| 'worktreeBasePath'
| 'projectHostSetupMethod'
| 'forkSyncMode'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
>
>
>(updates: T): T {
@@ -1523,6 +1529,26 @@ function sanitizeRepoUpdatesForPersistence<
sanitized.forkSyncMode = forkSyncMode
}
}
if ('customWorktreeVisibilitySources' in sanitized) {
const sources = normalizeCustomWorktreeVisibilitySources(
sanitized.customWorktreeVisibilitySources
)
if (!sources) {
delete sanitized.customWorktreeVisibilitySources
} else {
sanitized.customWorktreeVisibilitySources = sources
}
}
if ('worktreeVisibilitySourcePreferences' in sanitized) {
const preferences = normalizeWorktreeVisibilitySourcePreferences(
sanitized.worktreeVisibilitySourcePreferences
)
if (!preferences) {
delete sanitized.worktreeVisibilitySourcePreferences
} else {
sanitized.worktreeVisibilitySourcePreferences = preferences
}
}
return sanitized
}
@@ -4920,6 +4946,8 @@ export class Store {
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
| 'projectGroupId'
| 'projectGroupOrder'
| 'projectHostSetupMethod'
@@ -4938,6 +4966,20 @@ export class Store {
return null
}
const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates)
if (
'agentWorktreeVisibility' in sanitizedUpdates &&
!('worktreeVisibilitySourcePreferences' in sanitizedUpdates) &&
(sanitizedUpdates.agentWorktreeVisibility === 'hide' ||
sanitizedUpdates.agentWorktreeVisibility === 'show')
) {
sanitizedUpdates.worktreeVisibilitySourcePreferences = {
...repo.worktreeVisibilitySourcePreferences,
builtIn: {
claude: sanitizedUpdates.agentWorktreeVisibility,
gsd: sanitizedUpdates.agentWorktreeVisibility
}
}
}
if ('projectGroupId' in sanitizedUpdates) {
const nextGroupId = sanitizedUpdates.projectGroupId
if (
@@ -5103,6 +5145,8 @@ export class Store {
sourceControlAi: rawSourceControlAi,
projectHostSetupMethod: rawProjectHostSetupMethod,
forkSyncMode: rawForkSyncMode,
customWorktreeVisibilitySources: rawCustomWorktreeVisibilitySources,
worktreeVisibilitySourcePreferences: rawWorktreeVisibilitySourcePreferences,
...repoWithoutIcon
} = repo
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
@@ -5111,6 +5155,12 @@ export class Store {
const sourceControlAi = normalizeRepoSourceControlAiOverrides(rawSourceControlAi)
const projectHostSetupMethod = sanitizeRepoProjectHostSetupMethod(rawProjectHostSetupMethod)
const forkSyncMode = sanitizeForkSyncMode(rawForkSyncMode)
const customWorktreeVisibilitySources = normalizeCustomWorktreeVisibilitySources(
rawCustomWorktreeVisibilitySources
)
const worktreeVisibilitySourcePreferences = normalizeWorktreeVisibilitySourcePreferences(
rawWorktreeVisibilitySourcePreferences
)
// Why: never spawn git/gh username resolution in hydration — a stuck probe froze Windows startup for minutes (issue #7225); read only cache/persisted value.
const gitUsername = isFolderRepo(repo)
? ''
@@ -5124,6 +5174,10 @@ export class Store {
...(sourceControlAi !== undefined ? { sourceControlAi } : {}),
...(projectHostSetupMethod !== undefined ? { projectHostSetupMethod } : {}),
...(forkSyncMode !== undefined ? { forkSyncMode } : {}),
...(customWorktreeVisibilitySources !== undefined ? { customWorktreeVisibilitySources } : {}),
...(worktreeVisibilitySourcePreferences !== undefined
? { worktreeVisibilitySourcePreferences }
: {}),
kind: isFolderRepo(repo) ? 'folder' : 'git',
gitUsername,
hookSettings: {
+22 -19
View File
@@ -486,11 +486,12 @@ import {
isLegacyRepoForExternalWorktreeVisibility,
toDetectedWorktree
} from '../../shared/worktree-ownership'
import { isAgentScratchRepoRootPath } from '../../shared/agent-scratch-worktrees'
import {
createAgentScratchWorktreePathMatcher,
isAgentScratchRepoRootPath,
type AgentScratchWorktreePathMatcher
} from '../../shared/agent-scratch-worktrees'
createWorktreeVisibilitySourceMatcher,
normalizeCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from '../../shared/worktree-visibility-sources'
import {
BROWSER_HEADLESS_RUNTIME_CAPABILITY,
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY,
@@ -19150,6 +19151,8 @@ export class OrcaRuntimeService {
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
| 'projectGroupId'
| 'projectGroupOrder'
>
@@ -20956,13 +20959,13 @@ export class OrcaRuntimeService {
checkoutPaths.push(worktree.path)
checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths)
}
const agentScratchMatchersByRepoId = new Map(
const visibilitySourceMatchersByRepoId = new Map(
(this.store?.getRepos() ?? []).map((repo) => [
repo.id,
createAgentScratchWorktreePathMatcher([
repo.path,
...(checkoutPathsByRepoId.get(repo.id) ?? [])
])
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
])
)
const worktrees = resolved.filter((worktree) => {
@@ -20971,7 +20974,7 @@ export class OrcaRuntimeService {
}
return this.isRuntimeWorktreeVisible(
worktree,
agentScratchMatchersByRepoId.get(worktree.repoId)
visibilitySourceMatchersByRepoId.get(worktree.repoId)
)
})
return {
@@ -21013,10 +21016,10 @@ export class OrcaRuntimeService {
if (scan.ok) {
pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees)
}
const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([
repo.path,
...scan.worktrees.map((worktree) => worktree.path)
])
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
const detected = scan.worktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
@@ -21027,7 +21030,7 @@ export class OrcaRuntimeService {
const detectedWorktree = this.toRuntimeDetectedWorktree(
repo,
worktree,
agentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher
)
if (scan.ok) {
return detectedWorktree
@@ -21094,19 +21097,19 @@ export class OrcaRuntimeService {
private isRuntimeWorktreeVisible(
worktree: Worktree,
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher
): boolean {
const repo = this.store?.getRepo(worktree.repoId)
if (!repo || !this.store) {
return true
}
return this.toRuntimeDetectedWorktree(repo, worktree, agentScratchWorktreePathMatcher).visible
return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher).visible
}
private toRuntimeDetectedWorktree(
repo: Repo,
worktree: Worktree,
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher
): DetectedWorktree {
const settings = this.store?.getSettings()
if (!settings) {
@@ -21124,7 +21127,7 @@ export class OrcaRuntimeService {
settings,
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo),
isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo),
agentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher
})
}
@@ -3,6 +3,10 @@ import { OptionalFiniteNumber, OptionalString } from '../schemas'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../../../../shared/worktree-visibility-sources'
export const RepoSourceControlAiOverrides = z
.unknown()
@@ -55,6 +59,14 @@ export function createRepoUpdateSchema<T extends z.ZodRawShape>(
externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(),
importedExternalWorktreePaths: z.array(z.string()).optional(),
agentWorktreeVisibility: z.enum(['hide', 'show']).optional(),
customWorktreeVisibilitySources: z
.unknown()
.transform((value) => normalizeCustomWorktreeVisibilitySources(value))
.optional(),
worktreeVisibilitySourcePreferences: z
.unknown()
.transform((value) => normalizeWorktreeVisibilitySourcePreferences(value))
.optional(),
externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(),
projectGroupId: OptionalString.nullable().optional(),
projectGroupOrder: OptionalFiniteNumber,
+32
View File
@@ -330,6 +330,38 @@ describe('repo RPC methods', () => {
})
})
it('validates additive source settings at the remote RPC boundary', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepo: vi.fn().mockResolvedValue({ id: 'repo-1', path: '/srv/repo' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
await dispatcher.dispatch(
makeRequest('repo.update', {
repo: 'repo-1',
updates: {
customWorktreeVisibilitySources: [
{ id: 'team', rootPath: ' /srv/team ' },
{ id: 'relative', rootPath: '../team' }
],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'bad' },
custom: { team: 'hide' }
}
}
})
)
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team' }],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show' },
custom: { team: 'hide' }
}
})
})
it('persists resolved GitHub upstream metadata updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+2
View File
@@ -1197,6 +1197,8 @@ export type PreloadApi = {
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
| 'projectGroupId'
| 'projectGroupOrder'
| 'forkSyncMode'
@@ -0,0 +1,195 @@
import React, { useId, useRef, useState } from 'react'
import { FolderMinus, Search } from 'lucide-react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { translate } from '@/i18n/i18n'
import type { DetectedWorktreeListResult, Repo } from '../../../../shared/types'
import {
getHiddenImportableExternalWorktrees,
getVisibleNonOrcaWorktrees
} from '../../../../shared/external-worktree-inbox'
import { relativePathInsideRoot } from '../../../../shared/cross-platform-path'
type Props = {
repo: Repo
detected: DetectedWorktreeListResult | undefined
listState: 'checking' | 'ready' | 'failed'
busyPath: string | null
toggling: boolean
onShow: (worktreePath: string) => Promise<void>
}
export default function HiddenWorktreeRecoveryList({
repo,
detected,
listState,
busyPath,
toggling,
onShow
}: Props): React.JSX.Element | null {
const [query, setQuery] = useState('')
const headingId = useId()
const listRef = useRef<HTMLDivElement>(null)
const hidden = getHiddenImportableExternalWorktrees(detected)
const normalizedQuery = query.trim().toLocaleLowerCase()
const filtered = normalizedQuery
? hidden.filter(
(worktree) =>
worktree.displayName.toLocaleLowerCase().includes(normalizedQuery) ||
worktree.path.toLocaleLowerCase().includes(normalizedQuery)
)
: hidden
const discoveredCount = hidden.length + getVisibleNonOrcaWorktrees(detected).length
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => listRef.current,
estimateSize: () => 56,
getItemKey: (index) => filtered[index]?.id ?? index,
overscan: 3,
initialRect: { width: 480, height: 224 }
})
if (hidden.length === 0 && listState !== 'ready') {
return null
}
return (
<section className="grid min-w-0 gap-2">
<div className="flex items-center justify-between gap-3">
<div>
<h3 id={headingId} className="text-sm font-medium">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.7d21c5e848',
'Hidden worktrees ({{value0}})',
{ value0: hidden.length }
)}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.9b53f7a160',
'Show one without enabling its source.'
)}
</p>
</div>
{hidden.length >= 10 ? (
<div className="relative w-48 shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-2 size-3.5 text-muted-foreground" />
<Input
type="search"
className="h-8 pl-8 text-xs"
value={query}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilityDialog.search',
'Search hidden worktrees'
)}
placeholder={translate(
'auto.components.sidebar.WorktreeVisibilityDialog.searchPlaceholder',
'Search {{value0}} worktrees…',
{ value0: hidden.length }
)}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
) : null}
</div>
{filtered.length > 0 ? (
<div
ref={listRef}
role="region"
aria-labelledby={headingId}
className="scrollbar-sleek max-h-56 min-w-0 overflow-y-auto"
tabIndex={0}
style={{ height: `${Math.min(virtualizer.getTotalSize(), 224)}px` }}
>
<ul className="relative min-w-0" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const worktree = filtered[virtualRow.index]
if (!worktree) {
return null
}
const displayPath = relativePathInsideRoot(repo.path, worktree.path) || worktree.path
return (
<li
key={worktree.id}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 w-full pb-1"
style={{ transform: `translateY(${virtualRow.start}px)` }}
aria-posinset={virtualRow.index + 1}
aria-setsize={filtered.length}
>
<div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2 hover:bg-accent/50">
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{worktree.displayName}</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{displayPath}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={busyPath !== null || toggling || listState === 'checking'}
onClick={() => void onShow(worktree.path)}
>
{busyPath === worktree.path
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.2f80cd4b97',
'Showing…'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.e64b81d3a9',
'Show'
)}
</Button>
</div>
</li>
)
})}
</ul>
</div>
) : listState === 'ready' ? (
<div className="flex min-h-16 items-center gap-3 rounded-lg border border-border bg-muted/30 p-3">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
<FolderMinus className="size-3.5" />
</span>
<div className="min-w-0">
<div className="text-[13px] font-medium">
{normalizedQuery
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.noMatches',
'No matching worktrees'
)
: discoveredCount > 0
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.allShown',
'All discovered worktrees are shown'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.noneFound',
'No non-Orca worktrees found'
)}
</div>
<div className="text-xs text-muted-foreground">
{normalizedQuery
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.tryDifferentSearch',
'Try a different name or path.'
)
: discoveredCount > 0
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.disableSource',
'Disable a source to manage its worktrees individually.'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.appearWhenDetected',
'New worktrees will appear here when Orca detects them.'
)}
</div>
</div>
</div>
) : null}
</section>
)
}
@@ -38,6 +38,12 @@ vi.mock('@/components/ui/dialog', () => ({
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, unknown>) =>
values
@@ -124,7 +130,22 @@ beforeEach(() => {
mocks.state.modalData = { repoId: 'repo-1' }
mocks.state.repos = [makeRepo()]
mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected() }
mocks.state.updateRepo.mockResolvedValue(true)
mocks.state.updateRepo.mockImplementation(
async (repoId: string, updates: Record<string, unknown>) => {
const repo = (mocks.state.repos as Repo[]).find((candidate) => candidate.id === repoId)
if (!repo) {
return false
}
for (const [key, value] of Object.entries(updates)) {
if (value === null) {
delete (repo as unknown as Record<string, unknown>)[key]
} else {
;(repo as unknown as Record<string, unknown>)[key] = value
}
}
return true
}
)
mocks.state.fetchWorktrees.mockResolvedValue(true)
container = document.createElement('div')
document.body.appendChild(container)
@@ -168,10 +189,12 @@ function buttonWithText(text: string): HTMLButtonElement {
return button as HTMLButtonElement
}
function alwaysShowSwitch(): HTMLButtonElement {
const control = document.querySelector('[role="switch"]')
function sourceSwitch(label = 'Claude Code'): HTMLButtonElement {
const control = document.querySelector(
`[role="switch"][aria-label="Show current and future worktrees from ${label}"]`
)
if (!control) {
throw new Error('No Always show switch')
throw new Error(`No ${label} source switch`)
}
return control as HTMLButtonElement
}
@@ -187,9 +210,7 @@ describe('WorktreeVisibilityDialog', () => {
await renderDialog()
expect(document.body.textContent).toContain('Hidden worktrees (1)')
expect(document.body.textContent).toContain(
'Choose which hidden worktrees to show individually.'
)
expect(document.body.textContent).toContain('Show one without enabling its source.')
expect(document.body.textContent).toContain('scratch-1')
expect(document.body.textContent).toContain('.claude/worktrees/scratch-1')
expect(document.body.textContent).not.toContain('/repo/.claude')
@@ -212,6 +233,20 @@ describe('WorktreeVisibilityDialog', () => {
expect(document.body.textContent).toContain('Hidden worktrees (500)')
expect(document.querySelectorAll('ul > li').length).toBeLessThan(20)
const search = document.querySelector<HTMLInputElement>(
'input[aria-label="Search hidden worktrees"]'
)
expect(search).not.toBeNull()
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
search,
'scratch-499'
)
search!.dispatchEvent(new Event('input', { bubbles: true }))
search!.dispatchEvent(new Event('change', { bubbles: true }))
})
expect(document.body.textContent).toContain('scratch-499')
expect(document.body.textContent).not.toContain('scratch-0')
})
it('recovers a hidden worktree per path through the existing import exception', async () => {
@@ -234,7 +269,8 @@ describe('WorktreeVisibilityDialog', () => {
mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected([]) }
await renderDialog()
expect(document.body.textContent).not.toContain('Hidden worktrees')
expect(document.body.textContent).toContain('Hidden worktrees (0)')
expect(document.body.textContent).toContain('No non-Orca worktrees found')
})
it('says it is checking instead of claiming nothing is hidden on a fallback snapshot', async () => {
@@ -289,7 +325,7 @@ describe('WorktreeVisibilityDialog', () => {
mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {}))
await renderDialog()
expect(alwaysShowSwitch().disabled).toBe(true)
expect(sourceSwitch().disabled).toBe(true)
})
it('reports a failed refresh even while an older trusted snapshot is on screen', async () => {
@@ -323,7 +359,7 @@ describe('WorktreeVisibilityDialog', () => {
await click(buttonWithText('Show'))
expect(alwaysShowSwitch().disabled).toBe(true)
expect(sourceSwitch().disabled).toBe(true)
})
it('locks row actions and retry while the repo-wide toggle is in flight', async () => {
@@ -331,7 +367,7 @@ describe('WorktreeVisibilityDialog', () => {
mocks.state.updateRepo.mockImplementation(() => new Promise(() => {}))
await renderDialog()
await click(alwaysShowSwitch())
await click(sourceSwitch())
expect(buttonWithText('Show').disabled).toBe(true)
expect(buttonWithText('Try again').disabled).toBe(true)
@@ -392,12 +428,12 @@ describe('WorktreeVisibilityDialog', () => {
mocks.state.updateRepo.mockReturnValueOnce(update.promise)
await renderDialog()
mocks.state.fetchWorktrees.mockClear()
await click(alwaysShowSwitch())
await click(sourceSwitch())
await act(async () => root.render(null))
await renderDialog()
expect(alwaysShowSwitch().disabled).toBe(true)
expect(sourceSwitch().disabled).toBe(true)
expect(buttonWithText('Show').disabled).toBe(true)
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
@@ -406,7 +442,7 @@ describe('WorktreeVisibilityDialog', () => {
await update.promise
await Promise.resolve()
})
expect(alwaysShowSwitch().disabled).toBe(false)
expect(sourceSwitch().disabled).toBe(false)
})
it('does not carry a mutation fence across same-id repos on different hosts', async () => {
@@ -465,7 +501,20 @@ describe('WorktreeVisibilityDialog', () => {
await renderDialog()
mocks.state.fetchWorktrees.mockClear()
await click(alwaysShowSwitch())
await click(sourceSwitch())
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Could not update worktree visibility. Try again.'
)
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('does not report success when an older host strips additive source settings (STA-4092)', async () => {
mocks.state.updateRepo.mockResolvedValue(true)
await renderDialog()
mocks.state.fetchWorktrees.mockClear()
await click(sourceSwitch())
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Could not update worktree visibility. Try again.'
@@ -477,7 +526,7 @@ describe('WorktreeVisibilityDialog', () => {
await renderDialog()
mocks.state.fetchWorktrees.mockResolvedValue(false)
await click(alwaysShowSwitch())
await click(sourceSwitch())
expect(mocks.state.fetchWorktrees).toHaveBeenLastCalledWith('repo-1', {
requireAuthoritative: true
@@ -487,54 +536,107 @@ describe('WorktreeVisibilityDialog', () => {
)
})
it('enables the persistent policy for regular and agent worktrees', async () => {
it('toggles built-in sources independently', async () => {
await renderDialog()
expect(document.body.textContent).toContain('Always show')
expect(document.body.textContent).toContain('1 worktree currently hidden')
expect(alwaysShowSwitch().getAttribute('aria-checked')).toBe('false')
expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('false')
expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('false')
await click(alwaysShowSwitch())
await click(sourceSwitch('Claude Code'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
externalWorktreeVisibility: 'show',
agentWorktreeVisibility: 'show',
externalWorktreeDiscoverySuppressedAt: null
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' }
}
})
expect(mocks.state.closeModal).not.toHaveBeenCalled()
})
it('keeps the combined switch off until the agent policy is explicitly enabled', async () => {
it('migrates legacy Always show to both built-in source rows', async () => {
mocks.state.repos = [makeRepo({ agentWorktreeVisibility: 'show' })]
await renderDialog()
expect(sourceSwitch('Claude Code').getAttribute('aria-checked')).toBe('true')
expect(sourceSwitch('GSD').getAttribute('aria-checked')).toBe('true')
await click(sourceSwitch('Claude Code'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'hide', gsd: 'show' }
}
})
})
it('keeps ordinary non-Orca visibility on its own source row', async () => {
mocks.state.repos = [makeRepo({ externalWorktreeVisibility: 'show' })]
await renderDialog()
expect(alwaysShowSwitch().getAttribute('aria-checked')).toBe('false')
expect(document.body.textContent).toContain('scratch-1')
await click(alwaysShowSwitch())
expect(sourceSwitch('Other locations').getAttribute('aria-checked')).toBe('true')
await click(sourceSwitch('Other locations'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
externalWorktreeVisibility: 'show',
agentWorktreeVisibility: 'show',
externalWorktreeDiscoverySuppressedAt: null
externalWorktreeVisibility: 'hide'
})
})
it('turns the persistent policy off without closing the dialog', async () => {
it('adds custom locations disabled by default', async () => {
await renderDialog()
await click(buttonWithText('Add location'))
const input = document.querySelector<HTMLInputElement>('#custom-worktree-root')
expect(input).not.toBeNull()
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 click(buttonWithText('Add'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith(
'repo-1',
expect.objectContaining({
customWorktreeVisibilitySources: [
expect.objectContaining({ rootPath: '/srv/team-worktrees' })
],
worktreeVisibilitySourcePreferences: expect.objectContaining({
custom: expect.objectContaining({})
})
})
)
const update = mocks.state.updateRepo.mock.calls.at(-1)?.[1] as {
customWorktreeVisibilitySources: { id: string }[]
worktreeVisibilitySourcePreferences: { custom: Record<string, string> }
}
expect(
update.worktreeVisibilitySourcePreferences.custom[
update.customWorktreeVisibilitySources[0]!.id
]
).toBe('hide')
})
it('removes custom locations without changing other source preferences', async () => {
mocks.state.repos = [
makeRepo({ externalWorktreeVisibility: 'show', agentWorktreeVisibility: 'show' })
makeRepo({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
worktreeVisibilitySourcePreferences: { custom: { team: 'hide' } }
})
]
await renderDialog()
expect(document.body.textContent).toContain('0 worktrees currently shown')
expect(alwaysShowSwitch().getAttribute('aria-checked')).toBe('true')
expect(sourceSwitch('team-worktrees').getAttribute('aria-checked')).toBe('false')
const remove = document.querySelector<HTMLButtonElement>(
'button[aria-label="Remove team-worktrees"]'
)
expect(remove).not.toBeNull()
await click(remove!)
await click(alwaysShowSwitch())
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
externalWorktreeVisibility: 'hide',
agentWorktreeVisibility: 'hide'
})
expect(mocks.state.updateRepo).toHaveBeenCalledWith(
'repo-1',
expect.objectContaining({ customWorktreeVisibilitySources: [] })
)
expect(mocks.state.closeModal).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,4 @@
import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useVirtualizer } from '@tanstack/react-virtual'
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import {
Dialog,
@@ -10,19 +8,11 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
getHiddenImportableExternalWorktrees,
getVisibleNonOrcaWorktrees
} from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { relativePathInsideRoot } from '../../../../shared/cross-platform-path'
import {
effectiveAgentWorktreeVisibility,
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/worktree-ownership'
} from '../../../../shared/external-worktree-visibility'
import { translate } from '@/i18n/i18n'
import {
importNewExternalWorktreeInboxPaths,
@@ -41,6 +31,26 @@ import {
type ActiveVisibilityMutation,
useVisibilityMutationFence
} from './worktree-visibility-mutation-fence'
import WorktreeVisibilitySourceList, {
type WorktreeVisibilitySourceRow
} from './WorktreeVisibilitySourceList'
import type { CustomWorktreeVisibilitySource, Repo } from '../../../../shared/types'
import {
MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES,
buildWorktreeSourcePreferenceUpdate,
effectiveBuiltInWorktreeSourceVisibility,
effectiveCustomWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
removeCustomWorktreeSourcePreference
} from '../../../../shared/worktree-visibility-sources'
import HiddenWorktreeRecoveryList from './HiddenWorktreeRecoveryList'
function getLatestRepoForScope(scope: string): Repo | null {
return (
useAppStore.getState().repos.find((candidate) => getRepoHostIdentity(candidate) === scope) ??
null
)
}
export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
const activeModal = useAppStore((s) => s.activeModal)
@@ -55,9 +65,6 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
const [busyPath, setBusyPath] = useState<string | null>(null)
const [isToggling, setIsToggling] = useState(false)
const [listState, setListState] = useState<'checking' | 'ready' | 'failed'>('checking')
const alwaysShowSwitchId = useId()
const hiddenListHeadingId = useId()
const hiddenListRef = useRef<HTMLDivElement>(null)
const isOpen = activeModal === 'worktree-visibility'
const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : ''
@@ -76,25 +83,6 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
const effectiveBusyPath =
busyPath ?? (activeMutation?.kind === 'row' ? activeMutation.path : null)
const effectivelyToggling = isToggling || activeMutation?.kind === 'toggle'
const showOther = repo
? effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) ===
'show'
: false
const showAgentScratch = repo ? effectiveAgentWorktreeVisibility(repo) === 'show' : false
const alwaysShow = showOther && showAgentScratch
const hiddenImportable = getHiddenImportableExternalWorktrees(detected)
const hiddenCount = hiddenImportable.length
const otherCount = getVisibleNonOrcaWorktrees(detected).length
const hiddenWorktreeLabel = `${hiddenCount} ${hiddenCount === 1 ? 'worktree' : 'worktrees'}`
const shownWorktreeLabel = `${otherCount} ${otherCount === 1 ? 'worktree' : 'worktrees'}`
const hiddenListVirtualizer = useVirtualizer({
count: hiddenImportable.length,
getScrollElement: () => hiddenListRef.current,
estimateSize: () => 56,
getItemKey: (index) => hiddenImportable[index]?.id ?? index,
overscan: 3,
initialRect: { width: 480, height: 224 }
})
useLayoutEffect(() => {
currentMutationScopeRef.current = mutationScope
@@ -188,26 +176,22 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
[refreshTargetRepo, repo, updateTargetRepo]
)
const handleAlwaysShowChange = useCallback(
async (checked: boolean) => {
if (!repoId || checked === alwaysShow) {
return
const commitSourceUpdate = useCallback(
async (
updates: Parameters<typeof updateTargetRepo>[1],
isAccepted: (latestRepo: Repo) => boolean
): Promise<boolean> => {
if (!repoId) {
return false
}
const mutation: ActiveVisibilityMutation = { kind: 'toggle' }
startVisibilityMutation(mutationScope, mutation)
setActionState(null)
setIsToggling(true)
try {
const updated = await updateTargetRepo(repoId, {
externalWorktreeVisibility: checked ? 'show' : 'hide',
agentWorktreeVisibility: checked ? 'show' : 'hide',
// Why: showing hidden externals again should re-enable the inbox if the
// user previously opted out of discovery prompts for this repo.
// Why: null is the transport sentinel for clearing on remote runtime paths
// where `undefined` is stripped before persistence.
...(checked ? { externalWorktreeDiscoverySuppressedAt: null } : {})
})
if (!updated) {
const updated = await updateTargetRepo(repoId, updates)
const latestRepo = getLatestRepoForScope(mutationScope)
if (!updated || !latestRepo || !isAccepted(latestRepo)) {
if (currentMutationScopeRef.current === mutationScope) {
setActionState({
pending: false,
@@ -217,12 +201,13 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
)
})
}
return
return false
}
const refreshed = await refreshTargetRepo(repoId, { requireAuthoritative: true })
if (currentMutationScopeRef.current === mutationScope) {
setListState(refreshed ? 'ready' : 'failed')
}
return true
} finally {
finishVisibilityMutation(mutationScope, mutation)
if (currentMutationScopeRef.current === mutationScope) {
@@ -230,7 +215,105 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
}
}
},
[alwaysShow, mutationScope, refreshTargetRepo, repoId, updateTargetRepo]
[mutationScope, refreshTargetRepo, repoId, updateTargetRepo]
)
const handleSourceToggle = useCallback(
async (source: WorktreeVisibilitySourceRow, checked: boolean) => {
if (!repo) {
return
}
const visibility = checked ? 'show' : 'hide'
if (source.kind === 'other') {
await commitSourceUpdate(
{
externalWorktreeVisibility: visibility,
...(checked ? { externalWorktreeDiscoverySuppressedAt: null } : {})
},
(latestRepo) =>
effectiveExternalWorktreeVisibility(
latestRepo,
isLegacyRepoForExternalWorktreeVisibility(latestRepo)
) === visibility
)
return
}
const match =
source.kind === 'built-in'
? ({ kind: 'built-in', id: source.id } as const)
: ({ kind: 'custom', id: source.source.id } as const)
await commitSourceUpdate(
{
worktreeVisibilitySourcePreferences: buildWorktreeSourcePreferenceUpdate(
repo,
match,
visibility
)
},
(latestRepo) =>
source.kind === 'built-in'
? effectiveBuiltInWorktreeSourceVisibility(latestRepo, source.id) === visibility
: effectiveCustomWorktreeSourceVisibility(latestRepo, source.source.id) === visibility
)
},
[commitSourceUpdate, repo]
)
const handleAddSource = useCallback(
async (rootPath: string): Promise<boolean> => {
if (!repo) {
return false
}
const existing = normalizeCustomWorktreeVisibilitySources(
repo.customWorktreeVisibilitySources
)
if ((existing?.length ?? 0) >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) {
return false
}
const id = crypto.randomUUID().replaceAll('-', '')
const next = normalizeCustomWorktreeVisibilitySources([...(existing ?? []), { id, rootPath }])
if (!next || next.length !== (existing?.length ?? 0) + 1) {
return false
}
return commitSourceUpdate(
{
customWorktreeVisibilitySources: next,
worktreeVisibilitySourcePreferences: buildWorktreeSourcePreferenceUpdate(
repo,
{ kind: 'custom', id },
'hide'
)
},
(latestRepo) =>
normalizeCustomWorktreeVisibilitySources(
latestRepo.customWorktreeVisibilitySources
)?.some((source) => source.id === id) === true &&
effectiveCustomWorktreeSourceVisibility(latestRepo, id) === 'hide'
)
},
[commitSourceUpdate, repo]
)
const handleRemoveSource = useCallback(
async (source: CustomWorktreeVisibilitySource) => {
if (!repo) {
return
}
const next = (
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
).filter((candidate) => candidate.id !== source.id)
await commitSourceUpdate(
{
customWorktreeVisibilitySources: next,
worktreeVisibilitySourcePreferences: removeCustomWorktreeSourcePreference(repo, source.id)
},
(latestRepo) =>
!normalizeCustomWorktreeVisibilitySources(
latestRepo.customWorktreeVisibilitySources
)?.some((candidate) => candidate.id === source.id)
)
},
[commitSourceUpdate, repo]
)
if (!isOpen || !repo || !isGitRepoKind(repo)) {
@@ -239,7 +322,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
return (
<Dialog open onOpenChange={(open) => !open && closeModal()}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="scrollbar-sleek max-h-[calc(100vh-6rem)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-1.5">
<DialogTitle>
@@ -253,51 +336,14 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
<DialogDescription>{repo.displayName}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{alwaysShow ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{alwaysShow
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.3e045d4cb8',
'Shown in sidebar'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.5d02a5647f',
'Hidden from sidebar'
)}
</div>
<div className="text-xs text-muted-foreground">
{alwaysShow
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.8372e4bbd9',
'{{value0}} currently shown',
{ value0: shownWorktreeLabel }
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.25ddf19920',
'{{value0}} currently hidden',
{ value0: hiddenWorktreeLabel }
)}
</div>
</div>
<Label htmlFor={alwaysShowSwitchId} className="shrink-0 gap-2">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.f1f71b9f02',
'Always show'
)}
<Switch
id={alwaysShowSwitchId}
checked={alwaysShow}
disabled={
effectiveBusyPath !== null || effectivelyToggling || listState === 'checking'
}
onCheckedChange={(checked) => void handleAlwaysShowChange(checked)}
/>
</Label>
</div>
<WorktreeVisibilitySourceList
repo={repo}
worktrees={detected?.authoritative ? detected.worktrees : []}
disabled={effectiveBusyPath !== null || effectivelyToggling || listState === 'checking'}
onAdd={handleAddSource}
onRemove={handleRemoveSource}
onToggle={handleSourceToggle}
/>
{listState === 'checking' ? (
<p aria-live="polite" className="text-xs text-muted-foreground">
@@ -328,89 +374,14 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
</div>
) : null}
{hiddenImportable.length > 0 ? (
<div className="grid min-w-0 gap-2">
<div>
<h3 id={hiddenListHeadingId} className="text-sm font-medium">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.7d21c5e848',
'Hidden worktrees ({{value0}})',
{ value0: hiddenImportable.length }
)}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.9b53f7a160',
'Choose which hidden worktrees to show individually.'
)}
</p>
</div>
<div
ref={hiddenListRef}
aria-labelledby={hiddenListHeadingId}
className="scrollbar-sleek max-h-56 min-w-0 overflow-y-auto"
tabIndex={0}
style={{
height: `${Math.min(hiddenListVirtualizer.getTotalSize(), 224)}px`
}}
>
<ul
className="relative min-w-0"
style={{ height: `${hiddenListVirtualizer.getTotalSize()}px` }}
>
{hiddenListVirtualizer.getVirtualItems().map((virtualRow) => {
const worktree = hiddenImportable[virtualRow.index]
if (!worktree) {
return null
}
const displayPath =
relativePathInsideRoot(repo.path, worktree.path) || worktree.path
return (
<li
key={worktree.id}
ref={hiddenListVirtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 w-full pb-1"
style={{ transform: `translateY(${virtualRow.start}px)` }}
aria-posinset={virtualRow.index + 1}
aria-setsize={hiddenImportable.length}
>
<div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{worktree.displayName}</div>
<div className="truncate text-xs text-muted-foreground">
{displayPath}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={
effectiveBusyPath !== null ||
effectivelyToggling ||
listState === 'checking'
}
onClick={() => void handleShowWorktree(worktree.path)}
>
{effectiveBusyPath === worktree.path
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.2f80cd4b97',
'Showing…'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.e64b81d3a9',
'Show'
)}
</Button>
</div>
</li>
)
})}
</ul>
</div>
</div>
) : null}
<HiddenWorktreeRecoveryList
repo={repo}
detected={detected}
listState={listState}
busyPath={effectiveBusyPath}
toggling={effectivelyToggling}
onShow={handleShowWorktree}
/>
{actionState?.error ? (
<p className="text-xs text-destructive" role="alert">
@@ -67,9 +67,8 @@ describe('WorktreeVisibilityHelpPopover', () => {
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(content()?.getAttribute('aria-label')).toBe('Which worktrees are hidden by default?')
expect(content()?.textContent).toContain('current and future non-Orca worktrees')
expect(content()?.textContent).toContain('.claude/worktrees/*')
expect(content()?.textContent).toContain('.gsd-workspaces/*')
expect(content()?.textContent).toContain('current and future worktrees')
expect(content()?.textContent).toContain('show individual worktrees below')
await dispatch(
hoverTarget!,
@@ -26,7 +26,6 @@ export default function WorktreeVisibilityHelpPopover(): React.JSX.Element {
className="size-5 text-muted-foreground hover:bg-transparent hover:text-foreground dark:hover:bg-transparent"
aria-label={title}
aria-expanded={open}
title={title}
onClick={() => setOpen(true)}
>
<CircleHelp className="size-3.5" />
@@ -51,13 +50,13 @@ export default function WorktreeVisibilityHelpPopover(): React.JSX.Element {
<li>
{translate(
'auto.components.sidebar.WorktreeVisibilityHelpPopover.54a7c2f183',
'For newly added repositories, worktrees created manually or by another tool are hidden by default to avoid unexpected sidebar clutter.'
'Other worktrees start hidden to avoid unexpected sidebar clutter.'
)}
</li>
<li>
{translate(
'auto.components.sidebar.WorktreeVisibilityHelpPopover.b9e0d43675',
'Always show includes current and future non-Orca worktrees, including agent worktrees under .claude/worktrees/* and .gsd-workspaces/*. When it is off, use Show below to add one individually.'
'Enable a source for all current and future worktrees, or show individual worktrees below.'
)}
</li>
</ul>
@@ -0,0 +1,302 @@
import React, { useMemo, useState } from 'react'
import { Folder, Grid2X2, Plus, SquareTerminal, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
import type {
BuiltInWorktreeVisibilitySourceId,
CustomWorktreeVisibilitySource,
DetectedWorktree,
Repo
} from '../../../../shared/types'
import {
createWorktreeVisibilitySourceMatcher,
effectiveBuiltInWorktreeSourceVisibility,
effectiveCustomWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatch
} from '../../../../shared/worktree-visibility-sources'
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/external-worktree-visibility'
import { getRuntimePathBasename } from '../../../../shared/cross-platform-path'
export type WorktreeVisibilitySourceRow =
| { kind: 'built-in'; id: BuiltInWorktreeVisibilitySourceId }
| { kind: 'custom'; source: CustomWorktreeVisibilitySource }
| { kind: 'other' }
type Props = {
repo: Repo
worktrees: readonly DetectedWorktree[]
disabled: boolean
onAdd: (rootPath: string) => Promise<boolean>
onRemove: (source: CustomWorktreeVisibilitySource) => Promise<void>
onToggle: (source: WorktreeVisibilitySourceRow, enabled: boolean) => Promise<void>
}
function getSourceLabel(source: WorktreeVisibilitySourceRow): string {
if (source.kind === 'built-in') {
return source.id === 'claude'
? translate('auto.components.sidebar.WorktreeVisibilitySourceList.claude', 'Claude Code')
: translate('auto.components.sidebar.WorktreeVisibilitySourceList.gsd', 'GSD')
}
if (source.kind === 'other') {
return translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.other',
'Other locations'
)
}
return (
getRuntimePathBasename(source.source.rootPath) ||
translate('auto.components.sidebar.WorktreeVisibilitySourceList.custom', 'Custom location')
)
}
function getSourcePath(source: WorktreeVisibilitySourceRow): string {
if (source.kind === 'built-in') {
return source.id === 'claude' ? '.claude/worktrees/*' : '.gsd-workspaces/*'
}
if (source.kind === 'other') {
return translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.otherPath',
'Outside listed sources'
)
}
return `${source.source.rootPath.replace(/[\\/]+$/, '')}/*`
}
function isSourceEnabled(repo: Repo, source: WorktreeVisibilitySourceRow): boolean {
if (source.kind === 'built-in') {
return effectiveBuiltInWorktreeSourceVisibility(repo, source.id) === 'show'
}
if (source.kind === 'custom') {
return effectiveCustomWorktreeSourceVisibility(repo, source.source.id) === 'show'
}
return (
effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) ===
'show'
)
}
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
}
export default function WorktreeVisibilitySourceList({
repo,
worktrees,
disabled,
onAdd,
onRemove,
onToggle
}: Props): React.JSX.Element {
const [addOpen, setAddOpen] = useState(false)
const [rootPath, setRootPath] = useState('')
const [inputError, setInputError] = useState<string | null>(null)
const customSources = normalizeCustomWorktreeVisibilitySources(
repo.customWorktreeVisibilitySources
)
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, repo.path, worktrees]
)
const handleAdd = async (event: React.FormEvent) => {
event.preventDefault()
setInputError(null)
const added = await onAdd(rootPath)
if (added) {
setRootPath('')
setAddOpen(false)
return
}
setInputError(
translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.invalidPath',
'Enter a unique absolute path for this host.'
)
)
}
return (
<section className="grid min-w-0 gap-2" aria-labelledby="worktree-sources-heading">
<div>
<h3 id="worktree-sources-heading" className="text-sm font-medium">
{translate('auto.components.sidebar.WorktreeVisibilitySourceList.sources', 'Sources')}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.sourcesDescription',
'Enabled sources include current and future worktrees in the sidebar.'
)}
</p>
</div>
<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 : ''}`
return (
<div
key={key}
className={`grid min-h-14 grid-cols-[28px_minmax(0,1fr)_auto] items-center gap-2.5 px-2.5 py-2 ${index > 0 ? 'border-t border-border' : ''}`}
>
<span className="flex size-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
{source.kind === 'built-in' ? (
source.id === 'claude' ? (
<SquareTerminal className="size-3.5" />
) : (
<Grid2X2 className="size-3.5" />
)
) : (
<Folder className="size-3.5" />
)}
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[13px] font-medium">{label}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.found',
'{{value0}} found',
{ value0: count }
)}
</span>
</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{getSourcePath(source)}
</span>
</span>
<span className="flex items-center gap-1">
{source.kind === 'custom' ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-destructive"
disabled={disabled}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.remove',
'Remove {{value0}}',
{ value0: label }
)}
onClick={() => void onRemove(source.source)}
>
<Trash2 />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.removeLocation',
'Remove custom location'
)}
</TooltipContent>
</Tooltip>
) : null}
<Switch
checked={isSourceEnabled(repo, source)}
disabled={disabled}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.toggle',
'Show current and future worktrees from {{value0}}',
{ value0: label }
)}
onCheckedChange={(checked) => void onToggle(source, checked)}
/>
</span>
</div>
)
})}
</div>
<Popover open={addOpen} onOpenChange={setAddOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="w-fit" disabled={disabled}>
<Plus />
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.addLocation',
'Add location'
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
className="w-[390px] max-w-[calc(100vw-6rem)] p-3"
>
<form className="grid gap-2" onSubmit={(event) => void handleAdd(event)}>
<Label htmlFor="custom-worktree-root">
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.worktreeRoot',
'Worktree root'
)}
</Label>
<div className="flex items-center gap-2">
<Input
id="custom-worktree-root"
className="font-mono text-xs"
value={rootPath}
autoComplete="off"
spellCheck={false}
aria-invalid={inputError ? true : undefined}
aria-describedby="custom-worktree-root-help"
onChange={(event) => {
setRootPath(event.target.value)
setInputError(null)
}}
/>
<Button type="submit" size="sm" disabled={disabled || !rootPath.trim()}>
{translate('auto.components.sidebar.WorktreeVisibilitySourceList.add', 'Add')}
</Button>
</div>
<p
id="custom-worktree-root-help"
className={`text-[11px] ${inputError ? 'text-destructive' : 'text-muted-foreground'}`}
role={inputError ? 'alert' : undefined}
>
{inputError ??
translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.rootHelp',
'Orca will recognize worktrees beneath this folder.'
)}
</p>
</form>
</PopoverContent>
</Popover>
</section>
)
}
+30 -4
View File
@@ -4903,8 +4903,8 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "Which worktrees are hidden by default?",
"8db4e19a26": "This setting never hides worktrees created through Orca.",
"54a7c2f183": "For newly added repositories, worktrees created manually or by another tool are hidden by default to avoid unexpected sidebar clutter.",
"b9e0d43675": "Always show includes current and future non-Orca worktrees, including agent worktrees under .claude/worktrees/* and .gsd-workspaces/*. When it is off, use Show below to add one individually."
"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."
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "Non-Orca worktrees",
@@ -4918,10 +4918,18 @@
"b8d24e61f5": "Could not list this repo's worktrees.",
"c5e70a93b1": "Try again",
"7d21c5e848": "Hidden worktrees ({{value0}})",
"9b53f7a160": "Choose which hidden worktrees to show individually.",
"9b53f7a160": "Show one without enabling its source.",
"2f80cd4b97": "Showing…",
"e64b81d3a9": "Show",
"d40d436fc2": "Could not update worktree visibility. Try again."
"d40d436fc2": "Could not update worktree visibility. Try again.",
"search": "Search hidden worktrees",
"searchPlaceholder": "Search {{value0}} worktrees…",
"noMatches": "No matching worktrees",
"allShown": "All discovered worktrees are shown",
"noneFound": "No non-Orca worktrees found",
"tryDifferentSearch": "Try a different name or path.",
"disableSource": "Disable a source to manage its worktrees individually.",
"appearWhenDetected": "New worktrees will appear here when Orca detects them."
},
"add": {
"repo": {
@@ -5419,6 +5427,24 @@
},
"WorktreeListScrollToTopButton": {
"jumpToTop": "Jump to top"
},
"WorktreeVisibilitySourceList": {
"claude": "Claude Code",
"gsd": "GSD",
"other": "Other locations",
"custom": "Custom location",
"otherPath": "Outside listed sources",
"invalidPath": "Enter a unique absolute path for this host.",
"sources": "Sources",
"sourcesDescription": "Enabled sources include current and future worktrees in the sidebar.",
"found": "{{value0}} found",
"remove": "Remove {{value0}}",
"removeLocation": "Remove custom location",
"toggle": "Show current and future worktrees from {{value0}}",
"addLocation": "Add location",
"worktreeRoot": "Worktree root",
"add": "Add",
"rootHelp": "Orca will recognize worktrees beneath this folder."
}
},
"shared": {
+30 -4
View File
@@ -4700,8 +4700,8 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "¿Qué worktrees se ocultan de forma predeterminada?",
"8db4e19a26": "Este ajuste nunca oculta los worktrees creados mediante Orca.",
"54a7c2f183": "En los repositorios recién añadidos, los worktrees creados manualmente o con otra herramienta se ocultan de forma predeterminada para evitar elementos inesperados en la barra lateral.",
"b9e0d43675": "«Mostrar siempre» incluye los worktrees ajenos a Orca actuales y futuros, incluidos los worktrees de agentes en .claude/worktrees/* y .gsd-workspaces/*. Cuando está desactivado, usa «Mostrar» abajo para añadir uno individualmente."
"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."
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "Worktrees que no son de Orca",
@@ -4715,10 +4715,36 @@
"b8d24e61f5": "No se pudieron listar los worktrees de este repositorio.",
"c5e70a93b1": "Reintentar",
"7d21c5e848": "Worktrees ocultos ({{value0}})",
"9b53f7a160": "Elige qué worktrees ocultos mostrar de forma individual.",
"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."
"d40d436fc2": "No se pudo actualizar la visibilidad de los worktrees. Inténtalo de nuevo.",
"search": "Buscar worktrees ocultos",
"searchPlaceholder": "Buscar en {{value0}} worktrees…",
"noMatches": "No hay worktrees coincidentes",
"allShown": "Todos los worktrees detectados se muestran",
"noneFound": "No se encontraron worktrees ajenos a Orca",
"tryDifferentSearch": "Prueba con otro nombre o ruta.",
"disableSource": "Desactiva una fuente para gestionar sus worktrees individualmente.",
"appearWhenDetected": "Los worktrees nuevos aparecerán aquí cuando Orca los detecte."
},
"WorktreeVisibilitySourceList": {
"claude": "Claude Code",
"gsd": "GSD",
"other": "Otras ubicaciones",
"custom": "Ubicación personalizada",
"otherPath": "Fuera de las fuentes listadas",
"invalidPath": "Introduce una ruta absoluta única para este host.",
"sources": "Fuentes",
"sourcesDescription": "Las fuentes activadas incluyen los worktrees actuales y futuros en la barra lateral.",
"found": "{{value0}} encontrados",
"remove": "Eliminar {{value0}}",
"removeLocation": "Eliminar ubicación personalizada",
"toggle": "Mostrar los worktrees actuales y futuros de {{value0}}",
"addLocation": "Añadir ubicación",
"worktreeRoot": "Raíz de worktrees",
"add": "Añadir",
"rootHelp": "Orca reconocerá los worktrees dentro de esta carpeta."
},
"add": {
"repo": {
+30 -4
View File
@@ -4681,8 +4681,8 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "デフォルトで非表示になるワークツリー",
"8db4e19a26": "この設定では、Orca で作成されたワークツリーが非表示になることはありません。",
"54a7c2f183": "新しく追加したリポジトリでは、手動または別のツールで作成したワークツリーは、意図しない項目でサイドバーが埋まらないよう、デフォルトでは非表示です。",
"b9e0d43675": "「常に表示」では、.claude/worktrees/* および .gsd-workspaces/* にある Agent ワークツリーを含む、Orca 以外で作成された現在および今後のワークツリーが表示されます。オフの場合は、下の「表示」を使用して個別に追加できます。"
"54a7c2f183": "その他のワークツリーは、サイドバーが意図せず煩雑にならないようデフォルトで非表示です。",
"b9e0d43675": "ソースを有効にすると現在および今後のワークツリーをすべて表示できます。下から個別に表示することもできます。"
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "Orca 以外のワークツリー",
@@ -4696,10 +4696,36 @@
"b8d24e61f5": "このリポジトリのワークツリーを一覧できませんでした。",
"c5e70a93b1": "再試行",
"7d21c5e848": "非表示のワークツリー({{value0}})",
"9b53f7a160": "個別にサイドバーへ表示する非表示のワークツリーを選択します。",
"9b53f7a160": "ソースを有効にせずに個別に表示します。",
"2f80cd4b97": "表示中…",
"e64b81d3a9": "表示",
"d40d436fc2": "ワークツリーの表示設定を更新できませんでした。もう一度お試しください。"
"d40d436fc2": "ワークツリーの表示設定を更新できませんでした。もう一度お試しください。",
"search": "非表示のワークツリーを検索",
"searchPlaceholder": "{{value0}} 件のワークツリーを検索…",
"noMatches": "一致するワークツリーはありません",
"allShown": "検出されたワークツリーはすべて表示されています",
"noneFound": "Orca 以外のワークツリーは見つかりませんでした",
"tryDifferentSearch": "別の名前またはパスをお試しください。",
"disableSource": "ソースを無効にするとワークツリーを個別に管理できます。",
"appearWhenDetected": "Orca が新しいワークツリーを検出すると、ここに表示されます。"
},
"WorktreeVisibilitySourceList": {
"claude": "Claude Code",
"gsd": "GSD",
"other": "その他の場所",
"custom": "カスタムの場所",
"otherPath": "一覧にないソース",
"invalidPath": "このホストの一意な絶対パスを入力してください。",
"sources": "ソース",
"sourcesDescription": "有効なソースの現在および今後のワークツリーがサイドバーに表示されます。",
"found": "{{value0}} 件検出",
"remove": "{{value0}} を削除",
"removeLocation": "カスタムの場所を削除",
"toggle": "{{value0}} の現在および今後のワークツリーを表示",
"addLocation": "場所を追加",
"worktreeRoot": "ワークツリールート",
"add": "追加",
"rootHelp": "Orca はこのフォルダー配下のワークツリーを認識します。"
},
"add": {
"repo": {
+30 -4
View File
@@ -4681,8 +4681,8 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "기본적으로 숨겨지는 워크트리",
"8db4e19a26": "이 설정은 Orca에서 만든 워크트리를 숨기지 않습니다.",
"54a7c2f183": "새로 추가한 저장소에서는 직접 만들거나 다른 도구에서 만든 워크트리가 예기치 않은 항목으로 사이드바를 복잡하게 하지 않도록 기본적으로 숨겨집니다.",
"b9e0d43675": "'항상 표시'는 .claude/worktrees/* 및 .gsd-workspaces/* 아래의 에이전트 워크트리를 포함하여 현재와 앞으로 생성되는 Orca 외부 워크트리를 표시합니다. 꺼져 있을 때는 아래의 '표시'를 사용해 하나씩 추가할 수 있습니다."
"54a7c2f183": "다른 워크트리는 사이드바가 예기치 않게 복잡해지지 않도록 기본적으로 숨겨집니다.",
"b9e0d43675": "소스를 활성화해 현재와 앞으로 생성되는 모든 워크트리를 표시하거나 아래에서 개별 워크트리를 표시하세요."
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "Orca 외부 워크트리",
@@ -4696,10 +4696,36 @@
"b8d24e61f5": "이 저장소의 워크트리를 나열할 수 없습니다.",
"c5e70a93b1": "다시 시도",
"7d21c5e848": "숨겨진 워크트리({{value0}})",
"9b53f7a160": "개별적으로 사이드바에 표시할 숨겨진 워크트리를 선택하세요.",
"9b53f7a160": "소스를 활성화하지 않고 하나만 표시합니다.",
"2f80cd4b97": "표시 중…",
"e64b81d3a9": "표시",
"d40d436fc2": "워크트리 표시 설정을 업데이트하지 못했습니다. 다시 시도하세요."
"d40d436fc2": "워크트리 표시 설정을 업데이트하지 못했습니다. 다시 시도하세요.",
"search": "숨겨진 워크트리 검색",
"searchPlaceholder": "워크트리 {{value0}}개 검색…",
"noMatches": "일치하는 워크트리가 없습니다",
"allShown": "발견된 모든 워크트리가 표시 중입니다",
"noneFound": "Orca 외부 워크트리를 찾지 못했습니다",
"tryDifferentSearch": "다른 이름이나 경로를 시도하세요.",
"disableSource": "소스를 비활성화하면 워크트리를 개별적으로 관리할 수 있습니다.",
"appearWhenDetected": "Orca가 새 워크트리를 발견하면 여기에 표시됩니다."
},
"WorktreeVisibilitySourceList": {
"claude": "Claude Code",
"gsd": "GSD",
"other": "기타 위치",
"custom": "사용자 지정 위치",
"otherPath": "목록에 없는 소스",
"invalidPath": "이 호스트의 고유한 절대 경로를 입력하세요.",
"sources": "소스",
"sourcesDescription": "활성화된 소스의 현재와 앞으로 생성되는 워크트리가 사이드바에 표시됩니다.",
"found": "{{value0}}개 발견",
"remove": "{{value0}} 제거",
"removeLocation": "사용자 지정 위치 제거",
"toggle": "{{value0}}의 현재와 앞으로 생성되는 워크트리 표시",
"addLocation": "위치 추가",
"worktreeRoot": "워크트리 루트",
"add": "추가",
"rootHelp": "Orca는 이 폴더 아래의 워크트리를 인식합니다."
},
"add": {
"repo": {
+30 -4
View File
@@ -4693,8 +4693,8 @@
"WorktreeVisibilityHelpPopover": {
"c41f2d7e90": "哪些工作树默认隐藏?",
"8db4e19a26": "此设置不会隐藏通过 Orca 创建的工作树。",
"54a7c2f183": "对于新添加的仓库,手动或通过其他工具创建的工作树默认隐藏,以免侧边栏出现意外内容。",
"b9e0d43675": "“始终显示”会显示当前及以后创建的非 Orca 工作树,包括 .claude/worktrees/* 和 .gsd-workspaces/* 下的智能体工作树。关闭时,可使用下方的“显示”逐个添加。"
"54a7c2f183": "其他工作树默认隐藏,以免侧边栏意外变得杂乱。",
"b9e0d43675": "启用来源可显示其当前及未来的所有工作树,也可以在下方单独显示工作树。"
},
"WorktreeVisibilityDialog": {
"83a5ba8dd1": "非 Orca 工作树",
@@ -4708,10 +4708,36 @@
"b8d24e61f5": "无法列出此仓库的工作树。",
"c5e70a93b1": "重试",
"7d21c5e848": "已隐藏的工作树({{value0}})",
"9b53f7a160": "选择要单独显示的隐藏工作树。",
"9b53f7a160": "无需启用来源即可单独显示一个工作树。",
"2f80cd4b97": "显示中…",
"e64b81d3a9": "显示",
"d40d436fc2": "无法更新工作树可见性。请重试。"
"d40d436fc2": "无法更新工作树可见性。请重试。",
"search": "搜索隐藏的工作树",
"searchPlaceholder": "搜索 {{value0}} 个工作树…",
"noMatches": "没有匹配的工作树",
"allShown": "已显示所有检测到的工作树",
"noneFound": "未找到非 Orca 工作树",
"tryDifferentSearch": "请尝试其他名称或路径。",
"disableSource": "禁用来源后可单独管理其工作树。",
"appearWhenDetected": "Orca 检测到新工作树后,它们会显示在这里。"
},
"WorktreeVisibilitySourceList": {
"claude": "Claude Code",
"gsd": "GSD",
"other": "其他位置",
"custom": "自定义位置",
"otherPath": "所列来源之外",
"invalidPath": "请输入此主机上唯一的绝对路径。",
"sources": "来源",
"sourcesDescription": "已启用来源的当前及未来工作树会显示在侧边栏中。",
"found": "找到 {{value0}} 个",
"remove": "移除 {{value0}}",
"removeLocation": "移除自定义位置",
"toggle": "显示 {{value0}} 的当前及未来工作树",
"addLocation": "添加位置",
"worktreeRoot": "工作树根目录",
"add": "添加",
"rootHelp": "Orca 会识别此文件夹下的工作树。"
},
"add": {
"repo": {
+26
View File
@@ -87,6 +87,10 @@ import {
type ExecutionHostId
} from '../../../../shared/execution-host'
import { isRemovedRuntimeHostId } from './stale-runtime-host-rows'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../../../../shared/worktree-visibility-sources'
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
@@ -133,6 +137,8 @@ export type RepoUpdate = Partial<
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
| 'projectGroupId'
| 'projectGroupOrder'
>
@@ -272,6 +278,26 @@ function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate {
) {
delete sanitized.forkSyncMode
}
if ('customWorktreeVisibilitySources' in sanitized) {
const sources = normalizeCustomWorktreeVisibilitySources(
sanitized.customWorktreeVisibilitySources
)
if (!sources) {
delete sanitized.customWorktreeVisibilitySources
} else {
sanitized.customWorktreeVisibilitySources = sources
}
}
if ('worktreeVisibilitySourcePreferences' in sanitized) {
const preferences = normalizeWorktreeVisibilitySourcePreferences(
sanitized.worktreeVisibilitySourcePreferences
)
if (!preferences) {
delete sanitized.worktreeVisibilitySourcePreferences
} else {
sanitized.worktreeVisibilitySourcePreferences = preferences
}
}
return sanitized
}
+12 -20
View File
@@ -1,4 +1,8 @@
import { normalizeRuntimePathForComparison } from './cross-platform-path'
import {
createWorktreeVisibilitySourceMatcher,
type WorktreeVisibilitySourceMatcher
} from './worktree-visibility-sources'
/** Why: agent CLIs reserve these repo-root paths for scratch; broader matches
* can hide legitimate user worktrees (#9388). */
@@ -9,29 +13,17 @@ const AGENT_SCRATCH_PATH_PREFIXES: readonly (readonly string[])[] = [
export type AgentScratchWorktreePathMatcher = (worktreePath: string) => boolean
export function createAgentScratchWorktreeSourceMatcher(
checkoutPaths: readonly string[]
): WorktreeVisibilitySourceMatcher {
return createWorktreeVisibilitySourceMatcher(checkoutPaths)
}
export function createAgentScratchWorktreePathMatcher(
checkoutPaths: readonly string[]
): AgentScratchWorktreePathMatcher {
const checkoutPathKeys = new Set(checkoutPaths.map(normalizeRuntimePathForComparison))
return (worktreePath) => {
const segments = normalizeRuntimePathForComparison(worktreePath).split('/')
for (const prefix of AGENT_SCRATCH_PATH_PREFIXES) {
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('/')
// Why: splitting strips the separator from filesystem roots, but normalized checkout keys retain it.
const checkoutPathKey = /^[a-z]:$/i.test(checkoutPath)
? `${checkoutPath}/`
: checkoutPath || '/'
if (checkoutPathKeys.has(checkoutPathKey)) {
return true
}
}
}
return false
}
const classify = createAgentScratchWorktreeSourceMatcher(checkoutPaths)
return (worktreePath) => classify(worktreePath)?.kind === 'built-in'
}
export function isAgentScratchWorktreePath(repoPath: string, worktreePath: string): boolean {
+20
View File
@@ -111,6 +111,18 @@ export type IssueSourcePreference = 'upstream' | 'origin' | 'auto'
export type { ForkSyncMode, GitForkSyncExpectedUpstream, GitForkSyncResult } from './git-fork-sync'
export type ExternalWorktreeVisibility = 'hide' | 'show'
export type BuiltInWorktreeVisibilitySourceId = 'claude' | 'gsd'
export type CustomWorktreeVisibilitySource = {
id: string
rootPath: string
}
export type WorktreeVisibilitySourcePreferences = {
builtIn?: Partial<Record<BuiltInWorktreeVisibilitySourceId, ExternalWorktreeVisibility>>
custom?: Record<string, ExternalWorktreeVisibility>
}
export type ProjectProviderIdentity = {
provider: 'github'
owner: string
@@ -291,6 +303,10 @@ export type Repo = {
importedExternalWorktreePaths?: string[]
/** Opt-in repo policy for coding-agent scratch worktrees; absent means hide. */
agentWorktreeVisibility?: ExternalWorktreeVisibility
/** User-defined roots classified independently from ordinary external worktrees. */
customWorktreeVisibilitySources?: CustomWorktreeVisibilitySource[]
/** Per-source visibility; absent built-ins inherit the legacy agent policy. */
worktreeVisibilitySourcePreferences?: WorktreeVisibilitySourcePreferences
/** User permanently opted out of the new-external-worktree inbox for this repo. */
externalWorktreeDiscoverySuppressedAt?: number
/** Paths (relative to the primary checkout) that should be APFS clone-copied
@@ -707,6 +723,10 @@ export type DetectedWorktree = Worktree & {
ownership: WorktreeOwnership
selectedCheckout: boolean
visible: boolean
/** Optional additive source identity; older hosts omit it. */
visibilitySource?:
| { kind: 'built-in'; id: BuiltInWorktreeVisibilitySourceId }
| { kind: 'custom'; id: string }
}
export type DetectedWorktreeListResult = {
+50
View File
@@ -513,6 +513,56 @@ describe('agent scratch worktrees', () => {
}
})
it('applies built-in source preferences independently after legacy migration', () => {
const repo = makeRepo({
agentWorktreeVisibility: 'show',
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'hide', gsd: 'show' }
}
})
const settings = makeSettings()
expect(
toDetectedWorktree({
repo,
settings,
worktree: makeWorktree({ path: scratchPath, isMainWorktree: false }),
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
}).visible
).toBe(false)
expect(
toDetectedWorktree({
repo,
settings,
worktree: makeWorktree({
path: '/repos/app/.gsd-workspaces/phase-1',
isMainWorktree: false
}),
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
}).visible
).toBe(true)
})
it('lets a custom source override ordinary external visibility', () => {
const customPath = '/srv/team-worktrees/feature'
const repo = makeRepo({
externalWorktreeVisibility: 'show',
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
worktreeVisibilitySourcePreferences: { custom: { team: 'hide' } }
})
const settings = makeSettings()
const detected = toDetectedWorktree({
repo,
settings,
worktree: makeWorktree({ path: customPath, isMainWorktree: false }),
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
})
expect(detected.visibilitySource).toEqual({ kind: 'custom', id: 'team' })
expect(detected.visible).toBe(false)
expect(applyMetadataFallbackVisibility(detected).visible).toBe(false)
})
it('still shows agent scratch for the selected checkout or an explicit import', () => {
const repo = makeRepo({
externalWorktreeVisibility: 'hide',
+31 -6
View File
@@ -11,6 +11,12 @@ import {
isAgentScratchWorktreePath,
type AgentScratchWorktreePathMatcher
} from './agent-scratch-worktrees'
import {
createWorktreeVisibilitySourceMatcher,
effectiveWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from './worktree-visibility-sources'
import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox'
import {
effectiveAgentWorktreeVisibility,
@@ -145,6 +151,7 @@ export function classifyWorktreeOwnership(args: {
settings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
knownOrcaLayouts: OrcaWorkspaceLayout[]
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher
}): WorktreeOwnership {
if (hasStrongOrcaMetadata(args.meta)) {
return 'orca-managed'
@@ -153,8 +160,9 @@ export function classifyWorktreeOwnership(args: {
// Why: sub-agent scratch worktrees (e.g. .claude/worktrees) are tool
// plumbing, not workspaces; classify before layout heuristics (#9388).
if (
args.agentScratchWorktreePathMatcher?.(args.worktree.path) ??
isAgentScratchWorktreePath(args.repo.path, args.worktree.path)
args.worktreeVisibilitySourceMatcher?.(args.worktree.path)?.kind === 'built-in' ||
(args.agentScratchWorktreePathMatcher?.(args.worktree.path) ??
isAgentScratchWorktreePath(args.repo.path, args.worktree.path))
) {
return 'agent-scratch'
}
@@ -180,8 +188,19 @@ export function toDetectedWorktree(args: {
knownOrcaLayouts: OrcaWorkspaceLayout[]
isLegacyRepoForVisibility?: boolean
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher
}): DetectedWorktree {
const ownership = classifyWorktreeOwnership(args)
const sourceMatcher =
args.worktreeVisibilitySourceMatcher ??
createWorktreeVisibilitySourceMatcher(
[args.repo.path],
normalizeCustomWorktreeVisibilitySources(args.repo.customWorktreeVisibilitySources) ?? []
)
const visibilitySource = sourceMatcher(args.worktree.path)
const ownership = classifyWorktreeOwnership({
...args,
worktreeVisibilitySourceMatcher: sourceMatcher
})
const selectedCheckout = areRuntimePathsEqual(args.worktree.path, args.repo.path)
const isLegacyRepoForVisibility =
args.isLegacyRepoForVisibility ?? isLegacyRepoForExternalWorktreeVisibility(args.repo)
@@ -191,14 +210,16 @@ export function toDetectedWorktree(args: {
repo: args.repo,
isLegacyRepoForVisibility,
isSelectedCheckout: selectedCheckout,
importedExternalWorktreePaths: args.repo.importedExternalWorktreePaths
importedExternalWorktreePaths: args.repo.importedExternalWorktreePaths,
visibilitySource
})
return {
...args.worktree,
ownership,
selectedCheckout,
visible
visible,
...(visibilitySource ? { visibilitySource } : {})
}
}
@@ -209,6 +230,7 @@ export function shouldShowWorktree(args: {
isLegacyRepoForVisibility: boolean
isSelectedCheckout: boolean
importedExternalWorktreePaths?: readonly string[] | undefined
visibilitySource?: ReturnType<WorktreeVisibilitySourceMatcher>
}): boolean {
if (args.isSelectedCheckout) {
return true
@@ -223,6 +245,9 @@ export function shouldShowWorktree(args: {
) {
return true
}
if (args.visibilitySource) {
return effectiveWorktreeSourceVisibility(args.repo, args.visibilitySource) === 'show'
}
if (args.ownership === 'agent-scratch') {
return effectiveAgentWorktreeVisibility(args.repo) === 'show'
}
@@ -233,7 +258,7 @@ export function shouldShowWorktree(args: {
}
export function applyMetadataFallbackVisibility(detected: DetectedWorktree): DetectedWorktree {
if (detected.ownership === 'agent-scratch') {
if (detected.ownership === 'agent-scratch' || detected.visibilitySource) {
// Why: retain scratch policy, including explicit imports, while ordinary fallback fails open.
return detected
}
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import type { Repo } from './types'
import {
buildWorktreeSourcePreferenceUpdate,
createWorktreeVisibilitySourceMatcher,
effectiveBuiltInWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from './worktree-visibility-sources'
function repo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'repo',
addedAt: 1,
...overrides,
badgeColor: overrides.badgeColor ?? '#fff'
}
}
describe('worktree visibility sources', () => {
it('classifies each built-in independently across linked checkouts', () => {
const classify = createWorktreeVisibilitySourceMatcher(['/repo', '/worktrees/feature'])
expect(classify('/repo/.claude/worktrees/review')).toEqual({
kind: 'built-in',
id: 'claude'
})
expect(classify('/worktrees/feature/.gsd-workspaces/phase-1')).toEqual({
kind: 'built-in',
id: 'gsd'
})
expect(classify('/other/.claude/worktrees/review')).toBeNull()
})
it('matches custom descendants with Windows and WSL comparison semantics', () => {
const windows = createWorktreeVisibilitySourceMatcher(
[],
[{ id: 'team', rootPath: 'C:\\Users\\Dev\\Team' }]
)
expect(windows('c:\\users\\dev\\team\\feature')).toEqual({
kind: 'custom',
id: 'team'
})
const wsl = createWorktreeVisibilitySourceMatcher(
[],
[{ id: 'linux', rootPath: '//wsl$/Ubuntu/home/dev/team' }]
)
expect(wsl('//wsl.localhost/Ubuntu/home/dev/team/feature')).toEqual({
kind: 'custom',
id: 'linux'
})
expect(wsl('//wsl.localhost/Ubuntu/home/Dev/team/feature')).toBeNull()
})
it('gives built-ins precedence over overlapping custom roots', () => {
const classify = createWorktreeVisibilitySourceMatcher(
['/repo'],
[{ id: 'overlap', rootPath: '/repo/.claude/worktrees' }]
)
expect(classify('/repo/.claude/worktrees/review')).toEqual({
kind: 'built-in',
id: 'claude'
})
})
it('migrates the optional legacy agent policy lazily for both built-ins', () => {
const legacy = repo({ agentWorktreeVisibility: 'show' })
expect(effectiveBuiltInWorktreeSourceVisibility(legacy, 'claude')).toBe('show')
expect(effectiveBuiltInWorktreeSourceVisibility(legacy, 'gsd')).toBe('show')
expect(
buildWorktreeSourcePreferenceUpdate(legacy, { kind: 'built-in', id: 'claude' }, 'hide')
).toEqual({ builtIn: { claude: 'hide', gsd: 'show' } })
})
it('bounds and sanitizes persisted source definitions and preferences', () => {
expect(
normalizeCustomWorktreeVisibilitySources([
{ id: 'team', rootPath: ' /srv/team/ ' },
{ id: 'duplicate-root', rootPath: '/srv/team' },
{ id: 'relative', rootPath: '../team' },
{ id: 'bad id', rootPath: '/srv/other' }
])
).toEqual([{ id: 'team', rootPath: '/srv/team/' }])
expect(
normalizeWorktreeVisibilitySourcePreferences({
builtIn: { claude: 'show', gsd: 'invalid', unknown: 'show' },
custom: { team: 'hide', nope: 'invalid' }
})
).toEqual({ builtIn: { claude: 'show' }, custom: { team: 'hide' } })
})
})
+233
View File
@@ -0,0 +1,233 @@
import {
createNormalizedPathInsideOrEqualMatcher,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison
} from './cross-platform-path'
import type {
BuiltInWorktreeVisibilitySourceId,
CustomWorktreeVisibilitySource,
ExternalWorktreeVisibility,
Repo,
WorktreeVisibilitySourcePreferences
} from './types'
export const MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES = 32
const MAX_SOURCE_ID_LENGTH = 128
const MAX_SOURCE_PATH_LENGTH = 4096
export const BUILT_IN_WORKTREE_VISIBILITY_SOURCES: readonly {
id: BuiltInWorktreeVisibilitySourceId
relativeRootSegments: readonly string[]
}[] = [
{ id: 'claude', relativeRootSegments: ['.claude', 'worktrees'] },
{ id: 'gsd', relativeRootSegments: ['.gsd-workspaces'] }
]
export type WorktreeVisibilitySourceMatch =
| { kind: 'built-in'; id: BuiltInWorktreeVisibilitySourceId }
| { kind: 'custom'; id: string }
export type WorktreeVisibilitySourceMatcher = (
worktreePath: string
) => WorktreeVisibilitySourceMatch | null
function isValidSourceId(value: string): boolean {
return value.length > 0 && value.length <= MAX_SOURCE_ID_LENGTH && /^[A-Za-z0-9_-]+$/.test(value)
}
function normalizeSourceRootPath(value: string): string | null {
const trimmed = value.trim()
if (
!trimmed ||
trimmed.length > MAX_SOURCE_PATH_LENGTH ||
trimmed.includes('\0') ||
!isRuntimePathAbsolute(trimmed)
) {
return null
}
return trimmed
}
export function normalizeCustomWorktreeVisibilitySources(
value: unknown
): CustomWorktreeVisibilitySource[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
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)) {
if (!candidate || typeof candidate !== 'object') {
continue
}
const { id, rootPath } = candidate as { id?: unknown; rootPath?: unknown }
if (typeof id !== 'string' || !isValidSourceId(id) || typeof rootPath !== 'string') {
continue
}
const normalizedRootPath = normalizeSourceRootPath(rootPath)
const rootKey = normalizedRootPath
? normalizeRuntimePathForComparison(normalizedRootPath)
: null
if (!normalizedRootPath || !rootKey || ids.has(id) || roots.has(rootKey)) {
continue
}
ids.add(id)
roots.add(rootKey)
normalized.push({ id, rootPath: normalizedRootPath })
}
return normalized
}
function normalizeVisibility(value: unknown): ExternalWorktreeVisibility | undefined {
return value === 'hide' || value === 'show' ? value : undefined
}
export function normalizeWorktreeVisibilitySourcePreferences(
value: unknown
): WorktreeVisibilitySourcePreferences | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined
}
const raw = value as { builtIn?: unknown; custom?: unknown }
const builtInValue =
raw.builtIn && typeof raw.builtIn === 'object' && !Array.isArray(raw.builtIn)
? (raw.builtIn as Record<string, unknown>)
: {}
const builtIn = Object.fromEntries(
BUILT_IN_WORKTREE_VISIBILITY_SOURCES.flatMap(({ id }) => {
const visibility = normalizeVisibility(builtInValue[id])
return visibility ? [[id, visibility]] : []
})
) as WorktreeVisibilitySourcePreferences['builtIn']
const customValue =
raw.custom && typeof raw.custom === 'object' && !Array.isArray(raw.custom)
? (raw.custom as Record<string, unknown>)
: {}
const custom = Object.fromEntries(
Object.entries(customValue)
.filter(([id, visibility]) => isValidSourceId(id) && normalizeVisibility(visibility))
.slice(0, MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES)
.map(([id, visibility]) => [id, normalizeVisibility(visibility)!])
)
return {
...(Object.keys(builtIn ?? {}).length > 0 ? { builtIn } : {}),
...(Object.keys(custom).length > 0 ? { custom } : {})
}
}
function createDescendantMatcher(rootPath: string): (candidatePath: string) => boolean {
const matchesInsideOrEqual = createNormalizedPathInsideOrEqualMatcher(rootPath)
const normalizedRoot = normalizeRuntimePathForComparison(rootPath)
return (candidatePath) => {
const normalizedCandidate = normalizeRuntimePathForComparison(candidatePath)
return 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 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 }
}
}
for (const source of customMatchers) {
if (source.matches(worktreePath)) {
return { kind: 'custom', id: source.id }
}
}
return null
}
}
export function effectiveBuiltInWorktreeSourceVisibility(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
sourceId: BuiltInWorktreeVisibilitySourceId
): ExternalWorktreeVisibility {
const explicit = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
)?.builtIn?.[sourceId]
if (explicit) {
return explicit
}
return repo.agentWorktreeVisibility === 'show' ? 'show' : 'hide'
}
export function effectiveCustomWorktreeSourceVisibility(
repo: Pick<Repo, 'worktreeVisibilitySourcePreferences'>,
sourceId: string
): ExternalWorktreeVisibility {
return normalizeWorktreeVisibilitySourcePreferences(repo.worktreeVisibilitySourcePreferences)
?.custom?.[sourceId] === 'show'
? 'show'
: 'hide'
}
export function effectiveWorktreeSourceVisibility(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
source: WorktreeVisibilitySourceMatch
): ExternalWorktreeVisibility {
return source.kind === 'built-in'
? effectiveBuiltInWorktreeSourceVisibility(repo, source.id)
: effectiveCustomWorktreeSourceVisibility(repo, source.id)
}
export function buildWorktreeSourcePreferenceUpdate(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
source: WorktreeVisibilitySourceMatch,
visibility: ExternalWorktreeVisibility
): WorktreeVisibilitySourcePreferences {
const current = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
)
const builtIn = {
claude: effectiveBuiltInWorktreeSourceVisibility(repo, 'claude'),
gsd: effectiveBuiltInWorktreeSourceVisibility(repo, 'gsd'),
...current?.builtIn
}
const custom = { ...current?.custom }
if (source.kind === 'built-in') {
builtIn[source.id] = visibility
} else {
custom[source.id] = visibility
}
return {
builtIn,
...(Object.keys(custom).length > 0 ? { custom } : {})
}
}
export function removeCustomWorktreeSourcePreference(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
sourceId: string
): WorktreeVisibilitySourcePreferences {
const current = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
)
const custom = { ...current?.custom }
delete custom[sourceId]
return {
builtIn: {
claude: effectiveBuiltInWorktreeSourceVisibility(repo, 'claude'),
gsd: effectiveBuiltInWorktreeSourceVisibility(repo, 'gsd'),
...current?.builtIn
},
...(Object.keys(custom).length > 0 ? { custom } : {})
}
}