Merge branch 'main' of https://github.com/stablyai/orca into golden-agent-tui

This commit is contained in:
Jinjing
2026-08-13 10:06:23 -07:00
41 changed files with 2819 additions and 350 deletions
+13 -1
View File
@@ -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()
+10 -1
View File
@@ -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()
+1
View File
@@ -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",
+33
View File
@@ -2189,6 +2189,39 @@ describe('repos:add + repos:clone', () => {
})
})
it('validates source definitions and preferences through local repos:update', () => {
const updated = {
id: 'repo-source-visibility',
path: '/tmp/repo-source-visibility',
displayName: 'repo-source-visibility',
kind: 'git',
badgeColor: '#22c55e'
}
mockStore.updateRepo.mockReturnValue(updated)
handlers.get('repos:update')!(null, {
repoId: updated.id,
updates: {
customWorktreeVisibilitySources: [
{ id: 'team', rootPath: ' /srv/team ' },
{ id: 'bad id', rootPath: '/srv/other' }
],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'invalid' },
custom: { team: 'hide' }
}
}
})
expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, {
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team' }],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show' },
custom: { team: 'hide' }
}
})
})
it('prepares and invalidates roots when project host setup update changes worktree base path', () => {
const repo = {
id: 'repo-setup-update-root',
+26
View File
@@ -118,6 +118,10 @@ import {
} from '../project-groups/folder-workspace-path-status'
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../../shared/worktree-visibility-sources'
import { runWithGitReadCacheInvalidation } from '../git/status'
import { isAdmissibleDirectSshAuthority } from '../../shared/ssh-retained-payload-admission'
import { isCurrentSshProviderAuthority } from '../ssh/ssh-provider-authority'
@@ -2127,6 +2131,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
| 'projectGroupId'
| 'projectGroupOrder'
>
@@ -2205,6 +2211,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
) {
delete updates.agentWorktreeVisibility
}
if ('customWorktreeVisibilitySources' in updates) {
const normalized = normalizeCustomWorktreeVisibilitySources(
updates.customWorktreeVisibilitySources
)
if (!normalized) {
delete updates.customWorktreeVisibilitySources
} else {
updates.customWorktreeVisibilitySources = normalized
}
}
if ('worktreeVisibilitySourcePreferences' in updates) {
const normalized = normalizeWorktreeVisibilitySourcePreferences(
updates.worktreeVisibilitySourcePreferences
)
if (!normalized) {
delete updates.worktreeVisibilitySourcePreferences
} else {
updates.worktreeVisibilitySourcePreferences = normalized
}
}
if (
'externalWorktreeVisibilityPromptDismissedAt' in updates &&
updates.externalWorktreeVisibilityPromptDismissedAt !== undefined &&
+23 -14
View File
@@ -66,7 +66,10 @@ import {
isLegacyRepoForExternalWorktreeVisibility,
toDetectedWorktree
} from '../../shared/worktree-ownership'
import { createAgentScratchWorktreePathMatcher } from '../../shared/agent-scratch-worktrees'
import {
createWorktreeVisibilitySourceMatcher,
normalizeCustomWorktreeVisibilitySources
} from '../../shared/worktree-visibility-sources'
import {
assertWorktreeCleanForRemoval,
forceDeleteLocalBranch,
@@ -850,10 +853,10 @@ function buildDetectedGitWorktrees(
const liveWorktrees = dedupeWorktreesByPath(
gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable)
)
const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([
repo.path,
...liveWorktrees.map((worktree) => worktree.path)
])
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...liveWorktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
const detected = liveWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
let meta = store.getWorktreeMeta(worktreeId)
@@ -865,7 +868,7 @@ function buildDetectedGitWorktrees(
settings,
knownOrcaLayouts,
isLegacyRepoForVisibility,
agentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher
})
if (!detected.visible) {
return detected
@@ -879,7 +882,7 @@ function buildDetectedGitWorktrees(
settings,
knownOrcaLayouts,
isLegacyRepoForVisibility,
agentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher
})
})
return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {})
@@ -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)
})
+39 -3
View File
@@ -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(
+54
View File
@@ -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: {
+102
View File
@@ -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()
+62 -33
View File
@@ -486,11 +486,12 @@ import {
isLegacyRepoForExternalWorktreeVisibility,
toDetectedWorktree
} from '../../shared/worktree-ownership'
import { isAgentScratchRepoRootPath } from '../../shared/agent-scratch-worktrees'
import {
createAgentScratchWorktreePathMatcher,
isAgentScratchRepoRootPath,
type AgentScratchWorktreePathMatcher
} from '../../shared/agent-scratch-worktrees'
createWorktreeVisibilitySourceMatcher,
normalizeCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from '../../shared/worktree-visibility-sources'
import {
BROWSER_HEADLESS_RUNTIME_CAPABILITY,
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY,
@@ -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<string, string[]>()
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<string, WorktreeVisibilitySourceMatcher> {
const checkoutPathsByRepoId = new Map<string, string[]>()
for (const worktree of worktrees) {
const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? []
checkoutPaths.push(worktree.path)
checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths)
}
return new Map(
(this.store?.getRepos() ?? [])
.filter((repo) => checkoutPathsByRepoId.has(repo.id))
.map((repo) => [
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
)
])
)
}
private toRuntimeDetectedWorktree(
repo: Repo,
worktree: Worktree,
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
})
}
@@ -3,6 +3,10 @@ import { OptionalFiniteNumber, OptionalString } from '../schemas'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../../../../shared/worktree-visibility-sources'
export const RepoSourceControlAiOverrides = z
.unknown()
@@ -55,6 +59,14 @@ export function createRepoUpdateSchema<T extends z.ZodRawShape>(
externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(),
importedExternalWorktreePaths: z.array(z.string()).optional(),
agentWorktreeVisibility: z.enum(['hide', 'show']).optional(),
customWorktreeVisibilitySources: z
.unknown()
.transform((value) => normalizeCustomWorktreeVisibilitySources(value))
.optional(),
worktreeVisibilitySourcePreferences: z
.unknown()
.transform((value) => normalizeWorktreeVisibilitySourcePreferences(value))
.optional(),
externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(),
projectGroupId: OptionalString.nullable().optional(),
projectGroupOrder: OptionalFiniteNumber,
+32
View File
@@ -330,6 +330,38 @@ describe('repo RPC methods', () => {
})
})
it('validates additive source settings at the remote RPC boundary', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepo: vi.fn().mockResolvedValue({ id: 'repo-1', path: '/srv/repo' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
await dispatcher.dispatch(
makeRequest('repo.update', {
repo: 'repo-1',
updates: {
customWorktreeVisibilitySources: [
{ id: 'team', rootPath: ' /srv/team ' },
{ id: 'relative', rootPath: '../team' }
],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'bad' },
custom: { team: 'hide' }
}
}
})
)
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team' }],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show' },
custom: { team: 'hide' }
}
})
})
it('persists resolved GitHub upstream metadata updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -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()
+2
View File
@@ -1197,6 +1197,8 @@ export type PreloadApi = {
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
| 'agentWorktreeVisibility'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
| 'projectGroupId'
| 'projectGroupOrder'
| 'forkSyncMode'
@@ -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<void>
}
export default function HiddenWorktreeRecoveryList({
repo,
detected,
listState,
busyPath,
toggling,
onShow
}: Props): React.JSX.Element | null {
const [query, setQuery] = useState('')
const headingId = useId()
const listRef = useRef<HTMLDivElement>(null)
const hidden = getHiddenImportableExternalWorktrees(detected)
const normalizedQuery = query.trim().toLocaleLowerCase()
const filtered = normalizedQuery
? hidden.filter(
(worktree) =>
worktree.displayName.toLocaleLowerCase().includes(normalizedQuery) ||
worktree.path.toLocaleLowerCase().includes(normalizedQuery)
)
: hidden
const discoveredCount = hidden.length + getVisibleNonOrcaWorktrees(detected).length
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => listRef.current,
estimateSize: () => 56,
getItemKey: (index) => filtered[index]?.id ?? index,
overscan: 3,
initialRect: { width: 480, height: 224 }
})
if (hidden.length === 0 && listState !== 'ready') {
return null
}
return (
<section className="grid min-w-0 gap-2">
<div className="flex items-center justify-between gap-3">
<div>
<h3 id={headingId} className="text-sm font-medium">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.7d21c5e848',
'Hidden worktrees ({{value0}})',
{ value0: hidden.length }
)}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.HiddenWorktreeRecoveryList.64e6f53f05',
'Show one without enabling its source.'
)}
</p>
</div>
{hidden.length >= 10 ? (
<div className="relative w-48 shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-2 size-3.5 text-muted-foreground" />
<Input
type="search"
className="h-8 pl-8 text-xs"
value={query}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilityDialog.search',
'Search hidden worktrees'
)}
placeholder={translate(
'auto.components.sidebar.WorktreeVisibilityDialog.searchPlaceholder',
'Search {{value0}} worktrees…',
{ value0: hidden.length }
)}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
) : null}
</div>
{filtered.length > 0 ? (
<div
ref={listRef}
role="region"
aria-labelledby={headingId}
className="scrollbar-sleek max-h-56 min-w-0 overflow-y-auto"
tabIndex={0}
style={{ height: `${Math.min(virtualizer.getTotalSize(), 224)}px` }}
>
<ul className="relative min-w-0" style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const worktree = filtered[virtualRow.index]
if (!worktree) {
return null
}
const displayPath = relativePathInsideRoot(repo.path, worktree.path) || worktree.path
return (
<li
key={worktree.id}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 w-full pb-1"
style={{ transform: `translateY(${virtualRow.start}px)` }}
aria-posinset={virtualRow.index + 1}
aria-setsize={filtered.length}
>
<div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2 hover:bg-accent/50">
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{worktree.displayName}</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{displayPath}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={busyPath !== null || toggling || listState === 'checking'}
aria-label={translate(
'auto.components.sidebar.HiddenWorktreeRecoveryList.showWorktree',
'Show {{value0}} at {{value1}}',
{ value0: worktree.displayName, value1: displayPath }
)}
onClick={() => void onShow(worktree.path)}
>
{busyPath === worktree.path
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.2f80cd4b97',
'Showing…'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.e64b81d3a9',
'Show'
)}
</Button>
</div>
</li>
)
})}
</ul>
</div>
) : listState === 'ready' ? (
<div className="flex min-h-16 items-center gap-3 rounded-lg border border-border bg-muted/30 p-3">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
<FolderMinus className="size-3.5" />
</span>
<div className="min-w-0">
<div className="text-[13px] font-medium">
{normalizedQuery
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.noMatches',
'No matching worktrees'
)
: discoveredCount > 0
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.allShown',
'All discovered worktrees are shown'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.noneFound',
'No non-Orca worktrees found'
)}
</div>
<div className="text-xs text-muted-foreground">
{normalizedQuery
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.tryDifferentSearch',
'Try a different name or path.'
)
: discoveredCount > 0
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.disableSource',
'Disable a source to manage its worktrees individually.'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.appearWhenDetected',
'New worktrees will appear here when Orca detects them.'
)}
</div>
</div>
</div>
) : null}
</section>
)
}
@@ -38,6 +38,12 @@ vi.mock('@/components/ui/dialog', () => ({
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, unknown>) =>
values
@@ -124,7 +130,22 @@ beforeEach(() => {
mocks.state.modalData = { repoId: 'repo-1' }
mocks.state.repos = [makeRepo()]
mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected() }
mocks.state.updateRepo.mockResolvedValue(true)
mocks.state.updateRepo.mockImplementation(
async (repoId: string, updates: Record<string, unknown>) => {
const repo = (mocks.state.repos as Repo[]).find((candidate) => candidate.id === repoId)
if (!repo) {
return false
}
for (const [key, value] of Object.entries(updates)) {
if (value === null) {
delete (repo as unknown as Record<string, unknown>)[key]
} else {
;(repo as unknown as Record<string, unknown>)[key] = value
}
}
return true
}
)
mocks.state.fetchWorktrees.mockResolvedValue(true)
container = document.createElement('div')
document.body.appendChild(container)
@@ -168,10 +189,12 @@ function buttonWithText(text: string): HTMLButtonElement {
return button as HTMLButtonElement
}
function alwaysShowSwitch(): HTMLButtonElement {
const control = document.querySelector('[role="switch"]')
function sourceSwitch(label = 'Claude Code'): HTMLButtonElement {
const control = document.querySelector(
`[role="switch"][aria-label="Show current and future worktrees from ${label}"]`
)
if (!control) {
throw new Error('No Always show switch')
throw new Error(`No ${label} source switch`)
}
return control as HTMLButtonElement
}
@@ -182,14 +205,20 @@ async function click(element: HTMLElement): Promise<void> {
})
}
async function setInputValue(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
})
}
describe('WorktreeVisibilityDialog', () => {
it('lists a hidden agent worktree with a repo-relative path', async () => {
await renderDialog()
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<HTMLInputElement>(
'input[aria-label="Search hidden worktrees"]'
)
expect(search).not.toBeNull()
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(
search,
'scratch-499'
)
search!.dispatchEvent(new Event('input', { bubbles: true }))
search!.dispatchEvent(new Event('change', { bubbles: true }))
})
expect(document.body.textContent).toContain('scratch-499')
expect(document.body.textContent).not.toContain('scratch-0')
})
it('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<HTMLInputElement>('#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<string, string> }
}
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<HTMLInputElement>('#custom-worktree-root')!,
'/srv/x'
)
expect(normalize).not.toHaveBeenCalled()
normalize.mockRestore()
})
it('distinguishes invalid, duplicate, limit, and save failures when adding a source', async () => {
const existing = Array.from({ length: 32 }, (_, index) => ({
id: `source-${index}`,
rootPath: `/srv/source-${index}`
}))
mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing })]
await renderDialog()
const input = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(input, '/srv/new')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Remove a custom location before adding another.'
)
expect(mocks.state.updateRepo).not.toHaveBeenCalled()
mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing.slice(0, 1) })]
await act(async () => root.render(null))
await renderDialog()
const duplicateInput = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(duplicateInput, '/srv/source-0')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'This location is already listed.'
)
await setInputValue(duplicateInput, 'relative/path')
await click(buttonWithText('Add'))
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'Enter an absolute path for this host.'
)
mocks.state.updateRepo.mockResolvedValue(false)
await setInputValue(duplicateInput, '/srv/valid')
await click(buttonWithText('Add'))
const alerts = [...document.querySelectorAll('[role="alert"]')]
expect(alerts).toHaveLength(1)
expect(alerts[0]?.textContent).toContain('Could not update worktree visibility. Try again.')
expect(alerts[0]?.textContent).not.toContain('Enter an absolute path')
})
it('reports only unsupported-host copy when an older host strips an added source', async () => {
mocks.state.updateRepo.mockResolvedValue(true)
await renderDialog()
mocks.state.fetchWorktrees.mockClear()
const input = document.querySelector<HTMLInputElement>('#custom-worktree-root')!
await setInputValue(input, '/srv/valid')
await click(buttonWithText('Add'))
const alerts = [...document.querySelectorAll('[role="alert"]')]
expect(alerts).toHaveLength(1)
expect(alerts[0]?.textContent).toContain(
"This host doesn't support source-specific worktree visibility."
)
expect(alerts[0]?.textContent).not.toContain('Enter an absolute path')
expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled()
})
it('removes custom locations without changing other source preferences', async () => {
mocks.state.repos = [
makeRepo({
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<HTMLButtonElement>(
'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()
})
})
@@ -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<string | null>(null)
const [isToggling, setIsToggling] = useState(false)
const [listState, setListState] = useState<'checking' | 'ready' | 'failed'>('checking')
const alwaysShowSwitchId = useId()
const hiddenListHeadingId = useId()
const hiddenListRef = useRef<HTMLDivElement>(null)
const isOpen = activeModal === 'worktree-visibility'
const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : ''
@@ -76,25 +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<typeof updateTargetRepo>[1],
isAccepted: (latestRepo: Repo) => boolean
): Promise<boolean> => {
if (!repoId) {
return false
}
const mutation: ActiveVisibilityMutation = { kind: 'toggle' }
startVisibilityMutation(mutationScope, mutation)
setActionState(null)
setIsToggling(true)
try {
const updated = await updateTargetRepo(repoId, {
externalWorktreeVisibility: checked ? 'show' : 'hide',
agentWorktreeVisibility: checked ? 'show' : 'hide',
// Why: showing hidden externals again should re-enable the inbox if the
// user previously opted out of discovery prompts for this repo.
// Why: null is the transport sentinel for clearing on remote runtime paths
// where `undefined` is stripped before persistence.
...(checked ? { externalWorktreeDiscoverySuppressedAt: null } : {})
})
if (!updated) {
const updated = await updateTargetRepo(repoId, updates)
const latestRepo = getLatestRepoForScope(mutationScope)
if (!updated || !latestRepo || !isAccepted(latestRepo)) {
if (currentMutationScopeRef.current === mutationScope) {
setActionState({
pending: false,
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<WorktreeVisibilitySourceAddResult> => {
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 (
<Dialog open onOpenChange={(open) => !open && closeModal()}>
<DialogContent className="sm:max-w-lg">
<DialogContent className="scrollbar-sleek max-h-[calc(100vh-6rem)] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-1.5">
<DialogTitle>
@@ -253,51 +348,14 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
<DialogDescription>{repo.displayName}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{alwaysShow ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{alwaysShow
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.3e045d4cb8',
'Shown in sidebar'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.5d02a5647f',
'Hidden from sidebar'
)}
</div>
<div className="text-xs text-muted-foreground">
{alwaysShow
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.8372e4bbd9',
'{{value0}} currently shown',
{ value0: shownWorktreeLabel }
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.25ddf19920',
'{{value0}} currently hidden',
{ value0: hiddenWorktreeLabel }
)}
</div>
</div>
<Label htmlFor={alwaysShowSwitchId} className="shrink-0 gap-2">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.f1f71b9f02',
'Always show'
)}
<Switch
id={alwaysShowSwitchId}
checked={alwaysShow}
disabled={
effectiveBusyPath !== null || effectivelyToggling || listState === 'checking'
}
onCheckedChange={(checked) => void handleAlwaysShowChange(checked)}
/>
</Label>
</div>
<WorktreeVisibilitySourceList
repo={repo}
worktrees={detected?.authoritative ? detected.worktrees : []}
disabled={effectiveBusyPath !== null || effectivelyToggling || listState === 'checking'}
onAdd={handleAddSource}
onRemove={handleRemoveSource}
onToggle={handleSourceToggle}
/>
{listState === 'checking' ? (
<p aria-live="polite" className="text-xs text-muted-foreground">
@@ -328,89 +386,14 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
</div>
) : null}
{hiddenImportable.length > 0 ? (
<div className="grid min-w-0 gap-2">
<div>
<h3 id={hiddenListHeadingId} className="text-sm font-medium">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.7d21c5e848',
'Hidden worktrees ({{value0}})',
{ value0: hiddenImportable.length }
)}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilityDialog.9b53f7a160',
'Choose which hidden worktrees to show individually.'
)}
</p>
</div>
<div
ref={hiddenListRef}
aria-labelledby={hiddenListHeadingId}
className="scrollbar-sleek max-h-56 min-w-0 overflow-y-auto"
tabIndex={0}
style={{
height: `${Math.min(hiddenListVirtualizer.getTotalSize(), 224)}px`
}}
>
<ul
className="relative min-w-0"
style={{ height: `${hiddenListVirtualizer.getTotalSize()}px` }}
>
{hiddenListVirtualizer.getVirtualItems().map((virtualRow) => {
const worktree = hiddenImportable[virtualRow.index]
if (!worktree) {
return null
}
const displayPath =
relativePathInsideRoot(repo.path, worktree.path) || worktree.path
return (
<li
key={worktree.id}
ref={hiddenListVirtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 w-full pb-1"
style={{ transform: `translateY(${virtualRow.start}px)` }}
aria-posinset={virtualRow.index + 1}
aria-setsize={hiddenImportable.length}
>
<div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm">{worktree.displayName}</div>
<div className="truncate text-xs text-muted-foreground">
{displayPath}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={
effectiveBusyPath !== null ||
effectivelyToggling ||
listState === 'checking'
}
onClick={() => void handleShowWorktree(worktree.path)}
>
{effectiveBusyPath === worktree.path
? translate(
'auto.components.sidebar.WorktreeVisibilityDialog.2f80cd4b97',
'Showing…'
)
: translate(
'auto.components.sidebar.WorktreeVisibilityDialog.e64b81d3a9',
'Show'
)}
</Button>
</div>
</li>
)
})}
</ul>
</div>
</div>
) : null}
<HiddenWorktreeRecoveryList
repo={repo}
detected={detected}
listState={listState}
busyPath={effectiveBusyPath}
toggling={effectivelyToggling}
onShow={handleShowWorktree}
/>
{actionState?.error ? (
<p className="text-xs text-destructive" role="alert">
@@ -67,9 +67,8 @@ describe('WorktreeVisibilityHelpPopover', () => {
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(content()?.getAttribute('aria-label')).toBe('Which worktrees are hidden by default?')
expect(content()?.textContent).toContain('current and future non-Orca worktrees')
expect(content()?.textContent).toContain('.claude/worktrees/*')
expect(content()?.textContent).toContain('.gsd-workspaces/*')
expect(content()?.textContent).toContain('current and future worktrees')
expect(content()?.textContent).toContain('show individual worktrees below')
await dispatch(
hoverTarget!,
@@ -26,7 +26,6 @@ export default function WorktreeVisibilityHelpPopover(): React.JSX.Element {
className="size-5 text-muted-foreground hover:bg-transparent hover:text-foreground dark:hover:bg-transparent"
aria-label={title}
aria-expanded={open}
title={title}
onClick={() => setOpen(true)}
>
<CircleHelp className="size-3.5" />
@@ -50,14 +49,14 @@ export default function WorktreeVisibilityHelpPopover(): React.JSX.Element {
</li>
<li>
{translate(
'auto.components.sidebar.WorktreeVisibilityHelpPopover.54a7c2f183',
'For newly added repositories, worktrees created manually or by another tool are hidden by default to avoid unexpected sidebar clutter.'
'auto.components.sidebar.WorktreeVisibilityHelpPopover.ec1e6a10fb',
'Other worktrees start hidden to avoid unexpected sidebar clutter.'
)}
</li>
<li>
{translate(
'auto.components.sidebar.WorktreeVisibilityHelpPopover.b9e0d43675',
'Always show includes current and future non-Orca worktrees, including agent worktrees under .claude/worktrees/* and .gsd-workspaces/*. When it is off, use Show below to add one individually.'
'auto.components.sidebar.WorktreeVisibilityHelpPopover.1c68c9cf77',
'Enable a source for all current and future worktrees, or show individual worktrees below.'
)}
</li>
</ul>
@@ -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<WorktreeVisibilitySourceAddResult>
onRemove: (source: CustomWorktreeVisibilitySource) => Promise<void>
onToggle: (source: WorktreeVisibilitySourceRow, enabled: boolean) => Promise<void>
}
export type WorktreeVisibilitySourceAddResult =
| 'added'
| 'invalid-path'
| 'duplicate-path'
| 'limit'
| 'save-failed'
function getSourceLabel(source: WorktreeVisibilitySourceRow): string {
if (source.kind === 'built-in') {
return source.id === 'claude'
? 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<string | null>(null)
const customSources = useMemo(
() => normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [],
[repo.customWorktreeVisibilitySources]
)
const sources = useMemo<WorktreeVisibilitySourceRow[]>(
() => [
{ kind: 'built-in', id: 'claude' },
{ kind: 'built-in', id: 'gsd' },
...customSources.map((source) => ({ kind: 'custom' as const, source })),
{ kind: 'other' }
],
[customSources]
)
const classify = useMemo(
() =>
createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
customSources
),
[customSources, repo.path, worktrees]
)
const sourceCounts = useMemo(() => {
const counts = new Map<string, number>()
for (const worktree of worktrees) {
if (worktree.selectedCheckout || worktree.ownership === 'orca-managed') {
continue
}
const key = sourceMatchKey(worktree.visibilitySource ?? classify(worktree.path))
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
}, [classify, worktrees])
const handleAdd = async (event: React.FormEvent) => {
event.preventDefault()
setInputError(null)
const 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 (
<section className="grid min-w-0 gap-2" aria-labelledby="worktree-sources-heading">
<div>
<h3 id="worktree-sources-heading" className="text-sm font-medium">
{translate('auto.components.sidebar.WorktreeVisibilitySourceList.sources', 'Sources')}
</h3>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.sourcesDescription',
'Enabled sources include current and future worktrees in the sidebar.'
)}
</p>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-muted/30">
{sources.map((source, index) => {
const label = getSourceLabel(source)
const key = sourceRowKey(source)
const count = sourceCounts.get(key) ?? 0
const accessibleLabel = getAccessibleSourceLabel(source, label)
return (
<div
key={key}
className={`grid min-h-14 grid-cols-[28px_minmax(0,1fr)_auto] items-center gap-2.5 px-2.5 py-2 ${index > 0 ? 'border-t border-border' : ''}`}
>
<span className="flex size-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
{source.kind === 'built-in' ? (
source.id === 'claude' ? (
<SquareTerminal className="size-3.5" />
) : (
<Grid2X2 className="size-3.5" />
)
) : (
<Folder className="size-3.5" />
)}
</span>
<span className="min-w-0">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[13px] font-medium">{label}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.found',
'{{value0}} found',
{ value0: count }
)}
</span>
</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground">
{getSourcePath(source)}
</span>
</span>
<span className="flex items-center gap-1">
{source.kind === 'custom' ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-destructive"
disabled={disabled}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.remove',
'Remove {{value0}}',
{ value0: accessibleLabel }
)}
onClick={() => void onRemove(source.source)}
>
<Trash2 />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.removeLocation',
'Remove custom location'
)}
</TooltipContent>
</Tooltip>
) : null}
<Switch
checked={isSourceEnabled(repo, source)}
disabled={disabled}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.toggle',
'Show current and future worktrees from {{value0}}',
{ value0: accessibleLabel }
)}
onCheckedChange={(checked) => void onToggle(source, checked)}
/>
</span>
</div>
)
})}
<form
className="grid gap-2 border-t border-border bg-background/50 px-2.5 py-2.5"
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.addLocation',
'Add location'
)}
onSubmit={(event) => void handleAdd(event)}
>
<Label htmlFor="custom-worktree-root">
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.worktreeRoot',
'Worktree root'
)}
</Label>
<div className="flex items-center gap-2">
<Input
id="custom-worktree-root"
className="font-mono text-xs"
value={rootPath}
autoComplete="off"
spellCheck={false}
aria-invalid={inputError ? true : undefined}
aria-describedby="custom-worktree-root-help"
onChange={(event) => {
setRootPath(event.target.value)
setInputError(null)
}}
/>
<Button type="submit" size="sm" disabled={disabled || !rootPath.trim()}>
{translate('auto.components.sidebar.WorktreeVisibilitySourceList.add', 'Add')}
</Button>
</div>
<p
id="custom-worktree-root-help"
className={`text-[11px] ${inputError ? 'text-destructive' : 'text-muted-foreground'}`}
role={inputError ? 'alert' : undefined}
>
{inputError ??
translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.rootHelp',
'Orca will recognize worktrees beneath this folder.'
)}
</p>
</form>
</div>
</section>
)
}
@@ -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.'
)
}
+36 -4
View File
@@ -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": {
+36 -4
View File
@@ -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": {
+36 -4
View File
@@ -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": {
+36 -4
View File
@@ -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": {
+36 -4
View File
@@ -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": {
@@ -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.'
})
})
})
+26
View File
@@ -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
}
+12 -20
View File
@@ -1,4 +1,8 @@
import { normalizeRuntimePathForComparison } from './cross-platform-path'
import {
createWorktreeVisibilitySourceMatcher,
type WorktreeVisibilitySourceMatcher
} from './worktree-visibility-sources'
/** Why: agent CLIs reserve these repo-root paths for scratch; broader matches
* can hide legitimate user worktrees (#9388). */
@@ -9,29 +13,17 @@ const AGENT_SCRATCH_PATH_PREFIXES: readonly (readonly string[])[] = [
export type AgentScratchWorktreePathMatcher = (worktreePath: string) => boolean
export function createAgentScratchWorktreeSourceMatcher(
checkoutPaths: readonly string[]
): WorktreeVisibilitySourceMatcher {
return createWorktreeVisibilitySourceMatcher(checkoutPaths)
}
export function createAgentScratchWorktreePathMatcher(
checkoutPaths: readonly string[]
): AgentScratchWorktreePathMatcher {
const checkoutPathKeys = new Set(checkoutPaths.map(normalizeRuntimePathForComparison))
return (worktreePath) => {
const segments = normalizeRuntimePathForComparison(worktreePath).split('/')
for (const prefix of AGENT_SCRATCH_PATH_PREFIXES) {
for (let index = 0; index + prefix.length < segments.length; index += 1) {
if (!prefix.every((segment, offset) => segments[index + offset] === segment)) {
continue
}
const checkoutPath = segments.slice(0, index).join('/')
// Why: splitting strips the separator from filesystem roots, but normalized checkout keys retain it.
const checkoutPathKey = /^[a-z]:$/i.test(checkoutPath)
? `${checkoutPath}/`
: checkoutPath || '/'
if (checkoutPathKeys.has(checkoutPathKey)) {
return true
}
}
}
return false
}
const classify = createAgentScratchWorktreeSourceMatcher(checkoutPaths)
return (worktreePath) => classify(worktreePath)?.kind === 'built-in'
}
export function isAgentScratchWorktreePath(repoPath: string, worktreePath: string): boolean {
+20
View File
@@ -111,6 +111,18 @@ export type IssueSourcePreference = 'upstream' | 'origin' | 'auto'
export type { ForkSyncMode, GitForkSyncExpectedUpstream, GitForkSyncResult } from './git-fork-sync'
export type ExternalWorktreeVisibility = 'hide' | 'show'
export type BuiltInWorktreeVisibilitySourceId = 'claude' | 'gsd'
export type CustomWorktreeVisibilitySource = {
id: string
rootPath: string
}
export type WorktreeVisibilitySourcePreferences = {
builtIn?: Partial<Record<BuiltInWorktreeVisibilitySourceId, ExternalWorktreeVisibility>>
custom?: Record<string, ExternalWorktreeVisibility>
}
export type ProjectProviderIdentity = {
provider: 'github'
owner: string
@@ -291,6 +303,10 @@ export type Repo = {
importedExternalWorktreePaths?: string[]
/** Opt-in repo policy for coding-agent scratch worktrees; absent means hide. */
agentWorktreeVisibility?: ExternalWorktreeVisibility
/** User-defined roots classified independently from ordinary external worktrees. */
customWorktreeVisibilitySources?: CustomWorktreeVisibilitySource[]
/** Per-source visibility; absent built-ins inherit the legacy agent policy. */
worktreeVisibilitySourcePreferences?: WorktreeVisibilitySourcePreferences
/** User permanently opted out of the new-external-worktree inbox for this repo. */
externalWorktreeDiscoverySuppressedAt?: number
/** Paths (relative to the primary checkout) that should be APFS clone-copied
@@ -707,6 +723,10 @@ export type DetectedWorktree = Worktree & {
ownership: WorktreeOwnership
selectedCheckout: boolean
visible: boolean
/** Optional additive source identity; older hosts omit it. */
visibilitySource?:
| { kind: 'built-in'; id: BuiltInWorktreeVisibilitySourceId }
| { kind: 'custom'; id: string }
}
export type DetectedWorktreeListResult = {
+50
View File
@@ -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',
+31 -6
View File
@@ -11,6 +11,12 @@ import {
isAgentScratchWorktreePath,
type AgentScratchWorktreePathMatcher
} from './agent-scratch-worktrees'
import {
createWorktreeVisibilitySourceMatcher,
effectiveWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from './worktree-visibility-sources'
import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox'
import {
effectiveAgentWorktreeVisibility,
@@ -145,6 +151,7 @@ export function classifyWorktreeOwnership(args: {
settings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
knownOrcaLayouts: OrcaWorkspaceLayout[]
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher
}): WorktreeOwnership {
if (hasStrongOrcaMetadata(args.meta)) {
return 'orca-managed'
@@ -153,8 +160,9 @@ export function classifyWorktreeOwnership(args: {
// Why: sub-agent scratch worktrees (e.g. .claude/worktrees) are tool
// plumbing, not workspaces; classify before layout heuristics (#9388).
if (
args.agentScratchWorktreePathMatcher?.(args.worktree.path) ??
isAgentScratchWorktreePath(args.repo.path, args.worktree.path)
args.worktreeVisibilitySourceMatcher?.(args.worktree.path)?.kind === 'built-in' ||
(args.agentScratchWorktreePathMatcher?.(args.worktree.path) ??
isAgentScratchWorktreePath(args.repo.path, args.worktree.path))
) {
return 'agent-scratch'
}
@@ -180,8 +188,19 @@ export function toDetectedWorktree(args: {
knownOrcaLayouts: OrcaWorkspaceLayout[]
isLegacyRepoForVisibility?: boolean
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher
}): DetectedWorktree {
const ownership = classifyWorktreeOwnership(args)
const sourceMatcher =
args.worktreeVisibilitySourceMatcher ??
createWorktreeVisibilitySourceMatcher(
[args.repo.path],
normalizeCustomWorktreeVisibilitySources(args.repo.customWorktreeVisibilitySources) ?? []
)
const visibilitySource = sourceMatcher(args.worktree.path)
const ownership = classifyWorktreeOwnership({
...args,
worktreeVisibilitySourceMatcher: sourceMatcher
})
const selectedCheckout = areRuntimePathsEqual(args.worktree.path, args.repo.path)
const isLegacyRepoForVisibility =
args.isLegacyRepoForVisibility ?? isLegacyRepoForExternalWorktreeVisibility(args.repo)
@@ -191,14 +210,16 @@ export function toDetectedWorktree(args: {
repo: args.repo,
isLegacyRepoForVisibility,
isSelectedCheckout: selectedCheckout,
importedExternalWorktreePaths: args.repo.importedExternalWorktreePaths
importedExternalWorktreePaths: args.repo.importedExternalWorktreePaths,
visibilitySource
})
return {
...args.worktree,
ownership,
selectedCheckout,
visible
visible,
...(visibilitySource ? { visibilitySource } : {})
}
}
@@ -209,6 +230,7 @@ export function shouldShowWorktree(args: {
isLegacyRepoForVisibility: boolean
isSelectedCheckout: boolean
importedExternalWorktreePaths?: readonly string[] | undefined
visibilitySource?: ReturnType<WorktreeVisibilitySourceMatcher>
}): boolean {
if (args.isSelectedCheckout) {
return true
@@ -223,6 +245,9 @@ export function shouldShowWorktree(args: {
) {
return true
}
if (args.visibilitySource) {
return effectiveWorktreeSourceVisibility(args.repo, args.visibilitySource) === 'show'
}
if (args.ownership === 'agent-scratch') {
return effectiveAgentWorktreeVisibility(args.repo) === 'show'
}
@@ -233,7 +258,7 @@ export function shouldShowWorktree(args: {
}
export function applyMetadataFallbackVisibility(detected: DetectedWorktree): DetectedWorktree {
if (detected.ownership === 'agent-scratch') {
if (detected.ownership === 'agent-scratch' || detected.visibilitySource) {
// Why: retain scratch policy, including explicit imports, while ordinary fallback fails open.
return detected
}
@@ -0,0 +1,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> = {}): 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' }
])
})
})
+240
View File
@@ -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<string>()
const roots = new Set<string>()
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<string, unknown>)
: {}
const builtIn = Object.fromEntries(
BUILT_IN_WORKTREE_VISIBILITY_SOURCES.flatMap(({ id }) => {
const visibility = normalizeVisibility(builtInValue[id])
return visibility ? [[id, visibility]] : []
})
) as WorktreeVisibilitySourcePreferences['builtIn']
const customValue =
raw.custom && typeof raw.custom === 'object' && !Array.isArray(raw.custom)
? (raw.custom as Record<string, unknown>)
: {}
const custom = Object.fromEntries(
Object.entries(customValue)
.filter(([id, visibility]) => isValidSourceId(id) && normalizeVisibility(visibility))
.slice(0, MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES)
.map(([id, visibility]) => [id, normalizeVisibility(visibility)!])
)
return {
...(Object.keys(builtIn ?? {}).length > 0 ? { builtIn } : {}),
...(Object.keys(custom).length > 0 ? { custom } : {})
}
}
function createDescendantMatcher(rootPath: string): (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<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
sourceId: BuiltInWorktreeVisibilitySourceId
): ExternalWorktreeVisibility {
const explicit = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
)?.builtIn?.[sourceId]
if (explicit) {
return explicit
}
return repo.agentWorktreeVisibility === 'show' ? 'show' : 'hide'
}
export function effectiveCustomWorktreeSourceVisibility(
repo: Pick<Repo, 'worktreeVisibilitySourcePreferences'>,
sourceId: string
): ExternalWorktreeVisibility {
return normalizeWorktreeVisibilitySourcePreferences(repo.worktreeVisibilitySourcePreferences)
?.custom?.[sourceId] === 'show'
? 'show'
: 'hide'
}
export function effectiveWorktreeSourceVisibility(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
source: WorktreeVisibilitySourceMatch
): ExternalWorktreeVisibility {
return source.kind === 'built-in'
? effectiveBuiltInWorktreeSourceVisibility(repo, source.id)
: effectiveCustomWorktreeSourceVisibility(repo, source.id)
}
export function buildWorktreeSourcePreferenceUpdate(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
source: WorktreeVisibilitySourceMatch,
visibility: ExternalWorktreeVisibility
): WorktreeVisibilitySourcePreferences {
const current = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
)
const builtIn = {
claude: effectiveBuiltInWorktreeSourceVisibility(repo, 'claude'),
gsd: effectiveBuiltInWorktreeSourceVisibility(repo, 'gsd'),
...current?.builtIn
}
const custom = { ...current?.custom }
if (source.kind === 'built-in') {
builtIn[source.id] = visibility
} else {
custom[source.id] = visibility
}
return {
builtIn,
...(Object.keys(custom).length > 0 ? { custom } : {})
}
}
export function removeCustomWorktreeSourcePreference(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
sourceId: string
): WorktreeVisibilitySourcePreferences {
const current = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
)
const custom = { ...current?.custom }
delete custom[sourceId]
return {
builtIn: {
claude: effectiveBuiltInWorktreeSourceVisibility(repo, 'claude'),
gsd: effectiveBuiltInWorktreeSourceVisibility(repo, 'gsd'),
...current?.builtIn
},
...(Object.keys(custom).length > 0 ? { custom } : {})
}
}
@@ -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()
})
@@ -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<string> {
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<void> {
await electronApp.evaluate(({ dialog }, folderPath) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [folderPath],
bookmarks: []
})
}, selectedPath)
}
async function selectCodexAndSkipToProject(page: Page): Promise<void> {
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('\\', '/'))
})
+88
View File
@@ -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)
})
@@ -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)
})
@@ -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()
})
+183
View File
@@ -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<void> {
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<void> {
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)
}
@@ -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
}
])
})
})