From 9ed2068c05703624ce3f160589db3ceeef97af1a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:36:40 -0700 Subject: [PATCH] fix(worktrees): align source visibility across runtimes --- src/main/ipc/worktrees.ts | 10 +- src/main/runtime/orca-runtime.test.ts | 102 +++++++++++ src/main/runtime/orca-runtime.ts | 64 ++++--- .../sidebar/HiddenWorktreeRecoveryList.tsx | 7 +- .../sidebar/WorktreeVisibilityDialog.test.tsx | 161 ++++++++++++++++-- .../sidebar/WorktreeVisibilityDialog.tsx | 26 ++- .../sidebar/WorktreeVisibilityHelpPopover.tsx | 4 +- .../sidebar/WorktreeVisibilitySourceList.tsx | 109 +++++++----- src/renderer/src/i18n/locales/en.json | 13 +- src/renderer/src/i18n/locales/es.json | 15 +- src/renderer/src/i18n/locales/ja.json | 13 +- src/renderer/src/i18n/locales/ko.json | 13 +- src/renderer/src/i18n/locales/zh.json | 13 +- .../i18n/worktree-visibility-locales.test.ts | 23 +++ .../worktree-visibility-sources.test.ts | 38 ++++- src/shared/worktree-visibility-sources.ts | 43 +++-- 16 files changed, 532 insertions(+), 122 deletions(-) create mode 100644 src/renderer/src/i18n/worktree-visibility-locales.test.ts diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 781073da840..37a144a9b6d 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1004,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 }) ) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec4d930ad79..c93cc9bea8c 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -35509,6 +35509,108 @@ describe('OrcaRuntimeService', () => { }) }) + it('applies linked-checkout source visibility to mobile summaries', async () => { + const linkedPath = '/tmp/linked' + const scratchPath = `${linkedPath}/.claude/worktrees/review` + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: TEST_REPO_PATH, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: linkedPath, + head: 'linked', + branch: 'feature/linked', + isBare: false, + isMainWorktree: false + }, + { + path: scratchPath, + head: 'scratch', + branch: 'feature/review', + isBare: false, + isMainWorktree: false + } + ]) + const makeRuntime = (externalWorktreeVisibility: 'hide' | 'show', claude: 'hide' | 'show') => { + const repo = { + ...store.getRepos()[0], + externalWorktreeVisibility, + externalWorktreeVisibilityLegacy: false, + worktreeVisibilitySourcePreferences: { builtIn: { claude, gsd: 'hide' as const } } + } + return new OrcaRuntimeService({ + ...store, + getRepos: () => [repo], + getRepo: () => repo + } as never) + } + + const hiddenSource = await makeRuntime('show', 'hide').getWorktreePs() + const shownSource = await makeRuntime('hide', 'show').getWorktreePs() + + expect(hiddenSource.worktrees.map((worktree) => worktree.path)).not.toContain(scratchPath) + expect(shownSource.worktrees.map((worktree) => worktree.path)).toContain(scratchPath) + }) + + it('resolves files through a source-visible linked-checkout worktree', async () => { + const linkedPath = '/tmp/linked' + const scratchPath = `${linkedPath}/.claude/worktrees/review` + vi.mocked(listWorktrees).mockResolvedValue([ + { + path: TEST_REPO_PATH, + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path: linkedPath, + head: 'linked', + branch: 'feature/linked', + isBare: false, + isMainWorktree: false + }, + { + path: scratchPath, + head: 'scratch', + branch: 'feature/review', + isBare: false, + isMainWorktree: false + } + ]) + const repo = { + ...store.getRepos()[0], + externalWorktreeVisibility: 'hide' as const, + externalWorktreeVisibilityLegacy: false, + worktreeVisibilitySourcePreferences: { + builtIn: { claude: 'show' as const, gsd: 'hide' as const } + } + } + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [repo], + getRepo: () => repo + } as never) + + const target = await ( + runtime as unknown as { + resolveKnownWorkspaceFileTarget: ( + path: string, + executionHostId: 'local' + ) => Promise<{ worktree: { path: string }; relativePath: string } | null> + } + ).resolveKnownWorkspaceFileTarget(`${scratchPath}/src/app.ts`, 'local') + + expect(target).toMatchObject({ + worktree: { path: scratchPath }, + relativePath: 'src/app.ts' + }) + }) + it('marks saved session tabs with live PTYs as host sidebar activity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5f47a719489..d4be38d94db 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9106,8 +9106,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 @@ -17642,8 +17650,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. @@ -20953,21 +20964,8 @@ export class OrcaRuntimeService { } const resolved = await this.listResolvedWorktrees() const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null - const checkoutPathsByRepoId = new Map() - for (const worktree of resolved) { - const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? [] - checkoutPaths.push(worktree.path) - checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths) - } - const visibilitySourceMatchersByRepoId = new Map( - (this.store?.getRepos() ?? []).map((repo) => [ - repo.id, - createWorktreeVisibilitySourceMatcher( - [repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])], - normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] - ) - ]) - ) + const visibilitySourceMatchersByRepoId = + this.buildRuntimeVisibilitySourceMatchersByRepoId(resolved) const worktrees = resolved.filter((worktree) => { if (repoId && worktree.repoId !== repoId) { return false @@ -20999,7 +20997,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, @@ -21106,6 +21110,28 @@ export class OrcaRuntimeService { return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher).visible } + private buildRuntimeVisibilitySourceMatchersByRepoId( + worktrees: readonly Worktree[] + ): Map { + const checkoutPathsByRepoId = new Map() + for (const worktree of worktrees) { + const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? [] + checkoutPaths.push(worktree.path) + checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths) + } + return new Map( + (this.store?.getRepos() ?? []) + .filter((repo) => checkoutPathsByRepoId.has(repo.id)) + .map((repo) => [ + repo.id, + createWorktreeVisibilitySourceMatcher( + [repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])], + normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [] + ) + ]) + ) + } + private toRuntimeDetectedWorktree( repo: Repo, worktree: Worktree, diff --git a/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx b/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx index 886d2e66aac..8f842bee693 100644 --- a/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx +++ b/src/renderer/src/components/sidebar/HiddenWorktreeRecoveryList.tsx @@ -67,7 +67,7 @@ export default function HiddenWorktreeRecoveryList({

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

@@ -131,6 +131,11 @@ export default function HiddenWorktreeRecoveryList({ 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 diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx index dcb17a988f3..7dfd864ce95 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx @@ -205,6 +205,14 @@ async function click(element: HTMLElement): Promise { }) } +async function setInputValue(input: HTMLInputElement, value: string): Promise { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }) +} + describe('WorktreeVisibilityDialog', () => { it('lists a hidden agent worktree with a repo-relative path', async () => { await renderDialog() @@ -249,6 +257,24 @@ describe('WorktreeVisibilityDialog', () => { 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 () => { // Why: individual recovery remains available while the repo-wide policy is off. await renderDialog() @@ -590,14 +616,7 @@ describe('WorktreeVisibilityDialog', () => { ).toBe(true) expect(input?.closest('form')?.getAttribute('aria-label')).toBe('Add location') expect(input?.closest('form')?.textContent).not.toContain('Add location') - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( - input, - '/srv/team-worktrees' - ) - input!.dispatchEvent(new Event('input', { bubbles: true })) - input!.dispatchEvent(new Event('change', { bubbles: true })) - }) + await setInputValue(input!, '/srv/team-worktrees') await click(buttonWithText('Add')) expect(mocks.state.updateRepo).toHaveBeenCalledWith( @@ -622,6 +641,82 @@ describe('WorktreeVisibilityDialog', () => { ).toBe('hide') }) + it('keeps source matching stable while typing in the inline form', async () => { + mocks.state.repos = [ + makeRepo({ + customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }] + }) + ] + await renderDialog() + const normalize = vi.spyOn(String.prototype, 'normalize') + + await setInputValue( + document.querySelector('#custom-worktree-root')!, + '/srv/x' + ) + + expect(normalize).not.toHaveBeenCalled() + normalize.mockRestore() + }) + + it('distinguishes invalid, duplicate, limit, and save failures when adding a source', async () => { + const existing = Array.from({ length: 32 }, (_, index) => ({ + id: `source-${index}`, + rootPath: `/srv/source-${index}` + })) + mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing })] + await renderDialog() + const input = document.querySelector('#custom-worktree-root')! + await setInputValue(input, '/srv/new') + await click(buttonWithText('Add')) + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Remove a custom location before adding another.' + ) + expect(mocks.state.updateRepo).not.toHaveBeenCalled() + + mocks.state.repos = [makeRepo({ customWorktreeVisibilitySources: existing.slice(0, 1) })] + await act(async () => root.render(null)) + await renderDialog() + const duplicateInput = document.querySelector('#custom-worktree-root')! + await setInputValue(duplicateInput, '/srv/source-0') + await click(buttonWithText('Add')) + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'This location is already listed.' + ) + + await setInputValue(duplicateInput, 'relative/path') + await click(buttonWithText('Add')) + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Enter an absolute path for this host.' + ) + + mocks.state.updateRepo.mockResolvedValue(false) + await setInputValue(duplicateInput, '/srv/valid') + await click(buttonWithText('Add')) + const alerts = [...document.querySelectorAll('[role="alert"]')] + expect(alerts).toHaveLength(1) + expect(alerts[0]?.textContent).toContain('Could not update worktree visibility. Try again.') + expect(alerts[0]?.textContent).not.toContain('Enter an absolute path') + }) + + it('reports only unsupported-host copy when an older host strips an added source', async () => { + mocks.state.updateRepo.mockResolvedValue(true) + await renderDialog() + mocks.state.fetchWorktrees.mockClear() + const input = document.querySelector('#custom-worktree-root')! + + await setInputValue(input, '/srv/valid') + await click(buttonWithText('Add')) + + const alerts = [...document.querySelectorAll('[role="alert"]')] + expect(alerts).toHaveLength(1) + expect(alerts[0]?.textContent).toContain( + "This host doesn't support source-specific worktree visibility." + ) + expect(alerts[0]?.textContent).not.toContain('Enter an absolute path') + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + }) + it('removes custom locations without changing other source preferences', async () => { mocks.state.repos = [ makeRepo({ @@ -631,17 +726,61 @@ describe('WorktreeVisibilityDialog', () => { ] await renderDialog() - expect(sourceSwitch('team-worktrees').getAttribute('aria-checked')).toBe('false') + expect(sourceSwitch('/srv/team-worktrees').getAttribute('aria-checked')).toBe('false') const remove = document.querySelector( - 'button[aria-label="Remove team-worktrees"]' + 'button[aria-label="Remove /srv/team-worktrees"]' ) expect(remove).not.toBeNull() await click(remove!) expect(mocks.state.updateRepo).toHaveBeenCalledWith( 'repo-1', - expect.objectContaining({ customWorktreeVisibilitySources: [] }) + expect.objectContaining({ + customWorktreeVisibilitySources: [], + worktreeVisibilitySourcePreferences: expect.not.objectContaining({ + custom: expect.objectContaining({ team: expect.anything() }) + }) + }) ) expect(mocks.state.closeModal).not.toHaveBeenCalled() }) + + it('rejects removal success when an older host leaves the custom preference behind', async () => { + const repo = makeRepo({ + customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }], + worktreeVisibilitySourcePreferences: { custom: { team: 'show' } } + }) + mocks.state.repos = [repo] + mocks.state.updateRepo.mockImplementation(async () => { + repo.customWorktreeVisibilitySources = [] + return true + }) + await renderDialog() + mocks.state.fetchWorktrees.mockClear() + + await click(document.querySelector('button[aria-label="Remove /srv/team-worktrees"]')!) + + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Could not update worktree visibility. Try again.' + ) + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + }) + + it('uses full paths to distinguish custom source controls with the same basename', async () => { + mocks.state.repos = [ + makeRepo({ + customWorktreeVisibilitySources: [ + { id: 'alpha', rootPath: '/srv/alpha/team' }, + { id: 'beta', rootPath: '/srv/beta/team' } + ] + }) + ] + + await renderDialog() + + expect(sourceSwitch('/srv/alpha/team')).not.toBeNull() + expect(sourceSwitch('/srv/beta/team')).not.toBeNull() + expect(document.querySelector('button[aria-label="Remove /srv/alpha/team"]')).not.toBeNull() + expect(document.querySelector('button[aria-label="Remove /srv/beta/team"]')).not.toBeNull() + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index ef95a945795..835f68d317e 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -32,6 +32,7 @@ import { useVisibilityMutationFence } from './worktree-visibility-mutation-fence' import WorktreeVisibilitySourceList, { + type WorktreeVisibilitySourceAddResult, type WorktreeVisibilitySourceRow } from './WorktreeVisibilitySourceList' import type { CustomWorktreeVisibilitySource, Repo } from '../../../../shared/types' @@ -41,6 +42,7 @@ import { effectiveBuiltInWorktreeSourceVisibility, effectiveCustomWorktreeSourceVisibility, normalizeCustomWorktreeVisibilitySources, + normalizeWorktreeVisibilitySourcePreferences, removeCustomWorktreeSourcePreference } from '../../../../shared/worktree-visibility-sources' import HiddenWorktreeRecoveryList from './HiddenWorktreeRecoveryList' @@ -262,22 +264,26 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { ) const handleAddSource = useCallback( - async (rootPath: string): Promise => { + async (rootPath: string): Promise => { if (!repo) { - return false + return 'save-failed' } const existing = normalizeCustomWorktreeVisibilitySources( repo.customWorktreeVisibilitySources ) if ((existing?.length ?? 0) >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) { - return false + return 'limit' } const id = crypto.randomUUID().replaceAll('-', '') - const next = normalizeCustomWorktreeVisibilitySources([...(existing ?? []), { id, rootPath }]) - if (!next || next.length !== (existing?.length ?? 0) + 1) { - return false + const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0] + if (!candidate) { + return 'invalid-path' } - return commitSourceUpdate( + const next = normalizeCustomWorktreeVisibilitySources([...(existing ?? []), candidate]) + if (!next || next.length !== (existing?.length ?? 0) + 1) { + return 'duplicate-path' + } + const saved = await commitSourceUpdate( { customWorktreeVisibilitySources: next, worktreeVisibilitySourcePreferences: buildWorktreeSourcePreferenceUpdate( @@ -292,6 +298,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { )?.some((source) => source.id === id) === true && effectiveCustomWorktreeSourceVisibility(latestRepo, id) === 'hide' ) + return saved ? 'added' : 'save-failed' }, [commitSourceUpdate, repo] ) @@ -312,7 +319,10 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { (latestRepo) => !normalizeCustomWorktreeVisibilitySources( latestRepo.customWorktreeVisibilitySources - )?.some((candidate) => candidate.id === source.id) + )?.some((candidate) => candidate.id === source.id) && + normalizeWorktreeVisibilitySourcePreferences( + latestRepo.worktreeVisibilitySourcePreferences + )?.custom?.[source.id] === undefined ) }, [commitSourceUpdate, repo] diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx index 77572d55155..07bf2d9325f 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx @@ -49,13 +49,13 @@ export default function WorktreeVisibilityHelpPopover(): React.JSX.Element {
  • {translate( - 'auto.components.sidebar.WorktreeVisibilityHelpPopover.54a7c2f183', + 'auto.components.sidebar.WorktreeVisibilityHelpPopover.ec1e6a10fb', 'Other worktrees start hidden to avoid unexpected sidebar clutter.' )}
  • {translate( - 'auto.components.sidebar.WorktreeVisibilityHelpPopover.b9e0d43675', + 'auto.components.sidebar.WorktreeVisibilityHelpPopover.1c68c9cf77', 'Enable a source for all current and future worktrees, or show individual worktrees below.' )}
  • diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx index fd61a40e683..e3bce6563cd 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx @@ -34,11 +34,18 @@ type Props = { repo: Repo worktrees: readonly DetectedWorktree[] disabled: boolean - onAdd: (rootPath: string) => Promise + onAdd: (rootPath: string) => Promise onRemove: (source: CustomWorktreeVisibilitySource) => Promise onToggle: (source: WorktreeVisibilitySourceRow, enabled: boolean) => Promise } +export type WorktreeVisibilitySourceAddResult = + | 'added' + | 'invalid-path' + | 'duplicate-path' + | 'limit' + | 'save-failed' + function getSourceLabel(source: WorktreeVisibilitySourceRow): string { if (source.kind === 'built-in') { return source.id === 'claude' @@ -83,17 +90,20 @@ function isSourceEnabled(repo: Repo, source: WorktreeVisibilitySourceRow): boole ) } -function matchesRow( - source: WorktreeVisibilitySourceRow, - match: WorktreeVisibilitySourceMatch | null -): boolean { - if (source.kind === 'other') { - return match === null - } - if (source.kind === 'built-in') { - return match?.kind === 'built-in' && match.id === source.id - } - return match?.kind === 'custom' && match.id === source.source.id +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({ @@ -106,37 +116,65 @@ export default function WorktreeVisibilitySourceList({ }: Props): React.JSX.Element { const [rootPath, setRootPath] = useState('') const [inputError, setInputError] = useState(null) - const customSources = normalizeCustomWorktreeVisibilitySources( - repo.customWorktreeVisibilitySources + const customSources = useMemo( + () => normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [], + [repo.customWorktreeVisibilitySources] + ) + const sources = useMemo( + () => [ + { kind: 'built-in', id: 'claude' }, + { kind: 'built-in', id: 'gsd' }, + ...customSources.map((source) => ({ kind: 'custom' as const, source })), + { kind: 'other' } + ], + [customSources] ) - const sources: WorktreeVisibilitySourceRow[] = [ - { kind: 'built-in', id: 'claude' }, - { kind: 'built-in', id: 'gsd' }, - ...(customSources ?? []).map((source) => ({ kind: 'custom' as const, source })), - { kind: 'other' } - ] const classify = useMemo( () => createWorktreeVisibilitySourceMatcher( [repo.path, ...worktrees.map((worktree) => worktree.path)], - customSources ?? [] + customSources ), [customSources, repo.path, worktrees] ) + const sourceCounts = useMemo(() => { + const counts = new Map() + for (const worktree of worktrees) { + if (worktree.selectedCheckout || worktree.ownership === 'orca-managed') { + continue + } + const key = sourceMatchKey(worktree.visibilitySource ?? classify(worktree.path)) + counts.set(key, (counts.get(key) ?? 0) + 1) + } + return counts + }, [classify, worktrees]) const handleAdd = async (event: React.FormEvent) => { event.preventDefault() setInputError(null) - const added = await onAdd(rootPath) - if (added) { + const result = await onAdd(rootPath) + if (result === 'added') { setRootPath('') return } + if (result === 'save-failed') { + return + } setInputError( - translate( - 'auto.components.sidebar.WorktreeVisibilitySourceList.invalidPath', - 'Enter a unique absolute path for this host.' - ) + 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.' + ) ) } @@ -156,16 +194,9 @@ export default function WorktreeVisibilitySourceList({
    {sources.map((source, index) => { const label = getSourceLabel(source) - const count = worktrees.filter( - (worktree) => - !worktree.selectedCheckout && - worktree.ownership !== 'orca-managed' && - matchesRow(source, worktree.visibilitySource ?? classify(worktree.path)) - ).length - const key = - source.kind === 'custom' - ? `custom:${source.source.id}` - : `${source.kind}:${source.kind === 'built-in' ? source.id : ''}` + const key = sourceRowKey(source) + const count = sourceCounts.get(key) ?? 0 + const accessibleLabel = getAccessibleSourceLabel(source, label) return (
    void onRemove(source.source)} > @@ -231,7 +262,7 @@ export default function WorktreeVisibilitySourceList({ aria-label={translate( 'auto.components.sidebar.WorktreeVisibilitySourceList.toggle', 'Show current and future worktrees from {{value0}}', - { value0: label } + { value0: accessibleLabel } )} onCheckedChange={(checked) => void onToggle(source, checked)} /> diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0c5f20df81c..cab22973f2a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4903,8 +4903,12 @@ "WorktreeVisibilityHelpPopover": { "c41f2d7e90": "Which worktrees are hidden by default?", "8db4e19a26": "This setting never hides worktrees created through Orca.", - "54a7c2f183": "Other worktrees start hidden to avoid unexpected sidebar clutter.", - "b9e0d43675": "Enable a source for all current and future worktrees, or show individual worktrees below." + "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", @@ -4918,7 +4922,6 @@ "b8d24e61f5": "Could not list this repo's worktrees.", "c5e70a93b1": "Try again", "7d21c5e848": "Hidden worktrees ({{value0}})", - "9b53f7a160": "Show one without enabling its source.", "2f80cd4b97": "Showing…", "e64b81d3a9": "Show", "d40d436fc2": "Could not update worktree visibility. Try again.", @@ -5435,7 +5438,9 @@ "other": "Other locations", "custom": "Custom location", "otherPath": "Outside listed sources", - "invalidPath": "Enter a unique absolute path for this host.", + "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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 11537ba4660..b17aa596443 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4700,8 +4700,12 @@ "WorktreeVisibilityHelpPopover": { "c41f2d7e90": "¿Qué worktrees se ocultan de forma predeterminada?", "8db4e19a26": "Este ajuste nunca oculta los worktrees creados mediante Orca.", - "54a7c2f183": "Los demás worktrees empiezan ocultos para evitar saturar inesperadamente la barra lateral.", - "b9e0d43675": "Activa una fuente para mostrar todos sus worktrees actuales y futuros, o muestra worktrees individuales abajo." + "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", @@ -4715,7 +4719,6 @@ "b8d24e61f5": "No se pudieron listar los worktrees de este repositorio.", "c5e70a93b1": "Reintentar", "7d21c5e848": "Worktrees ocultos ({{value0}})", - "9b53f7a160": "Muestra uno sin activar su fuente.", "2f80cd4b97": "Mostrando…", "e64b81d3a9": "Mostrar", "d40d436fc2": "No se pudo actualizar la visibilidad de los worktrees. Inténtalo de nuevo.", @@ -4735,10 +4738,12 @@ "other": "Otras ubicaciones", "custom": "Ubicación personalizada", "otherPath": "Fuera de las fuentes listadas", - "invalidPath": "Introduce una ruta absoluta única para este host.", + "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": "{{value0}} encontrados", + "found": "Encontrados: {{value0}}", "remove": "Eliminar {{value0}}", "removeLocation": "Eliminar ubicación personalizada", "toggle": "Mostrar los worktrees actuales y futuros de {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e4d32ee6908..1c53ab65a9d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4681,8 +4681,12 @@ "WorktreeVisibilityHelpPopover": { "c41f2d7e90": "デフォルトで非表示になるワークツリー", "8db4e19a26": "この設定では、Orca で作成されたワークツリーが非表示になることはありません。", - "54a7c2f183": "その他のワークツリーは、サイドバーが意図せず煩雑にならないようデフォルトで非表示です。", - "b9e0d43675": "ソースを有効にすると現在および今後のワークツリーをすべて表示できます。下から個別に表示することもできます。" + "ec1e6a10fb": "その他のワークツリーは、サイドバーが意図せず煩雑にならないようデフォルトで非表示です。", + "1c68c9cf77": "ソースを有効にすると現在および今後のワークツリーをすべて表示できます。下から個別に表示することもできます。" + }, + "HiddenWorktreeRecoveryList": { + "64e6f53f05": "ソースを有効にせずに個別に表示します。", + "showWorktree": "{{value1}} の {{value0}} を表示" }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Orca 以外のワークツリー", @@ -4696,7 +4700,6 @@ "b8d24e61f5": "このリポジトリのワークツリーを一覧できませんでした。", "c5e70a93b1": "再試行", "7d21c5e848": "非表示のワークツリー({{value0}})", - "9b53f7a160": "ソースを有効にせずに個別に表示します。", "2f80cd4b97": "表示中…", "e64b81d3a9": "表示", "d40d436fc2": "ワークツリーの表示設定を更新できませんでした。もう一度お試しください。", @@ -4716,7 +4719,9 @@ "other": "その他の場所", "custom": "カスタムの場所", "otherPath": "一覧にないソース", - "invalidPath": "このホストの一意な絶対パスを入力してください。", + "invalidPath": "このホストの絶対パスを入力してください。", + "duplicatePath": "この場所は既に一覧にあります。", + "limit": "別の場所を追加する前にカスタムの場所を削除してください。", "sources": "ソース", "sourcesDescription": "有効なソースの現在および今後のワークツリーがサイドバーに表示されます。", "found": "{{value0}} 件検出", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bd37d04584a..fb13bfa22a1 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4681,8 +4681,12 @@ "WorktreeVisibilityHelpPopover": { "c41f2d7e90": "기본적으로 숨겨지는 워크트리", "8db4e19a26": "이 설정은 Orca에서 만든 워크트리를 숨기지 않습니다.", - "54a7c2f183": "다른 워크트리는 사이드바가 예기치 않게 복잡해지지 않도록 기본적으로 숨겨집니다.", - "b9e0d43675": "소스를 활성화해 현재와 앞으로 생성되는 모든 워크트리를 표시하거나 아래에서 개별 워크트리를 표시하세요." + "ec1e6a10fb": "다른 워크트리는 사이드바가 예기치 않게 복잡해지지 않도록 기본적으로 숨겨집니다.", + "1c68c9cf77": "소스를 활성화해 현재와 앞으로 생성되는 모든 워크트리를 표시하거나 아래에서 개별 워크트리를 표시하세요." + }, + "HiddenWorktreeRecoveryList": { + "64e6f53f05": "소스를 활성화하지 않고 하나만 표시합니다.", + "showWorktree": "{{value1}}의 {{value0}} 표시" }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Orca 외부 워크트리", @@ -4696,7 +4700,6 @@ "b8d24e61f5": "이 저장소의 워크트리를 나열할 수 없습니다.", "c5e70a93b1": "다시 시도", "7d21c5e848": "숨겨진 워크트리({{value0}})", - "9b53f7a160": "소스를 활성화하지 않고 하나만 표시합니다.", "2f80cd4b97": "표시 중…", "e64b81d3a9": "표시", "d40d436fc2": "워크트리 표시 설정을 업데이트하지 못했습니다. 다시 시도하세요.", @@ -4716,7 +4719,9 @@ "other": "기타 위치", "custom": "사용자 지정 위치", "otherPath": "목록에 없는 소스", - "invalidPath": "이 호스트의 고유한 절대 경로를 입력하세요.", + "invalidPath": "이 호스트의 절대 경로를 입력하세요.", + "duplicatePath": "이 위치는 이미 목록에 있습니다.", + "limit": "다른 위치를 추가하기 전에 사용자 지정 위치를 제거하세요.", "sources": "소스", "sourcesDescription": "활성화된 소스의 현재와 앞으로 생성되는 워크트리가 사이드바에 표시됩니다.", "found": "{{value0}}개 발견", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d1603153f94..429219bae79 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4693,8 +4693,12 @@ "WorktreeVisibilityHelpPopover": { "c41f2d7e90": "哪些工作树默认隐藏?", "8db4e19a26": "此设置不会隐藏通过 Orca 创建的工作树。", - "54a7c2f183": "其他工作树默认隐藏,以免侧边栏意外变得杂乱。", - "b9e0d43675": "启用来源可显示其当前及未来的所有工作树,也可以在下方单独显示工作树。" + "ec1e6a10fb": "其他工作树默认隐藏,以免侧边栏意外变得杂乱。", + "1c68c9cf77": "启用来源可显示其当前及未来的所有工作树,也可以在下方单独显示工作树。" + }, + "HiddenWorktreeRecoveryList": { + "64e6f53f05": "无需启用来源即可单独显示一个工作树。", + "showWorktree": "显示位于 {{value1}} 的 {{value0}}" }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "非 Orca 工作树", @@ -4708,7 +4712,6 @@ "b8d24e61f5": "无法列出此仓库的工作树。", "c5e70a93b1": "重试", "7d21c5e848": "已隐藏的工作树({{value0}})", - "9b53f7a160": "无需启用来源即可单独显示一个工作树。", "2f80cd4b97": "显示中…", "e64b81d3a9": "显示", "d40d436fc2": "无法更新工作树可见性。请重试。", @@ -4728,7 +4731,9 @@ "other": "其他位置", "custom": "自定义位置", "otherPath": "所列来源之外", - "invalidPath": "请输入此主机上唯一的绝对路径。", + "invalidPath": "请输入此主机上的绝对路径。", + "duplicatePath": "此位置已在列表中。", + "limit": "请先移除一个自定义位置再添加新位置。", "sources": "来源", "sourcesDescription": "已启用来源的当前及未来工作树会显示在侧边栏中。", "found": "找到 {{value0}} 个", diff --git a/src/renderer/src/i18n/worktree-visibility-locales.test.ts b/src/renderer/src/i18n/worktree-visibility-locales.test.ts new file mode 100644 index 00000000000..a62fdc8f2f8 --- /dev/null +++ b/src/renderer/src/i18n/worktree-visibility-locales.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import es from './locales/es.json' + +describe('worktree visibility locales', () => { + it('uses number-neutral Spanish source counts', () => { + const found = es.auto.components.sidebar.WorktreeVisibilitySourceList.found + + expect(found.replace('{{value0}}', '1')).toBe('Encontrados: 1') + expect(found.replace('{{value0}}', '2')).toBe('Encontrados: 2') + }) + + it('keeps current fallback-derived keys in the English catalog', () => { + expect(en.auto.components.sidebar.WorktreeVisibilityHelpPopover).toMatchObject({ + ec1e6a10fb: 'Other worktrees start hidden to avoid unexpected sidebar clutter.', + '1c68c9cf77': + 'Enable a source for all current and future worktrees, or show individual worktrees below.' + }) + expect(en.auto.components.sidebar.HiddenWorktreeRecoveryList).toMatchObject({ + '64e6f53f05': 'Show one without enabling its source.' + }) + }) +}) diff --git a/src/shared/worktree-visibility-sources.test.ts b/src/shared/worktree-visibility-sources.test.ts index a018c0874d0..53d63911324 100644 --- a/src/shared/worktree-visibility-sources.test.ts +++ b/src/shared/worktree-visibility-sources.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { Repo } from './types' import { buildWorktreeSourcePreferenceUpdate, @@ -65,6 +65,22 @@ describe('worktree visibility sources', () => { }) }) + 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') @@ -90,4 +106,24 @@ describe('worktree visibility sources', () => { }) ).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: '\\Users\\dev\\team' }, + { id: 'drive', rootPath: 'C:\\Users\\dev\\team' }, + { id: 'unc', rootPath: '\\\\server\\share\\team' } + ]) + ).toEqual([ + { id: 'drive', rootPath: 'C:\\Users\\dev\\team' }, + { id: 'unc', rootPath: '\\\\server\\share\\team' } + ]) + }) }) diff --git a/src/shared/worktree-visibility-sources.ts b/src/shared/worktree-visibility-sources.ts index b7979a0dcd3..e4153daa86d 100644 --- a/src/shared/worktree-visibility-sources.ts +++ b/src/shared/worktree-visibility-sources.ts @@ -41,6 +41,7 @@ function normalizeSourceRootPath(value: string): string | null { !trimmed || trimmed.length > MAX_SOURCE_PATH_LENGTH || trimmed.includes('\0') || + (trimmed.startsWith('\\') && !trimmed.startsWith('\\\\')) || !isRuntimePathAbsolute(trimmed) ) { return null @@ -57,7 +58,10 @@ export function normalizeCustomWorktreeVisibilitySources( const ids = new Set() const roots = new Set() const normalized: CustomWorktreeVisibilitySource[] = [] - for (const candidate of value.slice(0, MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES)) { + for (const candidate of value) { + if (normalized.length >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) { + break + } if (!candidate || typeof candidate !== 'object') { continue } @@ -116,39 +120,42 @@ export function normalizeWorktreeVisibilitySourcePreferences( } } -function createDescendantMatcher(rootPath: string): (candidatePath: string) => boolean { +function createDescendantMatcher(rootPath: string): (normalizedCandidate: string) => boolean { const matchesInsideOrEqual = createNormalizedPathInsideOrEqualMatcher(rootPath) const normalizedRoot = normalizeRuntimePathForComparison(rootPath) - return (candidatePath) => { - const normalizedCandidate = normalizeRuntimePathForComparison(candidatePath) - return normalizedCandidate !== normalizedRoot && matchesInsideOrEqual(normalizedCandidate) - } + return (normalizedCandidate) => + normalizedCandidate !== normalizedRoot && matchesInsideOrEqual(normalizedCandidate) } export function createWorktreeVisibilitySourceMatcher( checkoutPaths: readonly string[], customSources: readonly CustomWorktreeVisibilitySource[] = [] ): WorktreeVisibilitySourceMatcher { - const builtInMatchers = checkoutPaths.flatMap((checkoutPath) => - BUILT_IN_WORKTREE_VISIBILITY_SOURCES.map(({ id, relativeRootSegments }) => ({ - id, - matches: createDescendantMatcher( - `${checkoutPath.replace(/[\\/]+$/, '')}/${relativeRootSegments.join('/')}` - ) - })) - ) + const checkoutPathKeys = new Set(checkoutPaths.map(normalizeRuntimePathForComparison)) const customMatchers = customSources.map(({ id, rootPath }) => ({ id, matches: createDescendantMatcher(rootPath) })) return (worktreePath) => { - for (const source of builtInMatchers) { - if (source.matches(worktreePath)) { - return { kind: 'built-in', id: source.id } + 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(worktreePath)) { + if (source.matches(normalizedCandidate)) { return { kind: 'custom', id: source.id } } }