Add global external worktree visibility defaults

This commit is contained in:
Brennan Benson
2026-08-13 11:19:49 -07:00
parent 70abf5cacc
commit 70f00b746c
65 changed files with 2051 additions and 180 deletions
+8 -15
View File
@@ -1185,14 +1185,12 @@ describe('repos:addRemote', () => {
kind: 'git',
displayName: 'project',
badgeColor: DEFAULT_REPO_BADGE_COLOR,
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: 'imported-existing-folder'
})
)
expect(result).toHaveProperty('repo.id')
expect(result).toHaveProperty('repo.connectionId', 'conn-1')
expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide')
expect(result).not.toHaveProperty('repo.externalWorktreeVisibility')
})
it('uses custom displayName when provided', async () => {
@@ -1234,9 +1232,7 @@ describe('repos:addRemote', () => {
connectionId: 'conn-1',
kind: 'git',
displayName: 'orca',
badgeColor: DEFAULT_REPO_BADGE_COLOR,
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false
badgeColor: DEFAULT_REPO_BADGE_COLOR
})
)
expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', {
@@ -1462,8 +1458,7 @@ describe('repos:addRemote', () => {
path: '/home/user/created',
connectionId: 'conn-1',
kind: 'git',
displayName: 'created',
externalWorktreeVisibility: 'hide'
displayName: 'created'
})
)
expect(result).toHaveProperty('repo.path', '/home/user/created')
@@ -1879,19 +1874,19 @@ describe('repos:add + repos:clone', () => {
expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR)
})
it('defaults new git repos:add records to hiding non-Orca worktrees', async () => {
it('inherits global non-Orca visibility while retaining the mixed-version safety marker', async () => {
const result = await handlers.get('repos:add')!(null, { path: '/tmp/from-add', kind: 'git' })
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/tmp/from-add',
kind: 'git',
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: 'imported-existing-folder'
})
)
expect(result).toHaveProperty('repo.externalWorktreeVisibility', 'hide')
expect(result).not.toHaveProperty('repo.externalWorktreeVisibility')
expect(result).toHaveProperty('repo.externalWorktreeVisibilityLegacy', false)
})
it('prepares the worktree root when adding a local git repo', async () => {
@@ -2280,13 +2275,11 @@ describe('repos:add + repos:clone', () => {
expect.objectContaining({
path: join(destination, 'orca'),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
kind: 'git',
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false
kind: 'git'
})
)
expect(result).toHaveProperty('badgeColor', DEFAULT_REPO_BADGE_COLOR)
expect(result).toHaveProperty('externalWorktreeVisibility', 'hide')
expect(result).not.toHaveProperty('externalWorktreeVisibility')
})
it('drops a same-path negative submodule cache before a local clone', async () => {
+4 -7
View File
@@ -356,7 +356,6 @@ async function addLocalRepoFromPath(
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
// Why: new Add Project imports are explicit ready host setups; 'legacy-repo' is reserved for older records/projection.
projectHostSetupMethod: 'imported-existing-folder' as const
@@ -455,7 +454,6 @@ async function addRemoteRepoFromPath(
connectionId: args.connectionId,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const)
}
@@ -1774,7 +1772,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
addedAt: Date.now(),
kind: 'git',
...(args.connectionId ? { connectionId: args.connectionId } : {}),
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: 'imported-existing-folder',
...(group
@@ -2034,7 +2031,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: 'imported-existing-folder' as const
}
@@ -2126,7 +2122,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
| 'symlinkPaths'
| 'issueSourcePreference'
| 'forkSyncMode'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
@@ -2137,6 +2132,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
| 'projectGroupOrder'
>
> & {
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?:
| Repo['externalWorktreeDiscoverySuppressedAt']
@@ -2195,7 +2191,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
updates.badgeColor = badgeColor
}
}
if (
if ('externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility === null) {
updates.externalWorktreeVisibility = undefined
} else if (
'externalWorktreeVisibility' in updates &&
updates.externalWorktreeVisibility !== undefined &&
updates.externalWorktreeVisibility !== 'hide' &&
@@ -2551,7 +2549,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
...detected,
addedAt: Date.now(),
kind: 'git',
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false,
projectHostSetupMethod: 'cloned'
}
+75 -1
View File
@@ -4739,7 +4739,62 @@ describe('Store', () => {
expect(updated!.externalWorktreeVisibilityLegacy).toBe(true)
})
it('keeps rollback agent visibility writes authoritative for both built-in sources', async () => {
it('migrates implicit legacy visibility to an explicit override before defaulting global hide', async () => {
const persisted = getDefaultPersistedState(testState.dir)
delete persisted.settings.worktreeVisibilityDefaults
persisted.repos = [
makeRepo({
id: 'legacy',
kind: 'git',
externalWorktreeVisibility: undefined,
externalWorktreeVisibilityLegacy: undefined
}),
makeRepo({ id: 'explicit', externalWorktreeVisibility: 'hide' }),
makeRepo({
id: 'inherited',
externalWorktreeVisibility: undefined,
externalWorktreeVisibilityLegacy: false
})
]
writeDataFile(persisted)
const store = await createStore()
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({ external: 'hide' })
expect(store.getRepo('legacy')).toMatchObject({
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityLegacy: true
})
expect(store.getRepo('explicit')?.externalWorktreeVisibility).toBe('hide')
expect(store.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined()
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('legacy')).toMatchObject({
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityLegacy: true
})
expect(reloaded.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined()
})
it('merges visibility-default patches so future source defaults survive older controls', async () => {
const store = await createStore()
store.updateSettings({
worktreeVisibilityDefaults: {
external: 'show',
futureSource: 'hide'
} as GlobalSettings['worktreeVisibilityDefaults']
})
store.updateSettings({ worktreeVisibilityDefaults: { external: 'hide' } })
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({
external: 'hide',
futureSource: 'hide'
})
})
it('persists agent worktree visibility independently from external visibility', async () => {
const store = await createStore()
store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' }))
@@ -4794,6 +4849,25 @@ describe('Store', () => {
])
})
it('clears an explicit external-worktree override without stamping an inherited default', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityLegacy: true
})
)
const updated = store.updateRepo('r1', { externalWorktreeVisibility: null })
expect(updated?.externalWorktreeVisibility).toBeUndefined()
expect(updated?.externalWorktreeVisibilityLegacy).toBe(false)
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')?.externalWorktreeVisibility).toBeUndefined()
expect(reloaded.getRepo('r1')?.externalWorktreeVisibilityLegacy).toBe(false)
})
it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => {
const store = await createStore()
store.addRepo(
+31 -1
View File
@@ -222,6 +222,10 @@ import {
import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width'
import { clampCombinedDiffFileTreeWidth } from '../shared/combined-diff-file-tree-width'
import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership'
import {
migrateExternalWorktreeVisibilityDefaults,
normalizeWorktreeVisibilityDefaults
} from '../shared/external-worktree-visibility'
import { sanitizeRepoIcon } from '../shared/repo-icon'
import { normalizeRepoBadgeColor } from '../shared/repo-badge-color'
import {
@@ -3235,6 +3239,13 @@ export class Store {
// Merge with defaults in case new fields were added
const homeDir = homedir()
const defaults = getDefaultPersistedState(homeDir)
const migratedExternalVisibility = migrateExternalWorktreeVisibilityDefaults(
Array.isArray(parsed.repos) ? parsed.repos : [],
parsed.settings?.worktreeVisibilityDefaults
)
if (migratedExternalVisibility.changed) {
this.loadNeedsSave = true
}
const migratedTerminalScrollback = migrateTerminalScrollbackRows(parsed.settings)
if (migratedTerminalScrollback.needsSave) {
this.loadNeedsSave = true
@@ -3504,6 +3515,7 @@ export class Store {
parsed.featureInteractionTelemetryBuckets
),
projectGroups: normalizedProjectGroups,
repos: migratedExternalVisibility.repos,
folderWorkspaces: normalizeFolderWorkspaces(
parsed.folderWorkspaces,
normalizedProjectGroups
@@ -3522,6 +3534,7 @@ export class Store {
...defaults.settings,
// Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed.
...stripRetiredGlobalSettings(parsed.settings),
worktreeVisibilityDefaults: migratedExternalVisibility.defaults,
prBotAuthorOverrides: normalizePRBotAuthorOverrides(
parsed.settings?.prBotAuthorOverrides
),
@@ -5022,7 +5035,6 @@ export class Store {
| 'symlinkPaths'
| 'issueSourcePreference'
| 'forkSyncMode'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
@@ -5034,6 +5046,7 @@ export class Store {
| 'projectHostSetupMethod'
>
> & {
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
},
@@ -5095,6 +5108,15 @@ export class Store {
delete repo.worktreeBasePath
delete sanitizedUpdates.worktreeBasePath
}
if (
'externalWorktreeVisibility' in sanitizedUpdates &&
(sanitizedUpdates.externalWorktreeVisibility === undefined ||
sanitizedUpdates.externalWorktreeVisibility === null)
) {
delete repo.externalWorktreeVisibility
repo.externalWorktreeVisibilityLegacy = false
delete sanitizedUpdates.externalWorktreeVisibility
}
if (
'externalWorktreeVisibility' in sanitizedUpdates &&
repo.externalWorktreeVisibilityLegacy === undefined
@@ -5975,6 +5997,14 @@ export class Store {
if ('disabledTuiAgents' in updates) {
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
}
if ('worktreeVisibilityDefaults' in updates) {
sanitizedUpdates.worktreeVisibilityDefaults = {
...this.state.settings.worktreeVisibilityDefaults,
...(normalizeWorktreeVisibilityDefaults(updates.worktreeVisibilityDefaults) ?? {
external: 'hide'
})
}
}
if ('agentDefaultArgs' in updates) {
sanitizedUpdates.agentDefaultArgs = normalizeTuiAgentArgsRecord(updates.agentDefaultArgs)
sanitizedUpdates.agentYoloDefaultsMigrated = true
+19 -2
View File
@@ -1680,6 +1680,7 @@ describe('OrcaRuntimeService', () => {
} as never)
expect(runtime.getClientSettings()).toMatchObject({
worktreeVisibilityDefaults: { external: 'hide' },
experimentalNewWorktreeCardStyle: true,
compactWorktreeCards: true,
minimaxGroupId: 'group-42',
@@ -1805,6 +1806,23 @@ describe('OrcaRuntimeService', () => {
})
})
it('broadcasts visibility default changes to paired clients', async () => {
let settings = { ...store.getSettings(), worktreeVisibilityDefaults: { external: 'hide' } }
const runtime = new OrcaRuntimeService({
...store,
getSettings: () => settings,
updateSettings: (updates: Partial<typeof settings>) => {
settings = { ...settings, ...updates }
}
} as never)
const events: unknown[] = []
runtime.onClientEvent((event) => events.push(event))
await runtime.updateClientSettings({ worktreeVisibilityDefaults: { external: 'show' } })
expect(events).toContainEqual({ type: 'reposChanged' })
})
it('reconciles hooks only when paired-client hook settings change', async () => {
electronMocks.app.isPackaged = true
let settings = {
@@ -8227,11 +8245,10 @@ describe('OrcaRuntimeService', () => {
expect(added).toEqual([
expect.objectContaining({
badgeColor: DEFAULT_REPO_BADGE_COLOR,
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false
})
])
expect(repo.externalWorktreeVisibility).toBe('hide')
expect(repo.externalWorktreeVisibility).toBeUndefined()
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(colorStore, repo)
} finally {
spawnSpy.mockRestore()
+18 -15
View File
@@ -1166,6 +1166,7 @@ type RuntimeStore = {
localBaseRefSuggestionDismissed?: boolean
branchPrefix: string
branchPrefixCustom: string
worktreeVisibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
defaultTuiAgent?: GlobalSettings['defaultTuiAgent']
disabledTuiAgents?: GlobalSettings['disabledTuiAgents']
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
@@ -3487,6 +3488,7 @@ export class OrcaRuntimeService {
getClientSettings(): Pick<
GlobalSettings,
| 'worktreeVisibilityDefaults'
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentCmdOverrides'
@@ -3513,6 +3515,7 @@ export class OrcaRuntimeService {
}
const settings = this.store.getSettings()
return {
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
defaultTuiAgent: settings.defaultTuiAgent ?? null,
disabledTuiAgents: settings.disabledTuiAgents ?? [],
agentCmdOverrides: settings.agentCmdOverrides ?? {},
@@ -3564,6 +3567,7 @@ export class OrcaRuntimeService {
async updateClientSettings(
updates: Pick<
Partial<GlobalSettings>,
| 'worktreeVisibilityDefaults'
| 'agentStatusHooksEnabled'
| 'defaultTuiAgent'
| 'disabledTuiAgents'
@@ -3584,6 +3588,7 @@ export class OrcaRuntimeService {
): Promise<
Pick<
GlobalSettings,
| 'worktreeVisibilityDefaults'
| 'defaultTuiAgent'
| 'disabledTuiAgents'
| 'agentCmdOverrides'
@@ -3610,6 +3615,13 @@ 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
) {
this.notifyReposChanged()
}
if (
(typeof updates.agentStatusHooksEnabled === 'boolean' &&
before !== updates.agentStatusHooksEnabled) ||
@@ -18725,7 +18737,6 @@ export class OrcaRuntimeService {
badgeColor: DEFAULT_REPO_BADGE_COLOR,
addedAt: Date.now(),
kind: 'git',
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false,
...(group
? {
@@ -18862,12 +18873,7 @@ export class OrcaRuntimeService {
...detected,
addedAt: Date.now(),
kind,
...(kind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
...(kind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {})
}
this.store.addRepo(repo)
await prepareLocalWorktreeRootForRepo(this.store, repo)
@@ -18983,12 +18989,7 @@ export class OrcaRuntimeService {
...detected,
addedAt: Date.now(),
kind: repoKind,
...(repoKind === 'git'
? {
externalWorktreeVisibility: 'hide' as const,
externalWorktreeVisibilityLegacy: false
}
: {})
...(repoKind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {})
}
this.store.addRepo(repo)
await prepareLocalWorktreeRootForRepo(this.store, repo)
@@ -19157,7 +19158,6 @@ export class OrcaRuntimeService {
...detected,
addedAt: Date.now(),
kind: 'git',
externalWorktreeVisibility: 'hide',
externalWorktreeVisibilityLegacy: false
}
this.store.addRepo(repo)
@@ -19205,7 +19205,6 @@ export class OrcaRuntimeService {
| 'kind'
| 'symlinkPaths'
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
@@ -19216,6 +19215,7 @@ export class OrcaRuntimeService {
| 'projectGroupOrder'
>
> & {
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
}
@@ -19228,6 +19228,9 @@ export class OrcaRuntimeService {
if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) {
sanitizedUpdates.worktreeBasePath = undefined
}
if ('externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility === null) {
sanitizedUpdates.externalWorktreeVisibility = undefined
}
if (
'externalWorktreeDiscoverySuppressedAt' in updates &&
updates.externalWorktreeDiscoverySuppressedAt === null
@@ -138,6 +138,10 @@ const GitHubProjectSettings = z
export const SettingsUpdate = z
.object({
worktreeVisibilityDefaults: z
.object({ external: z.enum(['hide', 'show']).optional() })
.strict()
.optional(),
defaultTuiAgent: z
.unknown()
.transform((value) =>
@@ -21,6 +21,7 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
describe('client UI RPC methods', () => {
it('returns the runtime host agent settings needed by mobile create flows', async () => {
const settings = {
worktreeVisibilityDefaults: { external: 'show' as const },
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude'],
agentCmdOverrides: { codex: 'codex --profile work' },
@@ -92,6 +93,7 @@ describe('client UI RPC methods', () => {
const response = await dispatcher.dispatch(
makeRequest('settings.update', {
worktreeVisibilityDefaults: { external: 'show' },
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude', 'not-real', 'claude'],
defaultTaskSource: 'linear',
@@ -108,6 +110,7 @@ describe('client UI RPC methods', () => {
)
expect(runtime.updateClientSettings).toHaveBeenCalledWith({
worktreeVisibilityDefaults: { external: 'show' },
defaultTuiAgent: 'codex',
disabledTuiAgents: ['claude'],
defaultTaskSource: 'linear',
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalString, requiredString } from '../schemas'
import { projectRepoResultVisibilityForClient } from '../repo-visibility-projection'
const ProjectProviderIdentity = z.object({
provider: z.literal('github'),
@@ -129,29 +130,41 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
defineMethod({
name: 'projectHostSetup.setupExistingFolder',
params: ProjectHostSetupExistingFolder,
handler: async (params, { runtime }) => ({
result: await runtime.setupProjectExistingFolder(params)
handler: async (params, context) => ({
result: projectRepoResultVisibilityForClient(
await context.runtime.setupProjectExistingFolder(params),
context
)
})
}),
defineMethod({
name: 'projectHostSetup.clone',
params: ProjectHostSetupClone,
handler: async (params, { runtime }) => ({
result: await runtime.setupProjectClone(params)
handler: async (params, context) => ({
result: projectRepoResultVisibilityForClient(
await context.runtime.setupProjectClone(params),
context
)
})
}),
defineMethod({
name: 'projectHostSetup.update',
params: ProjectHostSetupUpdate,
handler: (params, { runtime }) => ({
result: runtime.updateProjectHostSetup(params)
handler: (params, context) => ({
result: projectRepoResultVisibilityForClient(
context.runtime.updateProjectHostSetup(params),
context
)
})
}),
defineMethod({
name: 'projectHostSetup.delete',
params: ProjectHostSetupDelete,
handler: (params, { runtime }) => ({
result: runtime.deleteProjectHostSetup(params)
handler: (params, context) => ({
result: projectRepoResultVisibilityForClient(
context.runtime.deleteProjectHostSetup(params),
context
)
})
})
]
@@ -54,7 +54,7 @@ export function createRepoUpdateSchema<T extends z.ZodRawShape>(
symlinkPaths: z.array(z.string()).optional(),
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(),
externalWorktreeVisibility: z.enum(['hide', 'show']).optional(),
externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(),
externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(),
importedExternalWorktreePaths: z.array(z.string()).optional(),
+81
View File
@@ -3,12 +3,74 @@ import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { REPO_METHODS } from './repo'
import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('repo RPC methods', () => {
it('projects inherited visibility for old clients but preserves inheritance for capable clients', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
enrichMissingRepoGitRemoteIdentities: vi.fn(),
listRepos: () => [{ id: 'repo-1', path: '/repo', externalWorktreeVisibilityLegacy: false }],
getClientSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const legacyReplies: string[] = []
const currentReplies: string[] = []
await dispatcher.dispatchStreaming(makeRequest('repo.list'), (reply) =>
legacyReplies.push(reply)
)
await dispatcher.dispatchStreaming(
makeRequest('repo.list'),
(reply) => currentReplies.push(reply),
{ clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] }
)
expect(JSON.parse(legacyReplies[0]!).result.repos[0].externalWorktreeVisibility).toBe('show')
expect(
JSON.parse(currentReplies[0]!).result.repos[0].externalWorktreeVisibility
).toBeUndefined()
})
it('projects inherited visibility on repo mutation responses for old clients', async () => {
const repo = {
id: 'repo-1',
path: '/repo',
kind: 'git' as const,
externalWorktreeVisibilityLegacy: false
}
const runtime = {
getRuntimeId: () => 'test-runtime',
addRepo: vi.fn().mockResolvedValue(repo),
getClientSettings: () => ({ worktreeVisibilityDefaults: { external: 'show' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const legacyResponse = await dispatcher.dispatch(
makeRequest('repo.add', { path: '/repo', kind: 'git' })
)
const currentReplies: string[] = []
await dispatcher.dispatchStreaming(
makeRequest('repo.add', { path: '/repo', kind: 'git' }),
(reply) => currentReplies.push(reply),
{ clientCapabilities: [WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY] }
)
const currentResponse = JSON.parse(currentReplies[0]!)
expect(legacyResponse).toMatchObject({
ok: true,
result: { repo: { externalWorktreeVisibility: 'show' } }
})
expect(currentResponse).toMatchObject({ ok: true, result: { repo } })
expect((currentResponse as { result: { repo: typeof repo } }).result.repo).not.toHaveProperty(
'externalWorktreeVisibility'
)
})
it('updates project runtime preferences on the runtime server', async () => {
const project = {
id: 'project-1',
@@ -362,6 +424,25 @@ describe('repo RPC methods', () => {
})
})
it('passes the null visibility sentinel through to clear a repository override', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepo: vi.fn().mockResolvedValue({ id: 'repo-1', path: '/srv/repo' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
await dispatcher.dispatch(
makeRequest('repo.update', {
repo: 'repo-1',
updates: { externalWorktreeVisibility: null }
})
)
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
externalWorktreeVisibility: null
})
})
it('persists resolved GitHub upstream metadata updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+46 -18
View File
@@ -4,6 +4,10 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
import { createRepoUpdateSchema } from './repo-update-schema'
import {
projectRepoResultVisibilityForClient,
projectRepoVisibilityForClient
} from '../repo-visibility-projection'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
@@ -108,9 +112,13 @@ export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.list',
params: null,
handler: (_params, { runtime }) => {
runtime.enrichMissingRepoGitRemoteIdentities?.()
return { repos: runtime.listRepos() }
handler: (_params, context) => {
context.runtime.enrichMissingRepoGitRemoteIdentities?.()
return {
repos: context.runtime
.listRepos()
.map((repo) => projectRepoVisibilityForClient(repo, context))
}
}
}),
...PROJECT_RUNTIME_METHODS,
@@ -141,8 +149,11 @@ export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'projectGroup.moveProject',
params: ProjectGroupMoveProject,
handler: async (params, { runtime }) => ({
repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order)
handler: async (params, context) => ({
repo: projectRepoVisibilityForClient(
await context.runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order),
context
)
})
}),
...FOLDER_WORKSPACE_METHODS,
@@ -177,15 +188,21 @@ export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.add',
params: RepoPath,
handler: async (params, { runtime }) => ({
repo: await runtime.addRepo(params.path, params.kind)
handler: async (params, context) => ({
repo: projectRepoVisibilityForClient(
await context.runtime.addRepo(params.path, params.kind),
context
)
})
}),
defineMethod({
name: 'repo.create',
params: RepoCreate,
handler: async (params, { runtime }) =>
runtime.createRepo(params.parentPath, params.name, params.kind)
handler: async (params, context) =>
projectRepoResultVisibilityForClient(
await context.runtime.createRepo(params.parentPath, params.name, params.kind),
context
)
}),
defineMethod({
name: 'repo.gitAvailable',
@@ -195,22 +212,30 @@ export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.clone',
params: RepoClone,
handler: async (params, { runtime }) => ({
repo: await runtime.cloneRepo(params.url, params.destination)
handler: async (params, context) => ({
repo: projectRepoVisibilityForClient(
await context.runtime.cloneRepo(params.url, params.destination),
context
)
})
}),
defineMethod({
name: 'repo.show',
params: RepoSelector,
handler: async (params, { runtime }) => ({ repo: await runtime.showRepo(params.repo) })
handler: async (params, context) => ({
repo: projectRepoVisibilityForClient(await context.runtime.showRepo(params.repo), context)
})
}),
defineMethod({
name: 'repo.update',
params: RepoUpdate,
handler: async (params, { runtime }) => ({
repo: await runtime.updateRepo(
params.repo,
params.updates as Parameters<typeof runtime.updateRepo>[1]
handler: async (params, context) => ({
repo: projectRepoVisibilityForClient(
await context.runtime.updateRepo(
params.repo,
params.updates as Parameters<typeof context.runtime.updateRepo>[1]
),
context
)
})
}),
@@ -227,8 +252,11 @@ export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.setBaseRef',
params: RepoSetBaseRef,
handler: async (params, { runtime }) => ({
repo: await runtime.setRepoBaseRef(params.repo, params.ref)
handler: async (params, context) => ({
repo: projectRepoVisibilityForClient(
await context.runtime.setRepoBaseRef(params.repo, params.ref),
context
)
})
}),
defineMethod({
@@ -0,0 +1,35 @@
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../shared/external-worktree-visibility'
import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type { Repo } from '../../../shared/types'
import type { RpcContext } from './core'
type RepoProjectionContext = Pick<RpcContext, 'clientCapabilities' | 'runtime'>
export function projectRepoVisibilityForClient(repo: Repo, context: RepoProjectionContext): Repo {
if (
context.clientCapabilities?.includes(WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY) ||
repo.kind === 'folder' ||
repo.externalWorktreeVisibility !== undefined
) {
return repo
}
return {
...repo,
externalWorktreeVisibility: effectiveExternalWorktreeVisibility(
repo,
isLegacyRepoForExternalWorktreeVisibility(repo),
context.runtime.getClientSettings?.().worktreeVisibilityDefaults
)
}
}
export function projectRepoResultVisibilityForClient<T extends object>(
result: T,
context: RepoProjectionContext
): T {
const repo = (result as { repo?: Repo }).repo
return repo ? { ...result, repo: projectRepoVisibilityForClient(repo, context) } : result
}
+1 -1
View File
@@ -1192,7 +1192,6 @@ export type PreloadApi = {
| 'worktreeBasePath'
| 'kind'
| 'issueSourcePreference'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
@@ -1204,6 +1203,7 @@ export type PreloadApi = {
| 'forkSyncMode'
>
> & {
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
}
@@ -660,6 +660,7 @@ function WorktreeJumpPaletteContent({
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
const sleepingAgentSessionsByPaneKey = useAppStore((s) => s.sleepingAgentSessionsByPaneKey)
const settings = useAppStore((s) => s.settings)
const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
@@ -1431,7 +1432,9 @@ function WorktreeJumpPaletteContent({
}
for (const repoId of buildImportedWorktreesCardCandidates({
repos,
detectedWorktreesByRepo
detectedWorktreesByRepo,
settings,
visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost
}).keys()) {
ids.add(repoId)
}
@@ -1439,7 +1442,15 @@ function WorktreeJumpPaletteContent({
ids.add(creation.request.repoId)
}
return ids
}, [allWorktrees, detectedWorktreesByRepo, pendingWorktreeCreations, repos, worktreesByRepo])
}, [
allWorktrees,
detectedWorktreesByRepo,
pendingWorktreeCreations,
repos,
settings,
worktreeVisibilityDefaultsByHost,
worktreesByRepo
])
const hasAnyProjectSearchCandidates = useMemo(
() =>
hasCmdJProjectSearchCandidates({
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { act } from 'react'
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'
vi.mock('./WorkspaceDirectorySetting', () => ({ WorkspaceDirectorySetting: () => null }))
vi.mock('./OpenInMenuSetting', () => ({ OpenInMenuSetting: () => null }))
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
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}
/>
)
})
const show = container.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="false"]')
expect(show?.textContent).toBe('Show')
act(() => show?.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()}
/>
)
})
expect(container.querySelectorAll('[role="radio"][aria-disabled="true"]')).toHaveLength(2)
})
})
@@ -2,7 +2,12 @@ import type React from 'react'
import type { GlobalSettings } from '../../../../shared/types'
import { OpenInMenuSetting } from './OpenInMenuSetting'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls'
import {
SettingsRow,
SettingsSegmentedControl,
SettingsSubsectionHeader,
SettingsSwitchRow
} from './SettingsFormControls'
import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting'
import { translate } from '@/i18n/i18n'
@@ -15,6 +20,7 @@ export function GeneralWorkspaceSettingsSection({
settings,
updateSettings
}: GeneralWorkspaceSettingsSectionProps): React.JSX.Element {
const visibilityDefaultsSupported = settings.worktreeVisibilityDefaults !== undefined
return (
<section key="workspace" className="space-y-4">
<SettingsSubsectionHeader
@@ -30,6 +36,58 @@ export function GeneralWorkspaceSettingsSection({
<WorkspaceDirectorySetting settings={settings} updateSettings={updateSettings} />
<SearchableSetting
title={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktrees',
'External worktrees'
)}
description={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktreesDescription',
'Choose whether worktrees created outside Orca appear by default.'
)}
keywords={['external', 'non-Orca', 'worktree', 'visibility', 'sidebar', 'show', 'hide']}
>
<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'
)
}
]}
/>
}
/>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.GeneralWorkspaceSettingsSection.ba3480642f',
@@ -33,11 +33,16 @@ import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSecti
import { matchesRepositoryIdentitySearch } from './repository-identity-search'
import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection'
import { getProjectRuntimeSessionSummary } from './repository-runtime-session-summary'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
export { getRepositoryPaneSearchEntries }
export { matchesRepositoryIdentitySearch } from './repository-identity-search'
type RepositoryPaneRepoUpdate = Omit<Partial<Repo>, 'sourceControlAi'> & {
type RepositoryPaneRepoUpdate = Omit<
Partial<Repo>,
'sourceControlAi' | 'externalWorktreeVisibility'
> & {
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
}
const EMPTY_WSL_DISTROS: string[] = []
@@ -52,7 +57,7 @@ type RepositoryPaneProps = {
repoId: string,
updates: RepositoryPaneRepoUpdate,
options?: { hostId?: ExecutionHostId }
) => void
) => void | Promise<boolean>
removeProject: (repoId: string) => void
project?: Project | null
selectedProjectSetupId?: string
@@ -95,6 +100,20 @@ export function RepositoryPane({
)
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const settings = useAppStore((state) => state.settings)
const worktreeVisibilityDefaultsByHost = useAppStore(
(state) => state.worktreeVisibilityDefaultsByHost
)
const repoOwnerSettings = settings
? {
...settings,
worktreeVisibilityDefaults: getRepoOwnerWorktreeVisibilityDefaults(
repo,
settings,
worktreeVisibilityDefaultsByHost
)
}
: null
const fetchWorktrees = useAppStore((state) => state.fetchWorktrees)
const runtimeSessionSummary = useAppStore(
useShallow((state) => getProjectRuntimeSessionSummary(state, repo.id))
)
@@ -170,6 +189,7 @@ export function RepositoryPane({
),
translate('auto.components.settings.repository.search.094adbe930', 'Default Worktree Base'),
translate('auto.components.settings.repository.search.443d127b5a', 'Worktree Location'),
translate('auto.components.settings.repository.search.externalWorktrees', 'External worktrees'),
translate('auto.components.settings.repository.search.projectRuntime', 'Project Runtime'),
translate('auto.components.settings.repository.search.c5266c2c9d', 'Remove Project')
])
@@ -351,8 +371,14 @@ export function RepositoryPane({
<RepositoryWorktreeDefaultsSection
repo={repo}
settings={settings}
settings={repoOwnerSettings}
updateRepo={updateSelectedRepo}
refreshRepo={(repoId) =>
fetchWorktrees(repoId, {
executionHostId: selectedHostId,
requireAuthoritative: true
})
}
forceVisible={forceFullPaneForRepoMatch}
/>
</>
@@ -3,7 +3,7 @@
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/types'
import type { GlobalSettings, Repo } from '../../../../shared/types'
import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection'
vi.mock('../../store', () => ({
@@ -15,6 +15,28 @@ vi.mock('./BaseRefPicker', () => ({
BaseRefPicker: () => null
}))
vi.mock('../ui/select', () => ({
Select: ({
value,
onValueChange,
children
}: {
value: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<select value={value} onChange={(event) => onValueChange(event.currentTarget.value)}>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
<option value={value}>{children}</option>
)
}))
const BASE_REPO: Repo = {
id: 'repo-1',
path: '/home/user/project',
@@ -39,13 +61,21 @@ afterEach(() => {
container.remove()
})
function render(repo: Repo, updateRepo: (repoId: string, updates: object) => void): void {
function render(
repo: Repo,
updateRepo: (repoId: string, updates: object) => void | Promise<boolean>,
options: {
settings?: Pick<GlobalSettings, 'workspaceDir' | 'worktreeVisibilityDefaults'> | null
refreshRepo?: (repoId: string) => void | Promise<unknown>
} = {}
): void {
act(() => {
root.render(
React.createElement(RepositoryWorktreeDefaultsSection, {
repo,
settings: null,
settings: options.settings ?? null,
updateRepo,
refreshRepo: options.refreshRepo ?? (() => {}),
forceVisible: true
})
)
@@ -141,3 +171,54 @@ describe('RepositoryWorktreeDefaultsSection — worktree path', () => {
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
})
})
describe('RepositoryWorktreeDefaultsSection — external visibility', () => {
it('shows the inherited effective value without stamping the repo', () => {
render(BASE_REPO, vi.fn(), {
settings: {
workspaceDir: '/home/user/orca/workspaces',
worktreeVisibilityDefaults: { external: 'show' }
}
})
expect(container.textContent).toContain('Using global: Show')
expect(container.querySelector('select')?.value).toBe('global')
})
it('clears the override with the remote-safe null sentinel and refreshes classification', async () => {
const updateRepo = vi.fn().mockResolvedValue(true)
const refreshRepo = vi.fn().mockResolvedValue(true)
render({ ...BASE_REPO, externalWorktreeVisibility: 'show' }, updateRepo, { refreshRepo })
await act(async () => {
const select = container.querySelector('select')!
select.value = 'global'
select.dispatchEvent(new Event('change', { bubbles: true }))
})
expect(updateRepo).toHaveBeenCalledWith('repo-1', { externalWorktreeVisibility: null })
expect(refreshRepo).toHaveBeenCalledWith('repo-1')
})
it('falls back to the effective explicit value when an older host rejects inheritance', async () => {
const updateRepo = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
render(
{
...BASE_REPO,
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityLegacy: false
},
updateRepo
)
await act(async () => {
const select = container.querySelector('select')!
select.value = 'global'
select.dispatchEvent(new Event('change', { bubbles: true }))
})
expect(updateRepo).toHaveBeenNthCalledWith(2, 'repo-1', {
externalWorktreeVisibility: 'show'
})
})
})
@@ -1,18 +1,29 @@
import type { GlobalSettings, Repo } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { BaseRefPicker } from './BaseRefPicker'
import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput'
import { SearchableSetting } from './SearchableSetting'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from '../../../../shared/external-worktree-visibility'
type RepositoryWorktreeDefaultsUpdate = Pick<Repo, 'worktreeBasePath' | 'worktreeBaseRef'>
type RepositoryWorktreeDefaultsUpdate = Pick<Repo, 'worktreeBasePath' | 'worktreeBaseRef'> & {
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
}
type RepositoryWorktreeDefaultsSectionProps = {
repo: Repo
settings: Pick<GlobalSettings, 'workspaceDir'> | null
updateRepo: (repoId: string, updates: Partial<RepositoryWorktreeDefaultsUpdate>) => void
settings: Pick<GlobalSettings, 'workspaceDir' | 'worktreeVisibilityDefaults'> | null
updateRepo: (
repoId: string,
updates: Partial<RepositoryWorktreeDefaultsUpdate>
) => void | Promise<boolean>
refreshRepo: (repoId: string) => void | Promise<unknown>
forceVisible: boolean
}
@@ -20,10 +31,96 @@ export function RepositoryWorktreeDefaultsSection({
repo,
settings,
updateRepo,
refreshRepo,
forceVisible
}: RepositoryWorktreeDefaultsSectionProps): React.JSX.Element {
const isLegacyRepo = isLegacyRepoForExternalWorktreeVisibility(repo)
const globalVisibility =
settings?.worktreeVisibilityDefaults?.external ?? (isLegacyRepo ? 'show' : 'hide')
const effectiveVisibility = effectiveExternalWorktreeVisibility(
repo,
isLegacyRepo,
settings?.worktreeVisibilityDefaults
)
const updateVisibility = async (value: string): Promise<void> => {
const visibility = value === 'global' ? null : value === 'show' ? 'show' : 'hide'
let updated = await updateRepo(repo.id, { externalWorktreeVisibility: visibility })
if (updated === false && visibility === null) {
updated = await updateRepo(repo.id, { externalWorktreeVisibility: effectiveVisibility })
}
if (updated !== false) {
await refreshRepo(repo.id)
}
}
return (
<>
<SearchableSetting
title={translate(
'auto.components.settings.RepositoryPane.externalWorktrees',
'External worktrees'
)}
description={translate(
'auto.components.settings.RepositoryPane.externalWorktreesDescription',
'Override whether worktrees created outside Orca appear for this project.'
)}
keywords={[repo.displayName, 'external', 'non-Orca', 'visibility', 'sidebar']}
className="space-y-2"
forceVisible={forceVisible}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-1">
<Label className="text-sm font-semibold">
{translate(
'auto.components.settings.RepositoryPane.externalWorktrees',
'External worktrees'
)}
</Label>
<p className="text-xs text-muted-foreground">
{repo.externalWorktreeVisibility === undefined
? translate(
'auto.components.settings.RepositoryPane.externalWorktreesInherited',
'Using global: {{value0}}',
{
value0:
effectiveVisibility === 'show'
? translate('auto.components.settings.RepositoryPane.show', 'Show')
: translate('auto.components.settings.RepositoryPane.hide', 'Hide')
}
)
: translate(
'auto.components.settings.RepositoryPane.externalWorktreesGlobal',
'Global default: {{value0}}',
{
value0:
globalVisibility === 'show'
? translate('auto.components.settings.RepositoryPane.show', 'Show')
: translate('auto.components.settings.RepositoryPane.hide', 'Hide')
}
)}
</p>
</div>
<Select
value={repo.externalWorktreeVisibility ?? 'global'}
onValueChange={(value) => void updateVisibility(value)}
>
<SelectTrigger size="sm" className="h-8 w-[132px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">
{translate('auto.components.settings.RepositoryPane.useGlobal', 'Use global')}
</SelectItem>
<SelectItem value="show">
{translate('auto.components.settings.RepositoryPane.show', 'Show')}
</SelectItem>
<SelectItem value="hide">
{translate('auto.components.settings.RepositoryPane.hide', 'Hide')}
</SelectItem>
</SelectContent>
</Select>
</div>
</SearchableSetting>
<SearchableSetting
title={translate(
'auto.components.settings.RepositoryPane.f88db4fece',
@@ -23,6 +23,25 @@ export const getGeneralWorkspaceSearchEntries = createLocalizedCatalog(() => [
...translateSearchKeyword('auto.components.settings.general.search.df10666259', 'worktree')
]
},
{
title: translate(
'auto.components.settings.general.search.externalWorktrees',
'External worktrees'
),
description: translate(
'auto.components.settings.general.search.externalWorktreesDescription',
'Choose whether worktrees created outside Orca appear by default.'
),
keywords: [
...translateSearchKeyword(
'auto.components.settings.general.search.externalKeyword',
'external'
),
...translateSearchKeyword('auto.components.settings.general.search.visibility', 'visibility'),
...translateSearchKeyword('auto.components.settings.general.search.sidebar', 'sidebar'),
...translateSearchKeyword('auto.components.settings.general.search.df10666259', 'worktree')
]
},
{
title: translate('auto.components.settings.general.search.141f71c69f', 'Nest Workspaces'),
description: translate(
@@ -5,6 +5,28 @@ import { translateSearchKeyword } from './settings-search-keywords'
export function getRepositoryGitWorktreeSearchEntries(repo: Repo): SettingsSearchEntry[] {
return [
{
title: translate(
'auto.components.settings.repository.search.externalWorktrees',
'External worktrees'
),
description: translate(
'auto.components.settings.repository.search.externalWorktreesDescription',
'Override whether worktrees created outside Orca appear for this project.'
),
keywords: [
repo.displayName,
...translateSearchKeyword(
'auto.components.settings.repository.search.external',
'external'
),
...translateSearchKeyword(
'auto.components.settings.repository.search.visibility',
'visibility'
),
...translateSearchKeyword('auto.components.settings.repository.search.sidebar', 'sidebar')
]
},
{
title: translate(
'auto.components.settings.repository.search.094adbe930',
@@ -67,9 +67,11 @@ import type {
WorktreeMeta,
WorkspaceLineage,
WorkspaceStatus,
WorkspaceStatusDefinition
WorkspaceStatusDefinition,
GlobalSettings
} from '../../../../shared/types'
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort'
import {
buildAttentionByWorktree,
@@ -644,10 +646,14 @@ function getSidebarRowRevealAncestorKeys(args: {
return [...keys]
}
function getWorktreeVisibilityMenuLabel(repo: Repo): string {
function getWorktreeVisibilityMenuLabel(
repo: Repo,
visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
): string {
const visibility = effectiveExternalWorktreeVisibility(
repo,
isLegacyRepoForExternalWorktreeVisibility(repo)
isLegacyRepoForExternalWorktreeVisibility(repo),
visibilityDefaults
)
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees'
}
@@ -1484,6 +1490,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration)
const settings = useAppStore((s) => s.settings)
const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost)
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
const reorderRepos = useAppStore((s) => s.reorderRepos)
const folderBackedProjectGroupIds = useMemo(
@@ -4638,7 +4645,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}}
>
<Eye className="size-3.5" />
{getWorktreeVisibilityMenuLabel(row.repo)}
{getWorktreeVisibilityMenuLabel(
row.repo,
getRepoOwnerWorktreeVisibilityDefaults(
row.repo,
settings,
worktreeVisibilityDefaultsByHost
)
)}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
@@ -5356,6 +5370,7 @@ const WorktreeList = React.memo(function WorktreeList({
useShallow((s) => selectWorktreeListReviewCacheInputs(s, groupBy, cardProps))
)
const settings = useAppStore((s) => s.settings)
const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost)
const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings)
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
@@ -5736,17 +5751,34 @@ const WorktreeList = React.memo(function WorktreeList({
repos: visibleReposForRows,
detectedWorktreesByRepo,
filterRepoIds,
forceVisibleRepoIds
forceVisibleRepoIds,
settings,
visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost
})
}, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows])
}, [
detectedWorktreesByRepo,
filterRepoIds,
importedWorktreeCardActionState,
settings,
worktreeVisibilityDefaultsByHost,
visibleReposForRows
])
const newExternalWorktreesInboxByRepo = useMemo(
() =>
buildNewExternalWorktreesInboxCandidates({
repos: visibleReposForRows,
detectedWorktreesByRepo,
filterRepoIds
filterRepoIds,
settings,
visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost
}),
[detectedWorktreesByRepo, filterRepoIds, visibleReposForRows]
[
detectedWorktreesByRepo,
filterRepoIds,
settings,
worktreeVisibilityDefaultsByHost,
visibleReposForRows
]
)
const placeholderRepoIds = useMemo(() => {
return getEmptyProjectPlaceholderRepoIds({
@@ -54,6 +54,7 @@ function getLatestRepoForScope(scope: string): Repo | null {
null
)
}
import { useRepoOwnerVisibilityDefaults } from './use-repo-owner-visibility-defaults'
export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
const activeModal = useAppStore((s) => s.activeModal)
@@ -86,6 +87,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
const effectiveBusyPath =
busyPath ?? (activeMutation?.kind === 'row' ? activeMutation.path : null)
const effectivelyToggling = isToggling || activeMutation?.kind === 'toggle'
const visibilityDefaults = useRepoOwnerVisibilityDefaults(repo)
useLayoutEffect(() => {
currentMutationScopeRef.current = mutationScope
@@ -351,6 +353,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
<WorktreeVisibilitySourceList
repo={repo}
worktrees={detected?.authoritative ? detected.worktrees : []}
visibilityDefaults={visibilityDefaults}
disabled={effectiveBusyPath !== null || effectivelyToggling || listState === 'checking'}
onAdd={handleAddSource}
onRemove={handleRemoveSource}
@@ -10,7 +10,8 @@ import type {
BuiltInWorktreeVisibilitySourceId,
CustomWorktreeVisibilitySource,
DetectedWorktree,
Repo
Repo,
WorktreeVisibilityDefaults
} from '../../../../shared/types'
import {
createWorktreeVisibilitySourceMatcher,
@@ -33,6 +34,7 @@ export type WorktreeVisibilitySourceRow =
type Props = {
repo: Repo
worktrees: readonly DetectedWorktree[]
visibilityDefaults?: WorktreeVisibilityDefaults
disabled: boolean
onAdd: (rootPath: string) => Promise<WorktreeVisibilitySourceAddResult>
onRemove: (source: CustomWorktreeVisibilitySource) => Promise<void>
@@ -77,7 +79,11 @@ function getSourcePath(source: WorktreeVisibilitySourceRow): string {
return `${source.source.rootPath.replace(/[\\/]+$/, '')}/*`
}
function isSourceEnabled(repo: Repo, source: WorktreeVisibilitySourceRow): boolean {
function isSourceEnabled(
repo: Repo,
source: WorktreeVisibilitySourceRow,
visibilityDefaults?: WorktreeVisibilityDefaults
): boolean {
if (source.kind === 'built-in') {
return effectiveBuiltInWorktreeSourceVisibility(repo, source.id) === 'show'
}
@@ -85,8 +91,11 @@ function isSourceEnabled(repo: Repo, source: WorktreeVisibilitySourceRow): boole
return effectiveCustomWorktreeSourceVisibility(repo, source.source.id) === 'show'
}
return (
effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) ===
'show'
effectiveExternalWorktreeVisibility(
repo,
isLegacyRepoForExternalWorktreeVisibility(repo),
visibilityDefaults
) === 'show'
)
}
@@ -109,6 +118,7 @@ function getAccessibleSourceLabel(source: WorktreeVisibilitySourceRow, label: st
export default function WorktreeVisibilitySourceList({
repo,
worktrees,
visibilityDefaults,
disabled,
onAdd,
onRemove,
@@ -257,7 +267,7 @@ export default function WorktreeVisibilitySourceList({
</Tooltip>
) : null}
<Switch
checked={isSourceEnabled(repo, source)}
checked={isSourceEnabled(repo, source, visibilityDefaults)}
disabled={disabled}
aria-label={translate(
'auto.components.sidebar.WorktreeVisibilitySourceList.toggle',
@@ -1,4 +1,12 @@
import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types'
import type {
DetectedWorktreeListResult,
Repo,
Worktree,
WorktreeVisibilityDefaults,
GlobalSettings
} from '../../../../shared/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
import { getHiddenExternalWorktrees } from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import {
@@ -19,6 +27,8 @@ export function buildImportedWorktreesCardCandidates(args: {
detectedWorktreesByRepo: Readonly<Record<string, DetectedWorktreeListResult | undefined>>
filterRepoIds?: readonly string[]
forceVisibleRepoIds?: ReadonlySet<string>
settings?: Pick<GlobalSettings, 'worktreeVisibilityDefaults'> | null
visibilityDefaultsByHost?: Partial<Record<ExecutionHostId, WorktreeVisibilityDefaults | null>>
}): Map<string, ImportedWorktreesCardCandidate> {
const visibleRepoIds = args.visibleWorktrees
? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId))
@@ -40,7 +50,12 @@ export function buildImportedWorktreesCardCandidates(args: {
}
const visibility = effectiveExternalWorktreeVisibility(
repo,
isLegacyRepoForExternalWorktreeVisibility(repo)
isLegacyRepoForExternalWorktreeVisibility(repo),
getRepoOwnerWorktreeVisibilityDefaults(
repo,
args.settings,
args.visibilityDefaultsByHost ?? {}
)
)
if (visibility !== 'hide' && !args.forceVisibleRepoIds?.has(repo.id)) {
continue
@@ -2,8 +2,12 @@ import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
Worktree
Worktree,
WorktreeVisibilityDefaults,
GlobalSettings
} from '../../../../shared/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
import { getNewExternalWorktreeInboxWorktrees } from '../../../../shared/external-worktree-inbox'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { NewExternalWorktreesInboxCandidate } from './worktree-list-groups'
@@ -13,6 +17,8 @@ export function buildNewExternalWorktreesInboxCandidates(args: {
visibleWorktrees?: readonly Worktree[]
detectedWorktreesByRepo: Readonly<Record<string, DetectedWorktreeListResult | undefined>>
filterRepoIds?: readonly string[]
settings?: Pick<GlobalSettings, 'worktreeVisibilityDefaults'> | null
visibilityDefaultsByHost?: Partial<Record<ExecutionHostId, WorktreeVisibilityDefaults | null>>
}): Map<string, NewExternalWorktreesInboxCandidate> {
const visibleRepoIds = args.visibleWorktrees
? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId))
@@ -31,7 +37,12 @@ export function buildNewExternalWorktreesInboxCandidates(args: {
}
const inboxWorktrees = getNewExternalWorktreeInboxWorktrees(
args.detectedWorktreesByRepo[repo.id],
repo
repo,
getRepoOwnerWorktreeVisibilityDefaults(
repo,
args.settings,
args.visibilityDefaultsByHost ?? {}
)
)
if (inboxWorktrees.length > 0) {
candidates.set(repo.id, { repo, inboxWorktrees })
@@ -0,0 +1,11 @@
import { useAppStore } from '@/store'
import type { Repo, WorktreeVisibilityDefaults } from '../../../../shared/types'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
export function useRepoOwnerVisibilityDefaults(
repo: Repo | null
): WorktreeVisibilityDefaults | undefined {
const settings = useAppStore((state) => state.settings)
const defaultsByHost = useAppStore((state) => state.worktreeVisibilityDefaultsByHost)
return repo ? getRepoOwnerWorktreeVisibilityDefaults(repo, settings, defaultsByHost) : undefined
}
+17 -2
View File
@@ -1251,16 +1251,31 @@ export function useIpcEvents(): void {
if (!store.settings) {
return
}
const { worktreeVisibilityDefaults, ...activeOwnerUpdates } = updates
const settingsUpdates = store.settings.activeRuntimeEnvironmentId
? activeOwnerUpdates
: updates
useAppStore.setState({
settings: {
...store.settings,
...updates,
...settingsUpdates,
notifications: {
...store.settings.notifications,
...updates.notifications
}
}
},
...(worktreeVisibilityDefaults
? {
worktreeVisibilityDefaultsByHost: {
...store.worktreeVisibilityDefaultsByHost,
local: worktreeVisibilityDefaults
}
}
: {})
})
if ('worktreeVisibilityDefaults' in updates) {
void store.fetchAllWorktrees()
}
})
)
+19 -4
View File
@@ -6316,7 +6316,11 @@
"7511097c5d": "Workspace",
"31e300af1c": "Ask Before Deleting Artifacts",
"fb29a73a17": "Show a confirmation dialog before deleting a shared artifact and breaking its public link.",
"bf46474e33": "Show a confirmation before deleting a shared artifact. Anyone holding its public link loses access."
"bf46474e33": "Show a confirmation before deleting a shared artifact. Anyone holding its public link loses access.",
"externalWorktrees": "External worktrees",
"externalWorktreesDescription": "Choose whether worktrees created outside Orca appear by default.",
"show": "Show",
"hide": "Hide"
},
"GhosttyImportModal": {
"9d3e56ca36": "Apply Changes",
@@ -7040,7 +7044,14 @@
"hostStateUnknown": "Unknown",
"nestedHostLabel": "{{value0}} via {{value1}}",
"hostStateWorkspaceWindowClosed": "Workspace window closed",
"hostWorkspaceWindowClosedHelp": "The server is reachable but its Orca window is closed. Open Orca on {{value0}} to use this setup."
"hostWorkspaceWindowClosedHelp": "The server is reachable but its Orca window is closed. Open Orca on {{value0}} to use this setup.",
"externalWorktrees": "External worktrees",
"externalWorktreesDescription": "Override whether worktrees created outside Orca appear for this project.",
"externalWorktreesInherited": "Using global: {{value0}}",
"show": "Show",
"hide": "Hide",
"externalWorktreesGlobal": "Global default: {{value0}}",
"useGlobal": "Use global"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "Command template",
@@ -8669,7 +8680,9 @@
"e61157e926": "Editor Word Wrap",
"005be5c699": "Wrap long lines in file editors instead of requiring horizontal scrolling.",
"editorFontFamily": "Editor Font Family",
"editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font."
"editorFontFamilyDesc": "Font used by file editors and diff views. Leave empty to follow the terminal font.",
"externalWorktrees": "External worktrees",
"externalWorktreesDescription": "Choose whether worktrees created outside Orca appear by default."
}
},
"git": {
@@ -9159,7 +9172,9 @@
"keepForkUpToDate": "Keep Fork Up to Date",
"keepForkUpToDateDescription": "Safely fast-forward this fork from upstream.",
"projectRuntime": "Project Runtime",
"projectRuntimeDescription": "Choose whether this project runs on Windows or WSL."
"projectRuntimeDescription": "Choose whether this project runs on Windows or WSL.",
"externalWorktrees": "External worktrees",
"externalWorktreesDescription": "Override whether worktrees created outside Orca appear for this project."
}
},
"runtime": {
+18 -3
View File
@@ -5974,7 +5974,11 @@
"5567191a6e": "Examinar",
"0e9fc0eadc": "Directorio de espacio de trabajo",
"e2955d9ccb": "Configura dónde se crean nuevos espacios de trabajo.",
"7511097c5d": "Espacio de trabajo"
"7511097c5d": "Espacio de trabajo",
"externalWorktrees": "Worktrees externos",
"externalWorktreesDescription": "Elige si los worktrees creados fuera de Orca aparecen de forma predeterminada.",
"show": "Mostrar",
"hide": "Ocultar"
},
"GhosttyImportModal": {
"9d3e56ca36": "Aplicar cambios",
@@ -6637,7 +6641,14 @@
"projectRuntimeDescription": "Elige si este proyecto se ejecuta en Windows o WSL.",
"hostStateDisconnected": "Disconnected",
"hostStateUnknown": "Unknown",
"nestedHostLabel": "{{value0}} via {{value1}}"
"nestedHostLabel": "{{value0}} via {{value1}}",
"externalWorktrees": "Worktrees externos",
"externalWorktreesDescription": "Sobrescribe si los worktrees creados fuera de Orca aparecen en este proyecto.",
"externalWorktreesInherited": "Usando el valor global: {{value0}}",
"show": "Mostrar",
"hide": "Ocultar",
"externalWorktreesGlobal": "Valor global predeterminado: {{value0}}",
"useGlobal": "Usar valor global"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "Plantilla de comando",
@@ -8258,7 +8269,9 @@
"e61157e926": "Ajuste de línea en el editor",
"005be5c699": "Ajusta las líneas largas en editores de archivos en lugar de requerir desplazamiento horizontal.",
"editorFontFamily": "Fuente del editor",
"editorFontFamilyDesc": "Fuente utilizada por los editores de archivos y vistas de diferencias. Dejar vacío para seguir la fuente de la terminal."
"editorFontFamilyDesc": "Fuente utilizada por los editores de archivos y vistas de diferencias. Dejar vacío para seguir la fuente de la terminal.",
"externalWorktrees": "Worktrees externos",
"externalWorktreesDescription": "Elige si los worktrees creados fuera de Orca aparecen de forma predeterminada."
}
},
"git": {
@@ -8742,6 +8755,8 @@
"keepForkUpToDateDescription": "Avanza este fork de forma segura desde upstream con fast-forward.",
"projectRuntime": "Runtime del proyecto",
"projectRuntimeDescription": "Elige si este proyecto se ejecuta en Windows o WSL.",
"externalWorktrees": "Worktrees externos",
"externalWorktreesDescription": "Sobrescribe si los worktrees creados fuera de Orca aparecen en este proyecto.",
"copy": "copy",
"clone": "clone",
"apfs": "apfs"
+18 -3
View File
@@ -5959,7 +5959,11 @@
"5567191a6e": "ブラウズ",
"0e9fc0eadc": "ワークスペースディレクトリ",
"e2955d9ccb": "新規ワークスペースを作成する場所を構成します。",
"7511097c5d": "ワークスペース"
"7511097c5d": "ワークスペース",
"externalWorktrees": "外部ワークツリー",
"externalWorktreesDescription": "Orca 外で作成されたワークツリーをデフォルトで表示するかどうかを選択します。",
"show": "表示",
"hide": "非表示"
},
"GhosttyImportModal": {
"9d3e56ca36": "変更を適用する",
@@ -6659,7 +6663,14 @@
"projectRuntimeDescription": "このプロジェクトを Windows と WSL のどちらで実行するかを選択します。",
"hostStateDisconnected": "切断済み",
"hostStateUnknown": "不明",
"nestedHostLabel": "{{value1}} 経由の {{value0}}"
"nestedHostLabel": "{{value1}} 経由の {{value0}}",
"externalWorktrees": "外部ワークツリー",
"externalWorktreesDescription": "このプロジェクトで Orca 外のワークツリーを表示するかどうかを上書きします。",
"externalWorktreesInherited": "グローバル設定を使用中: {{value0}}",
"show": "表示",
"hide": "非表示",
"externalWorktreesGlobal": "グローバルデフォルト: {{value0}}",
"useGlobal": "グローバル設定を使用"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "コマンドテンプレート",
@@ -8280,7 +8291,9 @@
"e61157e926": "エディターのワードラップ",
"005be5c699": "水平スクロールを使わずに、ファイルエディターで長い行を折り返します。",
"editorFontFamily": "エディターフォント",
"editorFontFamilyDesc": "ファイルエディターと差分ビューで使用されるフォント。空の場合はターミナルフォントに従います。"
"editorFontFamilyDesc": "ファイルエディターと差分ビューで使用されるフォント。空の場合はターミナルフォントに従います。",
"externalWorktrees": "外部ワークツリー",
"externalWorktreesDescription": "Orca 外で作成されたワークツリーをデフォルトで表示するかどうかを選択します。"
}
},
"git": {
@@ -8764,6 +8777,8 @@
"keepForkUpToDateDescription": "このフォークを upstream から安全に fast-forward します。",
"projectRuntime": "プロジェクトのランタイム",
"projectRuntimeDescription": "このプロジェクトを Windows と WSL のどちらで実行するかを選択します。",
"externalWorktrees": "外部ワークツリー",
"externalWorktreesDescription": "このプロジェクトで Orca 外のワークツリーを表示するかどうかを上書きします。",
"copy": "コピー",
"clone": "clone",
"apfs": "apfs"
+18 -3
View File
@@ -5959,7 +5959,11 @@
"5567191a6e": "찾아보기",
"0e9fc0eadc": "워크스페이스 디렉터리",
"e2955d9ccb": "새 워크스페이스가 생성되는 위치를 구성합니다.",
"7511097c5d": "워크스페이스"
"7511097c5d": "워크스페이스",
"externalWorktrees": "외부 작업 트리",
"externalWorktreesDescription": "Orca 외부에서 만든 작업 트리를 기본적으로 표시할지 선택합니다.",
"show": "표시",
"hide": "숨기기"
},
"GhosttyImportModal": {
"9d3e56ca36": "변경 사항 적용",
@@ -6622,7 +6626,14 @@
"projectRuntimeDescription": "이 프로젝트를 Windows에서 실행할지 WSL에서 실행할지 선택합니다.",
"hostStateDisconnected": "Disconnected",
"hostStateUnknown": "Unknown",
"nestedHostLabel": "{{value0}} via {{value1}}"
"nestedHostLabel": "{{value0}} via {{value1}}",
"externalWorktrees": "외부 작업 트리",
"externalWorktreesDescription": "이 프로젝트에서 Orca 외부 작업 트리를 표시할지 재정의합니다.",
"externalWorktreesInherited": "전역 설정 사용 중: {{value0}}",
"show": "표시",
"hide": "숨기기",
"externalWorktreesGlobal": "전역 기본값: {{value0}}",
"useGlobal": "전역 설정 사용"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "명령 템플릿",
@@ -8243,7 +8254,9 @@
"e61157e926": "편집기 자동 줄 바꿈",
"005be5c699": "가로 스크롤 대신 파일 편집기에서 긴 줄을 자동으로 줄 바꿈합니다.",
"editorFontFamily": "편집기 글꼴",
"editorFontFamilyDesc": "파일 편집기 및 diff 보기에서 사용되는 글꼴입니다. 비워두면 터미널 글꼴을 따릅니다."
"editorFontFamilyDesc": "파일 편집기 및 diff 보기에서 사용되는 글꼴입니다. 비워두면 터미널 글꼴을 따릅니다.",
"externalWorktrees": "외부 작업 트리",
"externalWorktreesDescription": "Orca 외부에서 만든 작업 트리를 기본적으로 표시할지 선택합니다."
}
},
"git": {
@@ -8727,6 +8740,8 @@
"keepForkUpToDateDescription": "이 포크를 upstream에서 안전하게 fast-forward합니다.",
"projectRuntime": "프로젝트 런타임",
"projectRuntimeDescription": "이 프로젝트를 Windows에서 실행할지 WSL에서 실행할지 선택합니다.",
"externalWorktrees": "외부 작업 트리",
"externalWorktreesDescription": "이 프로젝트에서 Orca 외부 작업 트리를 표시할지 재정의합니다.",
"copy": "copy",
"clone": "clone",
"apfs": "apfs"
+18 -3
View File
@@ -5971,7 +5971,11 @@
"5567191a6e": "浏览",
"0e9fc0eadc": "工作区目录",
"e2955d9ccb": "配置创建新工作区的位置。",
"7511097c5d": "工作区"
"7511097c5d": "工作区",
"externalWorktrees": "外部工作树",
"externalWorktreesDescription": "选择是否默认显示在 Orca 外部创建的工作树。",
"show": "显示",
"hide": "隐藏"
},
"GhosttyImportModal": {
"9d3e56ca36": "应用更改",
@@ -6634,7 +6638,14 @@
"projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。",
"hostStateDisconnected": "Disconnected",
"hostStateUnknown": "Unknown",
"nestedHostLabel": "{{value0}} via {{value1}}"
"nestedHostLabel": "{{value0}} via {{value1}}",
"externalWorktrees": "外部工作树",
"externalWorktreesDescription": "覆盖是否为此项目显示在 Orca 外部创建的工作树。",
"externalWorktreesInherited": "正在使用全局设置:{{value0}}",
"show": "显示",
"hide": "隐藏",
"externalWorktreesGlobal": "全局默认值:{{value0}}",
"useGlobal": "使用全局设置"
},
"RepositorySourceControlAiActionRows": {
"548a6e1281": "提示词模板",
@@ -8255,7 +8266,9 @@
"e61157e926": "编辑器自动换行",
"005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。",
"editorFontFamily": "编辑器字体",
"editorFontFamilyDesc": "文件编辑器和差异视图使用的字体,留空则跟随终端字体。"
"editorFontFamilyDesc": "文件编辑器和差异视图使用的字体,留空则跟随终端字体。",
"externalWorktrees": "外部工作树",
"externalWorktreesDescription": "选择是否默认显示在 Orca 外部创建的工作树。"
}
},
"git": {
@@ -8739,6 +8752,8 @@
"keepForkUpToDateDescription": "从 upstream 安全地快进此 Fork。",
"projectRuntime": "项目运行时",
"projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。",
"externalWorktrees": "外部工作树",
"externalWorktreesDescription": "覆盖是否为此项目显示在 Orca 外部创建的工作树。",
"copy": "复制",
"clone": "克隆",
"apfs": "apfs"
@@ -486,4 +486,22 @@ describe('purgeStaleRuntimeHostState', () => {
expect(s.activeRepoId).toBeNull()
expect(s.filterRepoIds).toEqual(['repo1'])
})
it('purges visibility state even when the removed host has no catalog rows', () => {
const store = createTestStore()
seedStore(store, {
worktreeVisibilityDefaultsByHost: {
local: { external: 'hide' },
[RUNTIME_A]: { external: 'show' }
},
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-a'
})
store.getState().purgeStaleRuntimeHostState(['env-a'])
expect(store.getState().worktreeVisibilityDefaultsByHost).toEqual({
local: { external: 'hide' }
})
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBeNull()
})
})
@@ -0,0 +1,134 @@
import { expect, it, vi } from 'vitest'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../../shared/protocol-version'
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
import { createTestStore } from './store-test-helpers'
it('hydrates a runtime owner default when refreshing its repositories', async () => {
clearRuntimeCompatibilityCacheForTests()
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: vi.fn(({ method }: { method: string }) => {
const result =
method === 'status.get'
? {
runtimeId: 'runtime-1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
}
: method === 'repo.list'
? { repos: [] }
: method === 'settings.get'
? { settings: { worktreeVisibilityDefaults: { external: 'show' } } }
: method === 'project.list'
? { projects: [] }
: { setups: [] }
return Promise.resolve({
ok: true,
result,
_meta: { runtimeId: 'runtime-1' }
})
})
}
}
})
const store = createTestStore()
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toEqual({
external: 'show'
})
expect(store.getState().settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' })
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBe('env-1')
})
it('clears focused support when a runtime omits the default', async () => {
clearRuntimeCompatibilityCacheForTests()
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: vi.fn(({ method }: { method: string }) =>
Promise.resolve({
ok: true,
result:
method === 'status.get'
? {
runtimeId: 'runtime-1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
}
: method === 'repo.list'
? { repos: [] }
: { settings: {} },
_meta: { runtimeId: 'runtime-1' }
})
)
}
}
})
const store = createTestStore()
store.setState({
settings: {
activeRuntimeEnvironmentId: 'env-1',
worktreeVisibilityDefaults: { external: 'show' }
} as never,
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-1'
})
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toBeNull()
expect(store.getState().settings?.worktreeVisibilityDefaults).toBeUndefined()
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBeNull()
})
it('preserves focused support when runtime default hydration fails', async () => {
clearRuntimeCompatibilityCacheForTests()
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: vi.fn(({ method }: { method: string }) =>
method === 'settings.get'
? Promise.reject(new Error('offline'))
: Promise.resolve({
ok: true,
result:
method === 'status.get'
? {
runtimeId: 'runtime-1',
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
}
: { repos: [] },
_meta: { runtimeId: 'runtime-1' }
})
)
}
}
})
const store = createTestStore()
store.setState({
settings: {
activeRuntimeEnvironmentId: 'env-1',
worktreeVisibilityDefaults: { external: 'show' }
} as never,
worktreeVisibilityDefaultsByHost: { 'runtime:env-1': { external: 'show' } },
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-1'
})
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toEqual({
external: 'show'
})
expect(store.getState().settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' })
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBe('env-1')
})
+74 -6
View File
@@ -55,6 +55,7 @@ import {
reconcileFetchedRepos
} from './repo-identity-reconcile'
import { retainValidFilterRepoIds } from './repo-filter-selection'
import { readRuntimeWorktreeVisibilityDefaults } from './worktree-visibility-owner-settings'
import {
mergeSshRepoReadoptions,
reconcileReadoptedSshRepoRows,
@@ -139,7 +140,6 @@ export type RepoUpdate = Partial<
| 'symlinkPaths'
| 'issueSourcePreference'
| 'forkSyncMode'
| 'externalWorktreeVisibility'
| 'externalWorktreeVisibilityPromptDismissedAt'
| 'externalWorktreeInboxBaselinePaths'
| 'importedExternalWorktreePaths'
@@ -150,6 +150,7 @@ export type RepoUpdate = Partial<
| 'projectGroupOrder'
>
> & {
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
sourceControlAi?: Repo['sourceControlAi'] | null
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
}
@@ -2169,7 +2170,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const targetHostId = getRuntimeTargetHostId(target)
claimRepoCatalogGeneration(get, targetHostId, catalogGeneration)
try {
const catalog = await fetchRepoCatalogForTarget(target)
const [catalog, visibilityDefaults] = await Promise.all([
fetchRepoCatalogForTarget(target),
readRuntimeWorktreeVisibilityDefaults(environmentId)
])
if (
runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration ||
!isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) ||
@@ -2219,6 +2223,35 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
)
return {
repos: finalizedRepos,
...(visibilityDefaults === undefined
? {}
: {
worktreeVisibilityDefaultsByHost: {
...s.worktreeVisibilityDefaultsByHost,
[targetHostId]: visibilityDefaults
}
}),
...(visibilityDefaults !== undefined && s.settings
? getActiveRuntimeTarget(s.settings).kind === 'environment' &&
s.settings.activeRuntimeEnvironmentId === environmentId
? visibilityDefaults
? {
settings: {
...s.settings,
worktreeVisibilityDefaults: visibilityDefaults
},
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: environmentId
}
: {
settings: Object.fromEntries(
Object.entries(s.settings).filter(
([key]) => key !== 'worktreeVisibilityDefaults'
)
) as GlobalSettings,
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null
}
: {}
: {}),
pendingSshRepoReadoptions: reconciliation.pendingReadoptions,
...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions),
...mergedProjectCompatibility,
@@ -2344,11 +2377,38 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
environmentId: environment.id
}
claimRepoCatalogGeneration(get, getRuntimeTargetHostId(target), generation)
try {
applyCatalog(await fetchRepoCatalogForTarget(target))
} catch (err) {
const [catalogResult, visibilityDefaults] = await Promise.all([
fetchRepoCatalogForTarget(target).then(
(catalog) => ({ ok: true as const, catalog }),
(error: unknown) => ({ ok: false as const, error })
),
readRuntimeWorktreeVisibilityDefaults(environment.id)
])
const hostId = getRuntimeTargetHostId(target)
if (
visibilityDefaults !== undefined &&
latestAllHostRepoCatalogGenerationByStore.get(get) === generation &&
isLatestRepoCatalogGeneration(get, hostId, generation)
) {
set((state) =>
isRemovedRuntimeHostId(hostId, state.removedRuntimeEnvironmentIds)
? state
: {
worktreeVisibilityDefaultsByHost: {
...state.worktreeVisibilityDefaultsByHost,
[hostId]: visibilityDefaults
}
}
)
}
if (catalogResult.ok) {
applyCatalog(catalogResult.catalog)
} else {
failed = true
console.warn(`Skipped repos for runtime environment ${environment.id}:`, err)
console.warn(
`Skipped repos for runtime environment ${environment.id}:`,
catalogResult.error
)
}
})
)
@@ -3818,6 +3878,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const {
sourceControlAi,
externalWorktreeDiscoverySuppressedAt,
externalWorktreeVisibility,
...updatesWithoutClearSentinels
} = sanitizedUpdates
mergedRepo = { ...mergedRepo, ...updatesWithoutClearSentinels }
@@ -3828,6 +3889,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
} else if (sourceControlAi !== undefined) {
mergedRepo = { ...mergedRepo, sourceControlAi }
}
if (externalWorktreeVisibility === null) {
const { externalWorktreeVisibility: _visibility, ...repoWithoutVisibility } =
mergedRepo
mergedRepo = { ...repoWithoutVisibility, externalWorktreeVisibilityLegacy: false }
} else if (externalWorktreeVisibility !== undefined) {
mergedRepo = { ...mergedRepo, externalWorktreeVisibility }
}
if (externalWorktreeDiscoverySuppressedAt === null) {
const {
externalWorktreeDiscoverySuppressedAt: _suppressedAt,
@@ -0,0 +1,58 @@
import { expect, it, vi } from 'vitest'
import {
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_PROTOCOL_VERSION
} from '../../../../shared/protocol-version'
import { createTestStore } from './store-test-helpers'
function compatibleStatus(runtimeId: string) {
return {
ok: true,
result: {
runtimeId,
graphStatus: 'ready',
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
},
_meta: { runtimeId }
}
}
it('does not persist a stale overlapping runtime switch', async () => {
let resolveFirst!: (value: unknown) => void
const firstStatus = new Promise((resolve) => (resolveFirst = resolve))
const persistPreference = vi.fn(({ environmentId }: { environmentId: string }) =>
Promise.resolve({ activeRuntimeEnvironmentId: environmentId })
)
vi.stubGlobal('window', {
api: {
settings: { setActiveRuntimeEnvironmentPreference: persistPreference },
runtimeEnvironments: {
getStatus: vi.fn(({ selector }: { selector: string }) =>
selector === 'env-a' ? firstStatus : Promise.resolve(compatibleStatus('runtime-b'))
),
call: vi.fn().mockResolvedValue({
ok: true,
result: { settings: { worktreeVisibilityDefaults: { external: 'hide' } } },
_meta: { runtimeId: 'runtime-b' }
})
}
}
})
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
fetchRepos: vi.fn().mockResolvedValue(undefined),
fetchAllWorktrees: vi.fn().mockResolvedValue(undefined),
fetchWorktreeLineage: vi.fn().mockResolvedValue(undefined),
fetchBrowserSessionProfiles: vi.fn().mockResolvedValue(undefined)
})
const first = store.getState().setActiveRuntimeEnvironmentPreference('env-a')
await store.getState().setActiveRuntimeEnvironmentPreference('env-b')
resolveFirst(compatibleStatus('runtime-a'))
await first
expect(persistPreference).toHaveBeenCalledOnce()
expect(persistPreference).toHaveBeenCalledWith({ environmentId: 'env-b' })
})
@@ -0,0 +1,25 @@
import { expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import { createTestStore } from './store-test-helpers'
it('does not overwrite a settings write with an older owner hydration', async () => {
let resolveSettingsRead!: (settings: GlobalSettings) => void
const settingsRead = new Promise<GlobalSettings>((resolve) => (resolveSettingsRead = resolve))
vi.stubGlobal('window', {
api: {
settings: {
get: vi.fn().mockReturnValue(settingsRead),
set: vi.fn().mockResolvedValue({ pluginSystemEnabled: true })
},
runtimeEnvironments: { list: vi.fn().mockResolvedValue([]) }
}
})
const store = createTestStore()
const hydration = store.getState().fetchSettings()
await store.getState().updateSettingsOrThrow({ pluginSystemEnabled: true })
resolveSettingsRead({ pluginSystemEnabled: false } as GlobalSettings)
await hydration
expect(store.getState().settings?.pluginSystemEnabled).toBe(true)
})
@@ -144,7 +144,9 @@ beforeEach(() => {
? { groups: [] }
: method === 'worktree.lineageList'
? { lineage: { [env2Lineage.worktreeId]: env2Lineage } }
: {}
: method === 'settings.get'
? { settings: {} }
: {}
return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } })
}
)
@@ -399,6 +401,7 @@ describe('createSettingsSlice runtime switching', () => {
expect(store.getState().repos.find((repo) => repo.id === 'repo-env-2')?.executionHostId).toBe(
'runtime:env-2'
)
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-2']).toBeNull()
expect(store.getState().projectGroups.map((group) => group.id)).toEqual(['group-env-1'])
expect(store.getState().worktreesByRepo['repo-env-1']?.map((worktree) => worktree.id)).toEqual([
'repo-env-1::/env-1/repo'
@@ -565,7 +568,6 @@ describe('createSettingsSlice runtime switching', () => {
true
)
// No teardown RPC was issued against the previous host's live resources.
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-1', method: 'terminal.close' })
)
@@ -573,7 +575,6 @@ describe('createSettingsSlice runtime switching', () => {
expect.objectContaining({ selector: 'env-1', method: 'browser.tabClose' })
)
// Every previous-host map is byte-for-byte unchanged after the switch.
expect(store.getState().tabsByWorktree).toEqual({
'repo-env-1::/env-1/repo': [
{
+78 -18
View File
@@ -28,9 +28,16 @@ import {
normalizeMobilePairingCustomAddress,
normalizeMobilePairingCustomAddresses
} from '../../../../shared/mobile-pairing-custom-address'
import {
hydrateOwnerWorktreeVisibilityDefaults,
type WorktreeVisibilityDefaultsByHost
} from './worktree-visibility-owner-settings'
import { persistVisibilityAwareSettings } from './worktree-visibility-settings-write'
export type SettingsSlice = SettingsSearchState & {
settings: GlobalSettings | null
worktreeVisibilityDefaultsByHost: WorktreeVisibilityDefaultsByHost
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: string | null
fetchSettings: () => Promise<void>
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
updateSettingsOrThrow: (updates: Partial<GlobalSettings>) => Promise<void>
@@ -42,6 +49,7 @@ type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
}
type SettingsStateSetter = Parameters<StateCreator<AppState, [], [], SettingsSlice>>[0]
let ownerSettingsHydrationGeneration = 0
function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null {
const trimmed = value?.trim()
@@ -126,14 +134,16 @@ function normalizeSettingsUpdates(
async function persistSettingsUpdates(
set: SettingsStateSetter,
updates: Partial<GlobalSettings>,
currentSettings: GlobalSettings | null
currentSettings: GlobalSettings | null,
supportedRuntimeEnvironmentId: string | null
): Promise<void> {
const nextSettings = await window.api.settings.set(
normalizeSettingsUpdates(updates, currentSettings)
)
set((state) => ({
settings: (nextSettings as GlobalSettings | undefined) ?? state.settings
}))
const normalizedUpdates = normalizeSettingsUpdates(updates, currentSettings)
await persistVisibilityAwareSettings({
normalizedUpdates,
currentSettings,
supportedRuntimeEnvironmentId,
set
})
}
/** Every known host has a recorded status entry, and no entry survives for a host that is gone. */
@@ -164,12 +174,26 @@ async function verifyRuntimeEnvironmentReachable(environmentId: string | null):
export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> = (set, get) => ({
settings: null,
worktreeVisibilityDefaultsByHost: {},
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null,
...createSettingsSearchState((state) => set(state)),
fetchSettings: async () => {
const generation = ++ownerSettingsHydrationGeneration
try {
const settings = await window.api.settings.get()
set({ settings })
const hydrated = await hydrateOwnerWorktreeVisibilityDefaults(
(await window.api.settings.get()) as GlobalSettings,
get().worktreeVisibilityDefaultsByHost
)
if (generation !== ownerSettingsHydrationGeneration) {
return
}
set({
settings: hydrated.settings,
worktreeVisibilityDefaultsByHost: hydrated.defaultsByHost,
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId:
hydrated.supportedRuntimeEnvironmentId
})
} catch (err) {
console.error('Failed to fetch settings:', err)
}
@@ -188,18 +212,37 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
},
updateSettings: async (updates) => {
++ownerSettingsHydrationGeneration
try {
await persistSettingsUpdates(set, updates, get().settings)
await persistSettingsUpdates(
set,
updates,
get().settings,
get().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId
)
if ('worktreeVisibilityDefaults' in updates) {
await get().fetchAllWorktrees()
}
} catch (err) {
console.error('Failed to update settings:', err)
}
},
updateSettingsOrThrow: async (updates) => {
await persistSettingsUpdates(set, updates, get().settings)
++ownerSettingsHydrationGeneration
await persistSettingsUpdates(
set,
updates,
get().settings,
get().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId
)
if ('worktreeVisibilityDefaults' in updates) {
await get().fetchAllWorktrees()
}
},
setActiveRuntimeEnvironmentPreference: async (environmentId) => {
const generation = ++ownerSettingsHydrationGeneration
const nextId = normalizeRuntimeEnvironmentId(environmentId)
const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId)
if (previousId === nextId) {
@@ -208,17 +251,34 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
try {
clearRuntimeCompatibilityCache(nextId)
await verifyRuntimeEnvironmentReachable(nextId)
if (generation !== ownerSettingsHydrationGeneration) {
return true
}
const nextSettings = await window.api.settings.setActiveRuntimeEnvironmentPreference({
environmentId: nextId
})
bumpProviderRuntimeSessionGeneration()
set((s) => ({
// Why: in the multi-host model this is a focus/default-host change,
// not a teardown boundary. Existing host-owned sessions stay alive.
settings:
(nextSettings as GlobalSettings | undefined) ??
(s.settings ? { ...s.settings, activeRuntimeEnvironmentId: nextId } : null)
}))
// Why: this is a focus change, so keep other host state while hydrating only the new owner's default.
const focusedSettings =
(nextSettings as GlobalSettings | undefined) ??
(get().settings ? { ...get().settings!, activeRuntimeEnvironmentId: nextId } : null)
if (focusedSettings) {
const hydrated = await hydrateOwnerWorktreeVisibilityDefaults(
focusedSettings,
get().worktreeVisibilityDefaultsByHost
)
if (generation !== ownerSettingsHydrationGeneration) {
return true
}
set({
settings: hydrated.settings,
worktreeVisibilityDefaultsByHost: hydrated.defaultsByHost,
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId:
hydrated.supportedRuntimeEnvironmentId
})
} else {
set({ settings: null })
}
// Why: hydration is host-merged by downstream slices. Switching focus
// should add/update the selected host without discarding other hosts.
await get().fetchRepos()
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearRuntimeCompatibilityCacheForTests,
markRuntimeEnvironmentCompatible
} from '@/runtime/runtime-rpc-client'
import type { GlobalSettings } from '../../../../shared/types'
import {
hydrateOwnerWorktreeVisibilityDefaults,
readRuntimeWorktreeVisibilityDefaults
} from './worktree-visibility-owner-settings'
const runtimeCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
markRuntimeEnvironmentCompatible('env-1')
runtimeCall.mockReset()
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
})
describe('runtime worktree visibility defaults', () => {
it('reads defaults from the selected runtime owner', async () => {
runtimeCall.mockResolvedValue({
ok: true,
result: { settings: { worktreeVisibilityDefaults: { external: 'show' } } },
_meta: { runtimeId: 'runtime-1' }
})
await expect(readRuntimeWorktreeVisibilityDefaults('env-1')).resolves.toEqual({
external: 'show'
})
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({ selector: 'env-1', method: 'settings.get' })
)
})
it('uses compatibility fallback semantics when the host omits the field', async () => {
runtimeCall.mockResolvedValue({
ok: true,
result: { settings: {} },
_meta: { runtimeId: 'runtime-1' }
})
await expect(readRuntimeWorktreeVisibilityDefaults('env-1')).resolves.toBeNull()
})
it('preserves cached support state when the host is temporarily unavailable', async () => {
runtimeCall.mockRejectedValue(new Error('offline'))
const hydrated = await hydrateOwnerWorktreeVisibilityDefaults(
{
activeRuntimeEnvironmentId: 'env-1',
worktreeVisibilityDefaults: { external: 'hide' }
} as GlobalSettings,
{ 'runtime:env-1': { external: 'show' } }
)
expect(hydrated.settings.worktreeVisibilityDefaults).toEqual({ external: 'show' })
expect(hydrated.supportedRuntimeEnvironmentId).toBe('env-1')
})
})
@@ -0,0 +1,85 @@
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import type { GlobalSettings, WorktreeVisibilityDefaults } from '../../../../shared/types'
export type WorktreeVisibilityDefaultsByHost = Partial<
Record<ExecutionHostId, WorktreeVisibilityDefaults | null>
>
export async function readRuntimeWorktreeVisibilityDefaults(
environmentId: string
): Promise<WorktreeVisibilityDefaults | null | undefined> {
try {
const result = await callRuntimeRpc<{ settings: Partial<GlobalSettings> }>(
{ kind: 'environment', environmentId },
'settings.get',
undefined,
{ timeoutMs: 15_000, reuseRecentCompatibilityFailure: true }
)
const external = result.settings.worktreeVisibilityDefaults?.external
return external === 'hide' || external === 'show' ? { external } : null
} catch {
return undefined
}
}
export async function hydrateOwnerWorktreeVisibilityDefaults(
settings: GlobalSettings,
defaultsByHost: WorktreeVisibilityDefaultsByHost
): Promise<{
settings: GlobalSettings
defaultsByHost: WorktreeVisibilityDefaultsByHost
supportedRuntimeEnvironmentId: string | null
}> {
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return {
settings,
supportedRuntimeEnvironmentId: null,
defaultsByHost: {
...defaultsByHost,
[LOCAL_EXECUTION_HOST_ID]: settings.worktreeVisibilityDefaults ?? { external: 'hide' }
}
}
}
const hostId = toRuntimeExecutionHostId(target.environmentId)
const localDefaults =
defaultsByHost[LOCAL_EXECUTION_HOST_ID] ?? settings.worktreeVisibilityDefaults
const ownerDefaultsByHost = localDefaults
? { ...defaultsByHost, [LOCAL_EXECUTION_HOST_ID]: localDefaults }
: defaultsByHost
const defaults = await readRuntimeWorktreeVisibilityDefaults(target.environmentId)
if (defaults) {
return {
settings: { ...settings, worktreeVisibilityDefaults: defaults },
defaultsByHost: { ...ownerDefaultsByHost, [hostId]: defaults },
supportedRuntimeEnvironmentId: target.environmentId
}
}
if (defaults === undefined) {
const cached = ownerDefaultsByHost[hostId]
if (cached) {
return {
settings: { ...settings, worktreeVisibilityDefaults: cached },
defaultsByHost: ownerDefaultsByHost,
supportedRuntimeEnvironmentId: target.environmentId
}
}
const { worktreeVisibilityDefaults: _unavailable, ...settingsWithoutDefaults } = settings
return {
settings: settingsWithoutDefaults as GlobalSettings,
defaultsByHost: ownerDefaultsByHost,
supportedRuntimeEnvironmentId: null
}
}
const { worktreeVisibilityDefaults: _unsupported, ...settingsWithoutDefaults } = settings
return {
settings: settingsWithoutDefaults as GlobalSettings,
defaultsByHost: { ...ownerDefaultsByHost, [hostId]: null },
supportedRuntimeEnvironmentId: null
}
}
@@ -0,0 +1,94 @@
import { expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import type { AppState } from '../types'
import { markRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client'
import { createTestStore } from './store-test-helpers'
import { persistVisibilityAwareSettings } from './worktree-visibility-settings-write'
it('preserves the active runtime default during unrelated local settings writes', async () => {
const currentSettings = {
activeRuntimeEnvironmentId: 'env-1',
worktreeVisibilityDefaults: { external: 'show' }
} as GlobalSettings
let state = {
settings: currentSettings,
worktreeVisibilityDefaultsByHost: { 'runtime:env-1': { external: 'show' } }
} as unknown as AppState
vi.stubGlobal('window', {
api: {
settings: {
set: vi.fn().mockResolvedValue({
pluginSystemEnabled: true,
worktreeVisibilityDefaults: { external: 'hide' }
})
}
}
})
await persistVisibilityAwareSettings({
normalizedUpdates: { pluginSystemEnabled: true },
currentSettings,
supportedRuntimeEnvironmentId: 'env-1',
set: (updater) => {
state = { ...state, ...updater(state) }
}
})
expect(state.settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' })
})
it('reclassifies worktrees after changing the host visibility default', async () => {
vi.stubGlobal('window', {
api: {
settings: {
set: vi.fn().mockResolvedValue({ worktreeVisibilityDefaults: { external: 'show' } })
}
}
})
const store = createTestStore()
const fetchAllWorktrees = vi.fn().mockResolvedValue(undefined)
store.setState({ fetchAllWorktrees })
await store.getState().updateSettings({ worktreeVisibilityDefaults: { external: 'show' } })
expect(fetchAllWorktrees).toHaveBeenCalledOnce()
})
it('does not restore a stale owner after its visibility write resolves', async () => {
markRuntimeEnvironmentCompatible('env-a')
let resolveUpdate!: (value: unknown) => void
const update = new Promise((resolve) => (resolveUpdate = resolve))
vi.stubGlobal('window', {
api: {
runtimeEnvironments: {
call: vi.fn().mockReturnValue(update)
}
}
})
const currentSettings = {
activeRuntimeEnvironmentId: 'env-a',
worktreeVisibilityDefaults: { external: 'hide' }
} as GlobalSettings
let state = {
settings: currentSettings,
worktreeVisibilityDefaultsByHost: {}
} as unknown as AppState
const write = persistVisibilityAwareSettings({
normalizedUpdates: { worktreeVisibilityDefaults: { external: 'show' } },
currentSettings,
supportedRuntimeEnvironmentId: 'env-a',
set: (updater) => {
state = { ...state, ...updater(state) }
}
})
state = { ...state, settings: { activeRuntimeEnvironmentId: 'env-b' } as GlobalSettings }
resolveUpdate({
ok: true,
result: { settings: { worktreeVisibilityDefaults: { external: 'show' } } },
_meta: { runtimeId: 'runtime-a' }
})
await write
expect(state.settings?.activeRuntimeEnvironmentId).toBe('env-b')
expect(state.worktreeVisibilityDefaultsByHost['runtime:env-a']).toEqual({ external: 'show' })
})
@@ -0,0 +1,82 @@
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
LOCAL_EXECUTION_HOST_ID,
toRuntimeExecutionHostId
} from '../../../../shared/execution-host'
import type { GlobalSettings } from '../../../../shared/types'
import type { AppState } from '../types'
export async function persistVisibilityAwareSettings(args: {
normalizedUpdates: Partial<GlobalSettings>
currentSettings: GlobalSettings | null
supportedRuntimeEnvironmentId: string | null
set: (updater: (state: AppState) => Partial<AppState>) => void
}): Promise<void> {
const { normalizedUpdates, currentSettings, supportedRuntimeEnvironmentId, set } = args
const target = getActiveRuntimeTarget(currentSettings)
if ('worktreeVisibilityDefaults' in normalizedUpdates && target.kind === 'environment') {
const { worktreeVisibilityDefaults, ...localUpdates } = normalizedUpdates
const localSettings =
Object.keys(localUpdates).length > 0
? ((await window.api.settings.set(localUpdates)) as GlobalSettings)
: currentSettings
let nextSettings = localSettings
if (target.environmentId === supportedRuntimeEnvironmentId) {
const result = await callRuntimeRpc<{ settings: Partial<GlobalSettings> }>(
target,
'settings.update',
{ worktreeVisibilityDefaults },
{ timeoutMs: 15_000 }
)
nextSettings = {
...localSettings,
worktreeVisibilityDefaults: result.settings.worktreeVisibilityDefaults
} as GlobalSettings
}
set((state) => {
const currentTarget = getActiveRuntimeTarget(state.settings)
const stillFocused =
currentTarget.kind === 'environment' && currentTarget.environmentId === target.environmentId
return {
settings: stillFocused ? nextSettings : state.settings,
worktreeVisibilityDefaultsByHost:
target.environmentId === supportedRuntimeEnvironmentId &&
nextSettings?.worktreeVisibilityDefaults
? {
...state.worktreeVisibilityDefaultsByHost,
[toRuntimeExecutionHostId(target.environmentId)]:
nextSettings.worktreeVisibilityDefaults
}
: state.worktreeVisibilityDefaultsByHost
}
})
return
}
const nextSettings = await window.api.settings.set(normalizedUpdates)
set((state) => ({
settings: (() => {
const persisted = (nextSettings as GlobalSettings | undefined) ?? state.settings
if (!persisted || target.kind !== 'environment') {
return persisted
}
const defaults =
state.worktreeVisibilityDefaultsByHost[toRuntimeExecutionHostId(target.environmentId)] ??
currentSettings?.worktreeVisibilityDefaults
if (target.environmentId === supportedRuntimeEnvironmentId && defaults) {
return { ...persisted, worktreeVisibilityDefaults: defaults }
}
const { worktreeVisibilityDefaults: _unsupported, ...settingsWithoutDefaults } = persisted
return settingsWithoutDefaults as GlobalSettings
})(),
...('worktreeVisibilityDefaults' in normalizedUpdates
? {
worktreeVisibilityDefaultsByHost: {
...state.worktreeVisibilityDefaultsByHost,
[LOCAL_EXECUTION_HOST_ID]: (nextSettings as GlobalSettings | undefined)
?.worktreeVisibilityDefaults ??
normalizedUpdates.worktreeVisibilityDefaults ?? { external: 'hide' }
}
}
: {})
}))
}
@@ -83,6 +83,7 @@ import {
getSettingsFocusedExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
@@ -6222,12 +6223,29 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const restoredSessionOwnersChanged =
survivingRestoredSessionOwners !== s.restoredRuntimeHostIdByWorkspaceSessionKey
let survivingVisibilityDefaults = s.worktreeVisibilityDefaultsByHost
for (const environmentId of removed) {
const hostId = toRuntimeExecutionHostId(environmentId)
if (hostId in survivingVisibilityDefaults) {
if (survivingVisibilityDefaults === s.worktreeVisibilityDefaultsByHost) {
survivingVisibilityDefaults = { ...survivingVisibilityDefaults }
}
delete survivingVisibilityDefaults[hostId]
}
}
const visibilityDefaultsChanged =
survivingVisibilityDefaults !== s.worktreeVisibilityDefaultsByHost
const visibilitySupportChanged = removed.has(
s.worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId ?? ''
)
if (
!reposChanged &&
!setupsChanged &&
!worktreesChanged &&
!detectedChanged &&
!restoredSessionOwnersChanged &&
!visibilityDefaultsChanged &&
!visibilitySupportChanged &&
removedWorktreeIds.size === 0
) {
return s
@@ -6252,6 +6270,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...(restoredSessionOwnersChanged
? { restoredRuntimeHostIdByWorkspaceSessionKey: survivingRestoredSessionOwners }
: {}),
...(visibilityDefaultsChanged
? { worktreeVisibilityDefaultsByHost: survivingVisibilityDefaults }
: {}),
...(visibilitySupportChanged
? { worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null }
: {}),
...(rowsChanged ? { sortEpoch: s.sortEpoch + 1 } : {}),
// Why: mirror validateRepoScopedUi so a filtered/active sidebar can't reference a purged repo id.
...(reposChanged
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { getRepoOwnerWorktreeVisibilityDefaults } from './worktree-visibility-defaults-by-host'
describe('getRepoOwnerWorktreeVisibilityDefaults', () => {
it('resolves defaults from each repository owner', () => {
const settings = { worktreeVisibilityDefaults: { external: 'hide' as const } }
const defaultsByHost = {
local: { external: 'hide' as const },
'runtime:show-host': { external: 'show' as const },
'runtime:legacy-host': null
}
expect(
getRepoOwnerWorktreeVisibilityDefaults(
{ executionHostId: 'runtime:show-host' },
settings,
defaultsByHost
)
).toEqual({ external: 'show' })
expect(
getRepoOwnerWorktreeVisibilityDefaults({ connectionId: 'ssh-box' }, settings, defaultsByHost)
).toEqual({ external: 'hide' })
expect(
getRepoOwnerWorktreeVisibilityDefaults(
{ executionHostId: 'runtime:legacy-host' },
settings,
defaultsByHost
)
).toBeUndefined()
})
})
@@ -0,0 +1,15 @@
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host'
import type { ExecutionHostId } from '../../../shared/execution-host'
import type { GlobalSettings, Repo, WorktreeVisibilityDefaults } from '../../../shared/types'
export function getRepoOwnerWorktreeVisibilityDefaults(
repo: Pick<Repo, 'connectionId' | 'executionHostId'>,
settings: Pick<GlobalSettings, 'worktreeVisibilityDefaults'> | null | undefined,
defaultsByHost: Partial<Record<ExecutionHostId, WorktreeVisibilityDefaults | null>>
): WorktreeVisibilityDefaults | undefined {
const hostId = getRepoExecutionHostId(repo)
if (parseExecutionHostId(hostId)?.kind === 'runtime') {
return defaultsByHost[hostId] ?? undefined
}
return defaultsByHost.local ?? settings?.worktreeVisibilityDefaults
}
@@ -1124,6 +1124,93 @@ describe('web settings preload API', () => {
expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }])
}, 15_000)
it('hydrates and updates external-worktree defaults owned by a paired runtime', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: {
settings: {
worktreeVisibilityDefaults: {
external: method === 'settings.update' ? 'show' : 'hide'
}
}
},
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
globals.storage.setItem(
'orca.web.settings.v1',
JSON.stringify({ worktreeVisibilityDefaults: { external: 'show' } })
)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
expect((await globals.window.api.settings.get()).worktreeVisibilityDefaults).toEqual({
external: 'hide'
})
expect(
JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}').worktreeVisibilityDefaults
).toEqual({ external: 'show' })
expect(
(
await globals.window.api.settings.set({
worktreeVisibilityDefaults: { external: 'show' }
})
).worktreeVisibilityDefaults
).toEqual({ external: 'show' })
expect(runtimeCalls).toEqual([
{ method: 'settings.get', params: undefined },
{
method: 'settings.update',
params: { worktreeVisibilityDefaults: { external: 'show' } }
}
])
})
it('does not send the additive visibility field to an older paired runtime', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { settings: {} },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
expect((await globals.window.api.settings.get()).worktreeVisibilityDefaults).toBeUndefined()
const settings = await globals.window.api.settings.set({
worktreeVisibilityDefaults: { external: 'show' }
})
expect(settings.worktreeVisibilityDefaults).toBeUndefined()
expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }])
})
it('hydrates new worktree card style from a paired runtime', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
+64 -4
View File
@@ -38,6 +38,7 @@ import type {
StatsSummary,
Worktree,
WorktreeLineage,
WorktreeVisibilityDefaults,
WorkspaceLineage,
WorkspaceSessionPatch,
WorkspaceSessionState
@@ -179,6 +180,8 @@ export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024
const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000
let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment()
let worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null = null
let worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null = null
let activeClient: WebRuntimeClient | null = null
let activeClientEnvironmentId: string | null = null
const manuallyDisconnectedEnvironmentIds = new Set<string>()
@@ -682,10 +685,24 @@ function createWebPreloadApi(): Partial<PreloadApi> {
settings: {
get: async () => getRuntimeBackedStoredSettings(),
// Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop.
getSync: () => getStoredSettings(),
getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()),
set: async (updates) => {
const sanitizedUpdates = { ...updates }
const runtimeEnvironment = requireActiveEnvironmentOrNull()
delete sanitizedUpdates.activeRuntimeEnvironmentId
if (
'worktreeVisibilityDefaults' in sanitizedUpdates &&
runtimeEnvironment &&
runtimeEnvironment.id !== worktreeVisibilityDefaultsRuntimeEnvironmentId
) {
delete sanitizedUpdates.worktreeVisibilityDefaults
}
if ('worktreeVisibilityDefaults' in sanitizedUpdates) {
sanitizedUpdates.worktreeVisibilityDefaults = {
...getStoredSettings().worktreeVisibilityDefaults,
...sanitizedUpdates.worktreeVisibilityDefaults
}
}
if ('computerAwakeMode' in sanitizedUpdates) {
Object.assign(
sanitizedUpdates,
@@ -720,7 +737,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates
})
writeStoredSettings(next)
return syncRuntimeBackedSettings(sanitizedUpdates, next)
return settingsForActiveVisibilityOwner(
await syncRuntimeBackedSettings(sanitizedUpdates, next)
)
},
setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => {
const requestedEnvironmentId = environmentId?.trim() || null
@@ -3807,6 +3826,18 @@ async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
15_000
)
const runtimeSettings: Partial<GlobalSettings> = {}
worktreeVisibilityDefaultsRuntimeEnvironmentId = null
worktreeVisibilityDefaultsRuntimeValue = null
if (
result.settings.worktreeVisibilityDefaults?.external === 'hide' ||
result.settings.worktreeVisibilityDefaults?.external === 'show'
) {
worktreeVisibilityDefaultsRuntimeValue = {
...result.settings.worktreeVisibilityDefaults,
external: result.settings.worktreeVisibilityDefaults.external
}
worktreeVisibilityDefaultsRuntimeEnvironmentId = requireActiveEnvironment().id
}
if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') {
runtimeSettings.experimentalNewWorktreeCardStyle =
result.settings.experimentalNewWorktreeCardStyle
@@ -3832,13 +3863,28 @@ async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
}
const next = mergeSettings(local, runtimeSettings)
writeStoredSettings(next)
return next
return settingsForActiveVisibilityOwner(next)
} catch {
// Why: unpaired/offline web clients keep a local settings fallback.
return local
return settingsForActiveVisibilityOwner(local)
}
}
function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings {
const environment = requireActiveEnvironmentOrNull()
if (!environment) {
return settings
}
if (
environment.id === worktreeVisibilityDefaultsRuntimeEnvironmentId &&
worktreeVisibilityDefaultsRuntimeValue
) {
return { ...settings, worktreeVisibilityDefaults: worktreeVisibilityDefaultsRuntimeValue }
}
const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings
return supportedSettings as GlobalSettings
}
async function syncRuntimeBackedSettings(
updates: Partial<GlobalSettings>,
localNext: GlobalSettings
@@ -3847,6 +3893,14 @@ async function syncRuntimeBackedSettings(
return localNext
}
const runtimeUpdates: Partial<GlobalSettings> = {}
if (
updates.worktreeVisibilityDefaults?.external === 'hide' ||
updates.worktreeVisibilityDefaults?.external === 'show'
) {
runtimeUpdates.worktreeVisibilityDefaults = {
external: updates.worktreeVisibilityDefaults.external
}
}
if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') {
runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle
}
@@ -3875,6 +3929,12 @@ async function syncRuntimeBackedSettings(
)
const runtimeSettings = { ...result.settings }
delete runtimeSettings.activeRuntimeEnvironmentId
const external = runtimeSettings.worktreeVisibilityDefaults?.external
if (external === 'hide' || external === 'show') {
worktreeVisibilityDefaultsRuntimeEnvironmentId = requireActiveEnvironment().id
worktreeVisibilityDefaultsRuntimeValue = { external }
}
delete runtimeSettings.worktreeVisibilityDefaults
const next = mergeSettings(localNext, runtimeSettings)
writeStoredSettings(next)
return next
@@ -13,7 +13,8 @@ import {
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
const fakeSockets: FakeWebSocket[] = []
@@ -83,7 +84,8 @@ describe('WebRuntimeClient', () => {
deviceToken: 'token',
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
+4 -2
View File
@@ -16,7 +16,8 @@ import {
} from './web-e2ee'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error'
import { withReconnectJitter } from '../../../shared/reconnect-jitter'
@@ -461,7 +462,8 @@ export class WebRuntimeClient {
deviceToken: this.pairing.deviceToken,
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
return
+1
View File
@@ -174,6 +174,7 @@ function getDefaultWorkspaceDir(homeDir: string): string {
export function getDefaultSettings(homedir: string): GlobalSettings {
return {
workspaceDir: getDefaultWorkspaceDir(homedir),
worktreeVisibilityDefaults: { external: 'hide' },
nestWorkspaces: true,
workspaceDirHistory: [],
refreshLocalBaseRefOnWorktreeCreate: false,
+18 -6
View File
@@ -1,5 +1,10 @@
import { normalizeRuntimePathForComparison } from './cross-platform-path'
import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types'
import type {
DetectedWorktree,
DetectedWorktreeListResult,
Repo,
WorktreeVisibilityDefaults
} from './types'
import {
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
@@ -103,7 +108,10 @@ export function hasCompletedInitialExternalWorktreeImportPrompt(
return typeof repo.externalWorktreeVisibilityPromptDismissedAt === 'number'
}
export function shouldOfferNewExternalWorktreeInbox(repo: Repo): boolean {
export function shouldOfferNewExternalWorktreeInbox(
repo: Repo,
defaults?: WorktreeVisibilityDefaults
): boolean {
if (isExternalWorktreeDiscoverySuppressed(repo)) {
return false
}
@@ -111,16 +119,20 @@ export function shouldOfferNewExternalWorktreeInbox(repo: Repo): boolean {
return false
}
return (
effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) ===
'hide'
effectiveExternalWorktreeVisibility(
repo,
isLegacyRepoForExternalWorktreeVisibility(repo),
defaults
) === 'hide'
)
}
export function getNewExternalWorktreeInboxWorktrees(
detected: DetectedWorktreeListResult | undefined,
repo: Repo
repo: Repo,
defaults?: WorktreeVisibilityDefaults
): DetectedWorktree[] {
if (!shouldOfferNewExternalWorktreeInbox(repo)) {
if (!shouldOfferNewExternalWorktreeInbox(repo, defaults)) {
return []
}
const baseline = new Set(
+47 -2
View File
@@ -1,5 +1,5 @@
import { normalizeRuntimePathSeparators } from './cross-platform-path'
import type { ExternalWorktreeVisibility, Repo } from './types'
import type { ExternalWorktreeVisibility, Repo, WorktreeVisibilityDefaults } from './types'
export const EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT = Date.UTC(2026, 4, 23)
export const UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH = 'Unknown location'
@@ -60,14 +60,59 @@ export function isLegacyRepoForExternalWorktreeVisibility(repo: Repo): boolean {
export function effectiveExternalWorktreeVisibility(
repo: Pick<Repo, 'externalWorktreeVisibility'>,
isLegacyRepoForVisibility: boolean
isLegacyRepoForVisibility: boolean,
defaults?: WorktreeVisibilityDefaults
): ExternalWorktreeVisibility {
if (repo.externalWorktreeVisibility) {
return repo.externalWorktreeVisibility
}
if (defaults?.external === 'show' || defaults?.external === 'hide') {
return defaults.external
}
return isLegacyRepoForVisibility ? 'show' : 'hide'
}
export function normalizeWorktreeVisibilityDefaults(
value: unknown
): WorktreeVisibilityDefaults | undefined {
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
}
export function migrateExternalWorktreeVisibilityDefaults(
repos: readonly Repo[],
value: unknown
): {
repos: Repo[]
defaults: WorktreeVisibilityDefaults
changed: boolean
} {
const defaults = normalizeWorktreeVisibilityDefaults(value)
if (defaults) {
return { repos: [...repos], defaults, changed: false }
}
const migratedRepos = repos.map((repo) => {
if (
repo.kind === 'folder' ||
repo.externalWorktreeVisibility !== undefined ||
!isLegacyRepoForExternalWorktreeVisibility(repo)
) {
return repo
}
return {
...repo,
externalWorktreeVisibility: 'show' as const,
externalWorktreeVisibilityLegacy: true
}
})
return { repos: migratedRepos, defaults: { external: 'hide' }, changed: true }
}
export function effectiveAgentWorktreeVisibility(
repo: Pick<Repo, 'agentWorktreeVisibility'>
): ExternalWorktreeVisibility {
+3
View File
@@ -88,6 +88,8 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY =
export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const
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 RUNTIME_CAPABILITIES = [
'runtime.status.compat.v1',
@@ -122,6 +124,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
ACCOUNT_IMPORT_RUNTIME_CAPABILITY,
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY
] as const
+4 -2
View File
@@ -15,7 +15,8 @@ import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest } from './remot
import { MAX_TIMER_DELAY_MS } from './timer-delay'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
const servers: WebSocketServer[] = []
@@ -74,7 +75,8 @@ describe('subscribeRemoteRuntimeRequest', () => {
deviceToken: 'device-token',
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
const bytes = new Uint8Array([1, 2, 3])
+6 -3
View File
@@ -24,7 +24,8 @@ import {
import type { RuntimeStatus } from './runtime-types'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_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
@@ -139,7 +140,8 @@ async function sendRemoteRuntimeRequestOnSocket<TResult>(
deviceToken: pairing.deviceToken,
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
const pendingRequest = {
@@ -511,7 +513,8 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
deviceToken: pairing.deviceToken,
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
return await new Promise((resolve, reject) => {
@@ -13,7 +13,8 @@ import {
import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connection'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
type TestServer = {
@@ -63,7 +64,8 @@ describe('RemoteRuntimeRequestConnection', () => {
expect.objectContaining({
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
)
@@ -114,7 +116,8 @@ async function createServer(): Promise<TestServer> {
deviceToken: 'device-token',
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
authenticated = true
@@ -32,7 +32,8 @@ import {
import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready'
@@ -212,7 +213,8 @@ export class RemoteRuntimeRequestConnection {
deviceToken: this.pairing.deviceToken,
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
}),
sharedKey
@@ -20,13 +20,9 @@ import { getRemoteRuntimeRequestAdmissionEvidence } from './remote-runtime-prepa
import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection'
import * as sharedControlProtocol from './remote-runtime-shared-control-protocol'
import { isRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
} from './protocol-version'
import * as protocolCapabilities from './protocol-version'
const TEST_PROJECT_PATH = path.join('tmp', 'project')
type TestServer = {
pairing: PairingOffer
requests: { id: string; method: string; params?: unknown }[]
@@ -66,8 +62,9 @@ describe('RemoteRuntimeSharedControlConnection', () => {
type: 'e2ee_auth',
deviceToken: 'device-token',
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
protocolCapabilities.SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
protocolCapabilities.AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
protocolCapabilities.WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
expect(server.requests.map((request) => request.method)).toEqual([
@@ -2,7 +2,8 @@ import { parseAuthenticatedFrame, parseReadyFrame } from './remote-runtime-reque
import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
} from './protocol-version'
import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch'
import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol'
@@ -43,7 +44,8 @@ export function handleSharedControlTextFrame(args: {
deviceToken: args.deviceToken,
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
]
})
return
+7
View File
@@ -123,6 +123,11 @@ export type WorktreeVisibilitySourcePreferences = {
custom?: Record<string, ExternalWorktreeVisibility>
}
export type WorktreeVisibilityDefaults = {
/** Default for worktrees Orca did not create. Future source-specific defaults extend this shape. */
external?: ExternalWorktreeVisibility
}
export type ProjectProviderIdentity = {
provider: 'github'
owner: string
@@ -2801,6 +2806,8 @@ export type AgentDashboardMode = 'in-window' | 'popout'
export type GlobalSettings = {
workspaceDir: string
/** Host-owned defaults used when a repository has no explicit visibility override. */
worktreeVisibilityDefaults?: WorktreeVisibilityDefaults
/** Per-host overrides keyed by ExecutionHostId. Effective value for a
* host-varying setting is `host override ?? client default`. */
hostSettingOverrides?: Partial<Record<ExecutionHostId, HostSettingOverrides>>
+50
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { GlobalSettings, Repo, Worktree, WorktreeMeta } from './types'
import { createAgentScratchWorktreePathMatcher } from './agent-scratch-worktrees'
import { migrateExternalWorktreeVisibilityDefaults } from './external-worktree-visibility'
import {
applyMetadataFallbackVisibility,
buildKnownOrcaWorkspaceLayouts,
@@ -340,6 +341,55 @@ describe('external worktree visibility policy', () => {
expect(effectiveExternalWorktreeVisibility(makeRepo(), true)).toBe('show')
})
it('resolves repo override before host default before the compatibility fallback', () => {
expect(
effectiveExternalWorktreeVisibility(makeRepo({ externalWorktreeVisibility: 'show' }), false, {
external: 'hide'
})
).toBe('show')
expect(effectiveExternalWorktreeVisibility(makeRepo(), true, { external: 'hide' })).toBe('hide')
expect(effectiveExternalWorktreeVisibility(makeRepo(), true)).toBe('show')
})
it('materializes only implicit legacy visibility when adding the safe global default', () => {
const legacy = makeRepo({ externalWorktreeVisibilityLegacy: undefined })
const explicit = makeRepo({ id: 'explicit', externalWorktreeVisibility: 'hide' })
const inherited = makeRepo({ id: 'inherited', externalWorktreeVisibilityLegacy: false })
const migrated = migrateExternalWorktreeVisibilityDefaults(
[legacy, explicit, inherited],
undefined
)
expect(migrated.defaults).toEqual({ external: 'hide' })
expect(migrated.repos).toEqual([
expect.objectContaining({
id: legacy.id,
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityLegacy: true
}),
explicit,
inherited
])
expect(
migrateExternalWorktreeVisibilityDefaults(migrated.repos, migrated.defaults).changed
).toBe(false)
})
it('does not materialize visibility on folder repositories', () => {
const folder = makeRepo({ kind: 'folder', externalWorktreeVisibilityLegacy: undefined })
const migrated = migrateExternalWorktreeVisibilityDefaults([folder], undefined)
expect(migrated.repos[0]?.externalWorktreeVisibility).toBeUndefined()
})
it('materializes legacy visibility on persisted repositories without a kind', () => {
const legacy = makeRepo({ kind: undefined, externalWorktreeVisibilityLegacy: undefined })
const migrated = migrateExternalWorktreeVisibilityDefaults([legacy], undefined)
expect(migrated.repos[0]?.externalWorktreeVisibility).toBe('show')
})
it('treats persisted repos without explicit visibility as legacy for upgrade safety', () => {
expect(isLegacyRepoForExternalWorktreeVisibility(makeRepo())).toBe(true)
})
+13 -2
View File
@@ -184,7 +184,10 @@ export function toDetectedWorktree(args: {
repo: Repo
worktree: Worktree
meta?: WorktreeMeta
settings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
settings: Pick<
GlobalSettings,
'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory' | 'worktreeVisibilityDefaults'
>
knownOrcaLayouts: OrcaWorkspaceLayout[]
isLegacyRepoForVisibility?: boolean
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
@@ -211,6 +214,7 @@ export function toDetectedWorktree(args: {
isLegacyRepoForVisibility,
isSelectedCheckout: selectedCheckout,
importedExternalWorktreePaths: args.repo.importedExternalWorktreePaths,
visibilityDefaults: args.settings.worktreeVisibilityDefaults,
visibilitySource
})
@@ -229,6 +233,7 @@ export function shouldShowWorktree(args: {
repo: Repo
isLegacyRepoForVisibility: boolean
isSelectedCheckout: boolean
visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
importedExternalWorktreePaths?: readonly string[] | undefined
visibilitySource?: ReturnType<WorktreeVisibilitySourceMatcher>
}): boolean {
@@ -254,7 +259,13 @@ export function shouldShowWorktree(args: {
if (args.ownership === 'unknown-legacy' && args.isLegacyRepoForVisibility) {
return true
}
return effectiveExternalWorktreeVisibility(args.repo, args.isLegacyRepoForVisibility) === 'show'
return (
effectiveExternalWorktreeVisibility(
args.repo,
args.isLegacyRepoForVisibility,
args.visibilityDefaults
) === 'show'
)
}
export function applyMetadataFallbackVisibility(detected: DetectedWorktree): DetectedWorktree {