mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into golden-agent-tui
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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('\\', '/'))
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user