diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 43277f5ac75..954f96d7565 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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 () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index a9ec6dbf4c5..274d4e1ce17 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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' } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index f378a76665d..3225c0320b6 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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( diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 8b53a7d98d6..8ee4818126b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9597ecde0b5..d7551557d4f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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) => { + 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() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 917d8c1de40..5201073b57d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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, + | '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 diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index ff125290da1..594b9325aaa 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -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) => diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 18c747c242c..50b577de439 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts index 60a821c3d31..db613d7992b 100644 --- a/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts +++ b/src/main/runtime/rpc/methods/project-runtime-rpc-methods.ts @@ -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 + ) }) }) ] diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts index f178bfa8d11..a8bddc66ff3 100644 --- a/src/main/runtime/rpc/methods/repo-update-schema.ts +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -54,7 +54,7 @@ export function createRepoUpdateSchema( 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(), diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 933c84a6d74..a267b705cb7 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts index 455192a8c11..7bf14b02fb1 100644 --- a/src/main/runtime/rpc/methods/repo.ts +++ b/src/main/runtime/rpc/methods/repo.ts @@ -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[1] + handler: async (params, context) => ({ + repo: projectRepoVisibilityForClient( + await context.runtime.updateRepo( + params.repo, + params.updates as Parameters[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({ diff --git a/src/main/runtime/rpc/repo-visibility-projection.ts b/src/main/runtime/rpc/repo-visibility-projection.ts new file mode 100644 index 00000000000..638a25bb3c9 --- /dev/null +++ b/src/main/runtime/rpc/repo-visibility-projection.ts @@ -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 + +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( + result: T, + context: RepoProjectionContext +): T { + const repo = (result as { repo?: Repo }).repo + return repo ? { ...result, repo: projectRepoVisibilityForClient(repo, context) } : result +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c6c8ac84f80..0e7f25445ac 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 } diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index f95bc25de5b..b70fbe8a674 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -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({ diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx new file mode 100644 index 00000000000..a8739ab8f4b --- /dev/null +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx @@ -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( + + ) + }) + + const show = container.querySelector('[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( + + ) + }) + + expect(container.querySelectorAll('[role="radio"][aria-disabled="true"]')).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx index 474628a5cf0..f4a5e2d9acb 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.tsx @@ -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 (
+ + 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' + ) + } + ]} + /> + } + /> + + , 'sourceControlAi'> & { +type RepositoryPaneRepoUpdate = Omit< + Partial, + '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 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({ + fetchWorktrees(repoId, { + executionHostId: selectedHostId, + requireAuthoritative: true + }) + } forceVisible={forceFullPaneForRepoMatch} /> diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx index eaf2b453988..9bda19ae35c 100644 --- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx @@ -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 + }) => ( + + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ) +})) + 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, + options: { + settings?: Pick | null + refreshRepo?: (repoId: string) => void | Promise + } = {} +): 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' + }) + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx index e9bef371510..f6b109170f8 100644 --- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.tsx @@ -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 +type RepositoryWorktreeDefaultsUpdate = Pick & { + externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null +} type RepositoryWorktreeDefaultsSectionProps = { repo: Repo - settings: Pick | null - updateRepo: (repoId: string, updates: Partial) => void + settings: Pick | null + updateRepo: ( + repoId: string, + updates: Partial + ) => void | Promise + refreshRepo: (repoId: string) => void | Promise 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 => { + 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 ( <> + +
+
+ +

+ {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') + } + )} +

+
+ +
+
+ [ ...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( diff --git a/src/renderer/src/components/settings/repository-git-worktree-search-entries.ts b/src/renderer/src/components/settings/repository-git-worktree-search-entries.ts index 2aacea52b61..776d4850ad0 100644 --- a/src/renderer/src/components/settings/repository-git-worktree-search-entries.ts +++ b/src/renderer/src/components/settings/repository-git-worktree-search-entries.ts @@ -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', diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index be462b94bf0..2618543c8fb 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 }} > - {getWorktreeVisibilityMenuLabel(row.repo)} + {getWorktreeVisibilityMenuLabel( + row.repo, + getRepoOwnerWorktreeVisibilityDefaults( + row.repo, + settings, + worktreeVisibilityDefaultsByHost + ) + )} ) : null} 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({ diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 835f68d317e..31145b6da44 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -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 { Promise onRemove: (source: CustomWorktreeVisibilitySource) => Promise @@ -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({ ) : null} > filterRepoIds?: readonly string[] forceVisibleRepoIds?: ReadonlySet + settings?: Pick | null + visibilityDefaultsByHost?: Partial> }): Map { 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 diff --git a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts index fd215cea7ad..f2ebf1813b4 100644 --- a/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts +++ b/src/renderer/src/components/sidebar/new-external-worktrees-inbox-candidates.ts @@ -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> filterRepoIds?: readonly string[] + settings?: Pick | null + visibilityDefaultsByHost?: Partial> }): Map { 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 }) diff --git a/src/renderer/src/components/sidebar/use-repo-owner-visibility-defaults.ts b/src/renderer/src/components/sidebar/use-repo-owner-visibility-defaults.ts new file mode 100644 index 00000000000..b4116c0aa6d --- /dev/null +++ b/src/renderer/src/components/sidebar/use-repo-owner-visibility-defaults.ts @@ -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 +} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index eb53914e3a9..61193e9145e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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() + } }) ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 84ceb08cf5a..ff527a1f164 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0dbd4d9b36f..d9fc42279a0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8c429f29d73..eb17c72441d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 09f846d3cdd..f3025a7e408 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b881b978574..b54a13ecb27 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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" diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index 0d05535ed2d..849a60a5392 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -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() + }) }) diff --git a/src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts b/src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts new file mode 100644 index 00000000000..8ac18ec3539 --- /dev/null +++ b/src/renderer/src/store/slices/repos-runtime-visibility-defaults.test.ts @@ -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') +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 4604287e74b..ef588fd6e3f 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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 = (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 = (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 = (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 = (set, const { sourceControlAi, externalWorktreeDiscoverySuppressedAt, + externalWorktreeVisibility, ...updatesWithoutClearSentinels } = sanitizedUpdates mergedRepo = { ...mergedRepo, ...updatesWithoutClearSentinels } @@ -3828,6 +3889,13 @@ export const createRepoSlice: StateCreator = (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, diff --git a/src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts b/src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts new file mode 100644 index 00000000000..ca97f01a901 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-switch-settings-persistence.test.ts @@ -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' }) +}) diff --git a/src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts b/src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts new file mode 100644 index 00000000000..025b89dee1f --- /dev/null +++ b/src/renderer/src/store/slices/settings-owner-hydration-write-fence.test.ts @@ -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((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) +}) diff --git a/src/renderer/src/store/slices/settings.test.ts b/src/renderer/src/store/slices/settings.test.ts index 756e6d9baa8..08f9cf43a3c 100644 --- a/src/renderer/src/store/slices/settings.test.ts +++ b/src/renderer/src/store/slices/settings.test.ts @@ -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': [ { diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 593d1650e1e..c6f115c1a96 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -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 updateSettings: (updates: Partial) => Promise updateSettingsOrThrow: (updates: Partial) => Promise @@ -42,6 +49,7 @@ type LegacyTerminalScrollbackSettingsUpdate = Partial & { } type SettingsStateSetter = Parameters>[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, - currentSettings: GlobalSettings | null + currentSettings: GlobalSettings | null, + supportedRuntimeEnvironmentId: string | null ): Promise { - 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 = (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 }, 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 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() diff --git a/src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts b/src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts new file mode 100644 index 00000000000..ecf1194352e --- /dev/null +++ b/src/renderer/src/store/slices/worktree-visibility-owner-settings.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts b/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts new file mode 100644 index 00000000000..60f34c4bb12 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-visibility-owner-settings.ts @@ -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 +> + +export async function readRuntimeWorktreeVisibilityDefaults( + environmentId: string +): Promise { + try { + const result = await callRuntimeRpc<{ settings: Partial }>( + { 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 + } +} diff --git a/src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts b/src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts new file mode 100644 index 00000000000..0049805d295 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-visibility-settings-write.test.ts @@ -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' }) +}) diff --git a/src/renderer/src/store/slices/worktree-visibility-settings-write.ts b/src/renderer/src/store/slices/worktree-visibility-settings-write.ts new file mode 100644 index 00000000000..123f4db1b7c --- /dev/null +++ b/src/renderer/src/store/slices/worktree-visibility-settings-write.ts @@ -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 + currentSettings: GlobalSettings | null + supportedRuntimeEnvironmentId: string | null + set: (updater: (state: AppState) => Partial) => void +}): Promise { + 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 }>( + 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' } + } + } + : {}) + })) +} diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 00e421cb7df..ce13e6af36f 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -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 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 ...(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 diff --git a/src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts b/src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts new file mode 100644 index 00000000000..790bbc6e1fd --- /dev/null +++ b/src/renderer/src/store/worktree-visibility-defaults-by-host.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/store/worktree-visibility-defaults-by-host.ts b/src/renderer/src/store/worktree-visibility-defaults-by-host.ts new file mode 100644 index 00000000000..10be50f078b --- /dev/null +++ b/src/renderer/src/store/worktree-visibility-defaults-by-host.ts @@ -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, + settings: Pick | null | undefined, + defaultsByHost: Partial> +): WorktreeVisibilityDefaults | undefined { + const hostId = getRepoExecutionHostId(repo) + if (parseExecutionHostId(hostId)?.kind === 'runtime') { + return defaultsByHost[hostId] ?? undefined + } + return defaultsByHost.local ?? settings?.worktreeVisibilityDefaults +} diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index a7447369f4b..40715147063 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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> { + 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> { + 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', () => ({ diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 2baa23a7b28..01f0cda88ec 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -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() @@ -682,10 +685,24 @@ function createWebPreloadApi(): Partial { 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 { 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 { 15_000 ) const runtimeSettings: Partial = {} + 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 { } 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, localNext: GlobalSettings @@ -3847,6 +3893,14 @@ async function syncRuntimeBackedSettings( return localNext } const runtimeUpdates: Partial = {} + 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 diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 7a402a42ba1..ef1ac0eefa6 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -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 ] }) diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index a51d93ac0d3..490ed7ba688 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -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 diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a01a778d2dd..4eda2762391 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/external-worktree-inbox.ts b/src/shared/external-worktree-inbox.ts index c42a1249bd7..ff91148d997 100644 --- a/src/shared/external-worktree-inbox.ts +++ b/src/shared/external-worktree-inbox.ts @@ -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( diff --git a/src/shared/external-worktree-visibility.ts b/src/shared/external-worktree-visibility.ts index fc4af8c1681..57d8bb1bbb9 100644 --- a/src/shared/external-worktree-visibility.ts +++ b/src/shared/external-worktree-visibility.ts @@ -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, - 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 ): ExternalWorktreeVisibility { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index eb61be79c97..d77af0328ea 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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 diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index 9ead47f4067..7371e2fa11f 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -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]) diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index c53565dc8a2..97441cab768 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -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( 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( 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) => { diff --git a/src/shared/remote-runtime-request-connection.test.ts b/src/shared/remote-runtime-request-connection.test.ts index 7f8f4e69871..8a162f80c5b 100644 --- a/src/shared/remote-runtime-request-connection.test.ts +++ b/src/shared/remote-runtime-request-connection.test.ts @@ -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 { 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 diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts index f79c5cdd136..e7555e7cca0 100644 --- a/src/shared/remote-runtime-request-connection.ts +++ b/src/shared/remote-runtime-request-connection.ts @@ -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 diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index f85b1d8fa6b..3a3f5241545 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -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([ diff --git a/src/shared/remote-runtime-shared-control-frame-handler.ts b/src/shared/remote-runtime-shared-control-frame-handler.ts index 96a3e1084ad..fe323a63a83 100644 --- a/src/shared/remote-runtime-shared-control-frame-handler.ts +++ b/src/shared/remote-runtime-shared-control-frame-handler.ts @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index 25b5df10022..3378ffda28a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -123,6 +123,11 @@ export type WorktreeVisibilitySourcePreferences = { custom?: Record } +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> diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index ce56ef2c0cd..5cc0a9dfee0 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -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) }) diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 22f73690bda..ac25864eb2e 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -184,7 +184,10 @@ export function toDetectedWorktree(args: { repo: Repo worktree: Worktree meta?: WorktreeMeta - settings: Pick + 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 }): 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 {