Expand global worktree visibility source defaults

This commit is contained in:
Brennan Benson
2026-08-13 12:01:00 -07:00
parent 70f00b746c
commit 77eb16e783
44 changed files with 1017 additions and 297 deletions
+4 -4
View File
@@ -68,7 +68,7 @@ import {
} from '../../shared/worktree-ownership'
import {
createWorktreeVisibilitySourceMatcher,
normalizeCustomWorktreeVisibilitySources
resolveCustomWorktreeVisibilitySources
} from '../../shared/worktree-visibility-sources'
import {
assertWorktreeCleanForRemoval,
@@ -855,7 +855,7 @@ function buildDetectedGitWorktrees(
)
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...liveWorktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
)
const detected = liveWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
@@ -1007,7 +1007,7 @@ function buildFolderDetectedWorktrees(store: Store, repo: Repo): DetectedWorktre
const worktrees = listFolderWorkspaces(store, repo)
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
)
return worktrees.map((worktree) =>
toDetectedWorktree({
@@ -1092,7 +1092,7 @@ function buildDisconnectedDetectedWorktrees(
const settings = store.getSettings()
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
resolveCustomWorktreeVisibilitySources(repo, settings.worktreeVisibilityDefaults)
)
const detected = worktrees.map((worktree) => {
const meta = store.getWorktreeMeta(worktree.id)
+32
View File
@@ -4794,6 +4794,38 @@ describe('Store', () => {
})
})
it('normalizes and persists global worktree source defaults', async () => {
const store = await createStore()
store.updateSettings({
worktreeVisibilityDefaults: {
external: 'show',
customSources: [
{ id: 'team', rootPath: ' /srv/team-worktrees ' },
{ id: 'invalid', rootPath: '../relative' }
],
sourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' },
custom: { team: 'show' }
}
}
})
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({
external: 'show',
customSources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
sourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' },
custom: { team: 'show' }
}
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getSettings().worktreeVisibilityDefaults).toEqual(
store.getSettings().worktreeVisibilityDefaults
)
})
it('persists agent worktree visibility independently from external visibility', async () => {
const store = await createStore()
store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' }))
+26 -19
View File
@@ -489,7 +489,7 @@ import {
import { isAgentScratchRepoRootPath } from '../../shared/agent-scratch-worktrees'
import {
createWorktreeVisibilitySourceMatcher,
normalizeCustomWorktreeVisibilitySources,
resolveCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from '../../shared/worktree-visibility-sources'
import {
@@ -3615,11 +3615,7 @@ export class OrcaRuntimeService {
const before = beforeSettings.agentStatusHooksEnabled !== false
this.store.updateSettings(updates, { notifyListeners: true })
const settings = this.store.getSettings()
if (
updates.worktreeVisibilityDefaults !== undefined &&
beforeSettings.worktreeVisibilityDefaults?.external !==
settings.worktreeVisibilityDefaults?.external
) {
if (updates.worktreeVisibilityDefaults !== undefined) {
this.notifyReposChanged()
}
if (
@@ -21015,15 +21011,19 @@ export class OrcaRuntimeService {
}
const resolved = await this.listResolvedWorktrees()
const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null
const visibilitySourceMatchersByRepoId =
this.buildRuntimeVisibilitySourceMatchersByRepoId(resolved)
const settings = this.store?.getSettings()
const visibilitySourceMatchersByRepoId = this.buildRuntimeVisibilitySourceMatchersByRepoId(
resolved,
settings?.worktreeVisibilityDefaults
)
const worktrees = resolved.filter((worktree) => {
if (repoId && worktree.repoId !== repoId) {
return false
}
return this.isRuntimeWorktreeVisible(
worktree,
visibilitySourceMatchersByRepoId.get(worktree.repoId)
visibilitySourceMatchersByRepoId.get(worktree.repoId),
settings
)
})
return {
@@ -21046,14 +21046,16 @@ export class OrcaRuntimeService {
repo: Repo
): Promise<DetectedWorktreeListResult> {
const store = this.requireStore()
const settings = store.getSettings()
const visibilityDefaults = settings.worktreeVisibilityDefaults
if (isFolderRepo(repo)) {
const worktrees = listRuntimeFolderWorkspaces(store, repo)
const matcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
)
const detected = worktrees.map((worktree) =>
this.toRuntimeDetectedWorktree(repo, worktree, matcher)
this.toRuntimeDetectedWorktree(repo, worktree, matcher, settings)
)
return {
repoId: repo.id,
@@ -21073,7 +21075,7 @@ export class OrcaRuntimeService {
}
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
)
const detected = scan.worktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
@@ -21085,7 +21087,8 @@ export class OrcaRuntimeService {
const detectedWorktree = this.toRuntimeDetectedWorktree(
repo,
worktree,
worktreeVisibilitySourceMatcher
worktreeVisibilitySourceMatcher,
settings
)
if (scan.ok) {
return detectedWorktree
@@ -21152,17 +21155,20 @@ export class OrcaRuntimeService {
private isRuntimeWorktreeVisible(
worktree: Worktree,
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher,
settings?: ReturnType<RuntimeStore['getSettings']>
): boolean {
const repo = this.store?.getRepo(worktree.repoId)
if (!repo || !this.store) {
return true
}
return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher).visible
return this.toRuntimeDetectedWorktree(repo, worktree, worktreeVisibilitySourceMatcher, settings)
.visible
}
private buildRuntimeVisibilitySourceMatchersByRepoId(
worktrees: readonly Worktree[]
worktrees: readonly Worktree[],
visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
): Map<string, WorktreeVisibilitySourceMatcher> {
const checkoutPathsByRepoId = new Map<string, string[]>()
for (const worktree of worktrees) {
@@ -21177,7 +21183,7 @@ export class OrcaRuntimeService {
repo.id,
createWorktreeVisibilitySourceMatcher(
[repo.path, ...(checkoutPathsByRepoId.get(repo.id) ?? [])],
normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []
resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
)
])
)
@@ -21186,9 +21192,10 @@ export class OrcaRuntimeService {
private toRuntimeDetectedWorktree(
repo: Repo,
worktree: Worktree,
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher
worktreeVisibilitySourceMatcher?: WorktreeVisibilitySourceMatcher,
providedSettings?: ReturnType<RuntimeStore['getSettings']>
): DetectedWorktree {
const settings = this.store?.getSettings()
const settings = providedSettings ?? this.store?.getSettings()
if (!settings) {
return {
...worktree,
@@ -22,6 +22,7 @@ import type { TaskProvider } from '../../../../shared/types'
import { ClientUiWorkspaceFilterFields } from './client-ui-workspace-filter-fields'
import { TaskResumeState } from './task-resume-state-schema'
import { omitUndefinedValues, tolerateUnknownValues } from './ui-update-value-tolerance'
import { WorktreeVisibilityDefaultsUpdate } from './worktree-visibility-defaults-schema'
const NullableString = z.string().nullable()
const StringArray = z.array(z.string())
@@ -138,10 +139,7 @@ const GitHubProjectSettings = z
export const SettingsUpdate = z
.object({
worktreeVisibilityDefaults: z
.object({ external: z.enum(['hide', 'show']).optional() })
.strict()
.optional(),
worktreeVisibilityDefaults: WorktreeVisibilityDefaultsUpdate.optional(),
defaultTuiAgent: z
.unknown()
.transform((value) =>
+19 -2
View File
@@ -93,7 +93,17 @@ describe('client UI RPC methods', () => {
const response = await dispatcher.dispatch(
makeRequest('settings.update', {
worktreeVisibilityDefaults: { external: 'show' },
worktreeVisibilityDefaults: {
external: 'show',
customSources: [
{ id: 'team', rootPath: ' /srv/team ' },
{ id: 'invalid', rootPath: '../relative' }
],
sourcePreferences: {
builtIn: { claude: 'show', unknown: 'show' },
custom: { team: 'hide', 'bad id': 'show' }
}
},
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude', 'not-real', 'claude'],
defaultTaskSource: 'linear',
@@ -110,7 +120,14 @@ describe('client UI RPC methods', () => {
)
expect(runtime.updateClientSettings).toHaveBeenCalledWith({
worktreeVisibilityDefaults: { external: 'show' },
worktreeVisibilityDefaults: {
external: 'show',
customSources: [{ id: 'team', rootPath: '/srv/team' }],
sourcePreferences: {
builtIn: { claude: 'show' },
custom: { team: 'hide' }
}
},
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude'],
defaultTaskSource: 'linear',
@@ -0,0 +1,19 @@
import { z } from 'zod'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from '../../../../shared/worktree-visibility-sources'
export const WorktreeVisibilityDefaultsUpdate = z
.object({
external: z.enum(['hide', 'show']).optional(),
customSources: z
.unknown()
.transform((value) => normalizeCustomWorktreeVisibilitySources(value))
.optional(),
sourcePreferences: z
.unknown()
.transform((value) => normalizeWorktreeVisibilitySourcePreferences(value))
.optional()
})
.strict()
@@ -23,6 +23,7 @@ import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls'
import { translate } from '@/i18n/i18n'
import { DefaultWindowsProjectRuntimeSetting } from './DefaultWindowsProjectRuntimeSetting'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
export {
createAutoSaveDelayDraftState,
@@ -79,6 +80,7 @@ const EMPTY_WSL_DISTROS: string[] = []
type GeneralPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
updateSettingsOrThrow?: (updates: Partial<GlobalSettings>) => void | Promise<void>
fontSuggestions: string[]
onRequestFontSuggestions?: () => void
wslSupportedPlatform?: boolean
@@ -90,6 +92,7 @@ type GeneralPaneProps = {
export function GeneralPane({
settings,
updateSettings,
updateSettingsOrThrow,
fontSuggestions,
onRequestFontSuggestions,
wslSupportedPlatform,
@@ -98,6 +101,13 @@ export function GeneralPane({
wslCapabilitiesLoading
}: GeneralPaneProps): React.JSX.Element {
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
const sourceDefaultsSupportedRuntimeEnvironmentId = useAppStore(
(s) => s.worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId
)
const activeRuntimeTarget = getActiveRuntimeTarget(settings)
const sourceDefaultsSupported =
activeRuntimeTarget.kind === 'local' ||
activeRuntimeTarget.environmentId === sourceDefaultsSupportedRuntimeEnvironmentId
const generalNavigationSearchEntries = getGeneralNavigationSearchEntries()
const tabOrderKeywords = getTabOrderControlSearchKeywords(generalNavigationSearchEntries)
const projectRuntimeSearchEntries = wslSupportedPlatform
@@ -148,6 +158,8 @@ export function GeneralPane({
key="workspace"
settings={settings}
updateSettings={updateSettings}
updateSettingsOrThrow={updateSettingsOrThrow}
sourceDefaultsSupported={sourceDefaultsSupported}
/>
) : null,
shouldShowProjectRuntimeSection(
@@ -5,9 +5,15 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import { GeneralWorkspaceSettingsSection } from './GeneralWorkspaceSettingsSection'
import type { ReactNode } from 'react'
vi.mock('./WorkspaceDirectorySetting', () => ({ WorkspaceDirectorySetting: () => null }))
vi.mock('./OpenInMenuSetting', () => ({ OpenInMenuSetting: () => null }))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>
}))
let container: HTMLDivElement
let root: Root
@@ -23,40 +29,101 @@ afterEach(() => {
container.remove()
})
describe('GeneralWorkspaceSettingsSection external visibility', () => {
it('writes the global Show default without touching repositories', () => {
const updateSettings = vi.fn()
act(() => {
root.render(
<GeneralWorkspaceSettingsSection
settings={getDefaultSettings('/home/user')}
updateSettings={updateSettings}
/>
)
})
function renderSection(
updateSettings: (updates: object) => void | Promise<void>,
options: {
sourceDefaultsSupported?: boolean
worktreeVisibilityDefaults?: ReturnType<typeof getDefaultSettings>['worktreeVisibilityDefaults']
} = {}
): void {
const settings = getDefaultSettings('/home/user')
settings.worktreeVisibilityDefaults = options.worktreeVisibilityDefaults
act(() => {
root.render(
<GeneralWorkspaceSettingsSection
settings={settings}
updateSettings={updateSettings}
sourceDefaultsSupported={options.sourceDefaultsSupported}
/>
)
})
}
const show = container.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="false"]')
expect(show?.textContent).toBe('Show')
act(() => show?.click())
function getSwitch(label: string): HTMLButtonElement {
const control = container.querySelector<HTMLButtonElement>(
`[role="switch"][aria-label="${label}"]`
)
if (!control) {
throw new Error(`switch not found: ${label}`)
}
return control
}
describe('GeneralWorkspaceSettingsSection external visibility', () => {
it('writes the global Other locations default without touching repositories', async () => {
const updateSettings = vi.fn()
renderSection(updateSettings)
await act(async () => {
getSwitch('Show current and future worktrees from Other locations').click()
})
expect(updateSettings).toHaveBeenCalledWith({
worktreeVisibilityDefaults: { external: 'show' }
})
})
it('disables the control when the active host does not support global defaults', () => {
act(() => {
root.render(
<GeneralWorkspaceSettingsSection
settings={{
...getDefaultSettings('/home/user'),
worktreeVisibilityDefaults: undefined
}}
updateSettings={vi.fn()}
/>
)
it('keeps Other locations available while source defaults require a newer host', () => {
renderSection(vi.fn(), { sourceDefaultsSupported: false })
expect(getSwitch('Show current and future worktrees from Claude Code').disabled).toBe(true)
expect(getSwitch('Show current and future worktrees from GSD').disabled).toBe(true)
expect(getSwitch('Show current and future worktrees from Other locations').disabled).toBe(false)
expect(container.querySelector<HTMLInputElement>('#custom-worktree-root')?.disabled).toBe(true)
expect(container.textContent).toContain('Update this server to configure source defaults.')
})
it('writes only Other locations when a downgraded host retains source fields', async () => {
const updateSettings = vi.fn()
renderSection(updateSettings, {
sourceDefaultsSupported: false,
worktreeVisibilityDefaults: {
external: 'hide',
customSources: [{ id: 'team', rootPath: '/srv/team' }],
sourcePreferences: { builtIn: { claude: 'show' } }
}
})
expect(container.querySelectorAll('[role="radio"][aria-disabled="true"]')).toHaveLength(2)
await act(async () => {
getSwitch('Show current and future worktrees from Other locations').click()
})
expect(updateSettings).toHaveBeenCalledWith({
worktreeVisibilityDefaults: { external: 'show' }
})
})
it('adds a global location disabled by default', async () => {
const updateSettings = vi.fn()
renderSection(updateSettings)
const input = container.querySelector<HTMLInputElement>('#custom-worktree-root')!
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
act(() => {
setValue?.call(input, '/srv/team-worktrees')
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => {
container.querySelector<HTMLButtonElement>('button[type="submit"]')?.click()
})
const defaults = updateSettings.mock.calls[0]?.[0].worktreeVisibilityDefaults
expect(defaults).toMatchObject({
external: 'hide',
customSources: [{ rootPath: '/srv/team-worktrees' }],
sourcePreferences: { custom: expect.any(Object) }
})
const sourceId = defaults.customSources[0].id
expect(defaults.sourcePreferences.custom).toEqual({ [sourceId]: 'hide' })
})
})
@@ -2,25 +2,24 @@ import type React from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { OpenInMenuSetting } from './OpenInMenuSetting'
import { SearchableSetting } from './SearchableSetting'
import {
SettingsRow,
SettingsSegmentedControl,
SettingsSubsectionHeader,
SettingsSwitchRow
} from './SettingsFormControls'
import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls'
import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting'
import { translate } from '@/i18n/i18n'
import { GlobalWorktreeVisibilitySourcesSetting } from './GlobalWorktreeVisibilitySourcesSetting'
type GeneralWorkspaceSettingsSectionProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
updateSettingsOrThrow?: (updates: Partial<GlobalSettings>) => void | Promise<void>
sourceDefaultsSupported?: boolean
}
export function GeneralWorkspaceSettingsSection({
settings,
updateSettings
updateSettings,
updateSettingsOrThrow = updateSettings,
sourceDefaultsSupported = true
}: GeneralWorkspaceSettingsSectionProps): React.JSX.Element {
const visibilityDefaultsSupported = settings.worktreeVisibilityDefaults !== undefined
return (
<section key="workspace" className="space-y-4">
<SettingsSubsectionHeader
@@ -43,48 +42,27 @@ export function GeneralWorkspaceSettingsSection({
)}
description={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktreesDescription',
'Choose whether worktrees created outside Orca appear by default.'
'Choose which worktrees created outside Orca appear by default on this host.'
)}
keywords={['external', 'non-Orca', 'worktree', 'visibility', 'sidebar', 'show', 'hide']}
keywords={[
'external',
'non-Orca',
'worktree',
'visibility',
'sidebar',
'show',
'hide',
'source',
'location',
'root',
'Claude',
'GSD'
]}
>
<SettingsRow
label={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktrees',
'External worktrees'
)}
description={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktreesDescription',
'Choose whether worktrees created outside Orca appear by default.'
)}
control={
<SettingsSegmentedControl
size="sm"
value={settings.worktreeVisibilityDefaults?.external ?? 'hide'}
ariaLabel={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktrees',
'External worktrees'
)}
onChange={(external) => updateSettings({ worktreeVisibilityDefaults: { external } })}
options={[
{
value: 'show',
disabled: !visibilityDefaultsSupported,
label: translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.show',
'Show'
)
},
{
value: 'hide',
disabled: !visibilityDefaultsSupported,
label: translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.hide',
'Hide'
)
}
]}
/>
}
<GlobalWorktreeVisibilitySourcesSetting
settings={settings}
sourceDefaultsSupported={sourceDefaultsSupported}
updateSettings={updateSettingsOrThrow}
/>
</SearchableSetting>
@@ -0,0 +1,171 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type {
CustomWorktreeVisibilitySource,
GlobalSettings,
WorktreeVisibilityDefaults
} from '../../../../shared/types'
import {
MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES,
normalizeCustomWorktreeVisibilitySources
} from '../../../../shared/worktree-visibility-sources'
import {
buildDefaultWorktreeSourcePreferenceUpdate,
removeDefaultCustomWorktreeSourcePreference
} from '../../../../shared/worktree-visibility-source-preferences'
import WorktreeVisibilitySourceList, {
type WorktreeVisibilitySourceAddResult,
type WorktreeVisibilitySourceRow
} from '../sidebar/WorktreeVisibilitySourceList'
import { translate } from '@/i18n/i18n'
type Props = {
settings: GlobalSettings
sourceDefaultsSupported: boolean
updateSettings: (updates: Partial<GlobalSettings>) => void | Promise<void>
}
const DEFAULT_WORKTREE_VISIBILITY_DEFAULTS: WorktreeVisibilityDefaults = { external: 'hide' }
export function GlobalWorktreeVisibilitySourcesSetting({
settings,
sourceDefaultsSupported,
updateSettings
}: Props): React.JSX.Element {
const defaults = settings.worktreeVisibilityDefaults ?? DEFAULT_WORKTREE_VISIBILITY_DEFAULTS
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const pendingRef = useRef(false)
const customSources = useMemo(
() => normalizeCustomWorktreeVisibilitySources(defaults.customSources) ?? [],
[defaults.customSources]
)
const removableSourceIds = useMemo(
() => new Set(customSources.map((source) => source.id)),
[customSources]
)
const commit = useCallback(
async (next: WorktreeVisibilityDefaults): Promise<boolean> => {
if (pendingRef.current) {
return false
}
pendingRef.current = true
setPending(true)
setError(null)
try {
await updateSettings({ worktreeVisibilityDefaults: next })
return true
} catch {
setError(
translate(
'auto.components.settings.GlobalWorktreeVisibilitySourcesSetting.saveFailed',
'Could not save visibility defaults.'
)
)
return false
} finally {
pendingRef.current = false
setPending(false)
}
},
[updateSettings]
)
const handleToggle = useCallback(
async (source: WorktreeVisibilitySourceRow, checked: boolean) => {
const visibility = checked ? 'show' : 'hide'
if (source.kind === 'other') {
await commit(
sourceDefaultsSupported ? { ...defaults, external: visibility } : { external: visibility }
)
return
}
if (!sourceDefaultsSupported) {
return
}
const match =
source.kind === 'built-in'
? ({ kind: 'built-in', id: source.id } as const)
: ({ kind: 'custom', id: source.source.id } as const)
await commit({
...defaults,
sourcePreferences: buildDefaultWorktreeSourcePreferenceUpdate(defaults, match, visibility)
})
},
[commit, defaults, sourceDefaultsSupported]
)
const handleAdd = useCallback(
async (rootPath: string): Promise<WorktreeVisibilitySourceAddResult> => {
if (!sourceDefaultsSupported) {
return 'save-failed'
}
if (customSources.length >= MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES) {
return 'limit'
}
const id = crypto.randomUUID().replaceAll('-', '')
const candidate = normalizeCustomWorktreeVisibilitySources([{ id, rootPath }])?.[0]
if (!candidate) {
return 'invalid-path'
}
const nextSources = normalizeCustomWorktreeVisibilitySources([...customSources, candidate])
if (!nextSources || nextSources.length !== customSources.length + 1) {
return 'duplicate-path'
}
const saved = await commit({
...defaults,
customSources: nextSources,
sourcePreferences: buildDefaultWorktreeSourcePreferenceUpdate(
defaults,
{ kind: 'custom', id },
'hide'
)
})
return saved ? 'added' : 'save-failed'
},
[commit, customSources, defaults, sourceDefaultsSupported]
)
const handleRemove = useCallback(
async (source: CustomWorktreeVisibilitySource) => {
if (!sourceDefaultsSupported) {
return
}
await commit({
...defaults,
customSources: customSources.filter((candidate) => candidate.id !== source.id),
sourcePreferences: removeDefaultCustomWorktreeSourcePreference(defaults, source.id)
})
},
[commit, customSources, defaults, sourceDefaultsSupported]
)
return (
<div className="space-y-2">
<WorktreeVisibilitySourceList
visibilityDefaults={defaults}
customSources={customSources}
removableSourceIds={removableSourceIds}
showCounts={false}
disabled={pending}
sourceDefaultsDisabled={!sourceDefaultsSupported}
onAdd={handleAdd}
onRemove={handleRemove}
onToggle={handleToggle}
/>
{!sourceDefaultsSupported ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.GlobalWorktreeVisibilitySourcesSetting.updateServer',
'Update this server to configure source defaults.'
)}
</p>
) : null}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</div>
)
}
@@ -1389,6 +1389,7 @@ function Settings(): React.JSX.Element {
<GeneralPane
settings={settings}
updateSettings={updateSettings}
updateSettingsOrThrow={updateSettingsOrThrow}
fontSuggestions={terminalFontSuggestions}
onRequestFontSuggestions={requestFontSuggestions}
wslSupportedPlatform={localWslSupportedPlatform}
@@ -18,7 +18,9 @@ const mocks = vi.hoisted(() => ({
repos: [] as unknown[],
updateRepo: vi.fn(),
fetchWorktrees: vi.fn(),
detectedWorktreesByRepo: {} as Record<string, unknown>
detectedWorktreesByRepo: {} as Record<string, unknown>,
settings: {} as Record<string, unknown>,
worktreeVisibilityDefaultsByHost: {} as Record<string, unknown>
}
}))
@@ -130,6 +132,8 @@ beforeEach(() => {
mocks.state.modalData = { repoId: 'repo-1' }
mocks.state.repos = [makeRepo()]
mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected() }
mocks.state.settings = {}
mocks.state.worktreeVisibilityDefaultsByHost = {}
mocks.state.updateRepo.mockImplementation(
async (repoId: string, updates: Record<string, unknown>) => {
const repo = (mocks.state.repos as Repo[]).find((candidate) => candidate.id === repoId)
@@ -573,7 +577,7 @@ describe('WorktreeVisibilityDialog', () => {
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' }
builtIn: { claude: 'show' }
}
})
expect(mocks.state.closeModal).not.toHaveBeenCalled()
@@ -783,4 +787,23 @@ describe('WorktreeVisibilityDialog', () => {
expect(document.querySelector('button[aria-label="Remove /srv/alpha/team"]')).not.toBeNull()
expect(document.querySelector('button[aria-label="Remove /srv/beta/team"]')).not.toBeNull()
})
it('shows inherited global locations without repository removal controls', async () => {
mocks.state.settings = {
worktreeVisibilityDefaults: {
external: 'hide',
customSources: [{ id: 'global-team', rootPath: '/srv/global-team' }],
sourcePreferences: { custom: { 'global-team': 'show' } }
}
}
await renderDialog()
expect(sourceSwitch('/srv/global-team').getAttribute('aria-checked')).toBe('true')
expect(document.querySelector('button[aria-label="Remove /srv/global-team"]')).toBeNull()
await click(sourceSwitch('/srv/global-team'))
expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', {
worktreeVisibilitySourcePreferences: { custom: { 'global-team': 'hide' } }
})
})
})
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import {
Dialog,
@@ -38,23 +38,23 @@ import WorktreeVisibilitySourceList, {
import type { CustomWorktreeVisibilitySource, Repo } from '../../../../shared/types'
import {
MAX_CUSTOM_WORKTREE_VISIBILITY_SOURCES,
buildWorktreeSourcePreferenceUpdate,
effectiveBuiltInWorktreeSourceVisibility,
effectiveCustomWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences,
removeCustomWorktreeSourcePreference
normalizeWorktreeVisibilitySourcePreferences
} from '../../../../shared/worktree-visibility-sources'
import {
buildWorktreeSourcePreferenceUpdate,
removeCustomWorktreeSourcePreference
} from '../../../../shared/worktree-visibility-source-preferences'
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
)
}
import { useRepoOwnerVisibilityDefaults } from './use-repo-owner-visibility-defaults'
import {
getLatestRepoForVisibilityScope,
getRepoCustomWorktreeVisibilitySourceIds,
isDuplicateWorktreeVisibilitySource
} from './worktree-visibility-repo-sources'
export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
const activeModal = useAppStore((s) => s.activeModal)
@@ -88,6 +88,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
busyPath ?? (activeMutation?.kind === 'row' ? activeMutation.path : null)
const effectivelyToggling = isToggling || activeMutation?.kind === 'toggle'
const visibilityDefaults = useRepoOwnerVisibilityDefaults(repo)
const removableSourceIds = useMemo(() => getRepoCustomWorktreeVisibilitySourceIds(repo), [repo])
useLayoutEffect(() => {
currentMutationScopeRef.current = mutationScope
@@ -195,7 +196,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
setIsToggling(true)
try {
const updated = await updateTargetRepo(repoId, updates)
const latestRepo = getLatestRepoForScope(mutationScope)
const latestRepo = getLatestRepoForVisibilityScope(mutationScope)
if (!updated || !latestRepo || !isAccepted(latestRepo)) {
if (currentMutationScopeRef.current === mutationScope) {
setActionState({
@@ -239,7 +240,8 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
(latestRepo) =>
effectiveExternalWorktreeVisibility(
latestRepo,
isLegacyRepoForExternalWorktreeVisibility(latestRepo)
isLegacyRepoForExternalWorktreeVisibility(latestRepo),
visibilityDefaults
) === visibility
)
return
@@ -258,11 +260,19 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
},
(latestRepo) =>
source.kind === 'built-in'
? effectiveBuiltInWorktreeSourceVisibility(latestRepo, source.id) === visibility
: effectiveCustomWorktreeSourceVisibility(latestRepo, source.source.id) === visibility
? effectiveBuiltInWorktreeSourceVisibility(
latestRepo,
source.id,
visibilityDefaults
) === visibility
: effectiveCustomWorktreeSourceVisibility(
latestRepo,
source.source.id,
visibilityDefaults
) === visibility
)
},
[commitSourceUpdate, repo]
[commitSourceUpdate, repo, visibilityDefaults]
)
const handleAddSource = useCallback(
@@ -281,6 +291,9 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
if (!candidate) {
return 'invalid-path'
}
if (isDuplicateWorktreeVisibilitySource(repo, visibilityDefaults, candidate)) {
return 'duplicate-path'
}
const next = normalizeCustomWorktreeVisibilitySources([...(existing ?? []), candidate])
if (!next || next.length !== (existing?.length ?? 0) + 1) {
return 'duplicate-path'
@@ -298,11 +311,11 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
normalizeCustomWorktreeVisibilitySources(
latestRepo.customWorktreeVisibilitySources
)?.some((source) => source.id === id) === true &&
effectiveCustomWorktreeSourceVisibility(latestRepo, id) === 'hide'
effectiveCustomWorktreeSourceVisibility(latestRepo, id, visibilityDefaults) === 'hide'
)
return saved ? 'added' : 'save-failed'
},
[commitSourceUpdate, repo]
[commitSourceUpdate, repo, visibilityDefaults]
)
const handleRemoveSource = useCallback(
@@ -354,6 +367,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
repo={repo}
worktrees={detected?.authoritative ? detected.worktrees : []}
visibilityDefaults={visibilityDefaults}
removableSourceIds={removableSourceIds}
disabled={effectiveBusyPath !== null || effectivelyToggling || listState === 'checking'}
onAdd={handleAddSource}
onRemove={handleRemoveSource}
@@ -15,9 +15,12 @@ import type {
} from '../../../../shared/types'
import {
createWorktreeVisibilitySourceMatcher,
effectiveDefaultBuiltInWorktreeSourceVisibility,
effectiveDefaultCustomWorktreeSourceVisibility,
effectiveBuiltInWorktreeSourceVisibility,
effectiveCustomWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences,
resolveCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatch
} from '../../../../shared/worktree-visibility-sources'
import {
@@ -32,10 +35,14 @@ export type WorktreeVisibilitySourceRow =
| { kind: 'other' }
type Props = {
repo: Repo
worktrees: readonly DetectedWorktree[]
repo?: Repo
worktrees?: readonly DetectedWorktree[]
visibilityDefaults?: WorktreeVisibilityDefaults
customSources?: readonly CustomWorktreeVisibilitySource[]
removableSourceIds?: ReadonlySet<string>
showCounts?: boolean
disabled: boolean
sourceDefaultsDisabled?: boolean
onAdd: (rootPath: string) => Promise<WorktreeVisibilitySourceAddResult>
onRemove: (source: CustomWorktreeVisibilitySource) => Promise<void>
onToggle: (source: WorktreeVisibilitySourceRow, enabled: boolean) => Promise<void>
@@ -48,6 +55,9 @@ export type WorktreeVisibilitySourceAddResult =
| 'limit'
| 'save-failed'
const EMPTY_VISIBILITY_DEFAULTS: WorktreeVisibilityDefaults = {}
const EMPTY_WORKTREES: readonly DetectedWorktree[] = []
function getSourceLabel(source: WorktreeVisibilitySourceRow): string {
if (source.kind === 'built-in') {
return source.id === 'claude'
@@ -80,20 +90,35 @@ function getSourcePath(source: WorktreeVisibilitySourceRow): string {
}
function isSourceEnabled(
repo: Repo,
repo: Repo | undefined,
source: WorktreeVisibilitySourceRow,
visibilityDefaults?: WorktreeVisibilityDefaults
visibilityDefaults: WorktreeVisibilityDefaults,
repoCustomSourceIds: ReadonlySet<string>
): boolean {
if (source.kind === 'built-in') {
return effectiveBuiltInWorktreeSourceVisibility(repo, source.id) === 'show'
return repo
? effectiveBuiltInWorktreeSourceVisibility(repo, source.id, visibilityDefaults) === 'show'
: effectiveDefaultBuiltInWorktreeSourceVisibility(visibilityDefaults, source.id) === 'show'
}
if (source.kind === 'custom') {
return effectiveCustomWorktreeSourceVisibility(repo, source.source.id) === 'show'
const explicit = repo
? normalizeWorktreeVisibilitySourcePreferences(repo.worktreeVisibilitySourcePreferences)
?.custom?.[source.source.id]
: undefined
return (
(explicit ??
(repoCustomSourceIds.has(source.source.id)
? 'hide'
: effectiveDefaultCustomWorktreeSourceVisibility(
visibilityDefaults,
source.source.id
))) === 'show'
)
}
return (
effectiveExternalWorktreeVisibility(
repo,
isLegacyRepoForExternalWorktreeVisibility(repo),
repo ?? {},
repo ? isLegacyRepoForExternalWorktreeVisibility(repo) : false,
visibilityDefaults
) === 'show'
)
@@ -117,9 +142,13 @@ function getAccessibleSourceLabel(source: WorktreeVisibilitySourceRow, label: st
export default function WorktreeVisibilitySourceList({
repo,
worktrees,
visibilityDefaults,
worktrees = EMPTY_WORKTREES,
visibilityDefaults = EMPTY_VISIBILITY_DEFAULTS,
customSources: providedCustomSources,
removableSourceIds,
showCounts = true,
disabled,
sourceDefaultsDisabled = false,
onAdd,
onRemove,
onToggle
@@ -127,8 +156,23 @@ export default function WorktreeVisibilitySourceList({
const [rootPath, setRootPath] = useState('')
const [inputError, setInputError] = useState<string | null>(null)
const customSources = useMemo(
() => normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? [],
[repo.customWorktreeVisibilitySources]
() =>
normalizeCustomWorktreeVisibilitySources(
providedCustomSources ??
(repo
? resolveCustomWorktreeVisibilitySources(repo, visibilityDefaults)
: visibilityDefaults.customSources)
) ?? [],
[providedCustomSources, repo, visibilityDefaults]
)
const repoCustomSourceIds = useMemo(
() =>
new Set(
normalizeCustomWorktreeVisibilitySources(repo?.customWorktreeVisibilitySources)?.map(
(source) => source.id
) ?? []
),
[repo?.customWorktreeVisibilitySources]
)
const sources = useMemo<WorktreeVisibilitySourceRow[]>(
() => [
@@ -142,10 +186,10 @@ export default function WorktreeVisibilitySourceList({
const classify = useMemo(
() =>
createWorktreeVisibilitySourceMatcher(
[repo.path, ...worktrees.map((worktree) => worktree.path)],
[...(repo ? [repo.path] : []), ...worktrees.map((worktree) => worktree.path)],
customSources
),
[customSources, repo.path, worktrees]
[customSources, repo, worktrees]
)
const sourceCounts = useMemo(() => {
const counts = new Map<string, number>()
@@ -207,6 +251,7 @@ export default function WorktreeVisibilitySourceList({
const key = sourceRowKey(source)
const count = sourceCounts.get(key) ?? 0
const accessibleLabel = getAccessibleSourceLabel(source, label)
const sourceDisabled = disabled || (source.kind !== 'other' && sourceDefaultsDisabled)
return (
<div
key={key}
@@ -226,20 +271,23 @@ export default function WorktreeVisibilitySourceList({
<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>
{showCounts ? (
<span className="shrink-0 text-[11px] text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.found',
'{{value0}} found',
{ value0: count }
)}
</span>
) : null}
</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' ? (
{source.kind === 'custom' &&
(!removableSourceIds || removableSourceIds.has(source.source.id)) ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -247,7 +295,7 @@ export default function WorktreeVisibilitySourceList({
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-destructive"
disabled={disabled}
disabled={sourceDisabled}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.remove',
'Remove {{value0}}',
@@ -267,8 +315,8 @@ export default function WorktreeVisibilitySourceList({
</Tooltip>
) : null}
<Switch
checked={isSourceEnabled(repo, source, visibilityDefaults)}
disabled={disabled}
checked={isSourceEnabled(repo, source, visibilityDefaults, repoCustomSourceIds)}
disabled={sourceDisabled}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.toggle',
'Show current and future worktrees from {{value0}}',
@@ -288,7 +336,7 @@ export default function WorktreeVisibilitySourceList({
)}
onSubmit={(event) => void handleAdd(event)}
>
<Label htmlFor="custom-worktree-root">
<Label htmlFor="custom-worktree-root" className="text-[13px]">
{translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.worktreeRoot',
'Worktree root'
@@ -301,6 +349,7 @@ export default function WorktreeVisibilitySourceList({
value={rootPath}
autoComplete="off"
spellCheck={false}
disabled={disabled || sourceDefaultsDisabled}
aria-invalid={inputError ? true : undefined}
aria-describedby="custom-worktree-root-help"
onChange={(event) => {
@@ -308,7 +357,11 @@ export default function WorktreeVisibilitySourceList({
setInputError(null)
}}
/>
<Button type="submit" size="sm" disabled={disabled || !rootPath.trim()}>
<Button
type="submit"
size="sm"
disabled={disabled || sourceDefaultsDisabled || !rootPath.trim()}
>
{translate('auto.components.sidebar.WorktreeVisibilitySourceList.add', 'Add')}
</Button>
</div>
@@ -0,0 +1,34 @@
import { useAppStore } from '@/store'
import type {
CustomWorktreeVisibilitySource,
Repo,
WorktreeVisibilityDefaults
} from '../../../../shared/types'
import {
normalizeCustomWorktreeVisibilitySources,
resolveCustomWorktreeVisibilitySources
} from '../../../../shared/worktree-visibility-sources'
import { getRepoHostIdentity } from '@/store/slices/repo-host-identity'
export function getLatestRepoForVisibilityScope(scope: string): Repo | null {
return useAppStore.getState().repos.find((repo) => getRepoHostIdentity(repo) === scope) ?? null
}
export function getRepoCustomWorktreeVisibilitySourceIds(repo: Repo | null): Set<string> {
return new Set(
normalizeCustomWorktreeVisibilitySources(repo?.customWorktreeVisibilitySources)?.map(
(source) => source.id
) ?? []
)
}
export function isDuplicateWorktreeVisibilitySource(
repo: Repo,
defaults: WorktreeVisibilityDefaults | undefined,
candidate: CustomWorktreeVisibilitySource
): boolean {
const current = resolveCustomWorktreeVisibilitySources(repo, defaults)
return (
normalizeCustomWorktreeVisibilitySources([...current, candidate])?.length !== current.length + 1
)
}
+4
View File
@@ -10700,6 +10700,10 @@
"title": "New terminals aren’t being saved",
"body": "The terminal host stopped responding. Orca kept it rather than ending anything it might still be hosting, but it can’t reach those terminals until the host responds again — reopening a pane retries, and works once it does. New terminals open outside the host and close when you quit Orca. Restarting the host usually clears this, and ends every terminal — both the ones it is still holding and the ones running outside it.",
"action": "Restart host"
},
"GlobalWorktreeVisibilitySourcesSetting": {
"saveFailed": "Could not save visibility defaults.",
"updateServer": "Update this server to configure source defaults."
}
},
"right": {
+4
View File
@@ -10133,6 +10133,10 @@
"setupRecipe": "Configura una receta de entorno para tu proveedor de nube.",
"createWorkspace": "Crea un espacio de trabajo y selecciona esa receta en Ejecutar en.",
"openSetup": "Configurar recetas de entorno"
},
"GlobalWorktreeVisibilitySourcesSetting": {
"saveFailed": "No se pudieron guardar los valores predeterminados de visibilidad.",
"updateServer": "Actualiza este servidor para configurar los valores predeterminados de las fuentes."
}
},
"right": {
+4
View File
@@ -10133,6 +10133,10 @@
"setupRecipe": "クラウドプロバイダー用の環境レシピをセットアップします。",
"createWorkspace": "ワークスペースを作成し、「実行先」でそのレシピを選択します。",
"openSetup": "環境レシピをセットアップ"
},
"GlobalWorktreeVisibilitySourcesSetting": {
"saveFailed": "表示設定のデフォルトを保存できませんでした。",
"updateServer": "ソースのデフォルトを設定するには、このサーバーを更新してください。"
}
},
"right": {
+4
View File
@@ -10133,6 +10133,10 @@
"setupRecipe": "클라우드 공급자를 위한 환경 레시피를 설정하세요.",
"createWorkspace": "워크스페이스를 만들고 실행 위치에서 해당 레시피를 선택하세요.",
"openSetup": "환경 레시피 설정"
},
"GlobalWorktreeVisibilitySourcesSetting": {
"saveFailed": "가시성 기본값을 저장할 수 없습니다.",
"updateServer": "소스 기본값을 구성하려면 이 서버를 업데이트하세요."
}
},
"right": {
+4
View File
@@ -10153,6 +10153,10 @@
"description": "用于语音听写的输入设备,系统默认会跟随操作系统的麦克风设置。",
"accessHint": "允许麦克风访问以列出输入设备。",
"allowAccess": "允许访问"
},
"GlobalWorktreeVisibilitySourcesSetting": {
"saveFailed": "无法保存可见性默认设置。",
"updateServer": "请更新此服务器以配置来源默认设置。"
}
},
"right": {
@@ -494,7 +494,8 @@ describe('purgeStaleRuntimeHostState', () => {
local: { external: 'hide' },
[RUNTIME_A]: { external: 'show' }
},
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-a'
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-a',
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: 'env-a'
})
store.getState().purgeStaleRuntimeHostState(['env-a'])
@@ -503,5 +504,8 @@ describe('purgeStaleRuntimeHostState', () => {
local: { external: 'hide' }
})
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBeNull()
expect(
store.getState().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId
).toBeNull()
})
})
+15 -5
View File
@@ -38,6 +38,7 @@ export type SettingsSlice = SettingsSearchState & {
settings: GlobalSettings | null
worktreeVisibilityDefaultsByHost: WorktreeVisibilityDefaultsByHost
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: string | null
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: string | null
fetchSettings: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
updateSettingsOrThrow: (updates: Partial<GlobalSettings>) => Promise<void>
@@ -135,13 +136,15 @@ async function persistSettingsUpdates(
set: SettingsStateSetter,
updates: Partial<GlobalSettings>,
currentSettings: GlobalSettings | null,
supportedRuntimeEnvironmentId: string | null
supportedRuntimeEnvironmentId: string | null,
sourceDefaultsSupportedRuntimeEnvironmentId: string | null
): Promise<void> {
const normalizedUpdates = normalizeSettingsUpdates(updates, currentSettings)
await persistVisibilityAwareSettings({
normalizedUpdates,
currentSettings,
supportedRuntimeEnvironmentId,
sourceDefaultsSupportedRuntimeEnvironmentId,
set
})
}
@@ -176,6 +179,7 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
settings: null,
worktreeVisibilityDefaultsByHost: {},
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null,
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null,
...createSettingsSearchState((state) => set(state)),
fetchSettings: async () => {
@@ -192,7 +196,9 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
settings: hydrated.settings,
worktreeVisibilityDefaultsByHost: hydrated.defaultsByHost,
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId:
hydrated.supportedRuntimeEnvironmentId
hydrated.supportedRuntimeEnvironmentId,
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId:
hydrated.sourceDefaultsSupportedRuntimeEnvironmentId
})
} catch (err) {
console.error('Failed to fetch settings:', err)
@@ -218,7 +224,8 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
set,
updates,
get().settings,
get().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId
get().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId,
get().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId
)
if ('worktreeVisibilityDefaults' in updates) {
await get().fetchAllWorktrees()
@@ -234,7 +241,8 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
set,
updates,
get().settings,
get().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId
get().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId,
get().worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId
)
if ('worktreeVisibilityDefaults' in updates) {
await get().fetchAllWorktrees()
@@ -274,7 +282,9 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
settings: hydrated.settings,
worktreeVisibilityDefaultsByHost: hydrated.defaultsByHost,
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId:
hydrated.supportedRuntimeEnvironmentId
hydrated.supportedRuntimeEnvironmentId,
worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId:
hydrated.sourceDefaultsSupportedRuntimeEnvironmentId
})
} else {
set({ settings: null })
@@ -57,5 +57,6 @@ describe('runtime worktree visibility defaults', () => {
expect(hydrated.settings.worktreeVisibilityDefaults).toEqual({ external: 'show' })
expect(hydrated.supportedRuntimeEnvironmentId).toBe('env-1')
expect(hydrated.sourceDefaultsSupportedRuntimeEnvironmentId).toBeNull()
})
})
@@ -1,10 +1,16 @@
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
runtimeEnvironmentSupportsCapability
} from '@/runtime/runtime-rpc-client'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import type { GlobalSettings, WorktreeVisibilityDefaults } from '../../../../shared/types'
import { normalizeWorktreeVisibilityDefaults } from '../../../../shared/external-worktree-visibility'
import { WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
export type WorktreeVisibilityDefaultsByHost = Partial<
Record<ExecutionHostId, WorktreeVisibilityDefaults | null>
@@ -20,8 +26,7 @@ export async function readRuntimeWorktreeVisibilityDefaults(
undefined,
{ timeoutMs: 15_000, reuseRecentCompatibilityFailure: true }
)
const external = result.settings.worktreeVisibilityDefaults?.external
return external === 'hide' || external === 'show' ? { external } : null
return normalizeWorktreeVisibilityDefaults(result.settings.worktreeVisibilityDefaults) ?? null
} catch {
return undefined
}
@@ -34,12 +39,14 @@ export async function hydrateOwnerWorktreeVisibilityDefaults(
settings: GlobalSettings
defaultsByHost: WorktreeVisibilityDefaultsByHost
supportedRuntimeEnvironmentId: string | null
sourceDefaultsSupportedRuntimeEnvironmentId: string | null
}> {
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return {
settings,
supportedRuntimeEnvironmentId: null,
sourceDefaultsSupportedRuntimeEnvironmentId: null,
defaultsByHost: {
...defaultsByHost,
[LOCAL_EXECUTION_HOST_ID]: settings.worktreeVisibilityDefaults ?? { external: 'hide' }
@@ -53,11 +60,21 @@ export async function hydrateOwnerWorktreeVisibilityDefaults(
? { ...defaultsByHost, [LOCAL_EXECUTION_HOST_ID]: localDefaults }
: defaultsByHost
const defaults = await readRuntimeWorktreeVisibilityDefaults(target.environmentId)
const sourceDefaultsSupported =
defaults !== undefined &&
(await runtimeEnvironmentSupportsCapability(
target.environmentId,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
15_000
).catch(() => false))
if (defaults) {
return {
settings: { ...settings, worktreeVisibilityDefaults: defaults },
defaultsByHost: { ...ownerDefaultsByHost, [hostId]: defaults },
supportedRuntimeEnvironmentId: target.environmentId
supportedRuntimeEnvironmentId: target.environmentId,
sourceDefaultsSupportedRuntimeEnvironmentId: sourceDefaultsSupported
? target.environmentId
: null
}
}
if (defaults === undefined) {
@@ -66,20 +83,25 @@ export async function hydrateOwnerWorktreeVisibilityDefaults(
return {
settings: { ...settings, worktreeVisibilityDefaults: cached },
defaultsByHost: ownerDefaultsByHost,
supportedRuntimeEnvironmentId: target.environmentId
supportedRuntimeEnvironmentId: target.environmentId,
sourceDefaultsSupportedRuntimeEnvironmentId: sourceDefaultsSupported
? target.environmentId
: null
}
}
const { worktreeVisibilityDefaults: _unavailable, ...settingsWithoutDefaults } = settings
return {
settings: settingsWithoutDefaults as GlobalSettings,
defaultsByHost: ownerDefaultsByHost,
supportedRuntimeEnvironmentId: null
supportedRuntimeEnvironmentId: null,
sourceDefaultsSupportedRuntimeEnvironmentId: null
}
}
const { worktreeVisibilityDefaults: _unsupported, ...settingsWithoutDefaults } = settings
return {
settings: settingsWithoutDefaults as GlobalSettings,
defaultsByHost: { ...ownerDefaultsByHost, [hostId]: null },
supportedRuntimeEnvironmentId: null
supportedRuntimeEnvironmentId: null,
sourceDefaultsSupportedRuntimeEnvironmentId: null
}
}
@@ -92,3 +92,28 @@ it('does not restore a stale owner after its visibility write resolves', async (
expect(state.settings?.activeRuntimeEnvironmentId).toBe('env-b')
expect(state.worktreeVisibilityDefaultsByHost['runtime:env-a']).toEqual({ external: 'show' })
})
it('rejects source-default writes before contacting an older runtime host', async () => {
const runtimeCall = vi.fn()
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
const currentSettings = {
activeRuntimeEnvironmentId: 'env-a',
worktreeVisibilityDefaults: { external: 'hide' }
} as GlobalSettings
await expect(
persistVisibilityAwareSettings({
normalizedUpdates: {
worktreeVisibilityDefaults: {
external: 'hide',
customSources: [{ id: 'team', rootPath: '/srv/team' }]
}
},
currentSettings,
supportedRuntimeEnvironmentId: 'env-a',
sourceDefaultsSupportedRuntimeEnvironmentId: null,
set: vi.fn()
})
).rejects.toThrow('Update this server to configure source defaults.')
expect(runtimeCall).not.toHaveBeenCalled()
})
@@ -10,12 +10,27 @@ export async function persistVisibilityAwareSettings(args: {
normalizedUpdates: Partial<GlobalSettings>
currentSettings: GlobalSettings | null
supportedRuntimeEnvironmentId: string | null
sourceDefaultsSupportedRuntimeEnvironmentId?: string | null
set: (updater: (state: AppState) => Partial<AppState>) => void
}): Promise<void> {
const { normalizedUpdates, currentSettings, supportedRuntimeEnvironmentId, set } = args
const {
normalizedUpdates,
currentSettings,
supportedRuntimeEnvironmentId,
sourceDefaultsSupportedRuntimeEnvironmentId = null,
set
} = args
const target = getActiveRuntimeTarget(currentSettings)
if ('worktreeVisibilityDefaults' in normalizedUpdates && target.kind === 'environment') {
const { worktreeVisibilityDefaults, ...localUpdates } = normalizedUpdates
if (
worktreeVisibilityDefaults &&
target.environmentId !== sourceDefaultsSupportedRuntimeEnvironmentId &&
('customSources' in worktreeVisibilityDefaults ||
'sourcePreferences' in worktreeVisibilityDefaults)
) {
throw new Error('Update this server to configure source defaults.')
}
const localSettings =
Object.keys(localUpdates).length > 0
? ((await window.api.settings.set(localUpdates)) as GlobalSettings)
@@ -6238,6 +6238,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const visibilitySupportChanged = removed.has(
s.worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId ?? ''
)
const visibilitySourceSupportChanged = removed.has(
s.worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId ?? ''
)
if (
!reposChanged &&
!setupsChanged &&
@@ -6246,6 +6249,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
!restoredSessionOwnersChanged &&
!visibilityDefaultsChanged &&
!visibilitySupportChanged &&
!visibilitySourceSupportChanged &&
removedWorktreeIds.size === 0
) {
return s
@@ -6276,6 +6280,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(visibilitySupportChanged
? { worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null }
: {}),
...(visibilitySourceSupportChanged
? { worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null }
: {}),
...(rowsChanged ? { sortEpoch: s.sortEpoch + 1 } : {}),
// Why: mirror validateRepoScopedUi so a filtered/active sidebar can't reference a purged repo id.
...(reposChanged
@@ -5,11 +5,11 @@ import type { GlobalSettings, Repo, WorktreeVisibilityDefaults } from '../../../
export function getRepoOwnerWorktreeVisibilityDefaults(
repo: Pick<Repo, 'connectionId' | 'executionHostId'>,
settings: Pick<GlobalSettings, 'worktreeVisibilityDefaults'> | null | undefined,
defaultsByHost: Partial<Record<ExecutionHostId, WorktreeVisibilityDefaults | null>>
defaultsByHost: Partial<Record<ExecutionHostId, WorktreeVisibilityDefaults | null>> | undefined
): WorktreeVisibilityDefaults | undefined {
const hostId = getRepoExecutionHostId(repo)
if (parseExecutionHostId(hostId)?.kind === 'runtime') {
return defaultsByHost[hostId] ?? undefined
return defaultsByHost?.[hostId] ?? undefined
}
return defaultsByHost.local ?? settings?.worktreeVisibilityDefaults
return defaultsByHost?.local ?? settings?.worktreeVisibilityDefaults
}
@@ -14,7 +14,8 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
const fakeSockets: FakeWebSocket[] = []
@@ -85,7 +86,8 @@ describe('WebRuntimeClient', () => {
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
+4 -2
View File
@@ -17,7 +17,8 @@ import {
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error'
import { withReconnectJitter } from '../../../shared/reconnect-jitter'
@@ -463,7 +464,8 @@ export class WebRuntimeClient {
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
return
+21 -4
View File
@@ -1,4 +1,8 @@
import { normalizeRuntimePathSeparators } from './cross-platform-path'
import {
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
} from './worktree-visibility-sources'
import type { ExternalWorktreeVisibility, Repo, WorktreeVisibilityDefaults } from './types'
export const EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT = Date.UTC(2026, 4, 23)
@@ -78,10 +82,23 @@ export function normalizeWorktreeVisibilityDefaults(
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined
}
const external = (value as { external?: unknown }).external
return external === 'show' || external === 'hide'
? { ...(value as WorktreeVisibilityDefaults), external }
: undefined
const {
external,
customSources: rawCustomSources,
sourcePreferences: rawSourcePreferences,
...futureDefaults
} = value as WorktreeVisibilityDefaults & Record<string, unknown>
if (external !== 'show' && external !== 'hide') {
return undefined
}
const customSources = normalizeCustomWorktreeVisibilitySources(rawCustomSources)
const sourcePreferences = normalizeWorktreeVisibilitySourcePreferences(rawSourcePreferences)
return {
...futureDefaults,
external,
...(customSources !== undefined ? { customSources } : {}),
...(sourcePreferences !== undefined ? { sourcePreferences } : {})
}
}
export function migrateExternalWorktreeVisibilityDefaults(
+3
View File
@@ -90,6 +90,8 @@ export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE =
'Remote file changes require a newer Orca server. Update the HUB and try again.'
export const WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY =
'worktree.visibility-defaults.v1' as const
export const WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY =
'worktree.visibility-source-defaults.v1' as const
export const RUNTIME_CAPABILITIES = [
'runtime.status.compat.v1',
@@ -125,6 +127,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
ACCOUNT_IMPORT_RUNTIME_CAPABILITY,
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY
] as const
+4 -2
View File
@@ -16,7 +16,8 @@ import { MAX_TIMER_DELAY_MS } from './timer-delay'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
const servers: WebSocketServer[] = []
@@ -76,7 +77,8 @@ describe('subscribeRemoteRuntimeRequest', () => {
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
const bytes = new Uint8Array([1, 2, 3])
+6 -3
View File
@@ -25,7 +25,8 @@ import type { RuntimeStatus } from './runtime-types'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
// Re-export so existing value importers of `RemoteRuntimeClientError` are
// unaffected; the class lives in a ws-free module so type-only consumers
@@ -141,7 +142,8 @@ async function sendRemoteRuntimeRequestOnSocket<TResult>(
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
const pendingRequest = {
@@ -514,7 +516,8 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
return await new Promise((resolve, reject) => {
@@ -14,7 +14,8 @@ import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connect
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
type TestServer = {
@@ -65,7 +66,8 @@ describe('RemoteRuntimeRequestConnection', () => {
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
)
@@ -117,7 +119,8 @@ async function createServer(): Promise<TestServer> {
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
authenticated = true
@@ -33,7 +33,8 @@ import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready'
@@ -214,7 +215,8 @@ export class RemoteRuntimeRequestConnection {
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
}),
sharedKey
@@ -64,7 +64,8 @@ describe('RemoteRuntimeSharedControlConnection', () => {
clientCapabilities: [
protocolCapabilities.SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
protocolCapabilities.AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
protocolCapabilities.WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
protocolCapabilities.WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
protocolCapabilities.WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
expect(server.requests.map((request) => request.method)).toEqual([
@@ -3,7 +3,8 @@ import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch'
import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol'
@@ -45,7 +46,8 @@ export function handleSharedControlTextFrame(args: {
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
]
})
return
+5 -1
View File
@@ -124,8 +124,12 @@ export type WorktreeVisibilitySourcePreferences = {
}
export type WorktreeVisibilityDefaults = {
/** Default for worktrees Orca did not create. Future source-specific defaults extend this shape. */
/** Default for worktrees outside a recognized source. */
external?: ExternalWorktreeVisibility
/** Host-owned roots applied to every repository on that host. */
customSources?: CustomWorktreeVisibilitySource[]
/** Defaults for built-in and host-owned custom sources. */
sourcePreferences?: WorktreeVisibilitySourcePreferences
}
export type ProjectProviderIdentity = {
+5 -50
View File
@@ -13,16 +13,11 @@ import {
} from './agent-scratch-worktrees'
import {
createWorktreeVisibilitySourceMatcher,
effectiveWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
resolveCustomWorktreeVisibilitySources,
type WorktreeVisibilitySourceMatcher
} from './worktree-visibility-sources'
import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox'
import {
effectiveAgentWorktreeVisibility,
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from './external-worktree-visibility'
import { isLegacyRepoForExternalWorktreeVisibility } from './external-worktree-visibility'
import { shouldShowWorktree } from './worktree-visibility-resolution'
import type {
DetectedWorktree,
GlobalSettings,
@@ -39,6 +34,7 @@ export {
EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT,
isLegacyRepoForExternalWorktreeVisibility
} from './external-worktree-visibility'
export { shouldShowWorktree } from './worktree-visibility-resolution'
export function buildKnownOrcaWorkspaceLayouts(
settings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>,
@@ -197,7 +193,7 @@ export function toDetectedWorktree(args: {
args.worktreeVisibilitySourceMatcher ??
createWorktreeVisibilitySourceMatcher(
[args.repo.path],
normalizeCustomWorktreeVisibilitySources(args.repo.customWorktreeVisibilitySources) ?? []
resolveCustomWorktreeVisibilitySources(args.repo, args.settings.worktreeVisibilityDefaults)
)
const visibilitySource = sourceMatcher(args.worktree.path)
const ownership = classifyWorktreeOwnership({
@@ -227,47 +223,6 @@ export function toDetectedWorktree(args: {
}
}
export function shouldShowWorktree(args: {
worktree: Pick<Worktree, 'path'>
ownership: WorktreeOwnership
repo: Repo
isLegacyRepoForVisibility: boolean
isSelectedCheckout: boolean
visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
importedExternalWorktreePaths?: readonly string[] | undefined
visibilitySource?: ReturnType<WorktreeVisibilitySourceMatcher>
}): boolean {
if (args.isSelectedCheckout) {
return true
}
if (args.ownership === 'orca-managed') {
return true
}
if (
isExplicitlyImportedExternalWorktreePath(args.worktree.path, {
importedExternalWorktreePaths: args.importedExternalWorktreePaths
})
) {
return true
}
if (args.visibilitySource) {
return effectiveWorktreeSourceVisibility(args.repo, args.visibilitySource) === 'show'
}
if (args.ownership === 'agent-scratch') {
return effectiveAgentWorktreeVisibility(args.repo) === 'show'
}
if (args.ownership === 'unknown-legacy' && args.isLegacyRepoForVisibility) {
return true
}
return (
effectiveExternalWorktreeVisibility(
args.repo,
args.isLegacyRepoForVisibility,
args.visibilityDefaults
) === 'show'
)
}
export function applyMetadataFallbackVisibility(detected: DetectedWorktree): DetectedWorktree {
if (detected.ownership === 'agent-scratch' || detected.visibilitySource) {
// Why: retain scratch policy, including explicit imports, while ordinary fallback fails open.
@@ -0,0 +1,54 @@
import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox'
import {
effectiveAgentWorktreeVisibility,
effectiveExternalWorktreeVisibility
} from './external-worktree-visibility'
import {
effectiveWorktreeSourceVisibility,
type WorktreeVisibilitySourceMatcher
} from './worktree-visibility-sources'
import type { GlobalSettings, Repo, Worktree, WorktreeOwnership } from './types'
export function shouldShowWorktree(args: {
worktree: Pick<Worktree, 'path'>
ownership: WorktreeOwnership
repo: Repo
isLegacyRepoForVisibility: boolean
isSelectedCheckout: boolean
visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
importedExternalWorktreePaths?: readonly string[] | undefined
visibilitySource?: ReturnType<WorktreeVisibilitySourceMatcher>
}): boolean {
if (args.isSelectedCheckout || args.ownership === 'orca-managed') {
return true
}
if (
isExplicitlyImportedExternalWorktreePath(args.worktree.path, {
importedExternalWorktreePaths: args.importedExternalWorktreePaths
})
) {
return true
}
if (args.visibilitySource) {
return (
effectiveWorktreeSourceVisibility(
args.repo,
args.visibilitySource,
args.visibilityDefaults
) === 'show'
)
}
if (args.ownership === 'agent-scratch') {
return effectiveAgentWorktreeVisibility(args.repo) === 'show'
}
if (args.ownership === 'unknown-legacy' && args.isLegacyRepoForVisibility) {
return true
}
return (
effectiveExternalWorktreeVisibility(
args.repo,
args.isLegacyRepoForVisibility,
args.visibilityDefaults
) === 'show'
)
}
@@ -0,0 +1,84 @@
import type {
ExternalWorktreeVisibility,
Repo,
WorktreeVisibilityDefaults,
WorktreeVisibilitySourcePreferences
} from './types'
import {
normalizeWorktreeVisibilitySourcePreferences,
type WorktreeVisibilitySourceMatch
} from './worktree-visibility-sources'
function legacyBuiltInPreferences(
repo: Pick<Repo, 'agentWorktreeVisibility'>
): WorktreeVisibilitySourcePreferences['builtIn'] {
return repo.agentWorktreeVisibility
? { claude: repo.agentWorktreeVisibility, gsd: repo.agentWorktreeVisibility }
: {}
}
function preferenceResult(
builtIn: NonNullable<WorktreeVisibilitySourcePreferences['builtIn']>,
custom: NonNullable<WorktreeVisibilitySourcePreferences['custom']>
): WorktreeVisibilitySourcePreferences {
return {
...(Object.keys(builtIn).length > 0 ? { builtIn } : {}),
...(Object.keys(custom).length > 0 ? { custom } : {})
}
}
export function buildWorktreeSourcePreferenceUpdate(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
source: WorktreeVisibilitySourceMatch,
visibility: ExternalWorktreeVisibility
): WorktreeVisibilitySourcePreferences {
const current = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
)
const builtIn = { ...legacyBuiltInPreferences(repo), ...current?.builtIn }
const custom = { ...current?.custom }
if (source.kind === 'built-in') {
builtIn[source.id] = visibility
} else {
custom[source.id] = visibility
}
return preferenceResult(builtIn, 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 preferenceResult({ ...legacyBuiltInPreferences(repo), ...current?.builtIn }, custom)
}
export function buildDefaultWorktreeSourcePreferenceUpdate(
defaults: WorktreeVisibilityDefaults,
source: WorktreeVisibilitySourceMatch,
visibility: ExternalWorktreeVisibility
): WorktreeVisibilitySourcePreferences {
const current = normalizeWorktreeVisibilitySourcePreferences(defaults.sourcePreferences)
const builtIn = { ...current?.builtIn }
const custom = { ...current?.custom }
if (source.kind === 'built-in') {
builtIn[source.id] = visibility
} else {
custom[source.id] = visibility
}
return preferenceResult(builtIn, custom)
}
export function removeDefaultCustomWorktreeSourcePreference(
defaults: WorktreeVisibilityDefaults,
sourceId: string
): WorktreeVisibilitySourcePreferences {
const current = normalizeWorktreeVisibilitySourcePreferences(defaults.sourcePreferences)
const custom = { ...current?.custom }
delete custom[sourceId]
return preferenceResult({ ...current?.builtIn }, custom)
}
+50 -2
View File
@@ -1,11 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from './types'
import {
buildWorktreeSourcePreferenceUpdate,
buildDefaultWorktreeSourcePreferenceUpdate,
buildWorktreeSourcePreferenceUpdate
} from './worktree-visibility-source-preferences'
import {
createWorktreeVisibilitySourceMatcher,
effectiveBuiltInWorktreeSourceVisibility,
effectiveCustomWorktreeSourceVisibility,
normalizeCustomWorktreeVisibilitySources,
normalizeWorktreeVisibilitySourcePreferences
normalizeWorktreeVisibilitySourcePreferences,
resolveCustomWorktreeVisibilitySources
} from './worktree-visibility-sources'
function repo(overrides: Partial<Repo> = {}): Repo {
@@ -90,6 +95,49 @@ describe('worktree visibility sources', () => {
).toEqual({ builtIn: { claude: 'hide', gsd: 'show' } })
})
it('inherits global built-in and custom visibility without stamping sibling defaults', () => {
const defaults = {
external: 'hide' as const,
customSources: [{ id: 'team', rootPath: '/srv/team' }],
sourcePreferences: {
builtIn: { claude: 'show' as const, gsd: 'show' as const },
custom: { team: 'show' as const }
}
}
const inherited = repo()
expect(effectiveBuiltInWorktreeSourceVisibility(inherited, 'gsd', defaults)).toBe('show')
expect(effectiveCustomWorktreeSourceVisibility(inherited, 'team', defaults)).toBe('show')
expect(
buildWorktreeSourcePreferenceUpdate(inherited, { kind: 'built-in', id: 'claude' }, 'hide')
).toEqual({ builtIn: { claude: 'hide' } })
expect(
buildWorktreeSourcePreferenceUpdate(inherited, { kind: 'custom', id: 'team' }, 'hide')
).toEqual({ custom: { team: 'hide' } })
expect(inherited.worktreeVisibilitySourcePreferences).toBeUndefined()
})
it('resolves global custom roots for every repo and keeps new global defaults isolated', () => {
const defaults = {
external: 'hide' as const,
customSources: [{ id: 'global', rootPath: '/srv/global-worktrees' }]
}
for (const checkout of ['/repos/alpha', '/repos/beta']) {
const current = repo({ path: checkout })
const classify = createWorktreeVisibilitySourceMatcher(
[checkout],
resolveCustomWorktreeVisibilitySources(current, defaults)
)
expect(classify('/srv/global-worktrees/feature')).toEqual({
kind: 'custom',
id: 'global'
})
}
expect(
buildDefaultWorktreeSourcePreferenceUpdate(defaults, { kind: 'custom', id: 'global' }, 'hide')
).toEqual({ custom: { global: 'hide' } })
})
it('bounds and sanitizes persisted source definitions and preferences', () => {
expect(
normalizeCustomWorktreeVisibilitySources([
+63 -55
View File
@@ -8,6 +8,7 @@ import type {
CustomWorktreeVisibilitySource,
ExternalWorktreeVisibility,
Repo,
WorktreeVisibilityDefaults,
WorktreeVisibilitySourcePreferences
} from './types'
@@ -165,7 +166,8 @@ export function createWorktreeVisibilitySourceMatcher(
export function effectiveBuiltInWorktreeSourceVisibility(
repo: Pick<Repo, 'agentWorktreeVisibility' | 'worktreeVisibilitySourcePreferences'>,
sourceId: BuiltInWorktreeVisibilitySourceId
sourceId: BuiltInWorktreeVisibilitySourceId,
defaults?: WorktreeVisibilityDefaults
): ExternalWorktreeVisibility {
const explicit = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
@@ -173,68 +175,74 @@ export function effectiveBuiltInWorktreeSourceVisibility(
if (explicit) {
return explicit
}
return repo.agentWorktreeVisibility === 'show' ? 'show' : 'hide'
if (repo.agentWorktreeVisibility) {
return repo.agentWorktreeVisibility
}
return effectiveDefaultBuiltInWorktreeSourceVisibility(defaults, sourceId)
}
export function effectiveCustomWorktreeSourceVisibility(
repo: Pick<Repo, 'worktreeVisibilitySourcePreferences'>,
sourceId: string
repo: Pick<Repo, 'customWorktreeVisibilitySources' | 'worktreeVisibilitySourcePreferences'>,
sourceId: string,
defaults?: WorktreeVisibilityDefaults
): ExternalWorktreeVisibility {
return normalizeWorktreeVisibilitySourcePreferences(repo.worktreeVisibilitySourcePreferences)
?.custom?.[sourceId] === 'show'
const explicit = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
)?.custom?.[sourceId]
if (explicit) {
return explicit
}
const isRepoSource = normalizeCustomWorktreeVisibilitySources(
repo.customWorktreeVisibilitySources
)?.some((source) => source.id === sourceId)
return isRepoSource ? 'hide' : effectiveDefaultCustomWorktreeSourceVisibility(defaults, sourceId)
}
export function effectiveWorktreeSourceVisibility(
repo: Pick<
Repo,
| 'agentWorktreeVisibility'
| 'customWorktreeVisibilitySources'
| 'worktreeVisibilitySourcePreferences'
>,
source: WorktreeVisibilitySourceMatch,
defaults?: WorktreeVisibilityDefaults
): ExternalWorktreeVisibility {
return source.kind === 'built-in'
? effectiveBuiltInWorktreeSourceVisibility(repo, source.id, defaults)
: effectiveCustomWorktreeSourceVisibility(repo, source.id, defaults)
}
export function effectiveDefaultBuiltInWorktreeSourceVisibility(
defaults: WorktreeVisibilityDefaults | undefined,
sourceId: BuiltInWorktreeVisibilitySourceId
): ExternalWorktreeVisibility {
return normalizeWorktreeVisibilitySourcePreferences(defaults?.sourcePreferences)?.builtIn?.[
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'>,
export function effectiveDefaultCustomWorktreeSourceVisibility(
defaults: WorktreeVisibilityDefaults | undefined,
sourceId: string
): WorktreeVisibilitySourcePreferences {
const current = normalizeWorktreeVisibilitySourcePreferences(
repo.worktreeVisibilitySourcePreferences
): ExternalWorktreeVisibility {
return normalizeWorktreeVisibilitySourcePreferences(defaults?.sourcePreferences)?.custom?.[
sourceId
] === 'show'
? 'show'
: 'hide'
}
export function resolveCustomWorktreeVisibilitySources(
repo: Pick<Repo, 'customWorktreeVisibilitySources'>,
defaults?: WorktreeVisibilityDefaults
): CustomWorktreeVisibilitySource[] {
return (
normalizeCustomWorktreeVisibilitySources([
...(normalizeCustomWorktreeVisibilitySources(repo.customWorktreeVisibilitySources) ?? []),
...(normalizeCustomWorktreeVisibilitySources(defaults?.customSources) ?? [])
]) ?? []
)
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 } : {})
}
}