diff --git a/.github/workflows/golden-e2e-experiment.yml b/.github/workflows/golden-e2e-experiment.yml index 1cb71941070..205940127f9 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,17 +67,25 @@ 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 xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-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' 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 env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-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' @@ -86,6 +94,10 @@ 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 + } + 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 d1eebcf4097..fe79e9c6094 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -783,7 +783,7 @@ jobs: { echo "## Release E2E Signal" echo "" - echo "- Platform golden E2E is release-blocking: terminal rendering, restrictive-umask profile writes, and agent TUI launch on Linux/macOS, plus fresh startup on Windows." + echo "- Platform golden E2E is release-blocking: terminal rendering, restrictive-umask profile writes, source control, and agent TUI launch on Linux/macOS, plus fresh startup and source control on Windows." echo "- Exception: every golden except terminal rendering runs with \`--if-present\`, so it is skipped (not failed) on older tags that predate its script." echo "- Full E2E runs separately after publication and cannot change the release result." echo "- Terminal rendering release evidence is diagnostic/non-blocking." @@ -880,6 +880,10 @@ 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: | @@ -894,6 +898,10 @@ jobs: if: runner.os == 'macOS' run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-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 @@ -901,6 +909,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 7c23e2cb120..b878dddd64b 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:agent-tui-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-agent-tui-launch.spec.ts tests/e2e/golden-shell-after-agent-exit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", 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/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/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 } : {}) + } +} 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-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) +}) 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 + } + ]) + }) +})