From b908b55f6d835b29b835adf7b0b615f15d5edf47 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:20:11 -0700 Subject: [PATCH 1/3] feat(worktrees): add per-source visibility controls (#14189) * feat(worktrees): add per-source visibility controls * fix(worktrees): explain unsupported visibility hosts * fix(worktrees): keep add location form inline * fix(worktrees): align source visibility across runtimes * test(worktrees): cover Windows drive-relative roots --- src/main/ipc/repos-remote.test.ts | 33 ++ src/main/ipc/repos.ts | 26 ++ src/main/ipc/worktrees.ts | 37 +- src/main/persistence.test.ts | 42 ++- src/main/persistence.ts | 54 +++ src/main/runtime/orca-runtime.test.ts | 102 +++++ src/main/runtime/orca-runtime.ts | 95 +++-- .../runtime/rpc/methods/repo-update-schema.ts | 12 + src/main/runtime/rpc/methods/repo.test.ts | 32 ++ src/preload/api-types.ts | 2 + .../sidebar/HiddenWorktreeRecoveryList.tsx | 200 ++++++++++ .../sidebar/WorktreeVisibilityDialog.test.tsx | 330 ++++++++++++++--- .../sidebar/WorktreeVisibilityDialog.tsx | 347 +++++++++--------- .../WorktreeVisibilityHelpPopover.test.tsx | 5 +- .../sidebar/WorktreeVisibilityHelpPopover.tsx | 9 +- .../sidebar/WorktreeVisibilitySourceList.tsx | 320 ++++++++++++++++ .../worktree-visibility-update-error.ts | 19 + src/renderer/src/i18n/locales/en.json | 40 +- src/renderer/src/i18n/locales/es.json | 40 +- src/renderer/src/i18n/locales/ja.json | 40 +- src/renderer/src/i18n/locales/ko.json | 40 +- src/renderer/src/i18n/locales/zh.json | 40 +- .../i18n/worktree-visibility-locales.test.ts | 23 ++ src/renderer/src/store/slices/repos.ts | 26 ++ src/shared/agent-scratch-worktrees.ts | 32 +- src/shared/types.ts | 20 + src/shared/worktree-ownership.test.ts | 50 +++ src/shared/worktree-ownership.ts | 37 +- .../worktree-visibility-sources.test.ts | 130 +++++++ src/shared/worktree-visibility-sources.ts | 240 ++++++++++++ 30 files changed, 2095 insertions(+), 328 deletions(-) create mode 100644 src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-visibility-update-error.ts create mode 100644 src/renderer/src/i18n/worktree-visibility-locales.test.ts create mode 100644 src/shared/worktree-visibility-sources.test.ts create mode 100644 src/shared/worktree-visibility-sources.ts 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..37a144a9b6d 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?.() ?? {}) @@ -1001,14 +1004,20 @@ function listFolderWorkspaces(store: Store, repo: Repo): Worktree[] { function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktree[] { const settings = store.getSettings() - return listFolderWorkspaces(store, repo).map((worktree) => + const worktrees = listFolderWorkspaces(store, repo) + const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + ) + return worktrees.map((worktree) => toDetectedWorktree({ repo, worktree, meta: store.getWorktreeMeta(worktree.id), settings, knownOrcaLayouts: [], - isLegacyRepoForVisibility: true + isLegacyRepoForVisibility: true, + worktreeVisibilitySourceMatcher }) ) } @@ -1081,10 +1090,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 +1103,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 899aaa001f8..f378a76665d 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4739,7 +4739,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' })) @@ -4747,17 +4747,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 14d50d4d297..8b53a7d98d6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -139,6 +139,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' @@ -1478,6 +1482,8 @@ function sanitizeRepoUpdatesForPersistence< | 'worktreeBasePath' | 'projectHostSetupMethod' | 'forkSyncMode' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' > > >(updates: T): T { @@ -1538,6 +1544,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 } @@ -5001,6 +5027,8 @@ export class Store { | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' | 'agentWorktreeVisibility' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' | 'projectGroupOrder' | 'projectHostSetupMethod' @@ -5019,6 +5047,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 ( @@ -5184,6 +5226,8 @@ export class Store { sourceControlAi: rawSourceControlAi, projectHostSetupMethod: rawProjectHostSetupMethod, forkSyncMode: rawForkSyncMode, + customWorktreeVisibilitySources: rawCustomWorktreeVisibilitySources, + worktreeVisibilitySourcePreferences: rawWorktreeVisibilitySourcePreferences, ...repoWithoutIcon } = repo const repoIcon = sanitizeRepoIcon(rawRepoIcon) @@ -5192,6 +5236,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) ? '' @@ -5205,6 +5255,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.test.ts b/src/main/runtime/orca-runtime.test.ts index 9d141068caa..9597ecde0b5 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -35502,6 +35502,108 @@ describe('OrcaRuntimeService', () => { }) }) + it('applies linked-checkout source visibility to mobile summaries', async () => { + const linkedPath = '/tmp/linked' + const scratchPath = `${linkedPath}/.claude/worktrees/review` + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: TEST_REPO_PATH, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: linkedPath, + head: 'linked', + branch: 'feature/linked', + isBare: false, + isMainWorktree: false + }, + { + path: scratchPath, + head: 'scratch', + branch: 'feature/review', + isBare: false, + isMainWorktree: false + } + ]) + const makeRuntime = (externalWorktreeVisibility: 'hide' | 'show', claude: 'hide' | 'show') => { + const repo = { + ...store.getRepos()[0], + externalWorktreeVisibility, + externalWorktreeVisibilityLegacy: false, + worktreeVisibilitySourcePreferences: { builtIn: { claude, gsd: 'hide' as const } } + } + return new OrcaRuntimeService({ + ...store, + getRepos: () => [repo], + getRepo: () => repo + } as never) + } + + const hiddenSource = await makeRuntime('show', 'hide').getWorktreePs() + const shownSource = await makeRuntime('hide', 'show').getWorktreePs() + + expect(hiddenSource.worktrees.map((worktree) => worktree.path)).not.toContain(scratchPath) + expect(shownSource.worktrees.map((worktree) => worktree.path)).toContain(scratchPath) + }) + + it('resolves files through a source-visible linked-checkout worktree', async () => { + const linkedPath = '/tmp/linked' + const scratchPath = `${linkedPath}/.claude/worktrees/review` + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: TEST_REPO_PATH, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: linkedPath, + head: 'linked', + branch: 'feature/linked', + isBare: false, + isMainWorktree: false + }, + { + path: scratchPath, + head: 'scratch', + branch: 'feature/review', + isBare: false, + isMainWorktree: false + } + ]) + const repo = { + ...store.getRepos()[0], + externalWorktreeVisibility: 'hide' as const, + externalWorktreeVisibilityLegacy: false, + worktreeVisibilitySourcePreferences: { + builtIn: { claude: 'show' as const, gsd: 'hide' as const } + } + } + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [repo], + getRepo: () => repo + } as never) + + const target = await ( + runtime as unknown as { + resolveKnownWorkspaceFileTarget: ( + path: string, + executionHostId: 'local' + ) => Promise<{ worktree: { path: string }; relativePath: string } | null> + } + ).resolveKnownWorkspaceFileTarget(`${scratchPath}/src/app.ts`, 'local') + + expect(target).toMatchObject({ + worktree: { path: scratchPath }, + relativePath: 'src/app.ts' + }) + }) + it('marks saved session tabs with live PTYs as host sidebar activity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b789248f44d..917d8c1de40 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, @@ -9182,8 +9183,16 @@ export class OrcaRuntimeService { executionHostId: ExecutionHostId } >() - for (const worktree of await this.listResolvedWorktrees()) { - if (!this.isRuntimeWorktreeVisible(worktree)) { + const resolvedWorktrees = await this.listResolvedWorktrees() + const visibilitySourceMatchersByRepoId = + this.buildRuntimeVisibilitySourceMatchersByRepoId(resolvedWorktrees) + for (const worktree of resolvedWorktrees) { + if ( + !this.isRuntimeWorktreeVisible( + worktree, + visibilitySourceMatchersByRepoId.get(worktree.repoId) + ) + ) { continue } const candidateConnectionId = this.store?.getRepo(worktree.repoId)?.connectionId ?? undefined @@ -17689,8 +17698,11 @@ export class OrcaRuntimeService { throw new Error('invalid_limit') } const resolvedWorktreeSnapshot = await this.listResolvedWorktreeSnapshot() + const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId( + resolvedWorktreeSnapshot.worktrees + ) const resolvedWorktrees = resolvedWorktreeSnapshot.worktrees.filter((worktree) => - this.isRuntimeWorktreeVisible(worktree) + this.isRuntimeWorktreeVisible(worktree, visibilitySourceMatchersByRepoId.get(worktree.repoId)) ) // Why: worktree.ps backs the mobile sidebar, so it must use the same // host-owned imported-worktree visibility gate as worktree.list/desktop. @@ -19198,6 +19210,8 @@ export class OrcaRuntimeService { | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' | 'agentWorktreeVisibility' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' | 'projectGroupOrder' > @@ -20998,28 +21012,15 @@ export class OrcaRuntimeService { } const resolved = await this.listResolvedWorktrees() const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null - const checkoutPathsByRepoId = new Map() - for (const worktree of resolved) { - const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? [] - checkoutPaths.push(worktree.path) - checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths) - } - const agentScratchMatchersByRepoId = new Map( - (this.store?.getRepos() ?? []).map((repo) => [ - repo.id, - createAgentScratchWorktreePathMatcher([ - repo.path, - ...(checkoutPathsByRepoId.get(repo.id) ?? []) - ]) - ]) - ) + const visibilitySourceMatchersByRepoId = + this.buildRuntimeVisibilitySourceMatchersByRepoId(resolved) const worktrees = resolved.filter((worktree) => { if (repoId && worktree.repoId !== repoId) { return false } return this.isRuntimeWorktreeVisible( worktree, - agentScratchMatchersByRepoId.get(worktree.repoId) + visibilitySourceMatchersByRepoId.get(worktree.repoId) ) }) return { @@ -21044,7 +21045,13 @@ export class OrcaRuntimeService { const store = this.requireStore() if (isFolderRepo(repo)) { const worktrees = listRuntimeFolderWorkspaces(store, repo) - const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree)) + const matcher = createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + ) + const detected = worktrees.map((worktree) => + this.toRuntimeDetectedWorktree(repo, worktree, matcher) + ) return { repoId: repo.id, authoritative: true, @@ -21061,10 +21068,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) @@ -21075,7 +21082,7 @@ export class OrcaRuntimeService { const detectedWorktree = this.toRuntimeDetectedWorktree( repo, worktree, - agentScratchWorktreePathMatcher + worktreeVisibilitySourceMatcher ) if (scan.ok) { return detectedWorktree @@ -21142,19 +21149,41 @@ 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 buildRuntimeVisibilitySourceMatchersByRepoId( + worktrees: readonly Worktree[] + ): Map { + const checkoutPathsByRepoId = new Map() + for (const worktree of worktrees) { + const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? [] + checkoutPaths.push(worktree.path) + checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths) + } + return new Map( + (this.store?.getRepos() ?? []) + .filter((repo) => checkoutPathsByRepoId.has(repo.id)) + .map((repo) => [ + repo.id, + createWorktreeVisibilitySourceMatcher( + [repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])], + normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + ) + ]) + ) } private toRuntimeDetectedWorktree( repo: Repo, worktree: Worktree, - agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher + worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher ): DetectedWorktree { const settings = this.store?.getSettings() if (!settings) { @@ -21172,7 +21201,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 266d659ba98..c6c8ac84f80 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..8f842bee693 --- /dev/null +++ b/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx @@ -0,0 +1,200 @@ +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.HiddenWorktreeRecoveryList.64e6f53f05', + '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..7dfd864ce95 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 } @@ -182,14 +205,20 @@ async function click(element: HTMLElement): Promise { }) } +async function setInputValue(input: HTMLInputElement, value: string): Promise { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }) +} + describe('WorktreeVisibilityDialog', () => { it('lists a hidden agent worktree with a repo-relative path', async () => { await renderDialog() 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 +241,38 @@ 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('gives each hidden worktree action a distinct accessible name', async () => { + mocks.state.detectedWorktreesByRepo = { + 'repo-1': makeDetected([ + makeWorktree({ displayName: 'scratch-a', path: '/repo/.claude/worktrees/scratch-a' }), + makeWorktree({ displayName: 'scratch-b', path: '/repo/.claude/worktrees/scratch-b' }) + ]) + } + + await renderDialog() + + expect( + document.querySelector('button[aria-label="Show scratch-a at .claude/worktrees/scratch-a"]') + ).not.toBeNull() + expect( + document.querySelector('button[aria-label="Show scratch-b at .claude/worktrees/scratch-b"]') + ).not.toBeNull() }) it('recovers a hidden worktree per path through the existing import exception', async () => { @@ -234,7 +295,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 +351,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 +385,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 +393,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 +454,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 +468,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 +527,7 @@ 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.' @@ -473,11 +535,25 @@ describe('WorktreeVisibilityDialog', () => { 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( + "This host doesn't support source-specific worktree visibility. Update Orca on the host to change this setting." + ) + expect(document.querySelector('[role="alert"]')?.textContent).not.toContain('Try again') + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + }) + it('reports a failed authoritative refresh after updating persistent visibility', async () => { await renderDialog() mocks.state.fetchWorktrees.mockResolvedValue(false) - await click(alwaysShowSwitch()) + await click(sourceSwitch()) expect(mocks.state.fetchWorktrees).toHaveBeenLastCalledWith('repo-1', { requireAuthoritative: true @@ -487,54 +563,224 @@ 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() + const input = document.querySelector('#custom-worktree-root') + expect(input).not.toBeNull() + expect( + document.querySelector('section[aria-labelledby="worktree-sources-heading"]')?.contains(input) + ).toBe(true) + expect(input?.closest('form')?.getAttribute('aria-label')).toBe('Add location') + expect(input?.closest('form')?.textContent).not.toContain('Add location') + await setInputValue(input!, '/srv/team-worktrees') + 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('keeps source matching stable while typing in the inline form', async () => { mocks.state.repos = [ - makeRepo({ externalWorktreeVisibility: 'show', agentWorktreeVisibility: 'show' }) + makeRepo({ + customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }] + }) + ] + await renderDialog() + const normalize = vi.spyOn(String.prototype, 'normalize') + + await setInputValue( + document.querySelector('#custom-worktree-root')!, + '/srv/x' + ) + + expect(normalize).not.toHaveBeenCalled() + normalize.mockRestore() + }) + + it('distinguishes invalid, duplicate, limit, and save failures when adding a source', async () => { + const existing = Array.from({ length: 32 }, (_, index) => ({ + id: `source-${index}`, + rootPath: `/srv/source-${index}` + })) + mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing })] + await renderDialog() + const input = document.querySelector('#custom-worktree-root')! + await setInputValue(input, '/srv/new') + await click(buttonWithText('Add')) + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Remove a custom location before adding another.' + ) + expect(mocks.state.updateRepo).not.toHaveBeenCalled() + + mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing.slice(0, 1) })] + await act(async () => root.render(null)) + await renderDialog() + const duplicateInput = document.querySelector('#custom-worktree-root')! + await setInputValue(duplicateInput, '/srv/source-0') + await click(buttonWithText('Add')) + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'This location is already listed.' + ) + + await setInputValue(duplicateInput, 'relative/path') + await click(buttonWithText('Add')) + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Enter an absolute path for this host.' + ) + + mocks.state.updateRepo.mockResolvedValue(false) + await setInputValue(duplicateInput, '/srv/valid') + await click(buttonWithText('Add')) + const alerts = [...document.querySelectorAll('[role="alert"]')] + expect(alerts).toHaveLength(1) + expect(alerts[0]?.textContent).toContain('Could not update worktree visibility. Try again.') + expect(alerts[0]?.textContent).not.toContain('Enter an absolute path') + }) + + it('reports only unsupported-host copy when an older host strips an added source', async () => { + mocks.state.updateRepo.mockResolvedValue(true) + await renderDialog() + mocks.state.fetchWorktrees.mockClear() + const input = document.querySelector('#custom-worktree-root')! + + await setInputValue(input, '/srv/valid') + await click(buttonWithText('Add')) + + const alerts = [...document.querySelectorAll('[role="alert"]')] + expect(alerts).toHaveLength(1) + expect(alerts[0]?.textContent).toContain( + "This host doesn't support source-specific worktree visibility." + ) + expect(alerts[0]?.textContent).not.toContain('Enter an absolute path') + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + }) + + it('removes custom locations without changing other source preferences', async () => { + mocks.state.repos = [ + makeRepo({ + 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('/srv/team-worktrees').getAttribute('aria-checked')).toBe('false') + const remove = document.querySelector( + 'button[aria-label="Remove /srv/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: [], + worktreeVisibilitySourcePreferences: expect.not.objectContaining({ + custom: expect.objectContaining({ team: expect.anything() }) + }) + }) + ) expect(mocks.state.closeModal).not.toHaveBeenCalled() }) + + it('rejects removal success when an older host leaves the custom preference behind', async () => { + const repo = makeRepo({ + customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }], + worktreeVisibilitySourcePreferences: { custom: { team: 'show' } } + }) + mocks.state.repos = [repo] + mocks.state.updateRepo.mockImplementation(async () => { + repo.customWorktreeVisibilitySources = [] + return true + }) + await renderDialog() + mocks.state.fetchWorktrees.mockClear() + + await click(document.querySelector('button[aria-label="Remove /srv/team-worktrees"]')!) + + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Could not update worktree visibility. Try again.' + ) + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + }) + + it('uses full paths to distinguish custom source controls with the same basename', async () => { + mocks.state.repos = [ + makeRepo({ + customWorktreeVisibilitySources: [ + { id: 'alpha', rootPath: '/srv/alpha/team' }, + { id: 'beta', rootPath: '/srv/beta/team' } + ] + }) + ] + + await renderDialog() + + expect(sourceSwitch('/srv/alpha/team')).not.toBeNull() + expect(sourceSwitch('/srv/beta/team')).not.toBeNull() + expect(document.querySelector('button[aria-label="Remove /srv/alpha/team"]')).not.toBeNull() + expect(document.querySelector('button[aria-label="Remove /srv/beta/team"]')).not.toBeNull() + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 9badad39b41..835f68d317e 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,29 @@ import { type ActiveVisibilityMutation, useVisibilityMutationFence } from './worktree-visibility-mutation-fence' +import WorktreeVisibilitySourceList, { + type WorktreeVisibilitySourceAddResult, + type WorktreeVisibilitySourceRow +} from './WorktreeVisibilitySourceList' +import type { CustomWorktreeVisibilitySource, Repo } from '../../../../shared/types' +import { + MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES, + buildWorktreeSourcePreferenceUpdate, + effectiveBuiltInWorktreeSourceVisibility, + effectiveCustomWorktreeSourceVisibility, + normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences, + removeCustomWorktreeSourcePreference +} from '../../../../shared/worktree-visibility-sources' +import HiddenWorktreeRecoveryList from './HiddenWorktreeRecoveryList' +import { worktreeVisibilityUpdateError } from './worktree-visibility-update-error' + +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 +68,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 +86,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,41 +179,39 @@ 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, - error: translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.d40d436fc2', - 'Could not update worktree visibility. Try again.' + error: worktreeVisibilityUpdateError( + updated, + latestRepo?.worktreeVisibilitySourcePreferences, + updates.worktreeVisibilitySourcePreferences ) }) } - 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 +219,113 @@ 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 'save-failed' + } + const existing = normalizeCustomWorktreeVisibilitySources( + repo.customWorktreeVisibilitySources + ) + if ((existing?.length ?? 0) >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) { + return 'limit' + } + const id = crypto.randomUUID().replaceAll('-', '') + const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0] + if (!candidate) { + return 'invalid-path' + } + const next = normalizeCustomWorktreeVisibilitySources([...(existing ?? []), candidate]) + if (!next || next.length !== (existing?.length ?? 0) + 1) { + return 'duplicate-path' + } + const saved = await commitSourceUpdate( + { + customWorktreeVisibilitySources: next, + worktreeVisibilitySourcePreferences: buildWorktreeSourcePreferenceUpdate( + repo, + { kind: 'custom', id }, + 'hide' + ) + }, + (latestRepo) => + normalizeCustomWorktreeVisibilitySources( + latestRepo.customWorktreeVisibilitySources + )?.some((source) => source.id === id) === true && + effectiveCustomWorktreeSourceVisibility(latestRepo, id) === 'hide' + ) + return saved ? 'added' : 'save-failed' + }, + [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) && + normalizeWorktreeVisibilitySourcePreferences( + latestRepo.worktreeVisibilitySourcePreferences + )?.custom?.[source.id] === undefined + ) + }, + [commitSourceUpdate, repo] ) if (!isOpen || !repo || !isGitRepoKind(repo)) { @@ -239,7 +334,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { return ( !open && closeModal()}> - +
@@ -253,51 +348,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 +386,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..07bf2d9325f 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)} > @@ -50,14 +49,14 @@ 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.' + 'auto.components.sidebar.WorktreeVisibilityHelpPopover.ec1e6a10fb', + '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.' + 'auto.components.sidebar.WorktreeVisibilityHelpPopover.1c68c9cf77', + '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..e3bce6563cd --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx @@ -0,0 +1,320 @@ +import React, { useMemo, useState } from 'react' +import { Folder, Grid2X2, SquareTerminal, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +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 +} + +export type WorktreeVisibilitySourceAddResult = + | 'added' + | 'invalid-path' + | 'duplicate-path' + | 'limit' + | 'save-failed' + +function getSourceLabel(source: WorktreeVisibilitySourceRow): string { + if (source.kind === 'built-in') { + return source.id === 'claude' + ? 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 sourceMatchKey(match: WorktreeVisibilitySourceMatch | null): string { + return match ? `${match.kind}:${match.id}` : 'other' +} + +function sourceRowKey(source: WorktreeVisibilitySourceRow): string { + return source.kind === 'custom' + ? `custom:${source.source.id}` + : source.kind === 'built-in' + ? `built-in:${source.id}` + : 'other' +} + +function getAccessibleSourceLabel(source: WorktreeVisibilitySourceRow, label: string): string { + return source.kind === 'custom' ? source.source.rootPath : label +} + +export default function WorktreeVisibilitySourceList({ + repo, + worktrees, + disabled, + onAdd, + onRemove, + onToggle +}: Props): React.JSX.Element { + const [rootPath, setRootPath] = useState('') + const [inputError, setInputError] = useState(null) + const customSources = useMemo( + () => normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [], + [repo.customWorktreeVisibilitySources] + ) + const sources = useMemo( + () => [ + { kind: 'built-in', id: 'claude' }, + { kind: 'built-in', id: 'gsd' }, + ...customSources.map((source) => ({ kind: 'custom' as const, source })), + { kind: 'other' } + ], + [customSources] + ) + const classify = useMemo( + () => + createWorktreeVisibilitySourceMatcher( + [repo.path, ...worktrees.map((worktree) => worktree.path)], + customSources + ), + [customSources, repo.path, worktrees] + ) + const sourceCounts = useMemo(() => { + const counts = new Map() + for (const worktree of worktrees) { + if (worktree.selectedCheckout || worktree.ownership === 'orca-managed') { + continue + } + const key = sourceMatchKey(worktree.visibilitySource ?? classify(worktree.path)) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + return counts + }, [classify, worktrees]) + + const handleAdd = async (event: React.FormEvent) => { + event.preventDefault() + setInputError(null) + const result = await onAdd(rootPath) + if (result === 'added') { + setRootPath('') + return + } + if (result === 'save-failed') { + return + } + setInputError( + result === 'limit' + ? translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.limit', + 'Remove a custom location before adding another.' + ) + : result === 'duplicate-path' + ? translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.duplicatePath', + 'This location is already listed.' + ) + : translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.invalidPath', + 'Enter an absolute path for this host.' + ) + ) + } + + 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 key = sourceRowKey(source) + const count = sourceCounts.get(key) ?? 0 + const accessibleLabel = getAccessibleSourceLabel(source, label) + 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/components/sidebar/worktree-visibility-update-error.ts b/src/renderer/src/components/sidebar/worktree-visibility-update-error.ts new file mode 100644 index 00000000000..74605160e68 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-update-error.ts @@ -0,0 +1,19 @@ +import { translate } from '@/i18n/i18n' +import type { Repo } from '../../../../shared/types' + +export function worktreeVisibilityUpdateError( + updateSucceeded: boolean, + returnedPreferences: Repo['worktreeVisibilitySourcePreferences'], + requestedPreferences: Repo['worktreeVisibilitySourcePreferences'] +): string { + if (updateSucceeded && returnedPreferences === undefined && requestedPreferences !== undefined) { + return translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.unsupportedHost', + "This host doesn't support source-specific worktree visibility. Update Orca on the host to change this setting." + ) + } + return translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.d40d436fc2', + 'Could not update worktree visibility. Try again.' + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1fd077c1acb..84ceb08cf5a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4890,8 +4890,12 @@ "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." + "ec1e6a10fb": "Other worktrees start hidden to avoid unexpected sidebar clutter.", + "1c68c9cf77": "Enable a source for all current and future worktrees, or show individual worktrees below." + }, + "HiddenWorktreeRecoveryList": { + "64e6f53f05": "Show one without enabling its source.", + "showWorktree": "Show {{value0}} at {{value1}}" }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Non-Orca worktrees", @@ -4905,10 +4909,18 @@ "b8d24e61f5": "Could not list this repo's worktrees.", "c5e70a93b1": "Try again", "7d21c5e848": "Hidden worktrees ({{value0}})", - "9b53f7a160": "Choose which hidden worktrees to show individually.", "2f80cd4b97": "Showing…", "e64b81d3a9": "Show", - "d40d436fc2": "Could not update worktree visibility. Try again." + "d40d436fc2": "Could not update worktree visibility. Try again.", + "unsupportedHost": "This host doesn't support source-specific worktree visibility. Update Orca on the host to change this setting.", + "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": { @@ -5406,6 +5418,26 @@ }, "WorktreeListScrollToTopButton": { "jumpToTop": "Jump to top" + }, + "WorktreeVisibilitySourceList": { + "claude": "Claude Code", + "gsd": "GSD", + "other": "Other locations", + "custom": "Custom location", + "otherPath": "Outside listed sources", + "invalidPath": "Enter an absolute path for this host.", + "duplicatePath": "This location is already listed.", + "limit": "Remove a custom location before adding another.", + "sources": "Sources", + "sourcesDescription": "Enabled sources include current and future worktrees in the sidebar.", + "found": "{{value0}} found", + "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 f90e2251501..0dbd4d9b36f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4672,8 +4672,12 @@ "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." + "ec1e6a10fb": "Los demás worktrees empiezan ocultos para evitar saturar inesperadamente la barra lateral.", + "1c68c9cf77": "Activa una fuente para mostrar todos sus worktrees actuales y futuros, o muestra worktrees individuales abajo." + }, + "HiddenWorktreeRecoveryList": { + "64e6f53f05": "Muestra uno sin activar su fuente.", + "showWorktree": "Mostrar {{value0}} en {{value1}}" }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Worktrees que no son de Orca", @@ -4687,10 +4691,38 @@ "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.", "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.", + "unsupportedHost": "Este host no admite la visibilidad de worktrees específica de cada fuente. Actualiza Orca en el host para cambiar esta opción.", + "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 para este host.", + "duplicatePath": "Esta ubicación ya está en la lista.", + "limit": "Elimina una ubicación personalizada antes de añadir otra.", + "sources": "Fuentes", + "sourcesDescription": "Las fuentes activadas incluyen los worktrees actuales y futuros en la barra lateral.", + "found": "Encontrados: {{value0}}", + "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 d7671bef9da..8c429f29d73 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4653,8 +4653,12 @@ "WorktreeVisibilityHelpPopover": { "c41f2d7e90": "デフォルトで非表示になるワークツリー", "8db4e19a26": "この設定では、Orca で作成されたワークツリーが非表示になることはありません。", - "54a7c2f183": "新しく追加したリポジトリでは、手動または別のツールで作成したワークツリーは、意図しない項目でサイドバーが埋まらないよう、デフォルトでは非表示です。", - "b9e0d43675": "「常に表示」では、.claude/worktrees/* および .gsd-workspaces/* にある Agent ワークツリーを含む、Orca 以外で作成された現在および今後のワークツリーが表示されます。オフの場合は、下の「表示」を使用して個別に追加できます。" + "ec1e6a10fb": "その他のワークツリーは、サイドバーが意図せず煩雑にならないようデフォルトで非表示です。", + "1c68c9cf77": "ソースを有効にすると現在および今後のワークツリーをすべて表示できます。下から個別に表示することもできます。" + }, + "HiddenWorktreeRecoveryList": { + "64e6f53f05": "ソースを有効にせずに個別に表示します。", + "showWorktree": "{{value1}} の {{value0}} を表示" }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Orca 以外のワークツリー", @@ -4668,10 +4672,38 @@ "b8d24e61f5": "このリポジトリのワークツリーを一覧できませんでした。", "c5e70a93b1": "再試行", "7d21c5e848": "非表示のワークツリー({{value0}})", - "9b53f7a160": "個別にサイドバーへ表示する非表示のワークツリーを選択します。", "2f80cd4b97": "表示中…", "e64b81d3a9": "表示", - "d40d436fc2": "ワークツリーの表示設定を更新できませんでした。もう一度お試しください。" + "d40d436fc2": "ワークツリーの表示設定を更新できませんでした。もう一度お試しください。", + "unsupportedHost": "このホストはソース別のワークツリー表示設定に対応していません。この設定を変更するには、ホストの Orca を更新してください。", + "search": "非表示のワークツリーを検索", + "searchPlaceholder": "{{value0}} 件のワークツリーを検索…", + "noMatches": "一致するワークツリーはありません", + "allShown": "検出されたワークツリーはすべて表示されています", + "noneFound": "Orca 以外のワークツリーは見つかりませんでした", + "tryDifferentSearch": "別の名前またはパスをお試しください。", + "disableSource": "ソースを無効にするとワークツリーを個別に管理できます。", + "appearWhenDetected": "Orca が新しいワークツリーを検出すると、ここに表示されます。" + }, + "WorktreeVisibilitySourceList": { + "claude": "Claude Code", + "gsd": "GSD", + "other": "その他の場所", + "custom": "カスタムの場所", + "otherPath": "一覧にないソース", + "invalidPath": "このホストの絶対パスを入力してください。", + "duplicatePath": "この場所は既に一覧にあります。", + "limit": "別の場所を追加する前にカスタムの場所を削除してください。", + "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 3c102ef55a8..09f846d3cdd 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4653,8 +4653,12 @@ "WorktreeVisibilityHelpPopover": { "c41f2d7e90": "기본적으로 숨겨지는 워크트리", "8db4e19a26": "이 설정은 Orca에서 만든 워크트리를 숨기지 않습니다.", - "54a7c2f183": "새로 추가한 저장소에서는 직접 만들거나 다른 도구에서 만든 워크트리가 예기치 않은 항목으로 사이드바를 복잡하게 하지 않도록 기본적으로 숨겨집니다.", - "b9e0d43675": "'항상 표시'는 .claude/worktrees/* 및 .gsd-workspaces/* 아래의 에이전트 워크트리를 포함하여 현재와 앞으로 생성되는 Orca 외부 워크트리를 표시합니다. 꺼져 있을 때는 아래의 '표시'를 사용해 하나씩 추가할 수 있습니다." + "ec1e6a10fb": "다른 워크트리는 사이드바가 예기치 않게 복잡해지지 않도록 기본적으로 숨겨집니다.", + "1c68c9cf77": "소스를 활성화해 현재와 앞으로 생성되는 모든 워크트리를 표시하거나 아래에서 개별 워크트리를 표시하세요." + }, + "HiddenWorktreeRecoveryList": { + "64e6f53f05": "소스를 활성화하지 않고 하나만 표시합니다.", + "showWorktree": "{{value1}}의 {{value0}} 표시" }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Orca 외부 워크트리", @@ -4668,10 +4672,38 @@ "b8d24e61f5": "이 저장소의 워크트리를 나열할 수 없습니다.", "c5e70a93b1": "다시 시도", "7d21c5e848": "숨겨진 워크트리({{value0}})", - "9b53f7a160": "개별적으로 사이드바에 표시할 숨겨진 워크트리를 선택하세요.", "2f80cd4b97": "표시 중…", "e64b81d3a9": "표시", - "d40d436fc2": "워크트리 표시 설정을 업데이트하지 못했습니다. 다시 시도하세요." + "d40d436fc2": "워크트리 표시 설정을 업데이트하지 못했습니다. 다시 시도하세요.", + "unsupportedHost": "이 호스트는 소스별 워크트리 표시 설정을 지원하지 않습니다. 이 설정을 변경하려면 호스트의 Orca를 업데이트하세요.", + "search": "숨겨진 워크트리 검색", + "searchPlaceholder": "워크트리 {{value0}}개 검색…", + "noMatches": "일치하는 워크트리가 없습니다", + "allShown": "발견된 모든 워크트리가 표시 중입니다", + "noneFound": "Orca 외부 워크트리를 찾지 못했습니다", + "tryDifferentSearch": "다른 이름이나 경로를 시도하세요.", + "disableSource": "소스를 비활성화하면 워크트리를 개별적으로 관리할 수 있습니다.", + "appearWhenDetected": "Orca가 새 워크트리를 발견하면 여기에 표시됩니다." + }, + "WorktreeVisibilitySourceList": { + "claude": "Claude Code", + "gsd": "GSD", + "other": "기타 위치", + "custom": "사용자 지정 위치", + "otherPath": "목록에 없는 소스", + "invalidPath": "이 호스트의 절대 경로를 입력하세요.", + "duplicatePath": "이 위치는 이미 목록에 있습니다.", + "limit": "다른 위치를 추가하기 전에 사용자 지정 위치를 제거하세요.", + "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 19919188ddc..b881b978574 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4665,8 +4665,12 @@ "WorktreeVisibilityHelpPopover": { "c41f2d7e90": "哪些工作树默认隐藏?", "8db4e19a26": "此设置不会隐藏通过 Orca 创建的工作树。", - "54a7c2f183": "对于新添加的仓库,手动或通过其他工具创建的工作树默认隐藏,以免侧边栏出现意外内容。", - "b9e0d43675": "“始终显示”会显示当前及以后创建的非 Orca 工作树,包括 .claude/worktrees/* 和 .gsd-workspaces/* 下的智能体工作树。关闭时,可使用下方的“显示”逐个添加。" + "ec1e6a10fb": "其他工作树默认隐藏,以免侧边栏意外变得杂乱。", + "1c68c9cf77": "启用来源可显示其当前及未来的所有工作树,也可以在下方单独显示工作树。" + }, + "HiddenWorktreeRecoveryList": { + "64e6f53f05": "无需启用来源即可单独显示一个工作树。", + "showWorktree": "显示位于 {{value1}} 的 {{value0}}" }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "非 Orca 工作树", @@ -4680,10 +4684,38 @@ "b8d24e61f5": "无法列出此仓库的工作树。", "c5e70a93b1": "重试", "7d21c5e848": "已隐藏的工作树({{value0}})", - "9b53f7a160": "选择要单独显示的隐藏工作树。", "2f80cd4b97": "显示中…", "e64b81d3a9": "显示", - "d40d436fc2": "无法更新工作树可见性。请重试。" + "d40d436fc2": "无法更新工作树可见性。请重试。", + "unsupportedHost": "此主机不支持按来源设置工作树可见性。请更新主机上的 Orca 以更改此设置。", + "search": "搜索隐藏的工作树", + "searchPlaceholder": "搜索 {{value0}} 个工作树…", + "noMatches": "没有匹配的工作树", + "allShown": "已显示所有检测到的工作树", + "noneFound": "未找到非 Orca 工作树", + "tryDifferentSearch": "请尝试其他名称或路径。", + "disableSource": "禁用来源后可单独管理其工作树。", + "appearWhenDetected": "Orca 检测到新工作树后,它们会显示在这里。" + }, + "WorktreeVisibilitySourceList": { + "claude": "Claude Code", + "gsd": "GSD", + "other": "其他位置", + "custom": "自定义位置", + "otherPath": "所列来源之外", + "invalidPath": "请输入此主机上的绝对路径。", + "duplicatePath": "此位置已在列表中。", + "limit": "请先移除一个自定义位置再添加新位置。", + "sources": "来源", + "sourcesDescription": "已启用来源的当前及未来工作树会显示在侧边栏中。", + "found": "找到 {{value0}} 个", + "remove": "移除 {{value0}}", + "removeLocation": "移除自定义位置", + "toggle": "显示 {{value0}} 的当前及未来工作树", + "addLocation": "添加位置", + "worktreeRoot": "工作树根目录", + "add": "添加", + "rootHelp": "Orca 会识别此文件夹下的工作树。" }, "add": { "repo": { diff --git a/src/renderer/src/i18n/worktree-visibility-locales.test.ts b/src/renderer/src/i18n/worktree-visibility-locales.test.ts new file mode 100644 index 00000000000..a62fdc8f2f8 --- /dev/null +++ b/src/renderer/src/i18n/worktree-visibility-locales.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import es from './locales/es.json' + +describe('worktree visibility locales', () => { + it('uses number-neutral Spanish source counts', () => { + const found = es.auto.components.sidebar.WorktreeVisibilitySourceList.found + + expect(found.replace('{{value0}}', '1')).toBe('Encontrados: 1') + expect(found.replace('{{value0}}', '2')).toBe('Encontrados: 2') + }) + + it('keeps current fallback-derived keys in the English catalog', () => { + expect(en.auto.components.sidebar.WorktreeVisibilityHelpPopover).toMatchObject({ + ec1e6a10fb: 'Other worktrees start hidden to avoid unexpected sidebar clutter.', + '1c68c9cf77': + 'Enable a source for all current and future worktrees, or show individual worktrees below.' + }) + expect(en.auto.components.sidebar.HiddenWorktreeRecoveryList).toMatchObject({ + '64e6f53f05': 'Show one without enabling its source.' + }) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 5a76fbf6963..4604287e74b 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -94,6 +94,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' @@ -140,6 +144,8 @@ export type RepoUpdate = Partial< | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' | 'agentWorktreeVisibility' + | 'customWorktreeVisibilitySources' + | 'worktreeVisibilitySourcePreferences' | 'projectGroupId' | 'projectGroupOrder' > @@ -279,6 +285,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 427b8c01f9a..25b5df10022 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 f4e5fe23206..ce56ef2c0cd 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -512,6 +512,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..875c34b4ad8 --- /dev/null +++ b/src/shared/worktree-visibility-sources.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } 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('normalizes each candidate once regardless of checkout count', () => { + const classify = createWorktreeVisibilitySourceMatcher( + Array.from({ length: 200 }, (_, index) => `/repo/worktree-${index}`), + Array.from({ length: 32 }, (_, index) => ({ + id: `custom-${index}`, + rootPath: `/custom/${index}` + })) + ) + const normalize = vi.spyOn(String.prototype, 'normalize') + + classify('/unmatched/worktree') + + expect(normalize).toHaveBeenCalledTimes(1) + normalize.mockRestore() + }) + + it('migrates the optional legacy agent policy lazily for both built-ins', () => { + const legacy = repo({ agentWorktreeVisibility: 'show' }) + expect(effectiveBuiltInWorktreeSourceVisibility(legacy, 'claude')).toBe('show') + 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' } }) + }) + + it('rejects drive-relative Windows roots and caps after validation', () => { + const invalid = Array.from({ length: 32 }, (_, index) => ({ + id: `bad-${index}`, + rootPath: 'relative' + })) + expect( + normalizeCustomWorktreeVisibilitySources([...invalid, { id: 'team', rootPath: '/srv/team' }]) + ).toEqual([{ id: 'team', rootPath: '/srv/team' }]) + expect( + normalizeCustomWorktreeVisibilitySources([ + { id: 'drive-relative', rootPath: 'C:Users\\dev\\team' }, + { id: 'root-relative', rootPath: '\\Users\\dev\\team' }, + { id: 'drive', rootPath: 'C:\\Users\\dev\\team' }, + { id: 'unc', rootPath: '\\\\server\\share\\team' } + ]) + ).toEqual([ + { id: 'drive', rootPath: 'C:\\Users\\dev\\team' }, + { id: 'unc', rootPath: '\\\\server\\share\\team' } + ]) + }) +}) diff --git a/src/shared/worktree-visibility-sources.ts b/src/shared/worktree-visibility-sources.ts new file mode 100644 index 00000000000..e4153daa86d --- /dev/null +++ b/src/shared/worktree-visibility-sources.ts @@ -0,0 +1,240 @@ +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') || + (trimmed.startsWith('\\') && !trimmed.startsWith('\\\\')) || + !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) { + if (normalized.length >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) { + break + } + 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): (normalizedCandidate: string) => boolean { + const matchesInsideOrEqual = createNormalizedPathInsideOrEqualMatcher(rootPath) + const normalizedRoot = normalizeRuntimePathForComparison(rootPath) + return (normalizedCandidate) => + normalizedCandidate !== normalizedRoot && matchesInsideOrEqual(normalizedCandidate) +} + +export function createWorktreeVisibilitySourceMatcher( + checkoutPaths: readonly string[], + customSources: readonly CustomWorktreeVisibilitySource[] = [] +): WorktreeVisibilitySourceMatcher { + const checkoutPathKeys = new Set(checkoutPaths.map(normalizeRuntimePathForComparison)) + const customMatchers = customSources.map(({ id, rootPath }) => ({ + id, + matches: createDescendantMatcher(rootPath) + })) + return (worktreePath) => { + const normalizedCandidate = normalizeRuntimePathForComparison(worktreePath) + const segments = normalizedCandidate.split('/') + for (const source of BUILT_IN_WORKTREE_VISIBILITY_SOURCES) { + const prefix = source.relativeRootSegments + for (let index = 0; index + prefix.length < segments.length; index += 1) { + if (!prefix.every((segment, offset) => segments[index + offset] === segment)) { + continue + } + const checkoutPath = segments.slice(0, index).join('/') + const checkoutPathKey = /^[a-z]:$/i.test(checkoutPath) + ? `${checkoutPath}/` + : checkoutPath || '/' + if (checkoutPathKeys.has(checkoutPathKey)) { + return { kind: 'built-in', id: source.id } + } + } + } + for (const source of customMatchers) { + if (source.matches(normalizedCandidate)) { + 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 } : {}) + } +} From e7b85266f531f9a219dff59d8647f86585b4fc7e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:31:20 -0700 Subject: [PATCH 2/3] Add golden E2E tests for fresh terminal and shell commands (#14302) * test(e2e): add golden tests for fresh terminal and shell commands Adds regression tests for terminal initialization in fresh profiles and shell command execution to the golden test suite, integrated across Linux, macOS, and Windows CI. * test(e2e): bracket shell command output between markers The echoed command can wrap or be clipped by the buffer tail. Bracket output between begin and end markers to reliably identify real output, and strip ANSI escape sequences that interfere with parsing. --- .github/workflows/golden-e2e-experiment.yml | 11 +- .../ssh-relay-upload-stage-commands.test.ts | 44 ++++---- .../e2e/golden-fresh-profile-terminal.spec.ts | 101 ++++++++++++++++++ tests/e2e/golden-shell-command.spec.ts | 88 +++++++++++++++ 4 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/golden-fresh-profile-terminal.spec.ts create mode 100644 tests/e2e/golden-shell-command.spec.ts diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 7bc5e2ae2c3..fe443880237 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -23,7 +23,7 @@ jobs: platform: linux - os: macos-15 platform: mac - # Match the release gate: Windows runs the fresh-startup golden only. + # Match the release gate: Windows runs a focused golden subset. - os: windows-2022 platform: windows @@ -67,6 +67,9 @@ jobs: if: runner.os == 'Linux' run: | xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + fi xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -74,6 +77,9 @@ jobs: if: runner.os == 'macOS' run: | env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts + if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + fi env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden @@ -84,6 +90,9 @@ jobs: $env:SKIP_BUILD = '1' $env:ORCA_E2E_FORWARD_APP_LOGS = '1' pnpm run --if-present test:e2e:windows-fresh-startup-golden + if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { + pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts + } - name: Upload Playwright traces if: failure() diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts index c1e3031f095..04f96c2ad81 100644 --- a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts +++ b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts @@ -115,26 +115,30 @@ describe.each([ RemoteHostPlatform ])[]) ])('%s relay upload stage commands', (_label, host) => { - it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => { - const pool = createPool() - for (let index = 0; index < Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT); index += 1) { - createStage(host, pool, index) - } - if (count > RELAY_UPLOAD_STAGE_SLOT_COUNT) { - mkdirSync(join(pool, 'foreign-extra')) - } + it.each([0, 1, 7, 8, 9])( + 'bounds reservation with %i occupied entries', + (count) => { + const pool = createPool() + for (let index = 0; index < Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT); index += 1) { + createStage(host, pool, index) + } + if (count > RELAY_UPLOAD_STAGE_SLOT_COUNT) { + mkdirSync(join(pool, 'foreign-extra')) + } - const result = runCommand(host, reserveRelayUploadStageCommand(host, pool, owner)) - if (count < RELAY_UPLOAD_STAGE_SLOT_COUNT) { - expect(result.status, result.stderr).toBe(0) - expect(parseReservedRelayUploadStage(host, pool, owner, result.stdout).slotName).toBe( - `slot-${count}` - ) - } else { - expect(result.status).not.toBe(0) - expect(result.stderr).toContain('staging quota is full') - } - }) + const result = runCommand(host, reserveRelayUploadStageCommand(host, pool, owner)) + if (count < RELAY_UPLOAD_STAGE_SLOT_COUNT) { + expect(result.status, result.stderr).toBe(0) + expect(parseReservedRelayUploadStage(host, pool, owner, result.stdout).slotName).toBe( + `slot-${count}` + ) + } else { + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('staging quota is full') + } + }, + 120_000 + ) it('promotes only the post-claim owned payload and removes its fixed stage', () => { const pool = createPool() @@ -201,7 +205,7 @@ describe.each([ expect(existsSync(join(pool, 'slot-0'))).toBe(true) expect(existsSync(join(pool, 'slot-1'))).toBe(true) expect(existsSync(join(pool, 'slot-2'))).toBe(false) - }) + }, 120_000) it('drains fixed stale claim and delete states across repeated deployments', () => { const pool = createPool() diff --git a/tests/e2e/golden-fresh-profile-terminal.spec.ts b/tests/e2e/golden-fresh-profile-terminal.spec.ts new file mode 100644 index 00000000000..5194868adb7 --- /dev/null +++ b/tests/e2e/golden-fresh-profile-terminal.spec.ts @@ -0,0 +1,101 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtemp } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + focusActiveTerminalInput, + getTerminalContent, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +test.use({ dismissOnboarding: false, seedTestRepo: false }) + +async function createGitRepo(): Promise { + const root = realpathSync(await mkdtemp(path.join(os.tmpdir(), 'orca-e2e-golden-fresh-'))) + const repoPath = path.join(root, 'golden-fresh-project') + mkdirSync(repoPath) + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }) + execFileSync('git', ['config', 'user.email', 'e2e@test.local'], { cwd: repoPath }) + execFileSync('git', ['config', 'user.name', 'E2E Test'], { cwd: repoPath }) + writeFileSync(path.join(repoPath, 'README.md'), '# golden-fresh-project\n') + execFileSync('git', ['add', 'README.md'], { cwd: repoPath }) + execFileSync('git', ['commit', '-m', 'Initial commit'], { cwd: repoPath }) + return repoPath +} + +async function stubFolderPicker( + electronApp: ElectronApplication, + selectedPath: string +): Promise { + await electronApp.evaluate(({ dialog }, folderPath) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: [folderPath], + bookmarks: [] + }) + }, selectedPath) +} + +async function selectCodexAndSkipToProject(page: Page): Promise { + const codexButton = page.getByRole('button', { name: /^Codex\s/ }).first() + if (!(await codexButton.isVisible())) { + await page.getByText(/Show \d+ more agents/).click() + } + await codexButton.click() + const footer = page.locator('footer').filter({ has: page.getByRole('button', { name: /Skip/i }) }) + await footer.getByRole('button', { name: /^Skip to project setup$/i }).click() + await expect(page.getByRole('dialog', { name: /Add a project/i })).toBeVisible() +} + +test('fresh profile opens a live project terminal @golden', async ({ + electronApp, + orcaPage, + registerPostElectronShutdownCleanup +}) => { + await waitForSessionReady(orcaPage) + await expect(orcaPage.locator('#root')).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible() + + await selectCodexAndSkipToProject(orcaPage) + const repoPath = await createGitRepo() + registerPostElectronShutdownCleanup(async () => + rmSync(path.dirname(repoPath), { recursive: true, force: true }) + ) + await stubFolderPicker(electronApp, repoPath) + await orcaPage + .getByRole('button', { name: /Browse for a folder|Open a folder|Browse folder/i }) + .click() + + await expect(orcaPage.getByText(path.basename(repoPath), { exact: true }).first()).toBeVisible({ + timeout: 30_000 + }) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage, 30_000) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 30_000) + await expect.poll(() => orcaPage.evaluate((id) => window.api.pty.hasPty(id), ptyId)).toBe(true) + + const marker = `orca-e2e-fresh-${Date.now()}` + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.type(`echo ${marker}`) + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await getTerminalContent(orcaPage)).split(marker).length - 1, { + message: 'marker should appear in both the echoed command and command output' + }) + .toBeGreaterThanOrEqual(2) + + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.type('git rev-parse --show-toplevel') + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await getTerminalContent(orcaPage)).replaceAll('\\', '/'), { + message: 'fresh project terminal should start in the selected repository' + }) + .toContain(repoPath.replaceAll('\\', '/')) +}) diff --git a/tests/e2e/golden-shell-command.spec.ts b/tests/e2e/golden-shell-command.spec.ts new file mode 100644 index 00000000000..4d3ee3b6832 --- /dev/null +++ b/tests/e2e/golden-shell-command.spec.ts @@ -0,0 +1,88 @@ +import { stripAnsiEscapeSequences } from '../../src/shared/ansi-escape-sequences' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible } from './helpers/store' +import { + focusActiveTerminalInput, + getTerminalContent, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +const CSI_REPLY_RE = /997;[12]n/ + +function shellBasename(processName: string): string { + return processName + .replaceAll('\\', '/') + .split('/') + .pop()! + .toLowerCase() + .replace(/\.exe$/, '') +} + +test('seeded project terminal runs a typed shell command @golden', async ({ orcaPage }) => { + await ensureTerminalVisible(orcaPage, 30_000) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 30_000) + expect(await getTerminalContent(orcaPage)).not.toMatch(CSI_REPLY_RE) + + const marker = `orca-e2e-alive-${Date.now()}` + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.type(`echo ${marker}`) + await orcaPage.keyboard.press('Enter') + await expect + .poll(async () => (await getTerminalContent(orcaPage)).split(marker).length - 1, { + message: 'marker should appear in both the echoed command and command output' + }) + .toBeGreaterThanOrEqual(2) + + if (process.platform === 'win32') { + let foregroundProcess = '' + await expect + .poll(async () => { + foregroundProcess = + (await orcaPage.evaluate((id) => window.api.pty.inspectProcess(id), ptyId)) + .foregroundProcess ?? '' + return foregroundProcess + }) + .not.toBe('') + const shell = shellBasename(foregroundProcess) + if (shell === 'cmd') { + const pwshAvailable = await orcaPage.evaluate(() => window.api.pwsh.isAvailable()) + expect(pwshAvailable, 'cmd.exe must not replace an available PowerShell default').toBe(false) + } + const begin = 'ORCA_E2E_PATH_BEGIN' + const end = 'ORCA_E2E_PATH_END' + const pathCommand = + shell === 'pwsh' || shell === 'powershell' + ? `Write-Output ${begin}; Write-Output $env:LOCALAPPDATA; Write-Output ${end}` + : shell === 'cmd' + ? `echo ${begin} & echo %LOCALAPPDATA% & echo ${end}` + : `printf '${begin}\\n%s\\n${end}\\n' "$LOCALAPPDATA"` + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.type(pathCommand) + await orcaPage.keyboard.press('Enter') + let expandedPath = '' + await expect + .poll(async () => { + // Why: the echoed command can wrap or be clipped by the buffer tail, so only a + // line that is exactly the marker — bracketed by both markers — is real output. + const lines = stripAnsiEscapeSequences(await getTerminalContent(orcaPage, 8_000)) + .split(/\r?\n/) + .map((line) => line.trim()) + const beginLine = lines.lastIndexOf(begin) + const endLine = beginLine === -1 ? -1 : lines.indexOf(end, beginLine + 1) + expandedPath = + endLine === -1 ? '' : (lines.slice(beginLine + 1, endLine).find(Boolean) ?? '') + return expandedPath + }) + .not.toBe('') + + expect(expandedPath).not.toBe('') + expect(expandedPath).not.toBe('%LOCALAPPDATA%') + expect(expandedPath).not.toBe('$env:LOCALAPPDATA') + expect(expandedPath).toMatch(/(?:[A-Za-z]:\\|\\\\)/) + } + + const finalBuffer = await getTerminalContent(orcaPage, 8_000) + expect(finalBuffer).not.toMatch(CSI_REPLY_RE) +}) From e84fb46eafb177b7be0f243a546cd30d6070309b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:56:30 -0700 Subject: [PATCH 3/3] test: add golden E2E tests for source control workflows (#14260) * test: add golden E2E tests for source control workflows - Tests core source control interactions: file edit/save, commit staging, and diff viewing - Integrated into CI/CD pipelines for Linux, macOS, and Windows - Includes helper utilities for test setup and worktree management * test(e2e): verify golden commit author and fix test flakiness - Configure git author name/email at worktree level during setup - Verify commits are made with correct author details in assertions - Add explicit timeouts to file visibility waits and git status polling - Fix test ordering to seed edits after source control is open - Simplify git status refresh logic to rely on automatic updates * Add rollback to createGoldenWorktree on setup failure Cleanup callbacks only register after setup succeeds. When a config command fails, the half-built worktree and branch leak into later test runs, causing flakiness. Now we roll back immediately and re-throw the setup error. * test(e2e): match explorer rows after the git status badge appears The golden file-save spec used an exact /^README.md$/ filter. After save, the explorer row text becomes "README.md M", so reopen clicked nothing. * test: strengthen golden worktree setup verification - Track working directory in git call inspection to verify correct execution context - Verify user.name/email config applies to worktree-specific settings, not repo - Add exhaustive setup call sequence assertions to catch setup/rollback leaks --- .github/workflows/golden-e2e-experiment.yml | 3 + .github/workflows/release-cut.yml | 9 + package.json | 1 + tests/e2e/golden-file-open-edit-save.spec.ts | 68 +++++++ .../e2e/golden-source-control-commit.spec.ts | 67 +++++++ .../golden-source-control-open-diff.spec.ts | 52 +++++ tests/e2e/helpers/golden-source-control.ts | 183 ++++++++++++++++++ .../golden-source-control.unit.test.ts | 117 +++++++++++ 8 files changed, 500 insertions(+) create mode 100644 tests/e2e/golden-file-open-edit-save.spec.ts create mode 100644 tests/e2e/golden-source-control-commit.spec.ts create mode 100644 tests/e2e/golden-source-control-open-diff.spec.ts create mode 100644 tests/e2e/helpers/golden-source-control.ts create mode 100644 tests/e2e/helpers/golden-source-control.unit.test.ts diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index fe443880237..205faa49957 100644 --- a/.github/workflows/golden-e2e-experiment.yml +++ b/.github/workflows/golden-e2e-experiment.yml @@ -72,6 +72,7 @@ jobs: fi xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden + xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on macOS if: runner.os == 'macOS' @@ -82,6 +83,7 @@ jobs: fi env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden + env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden - name: Run golden E2E tests on Windows if: runner.os == 'Windows' @@ -93,6 +95,7 @@ jobs: if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) { pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts } + pnpm run --if-present test:e2e:source-control-golden - name: Upload Playwright traces if: failure() diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 8e0a6c97812..c881765b340 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -879,12 +879,20 @@ jobs: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden + - name: Run source-control golden on Linux + if: runner.os == 'Linux' + run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden + - name: Run terminal rendering golden on macOS if: runner.os == 'macOS' run: | env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden + - name: Run source-control golden on macOS + if: runner.os == 'macOS' + run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden + - name: Run fresh-startup golden on Windows if: runner.os == 'Windows' shell: pwsh @@ -892,6 +900,7 @@ jobs: $env:SKIP_BUILD = '1' $env:ORCA_E2E_FORWARD_APP_LOGS = '1' pnpm run --if-present test:e2e:windows-fresh-startup-golden + pnpm run --if-present test:e2e:source-control-golden - name: Upload Playwright traces if: failure() diff --git a/package.json b/package.json index 6f7be4e6f4b..368e3660de6 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1", + "test:e2e:source-control-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-file-open-edit-save.spec.ts tests/e2e/golden-source-control-commit.spec.ts tests/e2e/golden-source-control-open-diff.spec.ts --grep @golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1", "test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2", diff --git a/tests/e2e/golden-file-open-edit-save.spec.ts b/tests/e2e/golden-file-open-edit-save.spec.ts new file mode 100644 index 00000000000..271025f7aa8 --- /dev/null +++ b/tests/e2e/golden-file-open-edit-save.spec.ts @@ -0,0 +1,68 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + activateGoldenWorktree, + cleanupGoldenWorktree, + createGoldenWorktree +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +const README_PATH = 'README.md' + +test('@golden opens, edits, saves, and reopens a tracked file', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createGoldenWorktree(testRepoPath, 'file-save') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + const sentinel = `Golden file save ${Date.now()}` + const readmePath = path.join(fixture.worktreePath, README_PATH) + + await waitForSessionReady(orcaPage) + await activateGoldenWorktree(orcaPage, testRepoPath, fixture.worktreePath) + await orcaPage.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('source-control') + state?.setRightSidebarOpen(true) + }) + await orcaPage.getByRole('button', { name: 'Explorer' }).click() + + const explorer = orcaPage.locator('[data-orca-explorer-shell]') + // Why: after save the row's full text is "README.md M" from the git badge. + const readmeRow = explorer.locator('[data-file-explorer-row]').filter({ + has: orcaPage.locator('[data-file-explorer-row-name]').getByText(README_PATH, { exact: true }) + }) + await expect(readmeRow).toBeVisible({ timeout: 10_000 }) + await readmeRow.click() + + await expect(orcaPage.locator('.editor-header-path').first()).toContainText(README_PATH, { + timeout: 20_000 + }) + const editor = orcaPage.locator('.rich-markdown-editor') + await expect(editor).toBeVisible({ timeout: 25_000 }) + await expect(editor).toContainText('Orca E2E Test Repo') + await editor.click() + await orcaPage.keyboard.press('ControlOrMeta+End') + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.type(sentinel) + await orcaPage.keyboard.press('ControlOrMeta+S') + + await expect.poll(() => readFileSync(readmePath, 'utf8'), { timeout: 10_000 }).toContain(sentinel) + const readmeTab = orcaPage.locator('[data-tab-id]').filter({ hasText: README_PATH }).last() + await readmeTab.getByRole('button', { name: 'Close tab' }).click() + await expect( + orcaPage.locator('.editor-header-path').filter({ hasText: README_PATH }) + ).toHaveCount(0) + + await readmeRow.click() + await expect(orcaPage.locator('.rich-markdown-editor')).toContainText(sentinel, { + timeout: 25_000 + }) + await expect(explorer).toBeVisible() + await expect(readmeRow).toBeVisible() + await expect( + orcaPage.getByText(path.basename(testRepoPath), { exact: true }).first() + ).toBeVisible() +}) diff --git a/tests/e2e/golden-source-control-commit.spec.ts b/tests/e2e/golden-source-control-commit.spec.ts new file mode 100644 index 00000000000..70fb7c4e3e1 --- /dev/null +++ b/tests/e2e/golden-source-control-commit.spec.ts @@ -0,0 +1,67 @@ +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + cleanupGoldenWorktree, + createGoldenWorktree, + GOLDEN_CHANGED_PATH, + GOLDEN_GIT_AUTHOR_EMAIL, + GOLDEN_GIT_AUTHOR_NAME, + installPassingNodePreCommitHook, + openGoldenSourceControl, + seedGoldenSourceEdit +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +test('@golden stages and commits a file through Source Control', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createGoldenWorktree(testRepoPath, 'commit') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + seedGoldenSourceEdit(fixture.worktreePath) + const hookMarkerPath = installPassingNodePreCommitHook(fixture) + + await waitForSessionReady(orcaPage) + await openGoldenSourceControl(orcaPage, testRepoPath, fixture) + + const unstagedRow = orcaPage + .locator('[data-testid="source-control-entry"][data-source-control-area="unstaged"]') + .filter({ hasText: path.basename(GOLDEN_CHANGED_PATH) }) + await expect(unstagedRow).toBeVisible() + const stageButton = unstagedRow.getByRole('button', { name: 'Stage' }) + await stageButton.focus() + await stageButton.press('Enter') + await expect(unstagedRow).toHaveCount(0, { timeout: 10_000 }) + + const stagedRow = orcaPage + .locator('[data-testid="source-control-entry"][data-source-control-area="staged"]') + .filter({ hasText: path.basename(GOLDEN_CHANGED_PATH) }) + await expect(stagedRow).toBeVisible({ timeout: 10_000 }) + await orcaPage.getByRole('textbox', { name: 'Commit message' }).fill('test: golden daily loop') + await orcaPage.getByRole('button', { name: 'Commit', exact: true }).click() + + await expect(stagedRow).toHaveCount(0, { timeout: 20_000 }) + await expect + .poll( + () => + execFileSync('git', ['status', '--porcelain'], { + cwd: fixture.worktreePath, + encoding: 'utf8' + }), + { timeout: 20_000, message: 'Golden commit worktree did not become clean' } + ) + .toBe('') + expect( + execFileSync('git', ['show', '-s', '--format=%an%n%ae%n%s', 'HEAD'], { + cwd: fixture.worktreePath, + encoding: 'utf8' + }).trim() + ).toBe(`${GOLDEN_GIT_AUTHOR_NAME}\n${GOLDEN_GIT_AUTHOR_EMAIL}\ntest: golden daily loop`) + await expect.poll(() => existsSync(hookMarkerPath), { timeout: 20_000 }).toBe(true) + await expect( + orcaPage.locator('[data-sonner-toast]').filter({ hasText: /node|command not found|cmd\.exe/i }) + ).toHaveCount(0) +}) diff --git a/tests/e2e/golden-source-control-open-diff.spec.ts b/tests/e2e/golden-source-control-open-diff.spec.ts new file mode 100644 index 00000000000..1c7f2735889 --- /dev/null +++ b/tests/e2e/golden-source-control-open-diff.spec.ts @@ -0,0 +1,52 @@ +import { realpathSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + cleanupGoldenWorktree, + createGoldenWorktree, + GOLDEN_ADDED_LINE, + GOLDEN_CHANGED_PATH, + GOLDEN_REMOVED_LINE, + openGoldenSourceControl, + seedGoldenSourceEdit +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +test('@golden opens an unstaged file diff from Source Control', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createGoldenWorktree(testRepoPath, 'open-diff') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + + await waitForSessionReady(orcaPage) + await openGoldenSourceControl(orcaPage, testRepoPath, fixture) + seedGoldenSourceEdit(fixture.worktreePath) + + const changedFile = orcaPage + .locator('[data-testid="source-control-entry"]') + .filter({ hasText: path.basename(GOLDEN_CHANGED_PATH) }) + await expect(changedFile).toBeVisible({ timeout: 15_000 }) + await changedFile.click() + + await expect(orcaPage.locator('.monaco-diff-editor')).toBeVisible({ timeout: 20_000 }) + await expect( + orcaPage + .locator('.original-in-monaco-diff-editor .view-line') + .filter({ hasText: GOLDEN_REMOVED_LINE }) + ).toBeVisible() + await expect( + orcaPage + .locator('.modified-in-monaco-diff-editor .view-line') + .filter({ hasText: GOLDEN_ADDED_LINE }) + ).toBeVisible() + await expect(orcaPage.locator('.editor-header-path').first()).toHaveAttribute( + 'title', + `${realpathSync(path.join(fixture.worktreePath, GOLDEN_CHANGED_PATH))} (diff)` + ) + + const probe = orcaPage.getByRole('button', { name: /Source Control/ }) + await probe.focus() + await expect(probe).toBeFocused() +}) diff --git a/tests/e2e/helpers/golden-source-control.ts b/tests/e2e/helpers/golden-source-control.ts new file mode 100644 index 00000000000..a0c2cf99300 --- /dev/null +++ b/tests/e2e/helpers/golden-source-control.ts @@ -0,0 +1,183 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' + +export const GOLDEN_CHANGED_PATH = 'src/index.ts' +export const GOLDEN_REMOVED_LINE = 'export const hello = "world"' +export const GOLDEN_ADDED_LINE = 'export const hello = "golden daily loop"' +export const GOLDEN_GIT_AUTHOR_NAME = 'Orca E2E' +export const GOLDEN_GIT_AUTHOR_EMAIL = 'orca-e2e@example.invalid' +const GOLDEN_PRE_COMMIT_MARKER = '.e2e-pre-commit-ran' + +export type GoldenWorktree = { + branchName: string + hooksPath?: string + worktreePath: string +} + +export function createGoldenWorktree(repoPath: string, label: string): GoldenWorktree { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}` + const branchName = `e2e-golden-${label}-${suffix}` + const worktreePath = path.join(os.tmpdir(), branchName) + execFileSync('git', ['worktree', 'add', worktreePath, '-b', branchName], { + cwd: repoPath, + stdio: 'pipe' + }) + const fixture: GoldenWorktree = { branchName, worktreePath } + // Callers only register cleanup once this returns, so roll back here or the + // half-built worktree and branch leak into every later run. + try { + execFileSync('git', ['config', 'extensions.worktreeConfig', 'true'], { + cwd: worktreePath, + stdio: 'pipe' + }) + execFileSync('git', ['config', '--worktree', 'user.name', GOLDEN_GIT_AUTHOR_NAME], { + cwd: worktreePath, + stdio: 'pipe' + }) + execFileSync('git', ['config', '--worktree', 'user.email', GOLDEN_GIT_AUTHOR_EMAIL], { + cwd: worktreePath, + stdio: 'pipe' + }) + } catch (setupError) { + try { + cleanupGoldenWorktree(repoPath, fixture) + } catch { + // Keep the setup failure as the reported cause. + } + throw setupError + } + return fixture +} + +export function cleanupGoldenWorktree(repoPath: string, fixture: GoldenWorktree): void { + try { + execFileSync('git', ['config', '--worktree', '--unset-all', 'core.hooksPath'], { + cwd: fixture.worktreePath, + stdio: 'pipe' + }) + } catch { + // The hook setting may not have been installed before setup failed. + } + if (fixture.hooksPath) { + rmSync(fixture.hooksPath, { recursive: true, force: true }) + } + try { + execFileSync('git', ['worktree', 'remove', '--force', fixture.worktreePath], { + cwd: repoPath, + stdio: 'pipe' + }) + } catch { + rmSync(fixture.worktreePath, { recursive: true, force: true }) + execFileSync('git', ['worktree', 'prune'], { cwd: repoPath, stdio: 'pipe' }) + } + execFileSync('git', ['branch', '-D', fixture.branchName], { cwd: repoPath, stdio: 'pipe' }) +} + +export function seedGoldenSourceEdit(worktreePath: string): void { + const changedPath = path.join(worktreePath, GOLDEN_CHANGED_PATH) + const original = readFileSync(changedPath, 'utf8') + if (!original.includes(GOLDEN_REMOVED_LINE)) { + throw new Error(`Golden source fixture is missing: ${GOLDEN_REMOVED_LINE}`) + } + writeFileSync(changedPath, original.replace(GOLDEN_REMOVED_LINE, GOLDEN_ADDED_LINE)) +} + +export function installPassingNodePreCommitHook(fixture: GoldenWorktree): string { + const hooksPath = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-git-hooks-')) + const hookPath = path.join(hooksPath, 'pre-commit') + const markerPath = path.join(hooksPath, GOLDEN_PRE_COMMIT_MARKER) + fixture.hooksPath = hooksPath + execFileSync('git', ['config', '--worktree', 'core.hooksPath', hooksPath], { + cwd: fixture.worktreePath, + stdio: 'pipe' + }) + writeFileSync( + hookPath, + `#!/bin/sh\nnode -e "const fs = require('node:fs'); const path = require('node:path'); fs.writeFileSync(path.join(path.dirname(process.argv[1]), '${GOLDEN_PRE_COMMIT_MARKER}'), 'ran')" "$0"\n` + ) + chmodSync(hookPath, 0o755) + return markerPath +} + +export async function activateGoldenWorktree( + page: Page, + repoPath: string, + worktreePath: string +): Promise { + await expect + .poll( + () => + page.evaluate( + async ({ registeredRepoPath, targetWorktreePath }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await store.getState().fetchRepos() + const normalize = (value: string): string => { + const normalized = value + .replace(/\\/g, '/') + .replace(/^\/private(?=\/var\/)/, '') + .replace(/\/+$/, '') + return navigator.userAgent.includes('Windows') ? normalized.toLowerCase() : normalized + } + const repo = store + .getState() + .repos.find((entry) => normalize(entry.path) === normalize(registeredRepoPath)) + if (!repo) { + return false + } + await store.getState().fetchWorktrees(repo.id) + const worktree = (store.getState().worktreesByRepo[repo.id] ?? []).find( + (entry) => normalize(entry.path) === normalize(targetWorktreePath) + ) + if (!worktree) { + return false + } + store.getState().setActiveRepo(repo.id) + store.getState().setActiveWorktree(worktree.id) + return true + }, + { registeredRepoPath: repoPath, targetWorktreePath: worktreePath } + ), + { timeout: 10_000, message: `Golden worktree did not load: ${worktreePath}` } + ) + .toBe(true) +} + +export async function openGoldenSourceControl( + page: Page, + repoPath: string, + fixture: GoldenWorktree +): Promise { + await activateGoldenWorktree(page, repoPath, fixture.worktreePath) + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + state.setRightSidebarTab('explorer') + state.setRightSidebarOpen(true) + }) + await page.getByRole('button', { name: /Source Control/ }).click() + await expect(page.getByRole('textbox', { name: 'Commit message' })).toBeVisible({ + timeout: 10_000 + }) + await expect + .poll( + () => + page.evaluate(() => { + const state = window.__store?.getState() + return state?.activeWorktreeId + ? Object.hasOwn(state.gitStatusByWorktree, state.activeWorktreeId) + : false + }), + { timeout: 10_000, message: 'Automatic Git status refresh did not complete' } + ) + .toBe(true) +} diff --git a/tests/e2e/helpers/golden-source-control.unit.test.ts b/tests/e2e/helpers/golden-source-control.unit.test.ts new file mode 100644 index 00000000000..c607c3fa89e --- /dev/null +++ b/tests/e2e/helpers/golden-source-control.unit.test.ts @@ -0,0 +1,117 @@ +import { execFileSync } from 'node:child_process' +import os from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createGoldenWorktree, + GOLDEN_GIT_AUTHOR_EMAIL, + GOLDEN_GIT_AUTHOR_NAME +} from './golden-source-control' + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })) + +const execFileSyncMock = vi.mocked(execFileSync) + +type GitCall = { args: string[]; cwd?: string } + +/** Setup runs in the new worktree while rollback runs in the repo, so cwd is part of the contract. */ +const gitCallsFor = (): GitCall[] => + execFileSyncMock.mock.calls.map(([, args, options]) => ({ + args: (args ?? []) as string[], + cwd: (options as { cwd?: string } | undefined)?.cwd + })) + +/** Args-only view for lookups that locate a call rather than assert its full shape. */ +const gitArgsFor = (): string[][] => gitCallsFor().map((call) => call.args) + +/** Returns the thrown value itself; `toThrow` only compares messages, not identity. */ +const captureThrow = (run: () => void): unknown => { + try { + run() + return undefined + } catch (error) { + return error + } +} + +/** The worktree path and branch name are randomly suffixed, so read them back off the add call. */ +const worktreeAddTargets = (): { branchName: string; worktreePath: string } => { + const addArgs = gitArgsFor().find((args) => args[0] === 'worktree' && args[1] === 'add') + expect(addArgs).toBeDefined() + const [, , worktreePath, , branchName] = addArgs as string[] + return { branchName, worktreePath } +} + +describe('createGoldenWorktree', () => { + beforeEach(() => { + execFileSyncMock.mockReset() + }) + + /** A half-built worktree leaks into later runs unless both the worktree and the branch go away. */ + it('rolls back the worktree and branch when a configuration command fails', () => { + const setupError = new Error('git config --worktree unsupported') + execFileSyncMock.mockImplementation(((_file: string, args: string[]) => { + if (args[0] === 'config' && args[1] === 'extensions.worktreeConfig') { + throw setupError + } + return '' + }) as unknown as typeof execFileSync) + + expect(captureThrow(() => createGoldenWorktree('/repo', 'rollback'))).toBe(setupError) + + const { branchName, worktreePath } = worktreeAddTargets() + expect(branchName).toMatch(/^e2e-golden-rollback-/) + expect(gitCallsFor()).toContainEqual({ + args: ['worktree', 'remove', '--force', worktreePath], + cwd: '/repo' + }) + expect(gitCallsFor()).toContainEqual({ args: ['branch', '-D', branchName], cwd: '/repo' }) + }) + + /** Rollback must still be attempted in full, and its own failure must not mask the setup cause. */ + it('keeps the setup error when rollback itself fails', () => { + const setupError = new Error('git config --worktree unsupported') + execFileSyncMock.mockImplementation(((_file: string, args: string[]) => { + if (args[0] === 'config' && args[1] === 'extensions.worktreeConfig') { + throw setupError + } + if (args[0] === 'branch') { + throw new Error('branch is still checked out') + } + return '' + }) as unknown as typeof execFileSync) + + expect(captureThrow(() => createGoldenWorktree('/repo', 'rollback-fails'))).toBe(setupError) + + const { branchName, worktreePath } = worktreeAddTargets() + expect(branchName).toMatch(/^e2e-golden-rollback-fails-/) + expect(gitCallsFor()).toContainEqual({ + args: ['worktree', 'remove', '--force', worktreePath], + cwd: '/repo' + }) + expect(gitCallsFor()).toContainEqual({ args: ['branch', '-D', branchName], cwd: '/repo' }) + }) + + /** The identity config must land on the worktree, not the repo, or commits pick up the host author. */ + it('returns the fixture when every setup command succeeds', () => { + execFileSyncMock.mockReturnValue('') + + const fixture = createGoldenWorktree('/repo', 'happy') + + expect(fixture.branchName).toMatch(/^e2e-golden-happy-/) + expect(fixture.worktreePath).toBe(path.join(os.tmpdir(), fixture.branchName)) + // Exhaustive: also proves no rollback ran. + expect(gitCallsFor()).toEqual([ + { args: ['worktree', 'add', fixture.worktreePath, '-b', fixture.branchName], cwd: '/repo' }, + { args: ['config', 'extensions.worktreeConfig', 'true'], cwd: fixture.worktreePath }, + { + args: ['config', '--worktree', 'user.name', GOLDEN_GIT_AUTHOR_NAME], + cwd: fixture.worktreePath + }, + { + args: ['config', '--worktree', 'user.email', GOLDEN_GIT_AUTHOR_EMAIL], + cwd: fixture.worktreePath + } + ]) + }) +})