mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Expand global worktree visibility source defaults
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -10133,6 +10133,10 @@
|
||||
"setupRecipe": "クラウドプロバイダー用の環境レシピをセットアップします。",
|
||||
"createWorkspace": "ワークスペースを作成し、「実行先」でそのレシピを選択します。",
|
||||
"openSetup": "環境レシピをセットアップ"
|
||||
},
|
||||
"GlobalWorktreeVisibilitySourcesSetting": {
|
||||
"saveFailed": "表示設定のデフォルトを保存できませんでした。",
|
||||
"updateServer": "ソースのデフォルトを設定するには、このサーバーを更新してください。"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -10133,6 +10133,10 @@
|
||||
"setupRecipe": "클라우드 공급자를 위한 환경 레시피를 설정하세요.",
|
||||
"createWorkspace": "워크스페이스를 만들고 실행 위치에서 해당 레시피를 선택하세요.",
|
||||
"openSetup": "환경 레시피 설정"
|
||||
},
|
||||
"GlobalWorktreeVisibilitySourcesSetting": {
|
||||
"saveFailed": "가시성 기본값을 저장할 수 없습니다.",
|
||||
"updateServer": "소스 기본값을 구성하려면 이 서버를 업데이트하세요."
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user