diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index bbd639eac32..43277f5ac75 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index b1500963f27..a9ec6dbf4c5 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 && diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ab299057169..781073da840 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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) }) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 06ee8a9d6a0..2c7bffe4126 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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( diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b62b25f7977..73f9455a4db 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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: { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 7cad4ad94bc..5f47a719489 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 }) } diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts index aaa0d886fb0..f178bfa8d11 100644 --- a/src/main/runtime/rpc/methods/repo-update-schema.ts +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -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( 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, diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 7c31660c1c4..933c84a6d74 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -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', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8c901236967..584c82f3ad9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1197,6 +1197,8 @@ export type PreloadApi = { | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' | 'agentWorktreeVisibility' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' | 'projectGroupOrder' | 'forkSyncMode' diff --git a/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx b/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx new file mode 100644 index 00000000000..886d2e66aac --- /dev/null +++ b/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx @@ -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 +} + +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(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 ( +
+
+
+

+ {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.7d21c5e848', + 'Hidden worktrees ({{value0}})', + { value0: hidden.length } + )} +

+

+ {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.9b53f7a160', + 'Show one without enabling its source.' + )} +

+
+ {hidden.length >= 10 ? ( +
+ + setQuery(event.target.value)} + /> +
+ ) : null} +
+ {filtered.length > 0 ? ( +
+
    + {virtualizer.getVirtualItems().map((virtualRow) => { + const worktree = filtered[virtualRow.index] + if (!worktree) { + return null + } + const displayPath = relativePathInsideRoot(repo.path, worktree.path) || worktree.path + return ( +
  • +
    +
    +
    {worktree.displayName}
    +
    + {displayPath} +
    +
    + +
    +
  • + ) + })} +
+
+ ) : listState === 'ready' ? ( +
+ + + +
+
+ {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' + )} +
+
+ {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.' + )} +
+
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx index 470d02dc3d1..18caa947455 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx @@ -38,6 +38,12 @@ vi.mock('@/components/ui/dialog', () => ({ DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
})) +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children} +})) + vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => 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) => { + 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)[key] + } else { + ;(repo as unknown as Record)[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( + '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('#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 } + } + 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( + '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() }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 9badad39b41..74269a68a55 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -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(null) const [isToggling, setIsToggling] = useState(false) const [listState, setListState] = useState<'checking' | 'ready' | 'failed'>('checking') - const alwaysShowSwitchId = useId() - const hiddenListHeadingId = useId() - const hiddenListRef = useRef(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[1], + isAccepted: (latestRepo: Repo) => boolean + ): Promise => { + 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 => { + 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 ( !open && closeModal()}> - +
@@ -253,51 +336,14 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { {repo.displayName} -
-
- {alwaysShow ? : } -
-
-
- {alwaysShow - ? translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.3e045d4cb8', - 'Shown in sidebar' - ) - : translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.5d02a5647f', - 'Hidden from sidebar' - )} -
-
- {alwaysShow - ? translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.8372e4bbd9', - '{{value0}} currently shown', - { value0: shownWorktreeLabel } - ) - : translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.25ddf19920', - '{{value0}} currently hidden', - { value0: hiddenWorktreeLabel } - )} -
-
- -
+ {listState === 'checking' ? (

@@ -328,89 +374,14 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {

) : null} - {hiddenImportable.length > 0 ? ( -
-
-

- {translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.7d21c5e848', - 'Hidden worktrees ({{value0}})', - { value0: hiddenImportable.length } - )} -

-

- {translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.9b53f7a160', - 'Choose which hidden worktrees to show individually.' - )} -

-
-
-
    - {hiddenListVirtualizer.getVirtualItems().map((virtualRow) => { - const worktree = hiddenImportable[virtualRow.index] - if (!worktree) { - return null - } - const displayPath = - relativePathInsideRoot(repo.path, worktree.path) || worktree.path - return ( -
  • -
    -
    -
    {worktree.displayName}
    -
    - {displayPath} -
    -
    - -
    -
  • - ) - })} -
-
-
- ) : null} + {actionState?.error ? (

diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx index 3822753ad2c..7bb344d3d30 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx @@ -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!, diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx index bcb96c79e84..77572d55155 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx @@ -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)} > @@ -51,13 +50,13 @@ export default function WorktreeVisibilityHelpPopover(): React.JSX.Element {

  • {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.' )}
  • {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.' )}
  • diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx new file mode 100644 index 00000000000..fee53f2f842 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx @@ -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 + onRemove: (source: CustomWorktreeVisibilitySource) => Promise + onToggle: (source: WorktreeVisibilitySourceRow, enabled: boolean) => Promise +} + +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(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 ( +
    +
    +

    + {translate('auto.components.sidebar.WorktreeVisibilitySourceList.sources', 'Sources')} +

    +

    + {translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.sourcesDescription', + 'Enabled sources include current and future worktrees in the sidebar.' + )} +

    +
    +
    + {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 ( +
    0 ? 'border-t border-border' : ''}`} + > + + {source.kind === 'built-in' ? ( + source.id === 'claude' ? ( + + ) : ( + + ) + ) : ( + + )} + + + + {label} + + {translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.found', + '{{value0}} found', + { value0: count } + )} + + + + {getSourcePath(source)} + + + + {source.kind === 'custom' ? ( + + + + + + {translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.removeLocation', + 'Remove custom location' + )} + + + ) : null} + void onToggle(source, checked)} + /> + +
    + ) + })} +
    + + + + + +
    void handleAdd(event)}> + +
    + { + setRootPath(event.target.value) + setInputError(null) + }} + /> + +
    +

    + {inputError ?? + translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.rootHelp', + 'Orca will recognize worktrees beneath this folder.' + )} +

    +
    +
    +
    +
    + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bcf81f68835..8333e0605f3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e4b9b41f6c1..0720ddd0da6 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index bc358d627fd..4e6e6cea2a0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 697effbfd2b..0fea5746c04 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7e604f6e7b1..c535dcfc407 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6353eaff25f..d7579fe5ca1 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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 } diff --git a/src/shared/agent-scratch-worktrees.ts b/src/shared/agent-scratch-worktrees.ts index 88be8646435..c9add6692b9 100644 --- a/src/shared/agent-scratch-worktrees.ts +++ b/src/shared/agent-scratch-worktrees.ts @@ -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 { diff --git a/src/shared/types.ts b/src/shared/types.ts index ab929644fbf..0a0ddd141e4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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> + custom?: Record +} + 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 = { diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index c0bdbfea483..5f3b079f774 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -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', diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index a5260b4adc5..22f73690bda 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -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 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 }): 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 } diff --git a/src/shared/worktree-visibility-sources.test.ts b/src/shared/worktree-visibility-sources.test.ts new file mode 100644 index 00000000000..a018c0874d0 --- /dev/null +++ b/src/shared/worktree-visibility-sources.test.ts @@ -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 { + 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' } }) + }) +}) diff --git a/src/shared/worktree-visibility-sources.ts b/src/shared/worktree-visibility-sources.ts new file mode 100644 index 00000000000..b7979a0dcd3 --- /dev/null +++ b/src/shared/worktree-visibility-sources.ts @@ -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() + const roots = new Set() + 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) + : {} + 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) + : {} + 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, + 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, + sourceId: string +): ExternalWorktreeVisibility { + return normalizeWorktreeVisibilitySourcePreferences(repo.worktreeVisibilitySourcePreferences) + ?.custom?.[sourceId] === 'show' + ? 'show' + : 'hide' +} + +export function effectiveWorktreeSourceVisibility( + repo: Pick, + source: WorktreeVisibilitySourceMatch +): ExternalWorktreeVisibility { + return source.kind === 'built-in' + ? effectiveBuiltInWorktreeSourceVisibility(repo, source.id) + : effectiveCustomWorktreeSourceVisibility(repo, source.id) +} + +export function buildWorktreeSourcePreferenceUpdate( + repo: Pick, + 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, + 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 } : {}) + } +}