diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 37a144a9b6d..2517ecf8461 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 3225c0320b6..ba9db1dbac2 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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' })) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5201073b57d..d507e3e24b9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { 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 ): 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 { const checkoutPathsByRepoId = new Map() 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 ): DetectedWorktree { - const settings = this.store?.getSettings() + const settings = providedSettings ?? this.store?.getSettings() if (!settings) { return { ...worktree, diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 594b9325aaa..74c048cd8e7 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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) => diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 50b577de439..8da827b15d9 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts new file mode 100644 index 00000000000..2bd263f301f --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-visibility-defaults-schema.ts @@ -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() diff --git a/src/renderer/src/components/settings/GeneralPane.tsx b/src/renderer/src/components/settings/GeneralPane.tsx index 021fc5d7111..2ea94edcf03 100644 --- a/src/renderer/src/components/settings/GeneralPane.tsx +++ b/src/renderer/src/components/settings/GeneralPane.tsx @@ -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) => void + updateSettingsOrThrow?: (updates: Partial) => void | Promise 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( diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx index a8739ab8f4b..7274a6fd93c 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx @@ -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( - - ) - }) +function renderSection( + updateSettings: (updates: object) => void | Promise, + options: { + sourceDefaultsSupported?: boolean + worktreeVisibilityDefaults?: ReturnType['worktreeVisibilityDefaults'] + } = {} +): void { + const settings = getDefaultSettings('/home/user') + settings.worktreeVisibilityDefaults = options.worktreeVisibilityDefaults + act(() => { + root.render( + + ) + }) +} - const show = container.querySelector('[role="radio"][aria-checked="false"]') - expect(show?.textContent).toBe('Show') - act(() => show?.click()) +function getSwitch(label: string): HTMLButtonElement { + const control = container.querySelector( + `[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( - - ) + 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('#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('#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('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' }) }) }) diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx index f4a5e2d9acb..00623d5126a 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -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) => void + updateSettingsOrThrow?: (updates: Partial) => void | Promise + sourceDefaultsSupported?: boolean } export function GeneralWorkspaceSettingsSection({ settings, - updateSettings + updateSettings, + updateSettingsOrThrow = updateSettings, + sourceDefaultsSupported = true }: GeneralWorkspaceSettingsSectionProps): React.JSX.Element { - const visibilityDefaultsSupported = settings.worktreeVisibilityDefaults !== undefined return (
- 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' - ) - } - ]} - /> - } + diff --git a/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx b/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx new file mode 100644 index 00000000000..ed8b270474f --- /dev/null +++ b/src/renderer/src/components/settings/GlobalWorktreeVisibilitySourcesSetting.tsx @@ -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) => void | Promise +} + +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(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 => { + 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 => { + 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 ( +
+ + {!sourceDefaultsSupported ? ( +

+ {translate( + 'auto.components.settings.GlobalWorktreeVisibilitySourcesSetting.updateServer', + 'Update this server to configure source defaults.' + )} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index cf07ff934cd..5db2e16a832 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1389,6 +1389,7 @@ function Settings(): React.JSX.Element { ({ repos: [] as unknown[], updateRepo: vi.fn(), fetchWorktrees: vi.fn(), - detectedWorktreesByRepo: {} as Record + detectedWorktreesByRepo: {} as Record, + settings: {} as Record, + worktreeVisibilityDefaultsByHost: {} as Record } })) @@ -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) => { 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' } } + }) + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 31145b6da44..4e9cbac0268 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -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} diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx index 6a63b16b827..9fe3ef0edc6 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilitySourceList.tsx @@ -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 + showCounts?: boolean disabled: boolean + sourceDefaultsDisabled?: boolean onAdd: (rootPath: string) => Promise onRemove: (source: CustomWorktreeVisibilitySource) => Promise onToggle: (source: WorktreeVisibilitySourceRow, enabled: boolean) => Promise @@ -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 ): 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(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( () => [ @@ -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() @@ -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 (
{label} - - {translate( - 'auto.components.sidebar.WorktreeVisibilitySourceList.found', - '{{value0}} found', - { value0: count } - )} - + {showCounts ? ( + + {translate( + 'auto.components.sidebar.WorktreeVisibilitySourceList.found', + '{{value0}} found', + { value0: count } + )} + + ) : null} {getSourcePath(source)} - {source.kind === 'custom' ? ( + {source.kind === 'custom' && + (!removableSourceIds || removableSourceIds.has(source.source.id)) ? (
diff --git a/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts b/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts new file mode 100644 index 00000000000..ba069654860 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-repo-sources.ts @@ -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 { + 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 + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ff527a1f164..df138f1bf84 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d9fc42279a0..15c6693ff98 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index eb17c72441d..0574fffb491 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -10133,6 +10133,10 @@ "setupRecipe": "クラウドプロバイダー用の環境レシピをセットアップします。", "createWorkspace": "ワークスペースを作成し、「実行先」でそのレシピを選択します。", "openSetup": "環境レシピをセットアップ" + }, + "GlobalWorktreeVisibilitySourcesSetting": { + "saveFailed": "表示設定のデフォルトを保存できませんでした。", + "updateServer": "ソースのデフォルトを設定するには、このサーバーを更新してください。" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f3025a7e408..2a03892a8ed 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -10133,6 +10133,10 @@ "setupRecipe": "클라우드 공급자를 위한 환경 레시피를 설정하세요.", "createWorkspace": "워크스페이스를 만들고 실행 위치에서 해당 레시피를 선택하세요.", "openSetup": "환경 레시피 설정" + }, + "GlobalWorktreeVisibilitySourcesSetting": { + "saveFailed": "가시성 기본값을 저장할 수 없습니다.", + "updateServer": "소스 기본값을 구성하려면 이 서버를 업데이트하세요." } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b54a13ecb27..255939bec42 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -10153,6 +10153,10 @@ "description": "用于语音听写的输入设备,系统默认会跟随操作系统的麦克风设置。", "accessHint": "允许麦克风访问以列出输入设备。", "allowAccess": "允许访问" + }, + "GlobalWorktreeVisibilitySourcesSetting": { + "saveFailed": "无法保存可见性默认设置。", + "updateServer": "请更新此服务器以配置来源默认设置。" } }, "right": { diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index 849a60a5392..4068cff89eb 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -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() }) }) diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index c6f115c1a96..a17cd44bdcc 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -38,6 +38,7 @@ export type SettingsSlice = SettingsSearchState & { settings: GlobalSettings | null worktreeVisibilityDefaultsByHost: WorktreeVisibilityDefaultsByHost worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: string | null + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: string | null fetchSettings: () => Promise updateSettings: (updates: Partial) => Promise updateSettingsOrThrow: (updates: Partial) => Promise @@ -135,13 +136,15 @@ async function persistSettingsUpdates( set: SettingsStateSetter, updates: Partial, currentSettings: GlobalSettings | null, - supportedRuntimeEnvironmentId: string | null + supportedRuntimeEnvironmentId: string | null, + sourceDefaultsSupportedRuntimeEnvironmentId: string | null ): Promise { const normalizedUpdates = normalizeSettingsUpdates(updates, currentSettings) await persistVisibilityAwareSettings({ normalizedUpdates, currentSettings, supportedRuntimeEnvironmentId, + sourceDefaultsSupportedRuntimeEnvironmentId, set }) } @@ -176,6 +179,7 @@ export const createSettingsSlice: StateCreator settings: null, worktreeVisibilityDefaultsByHost: {}, worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null, + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null, ...createSettingsSearchState((state) => set(state)), fetchSettings: async () => { @@ -192,7 +196,9 @@ export const createSettingsSlice: StateCreator 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 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 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 settings: hydrated.settings, worktreeVisibilityDefaultsByHost: hydrated.defaultsByHost, worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: - hydrated.supportedRuntimeEnvironmentId + hydrated.supportedRuntimeEnvironmentId, + worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: + hydrated.sourceDefaultsSupportedRuntimeEnvironmentId }) } else { set({ settings: null }) diff --git a/src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts b/src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts index ecf1194352e..7370762da01 100644 --- a/src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts +++ b/src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts @@ -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() }) }) diff --git a/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts b/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts index 60f34c4bb12..cb2ec31c617 100644 --- a/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts +++ b/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts @@ -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 @@ -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 } } diff --git a/src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts b/src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts index 0049805d295..1c50d08d2f4 100644 --- a/src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts +++ b/src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts @@ -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() +}) diff --git a/src/renderer/src/store/slices/worktree-visibility-settings-write.ts b/src/renderer/src/store/slices/worktree-visibility-settings-write.ts index 123f4db1b7c..34cc8fedb2c 100644 --- a/src/renderer/src/store/slices/worktree-visibility-settings-write.ts +++ b/src/renderer/src/store/slices/worktree-visibility-settings-write.ts @@ -10,12 +10,27 @@ export async function persistVisibilityAwareSettings(args: { normalizedUpdates: Partial currentSettings: GlobalSettings | null supportedRuntimeEnvironmentId: string | null + sourceDefaultsSupportedRuntimeEnvironmentId?: string | null set: (updater: (state: AppState) => Partial) => void }): Promise { - 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) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index ce13e6af36f..76d0f7c404a 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -6238,6 +6238,9 @@ export const createWorktreeSlice: StateCreator const visibilitySupportChanged = removed.has( s.worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId ?? '' ) + const visibilitySourceSupportChanged = removed.has( + s.worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId ?? '' + ) if ( !reposChanged && !setupsChanged && @@ -6246,6 +6249,7 @@ export const createWorktreeSlice: StateCreator !restoredSessionOwnersChanged && !visibilityDefaultsChanged && !visibilitySupportChanged && + !visibilitySourceSupportChanged && removedWorktreeIds.size === 0 ) { return s @@ -6276,6 +6280,9 @@ export const createWorktreeSlice: StateCreator ...(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 diff --git a/src/renderer/src/store/worktree-visibility-defaults-by-host.ts b/src/renderer/src/store/worktree-visibility-defaults-by-host.ts index 10be50f078b..c051362a471 100644 --- a/src/renderer/src/store/worktree-visibility-defaults-by-host.ts +++ b/src/renderer/src/store/worktree-visibility-defaults-by-host.ts @@ -5,11 +5,11 @@ import type { GlobalSettings, Repo, WorktreeVisibilityDefaults } from '../../../ export function getRepoOwnerWorktreeVisibilityDefaults( repo: Pick, settings: Pick | null | undefined, - defaultsByHost: Partial> + defaultsByHost: Partial> | 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 } diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index ef1ac0eefa6..1075be02cb8 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -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 ] }) diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 490ed7ba688..be649f2a7ac 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -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 diff --git a/src/shared/external-worktree-visibility.ts b/src/shared/external-worktree-visibility.ts index 57d8bb1bbb9..c8badde079b 100644 --- a/src/shared/external-worktree-visibility.ts +++ b/src/shared/external-worktree-visibility.ts @@ -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 + 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( diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d77af0328ea..f384b09c500 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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 diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index 7371e2fa11f..b2a89c1abf1 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -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]) diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index 97441cab768..a294c35852c 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -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( 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( 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) => { diff --git a/src/shared/remote-runtime-request-connection.test.ts b/src/shared/remote-runtime-request-connection.test.ts index 8a162f80c5b..9ef5098f4b1 100644 --- a/src/shared/remote-runtime-request-connection.test.ts +++ b/src/shared/remote-runtime-request-connection.test.ts @@ -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 { 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 diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts index e7555e7cca0..2ef1f5e62af 100644 --- a/src/shared/remote-runtime-request-connection.ts +++ b/src/shared/remote-runtime-request-connection.ts @@ -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 diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 3a3f5241545..9f0ee301791 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -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([ diff --git a/src/shared/remote-runtime-shared-control-frame-handler.ts b/src/shared/remote-runtime-shared-control-frame-handler.ts index fe323a63a83..61fcb40839e 100644 --- a/src/shared/remote-runtime-shared-control-frame-handler.ts +++ b/src/shared/remote-runtime-shared-control-frame-handler.ts @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index 3378ffda28a..373a25cbb93 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 = { diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index ac25864eb2e..ad0d3cadb27 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -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, @@ -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 - ownership: WorktreeOwnership - repo: Repo - isLegacyRepoForVisibility: boolean - isSelectedCheckout: boolean - visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults'] - importedExternalWorktreePaths?: readonly string[] | undefined - visibilitySource?: ReturnType -}): 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. diff --git a/src/shared/worktree-visibility-resolution.ts b/src/shared/worktree-visibility-resolution.ts new file mode 100644 index 00000000000..deb904cf32c --- /dev/null +++ b/src/shared/worktree-visibility-resolution.ts @@ -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 + ownership: WorktreeOwnership + repo: Repo + isLegacyRepoForVisibility: boolean + isSelectedCheckout: boolean + visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults'] + importedExternalWorktreePaths?: readonly string[] | undefined + visibilitySource?: ReturnType +}): 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' + ) +} diff --git a/src/shared/worktree-visibility-source-preferences.ts b/src/shared/worktree-visibility-source-preferences.ts new file mode 100644 index 00000000000..137ba2ed2af --- /dev/null +++ b/src/shared/worktree-visibility-source-preferences.ts @@ -0,0 +1,84 @@ +import type { + ExternalWorktreeVisibility, + Repo, + WorktreeVisibilityDefaults, + WorktreeVisibilitySourcePreferences +} from './types' +import { + normalizeWorktreeVisibilitySourcePreferences, + type WorktreeVisibilitySourceMatch +} from './worktree-visibility-sources' + +function legacyBuiltInPreferences( + repo: Pick +): WorktreeVisibilitySourcePreferences['builtIn'] { + return repo.agentWorktreeVisibility + ? { claude: repo.agentWorktreeVisibility, gsd: repo.agentWorktreeVisibility } + : {} +} + +function preferenceResult( + builtIn: NonNullable, + custom: NonNullable +): WorktreeVisibilitySourcePreferences { + return { + ...(Object.keys(builtIn).length > 0 ? { builtIn } : {}), + ...(Object.keys(custom).length > 0 ? { custom } : {}) + } +} + +export function buildWorktreeSourcePreferenceUpdate( + repo: Pick, + 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, + 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) +} diff --git a/src/shared/worktree-visibility-sources.test.ts b/src/shared/worktree-visibility-sources.test.ts index 875c34b4ad8..3144b9ce64d 100644 --- a/src/shared/worktree-visibility-sources.test.ts +++ b/src/shared/worktree-visibility-sources.test.ts @@ -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 { @@ -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([ diff --git a/src/shared/worktree-visibility-sources.ts b/src/shared/worktree-visibility-sources.ts index e4153daa86d..e4240776eba 100644 --- a/src/shared/worktree-visibility-sources.ts +++ b/src/shared/worktree-visibility-sources.ts @@ -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, - 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, - sourceId: string + repo: Pick, + 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, - source: WorktreeVisibilitySourceMatch -): ExternalWorktreeVisibility { - return source.kind === 'built-in' - ? effectiveBuiltInWorktreeSourceVisibility(repo, source.id) - : effectiveCustomWorktreeSourceVisibility(repo, source.id) -} - -export function buildWorktreeSourcePreferenceUpdate( - repo: Pick, - 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, +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, + 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 } : {}) - } }