mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(worktrees): align source visibility across runtimes
This commit is contained in:
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string, string[]>()
|
||||
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<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,
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function HiddenWorktreeRecoveryList({
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeVisibilityDialog.9b53f7a160',
|
||||
'auto.components.sidebar.HiddenWorktreeRecoveryList.64e6f53f05',
|
||||
'Show one without enabling its source.'
|
||||
)}
|
||||
</p>
|
||||
@@ -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
|
||||
|
||||
@@ -205,6 +205,14 @@ 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()
|
||||
@@ -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<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({
|
||||
@@ -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<HTMLButtonElement>(
|
||||
'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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean> => {
|
||||
async (rootPath: string): Promise<WorktreeVisibilitySourceAddResult> => {
|
||||
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]
|
||||
|
||||
@@ -49,13 +49,13 @@ export default function WorktreeVisibilityHelpPopover(): React.JSX.Element {
|
||||
</li>
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.sidebar.WorktreeVisibilityHelpPopover.54a7c2f183',
|
||||
'auto.components.sidebar.WorktreeVisibilityHelpPopover.ec1e6a10fb',
|
||||
'Other worktrees start hidden to avoid unexpected sidebar clutter.'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{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.'
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -34,11 +34,18 @@ type Props = {
|
||||
repo: Repo
|
||||
worktrees: readonly DetectedWorktree[]
|
||||
disabled: boolean
|
||||
onAdd: (rootPath: string) => Promise<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'
|
||||
@@ -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<string | null>(null)
|
||||
const customSources = normalizeCustomWorktreeVisibilitySources(
|
||||
repo.customWorktreeVisibilitySources
|
||||
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 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<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 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({
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-muted/30">
|
||||
{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 (
|
||||
<div
|
||||
key={key}
|
||||
@@ -210,7 +241,7 @@ export default function WorktreeVisibilitySourceList({
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeVisibilitySourceList.remove',
|
||||
'Remove {{value0}}',
|
||||
{ value0: label }
|
||||
{ value0: accessibleLabel }
|
||||
)}
|
||||
onClick={() => 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)}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} 件検出",
|
||||
|
||||
@@ -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}}개 발견",
|
||||
|
||||
@@ -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}} 个",
|
||||
|
||||
@@ -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.'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>()
|
||||
const roots = new Set<string>()
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user