mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
Add global external worktree visibility defaults
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -4739,7 +4739,62 @@ describe('Store', () => {
|
||||
expect(updated!.externalWorktreeVisibilityLegacy).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps rollback agent visibility writes authoritative for both built-in sources', async () => {
|
||||
it('migrates implicit legacy visibility to an explicit override before defaulting global hide', async () => {
|
||||
const persisted = getDefaultPersistedState(testState.dir)
|
||||
delete persisted.settings.worktreeVisibilityDefaults
|
||||
persisted.repos = [
|
||||
makeRepo({
|
||||
id: 'legacy',
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: undefined,
|
||||
externalWorktreeVisibilityLegacy: undefined
|
||||
}),
|
||||
makeRepo({ id: 'explicit', externalWorktreeVisibility: 'hide' }),
|
||||
makeRepo({
|
||||
id: 'inherited',
|
||||
externalWorktreeVisibility: undefined,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
})
|
||||
]
|
||||
writeDataFile(persisted)
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({ external: 'hide' })
|
||||
expect(store.getRepo('legacy')).toMatchObject({
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
})
|
||||
expect(store.getRepo('explicit')?.externalWorktreeVisibility).toBe('hide')
|
||||
expect(store.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined()
|
||||
|
||||
store.flush()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getRepo('legacy')).toMatchObject({
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
})
|
||||
expect(reloaded.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined()
|
||||
})
|
||||
|
||||
it('merges visibility-default patches so future source defaults survive older controls', async () => {
|
||||
const store = await createStore()
|
||||
store.updateSettings({
|
||||
worktreeVisibilityDefaults: {
|
||||
external: 'show',
|
||||
futureSource: 'hide'
|
||||
} as GlobalSettings['worktreeVisibilityDefaults']
|
||||
})
|
||||
|
||||
store.updateSettings({ worktreeVisibilityDefaults: { external: 'hide' } })
|
||||
|
||||
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({
|
||||
external: 'hide',
|
||||
futureSource: 'hide'
|
||||
})
|
||||
})
|
||||
|
||||
it('persists agent worktree visibility independently from external visibility', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' }))
|
||||
|
||||
@@ -4794,6 +4849,25 @@ describe('Store', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('clears an explicit external-worktree override without stamping an inherited default', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
})
|
||||
)
|
||||
|
||||
const updated = store.updateRepo('r1', { externalWorktreeVisibility: null })
|
||||
|
||||
expect(updated?.externalWorktreeVisibility).toBeUndefined()
|
||||
expect(updated?.externalWorktreeVisibilityLegacy).toBe(false)
|
||||
store.flush()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getRepo('r1')?.externalWorktreeVisibility).toBeUndefined()
|
||||
expect(reloaded.getRepo('r1')?.externalWorktreeVisibilityLegacy).toBe(false)
|
||||
})
|
||||
|
||||
it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
|
||||
+31
-1
@@ -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
|
||||
|
||||
@@ -1680,6 +1680,7 @@ describe('OrcaRuntimeService', () => {
|
||||
} as never)
|
||||
|
||||
expect(runtime.getClientSettings()).toMatchObject({
|
||||
worktreeVisibilityDefaults: { external: 'hide' },
|
||||
experimentalNewWorktreeCardStyle: true,
|
||||
compactWorktreeCards: true,
|
||||
minimaxGroupId: 'group-42',
|
||||
@@ -1805,6 +1806,23 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('broadcasts visibility default changes to paired clients', async () => {
|
||||
let settings = { ...store.getSettings(), worktreeVisibilityDefaults: { external: 'hide' } }
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getSettings: () => settings,
|
||||
updateSettings: (updates: Partial<typeof settings>) => {
|
||||
settings = { ...settings, ...updates }
|
||||
}
|
||||
} as never)
|
||||
const events: unknown[] = []
|
||||
runtime.onClientEvent((event) => events.push(event))
|
||||
|
||||
await runtime.updateClientSettings({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
|
||||
expect(events).toContainEqual({ type: 'reposChanged' })
|
||||
})
|
||||
|
||||
it('reconciles hooks only when paired-client hook settings change', async () => {
|
||||
electronMocks.app.isPackaged = true
|
||||
let settings = {
|
||||
@@ -8227,11 +8245,10 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(added).toEqual([
|
||||
expect.objectContaining({
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
})
|
||||
])
|
||||
expect(repo.externalWorktreeVisibility).toBe('hide')
|
||||
expect(repo.externalWorktreeVisibility).toBeUndefined()
|
||||
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(colorStore, repo)
|
||||
} finally {
|
||||
spawnSpy.mockRestore()
|
||||
|
||||
@@ -1166,6 +1166,7 @@ type RuntimeStore = {
|
||||
localBaseRefSuggestionDismissed?: boolean
|
||||
branchPrefix: string
|
||||
branchPrefixCustom: string
|
||||
worktreeVisibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
|
||||
defaultTuiAgent?: GlobalSettings['defaultTuiAgent']
|
||||
disabledTuiAgents?: GlobalSettings['disabledTuiAgents']
|
||||
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
|
||||
@@ -3487,6 +3488,7 @@ export class OrcaRuntimeService {
|
||||
|
||||
getClientSettings(): Pick<
|
||||
GlobalSettings,
|
||||
| 'worktreeVisibilityDefaults'
|
||||
| 'defaultTuiAgent'
|
||||
| 'disabledTuiAgents'
|
||||
| 'agentCmdOverrides'
|
||||
@@ -3513,6 +3515,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const settings = this.store.getSettings()
|
||||
return {
|
||||
worktreeVisibilityDefaults: settings.worktreeVisibilityDefaults ?? { external: 'hide' },
|
||||
defaultTuiAgent: settings.defaultTuiAgent ?? null,
|
||||
disabledTuiAgents: settings.disabledTuiAgents ?? [],
|
||||
agentCmdOverrides: settings.agentCmdOverrides ?? {},
|
||||
@@ -3564,6 +3567,7 @@ export class OrcaRuntimeService {
|
||||
async updateClientSettings(
|
||||
updates: Pick<
|
||||
Partial<GlobalSettings>,
|
||||
| 'worktreeVisibilityDefaults'
|
||||
| 'agentStatusHooksEnabled'
|
||||
| 'defaultTuiAgent'
|
||||
| 'disabledTuiAgents'
|
||||
@@ -3584,6 +3588,7 @@ export class OrcaRuntimeService {
|
||||
): Promise<
|
||||
Pick<
|
||||
GlobalSettings,
|
||||
| 'worktreeVisibilityDefaults'
|
||||
| 'defaultTuiAgent'
|
||||
| 'disabledTuiAgents'
|
||||
| 'agentCmdOverrides'
|
||||
@@ -3610,6 +3615,13 @@ export class OrcaRuntimeService {
|
||||
const before = beforeSettings.agentStatusHooksEnabled !== false
|
||||
this.store.updateSettings(updates, { notifyListeners: true })
|
||||
const settings = this.store.getSettings()
|
||||
if (
|
||||
updates.worktreeVisibilityDefaults !== undefined &&
|
||||
beforeSettings.worktreeVisibilityDefaults?.external !==
|
||||
settings.worktreeVisibilityDefaults?.external
|
||||
) {
|
||||
this.notifyReposChanged()
|
||||
}
|
||||
if (
|
||||
(typeof updates.agentStatusHooksEnabled === 'boolean' &&
|
||||
before !== updates.agentStatusHooksEnabled) ||
|
||||
@@ -18725,7 +18737,6 @@ export class OrcaRuntimeService {
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
...(group
|
||||
? {
|
||||
@@ -18862,12 +18873,7 @@ export class OrcaRuntimeService {
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind,
|
||||
...(kind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
: {})
|
||||
...(kind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {})
|
||||
}
|
||||
this.store.addRepo(repo)
|
||||
await prepareLocalWorktreeRootForRepo(this.store, repo)
|
||||
@@ -18983,12 +18989,7 @@ export class OrcaRuntimeService {
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
: {})
|
||||
...(repoKind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {})
|
||||
}
|
||||
this.store.addRepo(repo)
|
||||
await prepareLocalWorktreeRootForRepo(this.store, repo)
|
||||
@@ -19157,7 +19158,6 @@ export class OrcaRuntimeService {
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: 'git',
|
||||
externalWorktreeVisibility: 'hide',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
this.store.addRepo(repo)
|
||||
@@ -19205,7 +19205,6 @@ export class OrcaRuntimeService {
|
||||
| 'kind'
|
||||
| 'symlinkPaths'
|
||||
| 'issueSourcePreference'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'externalWorktreeInboxBaselinePaths'
|
||||
| 'importedExternalWorktreePaths'
|
||||
@@ -19216,6 +19215,7 @@ export class OrcaRuntimeService {
|
||||
| 'projectGroupOrder'
|
||||
>
|
||||
> & {
|
||||
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
|
||||
sourceControlAi?: Repo['sourceControlAi'] | null
|
||||
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
|
||||
}
|
||||
@@ -19228,6 +19228,9 @@ export class OrcaRuntimeService {
|
||||
if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) {
|
||||
sanitizedUpdates.worktreeBasePath = undefined
|
||||
}
|
||||
if ('externalWorktreeVisibility' in updates && updates.externalWorktreeVisibility === null) {
|
||||
sanitizedUpdates.externalWorktreeVisibility = undefined
|
||||
}
|
||||
if (
|
||||
'externalWorktreeDiscoverySuppressedAt' in updates &&
|
||||
updates.externalWorktreeDiscoverySuppressedAt === null
|
||||
|
||||
@@ -138,6 +138,10 @@ const GitHubProjectSettings = z
|
||||
|
||||
export const SettingsUpdate = z
|
||||
.object({
|
||||
worktreeVisibilityDefaults: z
|
||||
.object({ external: z.enum(['hide', 'show']).optional() })
|
||||
.strict()
|
||||
.optional(),
|
||||
defaultTuiAgent: z
|
||||
.unknown()
|
||||
.transform((value) =>
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
describe('client UI RPC methods', () => {
|
||||
it('returns the runtime host agent settings needed by mobile create flows', async () => {
|
||||
const settings = {
|
||||
worktreeVisibilityDefaults: { external: 'show' as const },
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['claude'],
|
||||
agentCmdOverrides: { codex: 'codex --profile work' },
|
||||
@@ -92,6 +93,7 @@ describe('client UI RPC methods', () => {
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('settings.update', {
|
||||
worktreeVisibilityDefaults: { external: 'show' },
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['claude', 'not-real', 'claude'],
|
||||
defaultTaskSource: 'linear',
|
||||
@@ -108,6 +110,7 @@ describe('client UI RPC methods', () => {
|
||||
)
|
||||
|
||||
expect(runtime.updateClientSettings).toHaveBeenCalledWith({
|
||||
worktreeVisibilityDefaults: { external: 'show' },
|
||||
defaultTuiAgent: 'codex',
|
||||
disabledTuiAgents: ['claude'],
|
||||
defaultTaskSource: 'linear',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
||||
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalString, requiredString } from '../schemas'
|
||||
import { projectRepoResultVisibilityForClient } from '../repo-visibility-projection'
|
||||
|
||||
const ProjectProviderIdentity = z.object({
|
||||
provider: z.literal('github'),
|
||||
@@ -129,29 +130,41 @@ export const PROJECT_RUNTIME_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.setupExistingFolder',
|
||||
params: ProjectHostSetupExistingFolder,
|
||||
handler: async (params, { runtime }) => ({
|
||||
result: await runtime.setupProjectExistingFolder(params)
|
||||
handler: async (params, context) => ({
|
||||
result: projectRepoResultVisibilityForClient(
|
||||
await context.runtime.setupProjectExistingFolder(params),
|
||||
context
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.clone',
|
||||
params: ProjectHostSetupClone,
|
||||
handler: async (params, { runtime }) => ({
|
||||
result: await runtime.setupProjectClone(params)
|
||||
handler: async (params, context) => ({
|
||||
result: projectRepoResultVisibilityForClient(
|
||||
await context.runtime.setupProjectClone(params),
|
||||
context
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.update',
|
||||
params: ProjectHostSetupUpdate,
|
||||
handler: (params, { runtime }) => ({
|
||||
result: runtime.updateProjectHostSetup(params)
|
||||
handler: (params, context) => ({
|
||||
result: projectRepoResultVisibilityForClient(
|
||||
context.runtime.updateProjectHostSetup(params),
|
||||
context
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'projectHostSetup.delete',
|
||||
params: ProjectHostSetupDelete,
|
||||
handler: (params, { runtime }) => ({
|
||||
result: runtime.deleteProjectHostSetup(params)
|
||||
handler: (params, context) => ({
|
||||
result: projectRepoResultVisibilityForClient(
|
||||
context.runtime.deleteProjectHostSetup(params),
|
||||
context
|
||||
)
|
||||
})
|
||||
})
|
||||
]
|
||||
|
||||
@@ -54,7 +54,7 @@ export function createRepoUpdateSchema<T extends z.ZodRawShape>(
|
||||
symlinkPaths: z.array(z.string()).optional(),
|
||||
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional(),
|
||||
forkSyncMode: z.enum(['ask', 'safe-auto', 'off']).optional(),
|
||||
externalWorktreeVisibility: z.enum(['hide', 'show']).optional(),
|
||||
externalWorktreeVisibility: z.enum(['hide', 'show']).nullable().optional(),
|
||||
externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(),
|
||||
externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(),
|
||||
importedExternalWorktreePaths: z.array(z.string()).optional(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4,6 +4,10 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
|
||||
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
|
||||
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
|
||||
import { createRepoUpdateSchema } from './repo-update-schema'
|
||||
import {
|
||||
projectRepoResultVisibilityForClient,
|
||||
projectRepoVisibilityForClient
|
||||
} from '../repo-visibility-projection'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
repo: requiredString('Missing repo selector')
|
||||
@@ -108,9 +112,13 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'repo.list',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => {
|
||||
runtime.enrichMissingRepoGitRemoteIdentities?.()
|
||||
return { repos: runtime.listRepos() }
|
||||
handler: (_params, context) => {
|
||||
context.runtime.enrichMissingRepoGitRemoteIdentities?.()
|
||||
return {
|
||||
repos: context.runtime
|
||||
.listRepos()
|
||||
.map((repo) => projectRepoVisibilityForClient(repo, context))
|
||||
}
|
||||
}
|
||||
}),
|
||||
...PROJECT_RUNTIME_METHODS,
|
||||
@@ -141,8 +149,11 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'projectGroup.moveProject',
|
||||
params: ProjectGroupMoveProject,
|
||||
handler: async (params, { runtime }) => ({
|
||||
repo: await runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order)
|
||||
handler: async (params, context) => ({
|
||||
repo: projectRepoVisibilityForClient(
|
||||
await context.runtime.moveProjectToGroup(params.repo, params.groupId ?? null, params.order),
|
||||
context
|
||||
)
|
||||
})
|
||||
}),
|
||||
...FOLDER_WORKSPACE_METHODS,
|
||||
@@ -177,15 +188,21 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'repo.add',
|
||||
params: RepoPath,
|
||||
handler: async (params, { runtime }) => ({
|
||||
repo: await runtime.addRepo(params.path, params.kind)
|
||||
handler: async (params, context) => ({
|
||||
repo: projectRepoVisibilityForClient(
|
||||
await context.runtime.addRepo(params.path, params.kind),
|
||||
context
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.create',
|
||||
params: RepoCreate,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.createRepo(params.parentPath, params.name, params.kind)
|
||||
handler: async (params, context) =>
|
||||
projectRepoResultVisibilityForClient(
|
||||
await context.runtime.createRepo(params.parentPath, params.name, params.kind),
|
||||
context
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.gitAvailable',
|
||||
@@ -195,22 +212,30 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'repo.clone',
|
||||
params: RepoClone,
|
||||
handler: async (params, { runtime }) => ({
|
||||
repo: await runtime.cloneRepo(params.url, params.destination)
|
||||
handler: async (params, context) => ({
|
||||
repo: projectRepoVisibilityForClient(
|
||||
await context.runtime.cloneRepo(params.url, params.destination),
|
||||
context
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.show',
|
||||
params: RepoSelector,
|
||||
handler: async (params, { runtime }) => ({ repo: await runtime.showRepo(params.repo) })
|
||||
handler: async (params, context) => ({
|
||||
repo: projectRepoVisibilityForClient(await context.runtime.showRepo(params.repo), context)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'repo.update',
|
||||
params: RepoUpdate,
|
||||
handler: async (params, { runtime }) => ({
|
||||
repo: await runtime.updateRepo(
|
||||
params.repo,
|
||||
params.updates as Parameters<typeof runtime.updateRepo>[1]
|
||||
handler: async (params, context) => ({
|
||||
repo: projectRepoVisibilityForClient(
|
||||
await context.runtime.updateRepo(
|
||||
params.repo,
|
||||
params.updates as Parameters<typeof context.runtime.updateRepo>[1]
|
||||
),
|
||||
context
|
||||
)
|
||||
})
|
||||
}),
|
||||
@@ -227,8 +252,11 @@ export const REPO_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'repo.setBaseRef',
|
||||
params: RepoSetBaseRef,
|
||||
handler: async (params, { runtime }) => ({
|
||||
repo: await runtime.setRepoBaseRef(params.repo, params.ref)
|
||||
handler: async (params, context) => ({
|
||||
repo: projectRepoVisibilityForClient(
|
||||
await context.runtime.setRepoBaseRef(params.repo, params.ref),
|
||||
context
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
effectiveExternalWorktreeVisibility,
|
||||
isLegacyRepoForExternalWorktreeVisibility
|
||||
} from '../../../shared/external-worktree-visibility'
|
||||
import { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import type { Repo } from '../../../shared/types'
|
||||
import type { RpcContext } from './core'
|
||||
|
||||
type RepoProjectionContext = Pick<RpcContext, 'clientCapabilities' | 'runtime'>
|
||||
|
||||
export function projectRepoVisibilityForClient(repo: Repo, context: RepoProjectionContext): Repo {
|
||||
if (
|
||||
context.clientCapabilities?.includes(WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY) ||
|
||||
repo.kind === 'folder' ||
|
||||
repo.externalWorktreeVisibility !== undefined
|
||||
) {
|
||||
return repo
|
||||
}
|
||||
return {
|
||||
...repo,
|
||||
externalWorktreeVisibility: effectiveExternalWorktreeVisibility(
|
||||
repo,
|
||||
isLegacyRepoForExternalWorktreeVisibility(repo),
|
||||
context.runtime.getClientSettings?.().worktreeVisibilityDefaults
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function projectRepoResultVisibilityForClient<T extends object>(
|
||||
result: T,
|
||||
context: RepoProjectionContext
|
||||
): T {
|
||||
const repo = (result as { repo?: Repo }).repo
|
||||
return repo ? { ...result, repo: projectRepoVisibilityForClient(repo, context) } : result
|
||||
}
|
||||
@@ -1192,7 +1192,6 @@ export type PreloadApi = {
|
||||
| 'worktreeBasePath'
|
||||
| 'kind'
|
||||
| 'issueSourcePreference'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'externalWorktreeInboxBaselinePaths'
|
||||
| 'importedExternalWorktreePaths'
|
||||
@@ -1204,6 +1203,7 @@ export type PreloadApi = {
|
||||
| 'forkSyncMode'
|
||||
>
|
||||
> & {
|
||||
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
|
||||
sourceControlAi?: Repo['sourceControlAi'] | null
|
||||
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
|
||||
}
|
||||
|
||||
@@ -660,6 +660,7 @@ function WorktreeJumpPaletteContent({
|
||||
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
|
||||
const sleepingAgentSessionsByPaneKey = useAppStore((s) => s.sleepingAgentSessionsByPaneKey)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost)
|
||||
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments)
|
||||
@@ -1431,7 +1432,9 @@ function WorktreeJumpPaletteContent({
|
||||
}
|
||||
for (const repoId of buildImportedWorktreesCardCandidates({
|
||||
repos,
|
||||
detectedWorktreesByRepo
|
||||
detectedWorktreesByRepo,
|
||||
settings,
|
||||
visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost
|
||||
}).keys()) {
|
||||
ids.add(repoId)
|
||||
}
|
||||
@@ -1439,7 +1442,15 @@ function WorktreeJumpPaletteContent({
|
||||
ids.add(creation.request.repoId)
|
||||
}
|
||||
return ids
|
||||
}, [allWorktrees, detectedWorktreesByRepo, pendingWorktreeCreations, repos, worktreesByRepo])
|
||||
}, [
|
||||
allWorktrees,
|
||||
detectedWorktreesByRepo,
|
||||
pendingWorktreeCreations,
|
||||
repos,
|
||||
settings,
|
||||
worktreeVisibilityDefaultsByHost,
|
||||
worktreesByRepo
|
||||
])
|
||||
const hasAnyProjectSearchCandidates = useMemo(
|
||||
() =>
|
||||
hasCmdJProjectSearchCandidates({
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { GeneralWorkspaceSettingsSection } from './GeneralWorkspaceSettingsSection'
|
||||
|
||||
vi.mock('./WorkspaceDirectorySetting', () => ({ WorkspaceDirectorySetting: () => null }))
|
||||
vi.mock('./OpenInMenuSetting', () => ({ OpenInMenuSetting: () => null }))
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
describe('GeneralWorkspaceSettingsSection external visibility', () => {
|
||||
it('writes the global Show default without touching repositories', () => {
|
||||
const updateSettings = vi.fn()
|
||||
act(() => {
|
||||
root.render(
|
||||
<GeneralWorkspaceSettingsSection
|
||||
settings={getDefaultSettings('/home/user')}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const show = container.querySelector<HTMLButtonElement>('[role="radio"][aria-checked="false"]')
|
||||
expect(show?.textContent).toBe('Show')
|
||||
act(() => show?.click())
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
worktreeVisibilityDefaults: { external: 'show' }
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the control when the active host does not support global defaults', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<GeneralWorkspaceSettingsSection
|
||||
settings={{
|
||||
...getDefaultSettings('/home/user'),
|
||||
worktreeVisibilityDefaults: undefined
|
||||
}}
|
||||
updateSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(container.querySelectorAll('[role="radio"][aria-disabled="true"]')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,12 @@ import type React from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { OpenInMenuSetting } from './OpenInMenuSetting'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls'
|
||||
import {
|
||||
SettingsRow,
|
||||
SettingsSegmentedControl,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSwitchRow
|
||||
} from './SettingsFormControls'
|
||||
import { WorkspaceDirectorySetting } from './WorkspaceDirectorySetting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -15,6 +20,7 @@ export function GeneralWorkspaceSettingsSection({
|
||||
settings,
|
||||
updateSettings
|
||||
}: GeneralWorkspaceSettingsSectionProps): React.JSX.Element {
|
||||
const visibilityDefaultsSupported = settings.worktreeVisibilityDefaults !== undefined
|
||||
return (
|
||||
<section key="workspace" className="space-y-4">
|
||||
<SettingsSubsectionHeader
|
||||
@@ -30,6 +36,58 @@ export function GeneralWorkspaceSettingsSection({
|
||||
|
||||
<WorkspaceDirectorySetting settings={settings} updateSettings={updateSettings} />
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktrees',
|
||||
'External worktrees'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktreesDescription',
|
||||
'Choose whether worktrees created outside Orca appear by default.'
|
||||
)}
|
||||
keywords={['external', 'non-Orca', 'worktree', 'visibility', 'sidebar', 'show', 'hide']}
|
||||
>
|
||||
<SettingsRow
|
||||
label={translate(
|
||||
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktrees',
|
||||
'External worktrees'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktreesDescription',
|
||||
'Choose whether worktrees created outside Orca appear by default.'
|
||||
)}
|
||||
control={
|
||||
<SettingsSegmentedControl
|
||||
size="sm"
|
||||
value={settings.worktreeVisibilityDefaults?.external ?? 'hide'}
|
||||
ariaLabel={translate(
|
||||
'auto.components.settings.GeneralWorkspaceSettingsSection.externalWorktrees',
|
||||
'External worktrees'
|
||||
)}
|
||||
onChange={(external) => updateSettings({ worktreeVisibilityDefaults: { external } })}
|
||||
options={[
|
||||
{
|
||||
value: 'show',
|
||||
disabled: !visibilityDefaultsSupported,
|
||||
label: translate(
|
||||
'auto.components.settings.GeneralWorkspaceSettingsSection.show',
|
||||
'Show'
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'hide',
|
||||
disabled: !visibilityDefaultsSupported,
|
||||
label: translate(
|
||||
'auto.components.settings.GeneralWorkspaceSettingsSection.hide',
|
||||
'Hide'
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.GeneralWorkspaceSettingsSection.ba3480642f',
|
||||
|
||||
@@ -33,11 +33,16 @@ import { RepositoryWindowsRuntimeSection } from './RepositoryWindowsRuntimeSecti
|
||||
import { matchesRepositoryIdentitySearch } from './repository-identity-search'
|
||||
import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection'
|
||||
import { getProjectRuntimeSessionSummary } from './repository-runtime-session-summary'
|
||||
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
|
||||
export { getRepositoryPaneSearchEntries }
|
||||
export { matchesRepositoryIdentitySearch } from './repository-identity-search'
|
||||
|
||||
type RepositoryPaneRepoUpdate = Omit<Partial<Repo>, 'sourceControlAi'> & {
|
||||
type RepositoryPaneRepoUpdate = Omit<
|
||||
Partial<Repo>,
|
||||
'sourceControlAi' | 'externalWorktreeVisibility'
|
||||
> & {
|
||||
sourceControlAi?: Repo['sourceControlAi'] | null
|
||||
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
|
||||
}
|
||||
|
||||
const EMPTY_WSL_DISTROS: string[] = []
|
||||
@@ -52,7 +57,7 @@ type RepositoryPaneProps = {
|
||||
repoId: string,
|
||||
updates: RepositoryPaneRepoUpdate,
|
||||
options?: { hostId?: ExecutionHostId }
|
||||
) => void
|
||||
) => void | Promise<boolean>
|
||||
removeProject: (repoId: string) => void
|
||||
project?: Project | null
|
||||
selectedProjectSetupId?: string
|
||||
@@ -95,6 +100,20 @@ export function RepositoryPane({
|
||||
)
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const worktreeVisibilityDefaultsByHost = useAppStore(
|
||||
(state) => state.worktreeVisibilityDefaultsByHost
|
||||
)
|
||||
const repoOwnerSettings = settings
|
||||
? {
|
||||
...settings,
|
||||
worktreeVisibilityDefaults: getRepoOwnerWorktreeVisibilityDefaults(
|
||||
repo,
|
||||
settings,
|
||||
worktreeVisibilityDefaultsByHost
|
||||
)
|
||||
}
|
||||
: null
|
||||
const fetchWorktrees = useAppStore((state) => state.fetchWorktrees)
|
||||
const runtimeSessionSummary = useAppStore(
|
||||
useShallow((state) => getProjectRuntimeSessionSummary(state, repo.id))
|
||||
)
|
||||
@@ -170,6 +189,7 @@ export function RepositoryPane({
|
||||
),
|
||||
translate('auto.components.settings.repository.search.094adbe930', 'Default Worktree Base'),
|
||||
translate('auto.components.settings.repository.search.443d127b5a', 'Worktree Location'),
|
||||
translate('auto.components.settings.repository.search.externalWorktrees', 'External worktrees'),
|
||||
translate('auto.components.settings.repository.search.projectRuntime', 'Project Runtime'),
|
||||
translate('auto.components.settings.repository.search.c5266c2c9d', 'Remove Project')
|
||||
])
|
||||
@@ -351,8 +371,14 @@ export function RepositoryPane({
|
||||
|
||||
<RepositoryWorktreeDefaultsSection
|
||||
repo={repo}
|
||||
settings={settings}
|
||||
settings={repoOwnerSettings}
|
||||
updateRepo={updateSelectedRepo}
|
||||
refreshRepo={(repoId) =>
|
||||
fetchWorktrees(repoId, {
|
||||
executionHostId: selectedHostId,
|
||||
requireAuthoritative: true
|
||||
})
|
||||
}
|
||||
forceVisible={forceFullPaneForRepoMatch}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type { GlobalSettings, Repo } from '../../../../shared/types'
|
||||
import { RepositoryWorktreeDefaultsSection } from './RepositoryWorktreeDefaultsSection'
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
@@ -15,6 +15,28 @@ vi.mock('./BaseRefPicker', () => ({
|
||||
BaseRefPicker: () => null
|
||||
}))
|
||||
|
||||
vi.mock('../ui/select', () => ({
|
||||
Select: ({
|
||||
value,
|
||||
onValueChange,
|
||||
children
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<select value={value} onChange={(event) => onValueChange(event.currentTarget.value)}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: () => null,
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
|
||||
<option value={value}>{children}</option>
|
||||
)
|
||||
}))
|
||||
|
||||
const BASE_REPO: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/home/user/project',
|
||||
@@ -39,13 +61,21 @@ afterEach(() => {
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function render(repo: Repo, updateRepo: (repoId: string, updates: object) => void): void {
|
||||
function render(
|
||||
repo: Repo,
|
||||
updateRepo: (repoId: string, updates: object) => void | Promise<boolean>,
|
||||
options: {
|
||||
settings?: Pick<GlobalSettings, 'workspaceDir' | 'worktreeVisibilityDefaults'> | null
|
||||
refreshRepo?: (repoId: string) => void | Promise<unknown>
|
||||
} = {}
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(RepositoryWorktreeDefaultsSection, {
|
||||
repo,
|
||||
settings: null,
|
||||
settings: options.settings ?? null,
|
||||
updateRepo,
|
||||
refreshRepo: options.refreshRepo ?? (() => {}),
|
||||
forceVisible: true
|
||||
})
|
||||
)
|
||||
@@ -141,3 +171,54 @@ describe('RepositoryWorktreeDefaultsSection — worktree path', () => {
|
||||
expect(updateRepo).toHaveBeenCalledWith('repo-1', { worktreeBasePath: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RepositoryWorktreeDefaultsSection — external visibility', () => {
|
||||
it('shows the inherited effective value without stamping the repo', () => {
|
||||
render(BASE_REPO, vi.fn(), {
|
||||
settings: {
|
||||
workspaceDir: '/home/user/orca/workspaces',
|
||||
worktreeVisibilityDefaults: { external: 'show' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('Using global: Show')
|
||||
expect(container.querySelector('select')?.value).toBe('global')
|
||||
})
|
||||
|
||||
it('clears the override with the remote-safe null sentinel and refreshes classification', async () => {
|
||||
const updateRepo = vi.fn().mockResolvedValue(true)
|
||||
const refreshRepo = vi.fn().mockResolvedValue(true)
|
||||
render({ ...BASE_REPO, externalWorktreeVisibility: 'show' }, updateRepo, { refreshRepo })
|
||||
|
||||
await act(async () => {
|
||||
const select = container.querySelector('select')!
|
||||
select.value = 'global'
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(updateRepo).toHaveBeenCalledWith('repo-1', { externalWorktreeVisibility: null })
|
||||
expect(refreshRepo).toHaveBeenCalledWith('repo-1')
|
||||
})
|
||||
|
||||
it('falls back to the effective explicit value when an older host rejects inheritance', async () => {
|
||||
const updateRepo = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
||||
render(
|
||||
{
|
||||
...BASE_REPO,
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
},
|
||||
updateRepo
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
const select = container.querySelector('select')!
|
||||
select.value = 'global'
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(updateRepo).toHaveBeenNthCalledWith(2, 'repo-1', {
|
||||
externalWorktreeVisibility: 'show'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import type { GlobalSettings, Repo } from '../../../../shared/types'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { BaseRefPicker } from './BaseRefPicker'
|
||||
import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
effectiveExternalWorktreeVisibility,
|
||||
isLegacyRepoForExternalWorktreeVisibility
|
||||
} from '../../../../shared/external-worktree-visibility'
|
||||
|
||||
type RepositoryWorktreeDefaultsUpdate = Pick<Repo, 'worktreeBasePath' | 'worktreeBaseRef'>
|
||||
type RepositoryWorktreeDefaultsUpdate = Pick<Repo, 'worktreeBasePath' | 'worktreeBaseRef'> & {
|
||||
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
|
||||
}
|
||||
|
||||
type RepositoryWorktreeDefaultsSectionProps = {
|
||||
repo: Repo
|
||||
settings: Pick<GlobalSettings, 'workspaceDir'> | null
|
||||
updateRepo: (repoId: string, updates: Partial<RepositoryWorktreeDefaultsUpdate>) => void
|
||||
settings: Pick<GlobalSettings, 'workspaceDir' | 'worktreeVisibilityDefaults'> | null
|
||||
updateRepo: (
|
||||
repoId: string,
|
||||
updates: Partial<RepositoryWorktreeDefaultsUpdate>
|
||||
) => void | Promise<boolean>
|
||||
refreshRepo: (repoId: string) => void | Promise<unknown>
|
||||
forceVisible: boolean
|
||||
}
|
||||
|
||||
@@ -20,10 +31,96 @@ export function RepositoryWorktreeDefaultsSection({
|
||||
repo,
|
||||
settings,
|
||||
updateRepo,
|
||||
refreshRepo,
|
||||
forceVisible
|
||||
}: RepositoryWorktreeDefaultsSectionProps): React.JSX.Element {
|
||||
const isLegacyRepo = isLegacyRepoForExternalWorktreeVisibility(repo)
|
||||
const globalVisibility =
|
||||
settings?.worktreeVisibilityDefaults?.external ?? (isLegacyRepo ? 'show' : 'hide')
|
||||
const effectiveVisibility = effectiveExternalWorktreeVisibility(
|
||||
repo,
|
||||
isLegacyRepo,
|
||||
settings?.worktreeVisibilityDefaults
|
||||
)
|
||||
const updateVisibility = async (value: string): Promise<void> => {
|
||||
const visibility = value === 'global' ? null : value === 'show' ? 'show' : 'hide'
|
||||
let updated = await updateRepo(repo.id, { externalWorktreeVisibility: visibility })
|
||||
if (updated === false && visibility === null) {
|
||||
updated = await updateRepo(repo.id, { externalWorktreeVisibility: effectiveVisibility })
|
||||
}
|
||||
if (updated !== false) {
|
||||
await refreshRepo(repo.id)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.RepositoryPane.externalWorktrees',
|
||||
'External worktrees'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.RepositoryPane.externalWorktreesDescription',
|
||||
'Override whether worktrees created outside Orca appear for this project.'
|
||||
)}
|
||||
keywords={[repo.displayName, 'external', 'non-Orca', 'visibility', 'sidebar']}
|
||||
className="space-y-2"
|
||||
forceVisible={forceVisible}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{translate(
|
||||
'auto.components.settings.RepositoryPane.externalWorktrees',
|
||||
'External worktrees'
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{repo.externalWorktreeVisibility === undefined
|
||||
? translate(
|
||||
'auto.components.settings.RepositoryPane.externalWorktreesInherited',
|
||||
'Using global: {{value0}}',
|
||||
{
|
||||
value0:
|
||||
effectiveVisibility === 'show'
|
||||
? translate('auto.components.settings.RepositoryPane.show', 'Show')
|
||||
: translate('auto.components.settings.RepositoryPane.hide', 'Hide')
|
||||
}
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.RepositoryPane.externalWorktreesGlobal',
|
||||
'Global default: {{value0}}',
|
||||
{
|
||||
value0:
|
||||
globalVisibility === 'show'
|
||||
? translate('auto.components.settings.RepositoryPane.show', 'Show')
|
||||
: translate('auto.components.settings.RepositoryPane.hide', 'Hide')
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={repo.externalWorktreeVisibility ?? 'global'}
|
||||
onValueChange={(value) => void updateVisibility(value)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-[132px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">
|
||||
{translate('auto.components.settings.RepositoryPane.useGlobal', 'Use global')}
|
||||
</SelectItem>
|
||||
<SelectItem value="show">
|
||||
{translate('auto.components.settings.RepositoryPane.show', 'Show')}
|
||||
</SelectItem>
|
||||
<SelectItem value="hide">
|
||||
{translate('auto.components.settings.RepositoryPane.hide', 'Hide')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title={translate(
|
||||
'auto.components.settings.RepositoryPane.f88db4fece',
|
||||
|
||||
@@ -23,6 +23,25 @@ export const getGeneralWorkspaceSearchEntries = createLocalizedCatalog(() => [
|
||||
...translateSearchKeyword('auto.components.settings.general.search.df10666259', 'worktree')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.general.search.externalWorktrees',
|
||||
'External worktrees'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.general.search.externalWorktreesDescription',
|
||||
'Choose whether worktrees created outside Orca appear by default.'
|
||||
),
|
||||
keywords: [
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.general.search.externalKeyword',
|
||||
'external'
|
||||
),
|
||||
...translateSearchKeyword('auto.components.settings.general.search.visibility', 'visibility'),
|
||||
...translateSearchKeyword('auto.components.settings.general.search.sidebar', 'sidebar'),
|
||||
...translateSearchKeyword('auto.components.settings.general.search.df10666259', 'worktree')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: translate('auto.components.settings.general.search.141f71c69f', 'Nest Workspaces'),
|
||||
description: translate(
|
||||
|
||||
@@ -5,6 +5,28 @@ import { translateSearchKeyword } from './settings-search-keywords'
|
||||
|
||||
export function getRepositoryGitWorktreeSearchEntries(repo: Repo): SettingsSearchEntry[] {
|
||||
return [
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.repository.search.externalWorktrees',
|
||||
'External worktrees'
|
||||
),
|
||||
description: translate(
|
||||
'auto.components.settings.repository.search.externalWorktreesDescription',
|
||||
'Override whether worktrees created outside Orca appear for this project.'
|
||||
),
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.repository.search.external',
|
||||
'external'
|
||||
),
|
||||
...translateSearchKeyword(
|
||||
'auto.components.settings.repository.search.visibility',
|
||||
'visibility'
|
||||
),
|
||||
...translateSearchKeyword('auto.components.settings.repository.search.sidebar', 'sidebar')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: translate(
|
||||
'auto.components.settings.repository.search.094adbe930',
|
||||
|
||||
@@ -67,9 +67,11 @@ import type {
|
||||
WorktreeMeta,
|
||||
WorkspaceLineage,
|
||||
WorkspaceStatus,
|
||||
WorkspaceStatusDefinition
|
||||
WorkspaceStatusDefinition,
|
||||
GlobalSettings
|
||||
} from '../../../../shared/types'
|
||||
import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants'
|
||||
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
|
||||
import { buildWorktreeComparator, compareWorktreeSortLabel } from './smart-sort'
|
||||
import {
|
||||
buildAttentionByWorktree,
|
||||
@@ -644,10 +646,14 @@ function getSidebarRowRevealAncestorKeys(args: {
|
||||
return [...keys]
|
||||
}
|
||||
|
||||
function getWorktreeVisibilityMenuLabel(repo: Repo): string {
|
||||
function getWorktreeVisibilityMenuLabel(
|
||||
repo: Repo,
|
||||
visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
|
||||
): string {
|
||||
const visibility = effectiveExternalWorktreeVisibility(
|
||||
repo,
|
||||
isLegacyRepoForExternalWorktreeVisibility(repo)
|
||||
isLegacyRepoForExternalWorktreeVisibility(repo),
|
||||
visibilityDefaults
|
||||
)
|
||||
return visibility === 'show' ? 'Hide non-Orca worktrees' : 'Show hidden worktrees'
|
||||
}
|
||||
@@ -1484,6 +1490,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const sshConnectedGeneration = useAppStore((s) => s.sshConnectedGeneration)
|
||||
const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost)
|
||||
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
|
||||
const reorderRepos = useAppStore((s) => s.reorderRepos)
|
||||
const folderBackedProjectGroupIds = useMemo(
|
||||
@@ -4638,7 +4645,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
{getWorktreeVisibilityMenuLabel(row.repo)}
|
||||
{getWorktreeVisibilityMenuLabel(
|
||||
row.repo,
|
||||
getRepoOwnerWorktreeVisibilityDefaults(
|
||||
row.repo,
|
||||
settings,
|
||||
worktreeVisibilityDefaultsByHost
|
||||
)
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
@@ -5356,6 +5370,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
useShallow((s) => selectWorktreeListReviewCacheInputs(s, groupBy, cardProps))
|
||||
)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost)
|
||||
const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings)
|
||||
const sshTargetLabels = useAppStore((s) => s.sshTargetLabels)
|
||||
const sshConnectionStates = useAppStore((s) => s.sshConnectionStates)
|
||||
@@ -5736,17 +5751,34 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
repos: visibleReposForRows,
|
||||
detectedWorktreesByRepo,
|
||||
filterRepoIds,
|
||||
forceVisibleRepoIds
|
||||
forceVisibleRepoIds,
|
||||
settings,
|
||||
visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost
|
||||
})
|
||||
}, [detectedWorktreesByRepo, filterRepoIds, importedWorktreeCardActionState, visibleReposForRows])
|
||||
}, [
|
||||
detectedWorktreesByRepo,
|
||||
filterRepoIds,
|
||||
importedWorktreeCardActionState,
|
||||
settings,
|
||||
worktreeVisibilityDefaultsByHost,
|
||||
visibleReposForRows
|
||||
])
|
||||
const newExternalWorktreesInboxByRepo = useMemo(
|
||||
() =>
|
||||
buildNewExternalWorktreesInboxCandidates({
|
||||
repos: visibleReposForRows,
|
||||
detectedWorktreesByRepo,
|
||||
filterRepoIds
|
||||
filterRepoIds,
|
||||
settings,
|
||||
visibilityDefaultsByHost: worktreeVisibilityDefaultsByHost
|
||||
}),
|
||||
[detectedWorktreesByRepo, filterRepoIds, visibleReposForRows]
|
||||
[
|
||||
detectedWorktreesByRepo,
|
||||
filterRepoIds,
|
||||
settings,
|
||||
worktreeVisibilityDefaultsByHost,
|
||||
visibleReposForRows
|
||||
]
|
||||
)
|
||||
const placeholderRepoIds = useMemo(() => {
|
||||
return getEmptyProjectPlaceholderRepoIds({
|
||||
|
||||
@@ -54,6 +54,7 @@ function getLatestRepoForScope(scope: string): Repo | null {
|
||||
null
|
||||
)
|
||||
}
|
||||
import { useRepoOwnerVisibilityDefaults } from './use-repo-owner-visibility-defaults'
|
||||
|
||||
export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
@@ -86,6 +87,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
|
||||
const effectiveBusyPath =
|
||||
busyPath ?? (activeMutation?.kind === 'row' ? activeMutation.path : null)
|
||||
const effectivelyToggling = isToggling || activeMutation?.kind === 'toggle'
|
||||
const visibilityDefaults = useRepoOwnerVisibilityDefaults(repo)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
currentMutationScopeRef.current = mutationScope
|
||||
@@ -351,6 +353,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null {
|
||||
<WorktreeVisibilitySourceList
|
||||
repo={repo}
|
||||
worktrees={detected?.authoritative ? detected.worktrees : []}
|
||||
visibilityDefaults={visibilityDefaults}
|
||||
disabled={effectiveBusyPath !== null || effectivelyToggling || listState === 'checking'}
|
||||
onAdd={handleAddSource}
|
||||
onRemove={handleRemoveSource}
|
||||
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
BuiltInWorktreeVisibilitySourceId,
|
||||
CustomWorktreeVisibilitySource,
|
||||
DetectedWorktree,
|
||||
Repo
|
||||
Repo,
|
||||
WorktreeVisibilityDefaults
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
createWorktreeVisibilitySourceMatcher,
|
||||
@@ -33,6 +34,7 @@ export type WorktreeVisibilitySourceRow =
|
||||
type Props = {
|
||||
repo: Repo
|
||||
worktrees: readonly DetectedWorktree[]
|
||||
visibilityDefaults?: WorktreeVisibilityDefaults
|
||||
disabled: boolean
|
||||
onAdd: (rootPath: string) => Promise<WorktreeVisibilitySourceAddResult>
|
||||
onRemove: (source: CustomWorktreeVisibilitySource) => Promise<void>
|
||||
@@ -77,7 +79,11 @@ function getSourcePath(source: WorktreeVisibilitySourceRow): string {
|
||||
return `${source.source.rootPath.replace(/[\\/]+$/, '')}/*`
|
||||
}
|
||||
|
||||
function isSourceEnabled(repo: Repo, source: WorktreeVisibilitySourceRow): boolean {
|
||||
function isSourceEnabled(
|
||||
repo: Repo,
|
||||
source: WorktreeVisibilitySourceRow,
|
||||
visibilityDefaults?: WorktreeVisibilityDefaults
|
||||
): boolean {
|
||||
if (source.kind === 'built-in') {
|
||||
return effectiveBuiltInWorktreeSourceVisibility(repo, source.id) === 'show'
|
||||
}
|
||||
@@ -85,8 +91,11 @@ function isSourceEnabled(repo: Repo, source: WorktreeVisibilitySourceRow): boole
|
||||
return effectiveCustomWorktreeSourceVisibility(repo, source.source.id) === 'show'
|
||||
}
|
||||
return (
|
||||
effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) ===
|
||||
'show'
|
||||
effectiveExternalWorktreeVisibility(
|
||||
repo,
|
||||
isLegacyRepoForExternalWorktreeVisibility(repo),
|
||||
visibilityDefaults
|
||||
) === 'show'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -109,6 +118,7 @@ function getAccessibleSourceLabel(source: WorktreeVisibilitySourceRow, label: st
|
||||
export default function WorktreeVisibilitySourceList({
|
||||
repo,
|
||||
worktrees,
|
||||
visibilityDefaults,
|
||||
disabled,
|
||||
onAdd,
|
||||
onRemove,
|
||||
@@ -257,7 +267,7 @@ export default function WorktreeVisibilitySourceList({
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Switch
|
||||
checked={isSourceEnabled(repo, source)}
|
||||
checked={isSourceEnabled(repo, source, visibilityDefaults)}
|
||||
disabled={disabled}
|
||||
aria-label={translate(
|
||||
'auto.components.sidebar.WorktreeVisibilitySourceList.toggle',
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { DetectedWorktreeListResult, Repo, Worktree } from '../../../../shared/types'
|
||||
import type {
|
||||
DetectedWorktreeListResult,
|
||||
Repo,
|
||||
Worktree,
|
||||
WorktreeVisibilityDefaults,
|
||||
GlobalSettings
|
||||
} from '../../../../shared/types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
|
||||
import { getHiddenExternalWorktrees } from '../../../../shared/external-worktree-inbox'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
@@ -19,6 +27,8 @@ export function buildImportedWorktreesCardCandidates(args: {
|
||||
detectedWorktreesByRepo: Readonly<Record<string, DetectedWorktreeListResult | undefined>>
|
||||
filterRepoIds?: readonly string[]
|
||||
forceVisibleRepoIds?: ReadonlySet<string>
|
||||
settings?: Pick<GlobalSettings, 'worktreeVisibilityDefaults'> | null
|
||||
visibilityDefaultsByHost?: Partial<Record<ExecutionHostId, WorktreeVisibilityDefaults | null>>
|
||||
}): Map<string, ImportedWorktreesCardCandidate> {
|
||||
const visibleRepoIds = args.visibleWorktrees
|
||||
? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId))
|
||||
@@ -40,7 +50,12 @@ export function buildImportedWorktreesCardCandidates(args: {
|
||||
}
|
||||
const visibility = effectiveExternalWorktreeVisibility(
|
||||
repo,
|
||||
isLegacyRepoForExternalWorktreeVisibility(repo)
|
||||
isLegacyRepoForExternalWorktreeVisibility(repo),
|
||||
getRepoOwnerWorktreeVisibilityDefaults(
|
||||
repo,
|
||||
args.settings,
|
||||
args.visibilityDefaultsByHost ?? {}
|
||||
)
|
||||
)
|
||||
if (visibility !== 'hide' && !args.forceVisibleRepoIds?.has(repo.id)) {
|
||||
continue
|
||||
|
||||
@@ -2,8 +2,12 @@ import type {
|
||||
DetectedWorktree,
|
||||
DetectedWorktreeListResult,
|
||||
Repo,
|
||||
Worktree
|
||||
Worktree,
|
||||
WorktreeVisibilityDefaults,
|
||||
GlobalSettings
|
||||
} from '../../../../shared/types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
|
||||
import { getNewExternalWorktreeInboxWorktrees } from '../../../../shared/external-worktree-inbox'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { NewExternalWorktreesInboxCandidate } from './worktree-list-groups'
|
||||
@@ -13,6 +17,8 @@ export function buildNewExternalWorktreesInboxCandidates(args: {
|
||||
visibleWorktrees?: readonly Worktree[]
|
||||
detectedWorktreesByRepo: Readonly<Record<string, DetectedWorktreeListResult | undefined>>
|
||||
filterRepoIds?: readonly string[]
|
||||
settings?: Pick<GlobalSettings, 'worktreeVisibilityDefaults'> | null
|
||||
visibilityDefaultsByHost?: Partial<Record<ExecutionHostId, WorktreeVisibilityDefaults | null>>
|
||||
}): Map<string, NewExternalWorktreesInboxCandidate> {
|
||||
const visibleRepoIds = args.visibleWorktrees
|
||||
? new Set(args.visibleWorktrees.map((worktree) => worktree.repoId))
|
||||
@@ -31,7 +37,12 @@ export function buildNewExternalWorktreesInboxCandidates(args: {
|
||||
}
|
||||
const inboxWorktrees = getNewExternalWorktreeInboxWorktrees(
|
||||
args.detectedWorktreesByRepo[repo.id],
|
||||
repo
|
||||
repo,
|
||||
getRepoOwnerWorktreeVisibilityDefaults(
|
||||
repo,
|
||||
args.settings,
|
||||
args.visibilityDefaultsByHost ?? {}
|
||||
)
|
||||
)
|
||||
if (inboxWorktrees.length > 0) {
|
||||
candidates.set(repo.id, { repo, inboxWorktrees })
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import type { Repo, WorktreeVisibilityDefaults } from '../../../../shared/types'
|
||||
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../store/worktree-visibility-defaults-by-host'
|
||||
|
||||
export function useRepoOwnerVisibilityDefaults(
|
||||
repo: Repo | null
|
||||
): WorktreeVisibilityDefaults | undefined {
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const defaultsByHost = useAppStore((state) => state.worktreeVisibilityDefaultsByHost)
|
||||
return repo ? getRepoOwnerWorktreeVisibilityDefaults(repo, settings, defaultsByHost) : undefined
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5971,7 +5971,11 @@
|
||||
"5567191a6e": "浏览",
|
||||
"0e9fc0eadc": "工作区目录",
|
||||
"e2955d9ccb": "配置创建新工作区的位置。",
|
||||
"7511097c5d": "工作区"
|
||||
"7511097c5d": "工作区",
|
||||
"externalWorktrees": "外部工作树",
|
||||
"externalWorktreesDescription": "选择是否默认显示在 Orca 外部创建的工作树。",
|
||||
"show": "显示",
|
||||
"hide": "隐藏"
|
||||
},
|
||||
"GhosttyImportModal": {
|
||||
"9d3e56ca36": "应用更改",
|
||||
@@ -6634,7 +6638,14 @@
|
||||
"projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。",
|
||||
"hostStateDisconnected": "Disconnected",
|
||||
"hostStateUnknown": "Unknown",
|
||||
"nestedHostLabel": "{{value0}} via {{value1}}"
|
||||
"nestedHostLabel": "{{value0}} via {{value1}}",
|
||||
"externalWorktrees": "外部工作树",
|
||||
"externalWorktreesDescription": "覆盖是否为此项目显示在 Orca 外部创建的工作树。",
|
||||
"externalWorktreesInherited": "正在使用全局设置:{{value0}}",
|
||||
"show": "显示",
|
||||
"hide": "隐藏",
|
||||
"externalWorktreesGlobal": "全局默认值:{{value0}}",
|
||||
"useGlobal": "使用全局设置"
|
||||
},
|
||||
"RepositorySourceControlAiActionRows": {
|
||||
"548a6e1281": "提示词模板",
|
||||
@@ -8255,7 +8266,9 @@
|
||||
"e61157e926": "编辑器自动换行",
|
||||
"005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。",
|
||||
"editorFontFamily": "编辑器字体",
|
||||
"editorFontFamilyDesc": "文件编辑器和差异视图使用的字体,留空则跟随终端字体。"
|
||||
"editorFontFamilyDesc": "文件编辑器和差异视图使用的字体,留空则跟随终端字体。",
|
||||
"externalWorktrees": "外部工作树",
|
||||
"externalWorktreesDescription": "选择是否默认显示在 Orca 外部创建的工作树。"
|
||||
}
|
||||
},
|
||||
"git": {
|
||||
@@ -8739,6 +8752,8 @@
|
||||
"keepForkUpToDateDescription": "从 upstream 安全地快进此 Fork。",
|
||||
"projectRuntime": "项目运行时",
|
||||
"projectRuntimeDescription": "选择此项目运行在 Windows 还是 WSL。",
|
||||
"externalWorktrees": "外部工作树",
|
||||
"externalWorktreesDescription": "覆盖是否为此项目显示在 Orca 外部创建的工作树。",
|
||||
"copy": "复制",
|
||||
"clone": "克隆",
|
||||
"apfs": "apfs"
|
||||
|
||||
@@ -486,4 +486,22 @@ describe('purgeStaleRuntimeHostState', () => {
|
||||
expect(s.activeRepoId).toBeNull()
|
||||
expect(s.filterRepoIds).toEqual(['repo1'])
|
||||
})
|
||||
|
||||
it('purges visibility state even when the removed host has no catalog rows', () => {
|
||||
const store = createTestStore()
|
||||
seedStore(store, {
|
||||
worktreeVisibilityDefaultsByHost: {
|
||||
local: { external: 'hide' },
|
||||
[RUNTIME_A]: { external: 'show' }
|
||||
},
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-a'
|
||||
})
|
||||
|
||||
store.getState().purgeStaleRuntimeHostState(['env-a'])
|
||||
|
||||
expect(store.getState().worktreeVisibilityDefaultsByHost).toEqual({
|
||||
local: { external: 'hide' }
|
||||
})
|
||||
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
it('hydrates a runtime owner default when refreshing its repositories', async () => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: vi.fn(({ method }: { method: string }) => {
|
||||
const result =
|
||||
method === 'status.get'
|
||||
? {
|
||||
runtimeId: 'runtime-1',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
}
|
||||
: method === 'repo.list'
|
||||
? { repos: [] }
|
||||
: method === 'settings.get'
|
||||
? { settings: { worktreeVisibilityDefaults: { external: 'show' } } }
|
||||
: method === 'project.list'
|
||||
? { projects: [] }
|
||||
: { setups: [] }
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
result,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never })
|
||||
|
||||
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
|
||||
|
||||
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toEqual({
|
||||
external: 'show'
|
||||
})
|
||||
expect(store.getState().settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' })
|
||||
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBe('env-1')
|
||||
})
|
||||
|
||||
it('clears focused support when a runtime omits the default', async () => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: vi.fn(({ method }: { method: string }) =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
result:
|
||||
method === 'status.get'
|
||||
? {
|
||||
runtimeId: 'runtime-1',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
}
|
||||
: method === 'repo.list'
|
||||
? { repos: [] }
|
||||
: { settings: {} },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
worktreeVisibilityDefaults: { external: 'show' }
|
||||
} as never,
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-1'
|
||||
})
|
||||
|
||||
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
|
||||
|
||||
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toBeNull()
|
||||
expect(store.getState().settings?.worktreeVisibilityDefaults).toBeUndefined()
|
||||
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves focused support when runtime default hydration fails', async () => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: vi.fn(({ method }: { method: string }) =>
|
||||
method === 'settings.get'
|
||||
? Promise.reject(new Error('offline'))
|
||||
: Promise.resolve({
|
||||
ok: true,
|
||||
result:
|
||||
method === 'status.get'
|
||||
? {
|
||||
runtimeId: 'runtime-1',
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
}
|
||||
: { repos: [] },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
worktreeVisibilityDefaults: { external: 'show' }
|
||||
} as never,
|
||||
worktreeVisibilityDefaultsByHost: { 'runtime:env-1': { external: 'show' } },
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: 'env-1'
|
||||
})
|
||||
|
||||
await store.getState().fetchRuntimeEnvironmentRepos('env-1')
|
||||
|
||||
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-1']).toEqual({
|
||||
external: 'show'
|
||||
})
|
||||
expect(store.getState().settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' })
|
||||
expect(store.getState().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId).toBe('env-1')
|
||||
})
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
reconcileFetchedRepos
|
||||
} from './repo-identity-reconcile'
|
||||
import { retainValidFilterRepoIds } from './repo-filter-selection'
|
||||
import { readRuntimeWorktreeVisibilityDefaults } from './worktree-visibility-owner-settings'
|
||||
import {
|
||||
mergeSshRepoReadoptions,
|
||||
reconcileReadoptedSshRepoRows,
|
||||
@@ -139,7 +140,6 @@ export type RepoUpdate = Partial<
|
||||
| 'symlinkPaths'
|
||||
| 'issueSourcePreference'
|
||||
| 'forkSyncMode'
|
||||
| 'externalWorktreeVisibility'
|
||||
| 'externalWorktreeVisibilityPromptDismissedAt'
|
||||
| 'externalWorktreeInboxBaselinePaths'
|
||||
| 'importedExternalWorktreePaths'
|
||||
@@ -150,6 +150,7 @@ export type RepoUpdate = Partial<
|
||||
| 'projectGroupOrder'
|
||||
>
|
||||
> & {
|
||||
externalWorktreeVisibility?: Repo['externalWorktreeVisibility'] | null
|
||||
sourceControlAi?: Repo['sourceControlAi'] | null
|
||||
externalWorktreeDiscoverySuppressedAt?: Repo['externalWorktreeDiscoverySuppressedAt'] | null
|
||||
}
|
||||
@@ -2169,7 +2170,10 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
const targetHostId = getRuntimeTargetHostId(target)
|
||||
claimRepoCatalogGeneration(get, targetHostId, catalogGeneration)
|
||||
try {
|
||||
const catalog = await fetchRepoCatalogForTarget(target)
|
||||
const [catalog, visibilityDefaults] = await Promise.all([
|
||||
fetchRepoCatalogForTarget(target),
|
||||
readRuntimeWorktreeVisibilityDefaults(environmentId)
|
||||
])
|
||||
if (
|
||||
runtimeRepoFetchGenerationByEnvironment.get(environmentId) !== requestGeneration ||
|
||||
!isLatestRepoCatalogGeneration(get, targetHostId, catalogGeneration) ||
|
||||
@@ -2219,6 +2223,35 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
)
|
||||
return {
|
||||
repos: finalizedRepos,
|
||||
...(visibilityDefaults === undefined
|
||||
? {}
|
||||
: {
|
||||
worktreeVisibilityDefaultsByHost: {
|
||||
...s.worktreeVisibilityDefaultsByHost,
|
||||
[targetHostId]: visibilityDefaults
|
||||
}
|
||||
}),
|
||||
...(visibilityDefaults !== undefined && s.settings
|
||||
? getActiveRuntimeTarget(s.settings).kind === 'environment' &&
|
||||
s.settings.activeRuntimeEnvironmentId === environmentId
|
||||
? visibilityDefaults
|
||||
? {
|
||||
settings: {
|
||||
...s.settings,
|
||||
worktreeVisibilityDefaults: visibilityDefaults
|
||||
},
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: environmentId
|
||||
}
|
||||
: {
|
||||
settings: Object.fromEntries(
|
||||
Object.entries(s.settings).filter(
|
||||
([key]) => key !== 'worktreeVisibilityDefaults'
|
||||
)
|
||||
) as GlobalSettings,
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null
|
||||
}
|
||||
: {}
|
||||
: {}),
|
||||
pendingSshRepoReadoptions: reconciliation.pendingReadoptions,
|
||||
...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions),
|
||||
...mergedProjectCompatibility,
|
||||
@@ -2344,11 +2377,38 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
environmentId: environment.id
|
||||
}
|
||||
claimRepoCatalogGeneration(get, getRuntimeTargetHostId(target), generation)
|
||||
try {
|
||||
applyCatalog(await fetchRepoCatalogForTarget(target))
|
||||
} catch (err) {
|
||||
const [catalogResult, visibilityDefaults] = await Promise.all([
|
||||
fetchRepoCatalogForTarget(target).then(
|
||||
(catalog) => ({ ok: true as const, catalog }),
|
||||
(error: unknown) => ({ ok: false as const, error })
|
||||
),
|
||||
readRuntimeWorktreeVisibilityDefaults(environment.id)
|
||||
])
|
||||
const hostId = getRuntimeTargetHostId(target)
|
||||
if (
|
||||
visibilityDefaults !== undefined &&
|
||||
latestAllHostRepoCatalogGenerationByStore.get(get) === generation &&
|
||||
isLatestRepoCatalogGeneration(get, hostId, generation)
|
||||
) {
|
||||
set((state) =>
|
||||
isRemovedRuntimeHostId(hostId, state.removedRuntimeEnvironmentIds)
|
||||
? state
|
||||
: {
|
||||
worktreeVisibilityDefaultsByHost: {
|
||||
...state.worktreeVisibilityDefaultsByHost,
|
||||
[hostId]: visibilityDefaults
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (catalogResult.ok) {
|
||||
applyCatalog(catalogResult.catalog)
|
||||
} else {
|
||||
failed = true
|
||||
console.warn(`Skipped repos for runtime environment ${environment.id}:`, err)
|
||||
console.warn(
|
||||
`Skipped repos for runtime environment ${environment.id}:`,
|
||||
catalogResult.error
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -3818,6 +3878,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
const {
|
||||
sourceControlAi,
|
||||
externalWorktreeDiscoverySuppressedAt,
|
||||
externalWorktreeVisibility,
|
||||
...updatesWithoutClearSentinels
|
||||
} = sanitizedUpdates
|
||||
mergedRepo = { ...mergedRepo, ...updatesWithoutClearSentinels }
|
||||
@@ -3828,6 +3889,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
} else if (sourceControlAi !== undefined) {
|
||||
mergedRepo = { ...mergedRepo, sourceControlAi }
|
||||
}
|
||||
if (externalWorktreeVisibility === null) {
|
||||
const { externalWorktreeVisibility: _visibility, ...repoWithoutVisibility } =
|
||||
mergedRepo
|
||||
mergedRepo = { ...repoWithoutVisibility, externalWorktreeVisibilityLegacy: false }
|
||||
} else if (externalWorktreeVisibility !== undefined) {
|
||||
mergedRepo = { ...mergedRepo, externalWorktreeVisibility }
|
||||
}
|
||||
if (externalWorktreeDiscoverySuppressedAt === null) {
|
||||
const {
|
||||
externalWorktreeDiscoverySuppressedAt: _suppressedAt,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
function compatibleStatus(runtimeId: string) {
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId,
|
||||
graphStatus: 'ready',
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
},
|
||||
_meta: { runtimeId }
|
||||
}
|
||||
}
|
||||
|
||||
it('does not persist a stale overlapping runtime switch', async () => {
|
||||
let resolveFirst!: (value: unknown) => void
|
||||
const firstStatus = new Promise((resolve) => (resolveFirst = resolve))
|
||||
const persistPreference = vi.fn(({ environmentId }: { environmentId: string }) =>
|
||||
Promise.resolve({ activeRuntimeEnvironmentId: environmentId })
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
settings: { setActiveRuntimeEnvironmentPreference: persistPreference },
|
||||
runtimeEnvironments: {
|
||||
getStatus: vi.fn(({ selector }: { selector: string }) =>
|
||||
selector === 'env-a' ? firstStatus : Promise.resolve(compatibleStatus('runtime-b'))
|
||||
),
|
||||
call: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { settings: { worktreeVisibilityDefaults: { external: 'hide' } } },
|
||||
_meta: { runtimeId: 'runtime-b' }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: null } as never,
|
||||
fetchRepos: vi.fn().mockResolvedValue(undefined),
|
||||
fetchAllWorktrees: vi.fn().mockResolvedValue(undefined),
|
||||
fetchWorktreeLineage: vi.fn().mockResolvedValue(undefined),
|
||||
fetchBrowserSessionProfiles: vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const first = store.getState().setActiveRuntimeEnvironmentPreference('env-a')
|
||||
await store.getState().setActiveRuntimeEnvironmentPreference('env-b')
|
||||
resolveFirst(compatibleStatus('runtime-a'))
|
||||
await first
|
||||
|
||||
expect(persistPreference).toHaveBeenCalledOnce()
|
||||
expect(persistPreference).toHaveBeenCalledWith({ environmentId: 'env-b' })
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
it('does not overwrite a settings write with an older owner hydration', async () => {
|
||||
let resolveSettingsRead!: (settings: GlobalSettings) => void
|
||||
const settingsRead = new Promise<GlobalSettings>((resolve) => (resolveSettingsRead = resolve))
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
settings: {
|
||||
get: vi.fn().mockReturnValue(settingsRead),
|
||||
set: vi.fn().mockResolvedValue({ pluginSystemEnabled: true })
|
||||
},
|
||||
runtimeEnvironments: { list: vi.fn().mockResolvedValue([]) }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
const hydration = store.getState().fetchSettings()
|
||||
|
||||
await store.getState().updateSettingsOrThrow({ pluginSystemEnabled: true })
|
||||
resolveSettingsRead({ pluginSystemEnabled: false } as GlobalSettings)
|
||||
await hydration
|
||||
|
||||
expect(store.getState().settings?.pluginSystemEnabled).toBe(true)
|
||||
})
|
||||
@@ -144,7 +144,9 @@ beforeEach(() => {
|
||||
? { groups: [] }
|
||||
: method === 'worktree.lineageList'
|
||||
? { lineage: { [env2Lineage.worktreeId]: env2Lineage } }
|
||||
: {}
|
||||
: method === 'settings.get'
|
||||
? { settings: {} }
|
||||
: {}
|
||||
return Promise.resolve({ id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-2' } })
|
||||
}
|
||||
)
|
||||
@@ -399,6 +401,7 @@ describe('createSettingsSlice runtime switching', () => {
|
||||
expect(store.getState().repos.find((repo) => repo.id === 'repo-env-2')?.executionHostId).toBe(
|
||||
'runtime:env-2'
|
||||
)
|
||||
expect(store.getState().worktreeVisibilityDefaultsByHost['runtime:env-2']).toBeNull()
|
||||
expect(store.getState().projectGroups.map((group) => group.id)).toEqual(['group-env-1'])
|
||||
expect(store.getState().worktreesByRepo['repo-env-1']?.map((worktree) => worktree.id)).toEqual([
|
||||
'repo-env-1::/env-1/repo'
|
||||
@@ -565,7 +568,6 @@ describe('createSettingsSlice runtime switching', () => {
|
||||
true
|
||||
)
|
||||
|
||||
// No teardown RPC was issued against the previous host's live resources.
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selector: 'env-1', method: 'terminal.close' })
|
||||
)
|
||||
@@ -573,7 +575,6 @@ describe('createSettingsSlice runtime switching', () => {
|
||||
expect.objectContaining({ selector: 'env-1', method: 'browser.tabClose' })
|
||||
)
|
||||
|
||||
// Every previous-host map is byte-for-byte unchanged after the switch.
|
||||
expect(store.getState().tabsByWorktree).toEqual({
|
||||
'repo-env-1::/env-1/repo': [
|
||||
{
|
||||
|
||||
@@ -28,9 +28,16 @@ import {
|
||||
normalizeMobilePairingCustomAddress,
|
||||
normalizeMobilePairingCustomAddresses
|
||||
} from '../../../../shared/mobile-pairing-custom-address'
|
||||
import {
|
||||
hydrateOwnerWorktreeVisibilityDefaults,
|
||||
type WorktreeVisibilityDefaultsByHost
|
||||
} from './worktree-visibility-owner-settings'
|
||||
import { persistVisibilityAwareSettings } from './worktree-visibility-settings-write'
|
||||
|
||||
export type SettingsSlice = SettingsSearchState & {
|
||||
settings: GlobalSettings | null
|
||||
worktreeVisibilityDefaultsByHost: WorktreeVisibilityDefaultsByHost
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: string | null
|
||||
fetchSettings: () => Promise<void>
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => Promise<void>
|
||||
updateSettingsOrThrow: (updates: Partial<GlobalSettings>) => Promise<void>
|
||||
@@ -42,6 +49,7 @@ type LegacyTerminalScrollbackSettingsUpdate = Partial<GlobalSettings> & {
|
||||
}
|
||||
|
||||
type SettingsStateSetter = Parameters<StateCreator<AppState, [], [], SettingsSlice>>[0]
|
||||
let ownerSettingsHydrationGeneration = 0
|
||||
|
||||
function normalizeRuntimeEnvironmentId(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
@@ -126,14 +134,16 @@ function normalizeSettingsUpdates(
|
||||
async function persistSettingsUpdates(
|
||||
set: SettingsStateSetter,
|
||||
updates: Partial<GlobalSettings>,
|
||||
currentSettings: GlobalSettings | null
|
||||
currentSettings: GlobalSettings | null,
|
||||
supportedRuntimeEnvironmentId: string | null
|
||||
): Promise<void> {
|
||||
const nextSettings = await window.api.settings.set(
|
||||
normalizeSettingsUpdates(updates, currentSettings)
|
||||
)
|
||||
set((state) => ({
|
||||
settings: (nextSettings as GlobalSettings | undefined) ?? state.settings
|
||||
}))
|
||||
const normalizedUpdates = normalizeSettingsUpdates(updates, currentSettings)
|
||||
await persistVisibilityAwareSettings({
|
||||
normalizedUpdates,
|
||||
currentSettings,
|
||||
supportedRuntimeEnvironmentId,
|
||||
set
|
||||
})
|
||||
}
|
||||
|
||||
/** Every known host has a recorded status entry, and no entry survives for a host that is gone. */
|
||||
@@ -164,12 +174,26 @@ async function verifyRuntimeEnvironmentReachable(environmentId: string | null):
|
||||
|
||||
export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice> = (set, get) => ({
|
||||
settings: null,
|
||||
worktreeVisibilityDefaultsByHost: {},
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null,
|
||||
...createSettingsSearchState((state) => set(state)),
|
||||
|
||||
fetchSettings: async () => {
|
||||
const generation = ++ownerSettingsHydrationGeneration
|
||||
try {
|
||||
const settings = await window.api.settings.get()
|
||||
set({ settings })
|
||||
const hydrated = await hydrateOwnerWorktreeVisibilityDefaults(
|
||||
(await window.api.settings.get()) as GlobalSettings,
|
||||
get().worktreeVisibilityDefaultsByHost
|
||||
)
|
||||
if (generation !== ownerSettingsHydrationGeneration) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
settings: hydrated.settings,
|
||||
worktreeVisibilityDefaultsByHost: hydrated.defaultsByHost,
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId:
|
||||
hydrated.supportedRuntimeEnvironmentId
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch settings:', err)
|
||||
}
|
||||
@@ -188,18 +212,37 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
|
||||
},
|
||||
|
||||
updateSettings: async (updates) => {
|
||||
++ownerSettingsHydrationGeneration
|
||||
try {
|
||||
await persistSettingsUpdates(set, updates, get().settings)
|
||||
await persistSettingsUpdates(
|
||||
set,
|
||||
updates,
|
||||
get().settings,
|
||||
get().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId
|
||||
)
|
||||
if ('worktreeVisibilityDefaults' in updates) {
|
||||
await get().fetchAllWorktrees()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update settings:', err)
|
||||
}
|
||||
},
|
||||
|
||||
updateSettingsOrThrow: async (updates) => {
|
||||
await persistSettingsUpdates(set, updates, get().settings)
|
||||
++ownerSettingsHydrationGeneration
|
||||
await persistSettingsUpdates(
|
||||
set,
|
||||
updates,
|
||||
get().settings,
|
||||
get().worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId
|
||||
)
|
||||
if ('worktreeVisibilityDefaults' in updates) {
|
||||
await get().fetchAllWorktrees()
|
||||
}
|
||||
},
|
||||
|
||||
setActiveRuntimeEnvironmentPreference: async (environmentId) => {
|
||||
const generation = ++ownerSettingsHydrationGeneration
|
||||
const nextId = normalizeRuntimeEnvironmentId(environmentId)
|
||||
const previousId = normalizeRuntimeEnvironmentId(get().settings?.activeRuntimeEnvironmentId)
|
||||
if (previousId === nextId) {
|
||||
@@ -208,17 +251,34 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
|
||||
try {
|
||||
clearRuntimeCompatibilityCache(nextId)
|
||||
await verifyRuntimeEnvironmentReachable(nextId)
|
||||
if (generation !== ownerSettingsHydrationGeneration) {
|
||||
return true
|
||||
}
|
||||
const nextSettings = await window.api.settings.setActiveRuntimeEnvironmentPreference({
|
||||
environmentId: nextId
|
||||
})
|
||||
bumpProviderRuntimeSessionGeneration()
|
||||
set((s) => ({
|
||||
// Why: in the multi-host model this is a focus/default-host change,
|
||||
// not a teardown boundary. Existing host-owned sessions stay alive.
|
||||
settings:
|
||||
(nextSettings as GlobalSettings | undefined) ??
|
||||
(s.settings ? { ...s.settings, activeRuntimeEnvironmentId: nextId } : null)
|
||||
}))
|
||||
// Why: this is a focus change, so keep other host state while hydrating only the new owner's default.
|
||||
const focusedSettings =
|
||||
(nextSettings as GlobalSettings | undefined) ??
|
||||
(get().settings ? { ...get().settings!, activeRuntimeEnvironmentId: nextId } : null)
|
||||
if (focusedSettings) {
|
||||
const hydrated = await hydrateOwnerWorktreeVisibilityDefaults(
|
||||
focusedSettings,
|
||||
get().worktreeVisibilityDefaultsByHost
|
||||
)
|
||||
if (generation !== ownerSettingsHydrationGeneration) {
|
||||
return true
|
||||
}
|
||||
set({
|
||||
settings: hydrated.settings,
|
||||
worktreeVisibilityDefaultsByHost: hydrated.defaultsByHost,
|
||||
worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId:
|
||||
hydrated.supportedRuntimeEnvironmentId
|
||||
})
|
||||
} else {
|
||||
set({ settings: null })
|
||||
}
|
||||
// Why: hydration is host-merged by downstream slices. Switching focus
|
||||
// should add/update the selected host without discarding other hosts.
|
||||
await get().fetchRepos()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearRuntimeCompatibilityCacheForTests,
|
||||
markRuntimeEnvironmentCompatible
|
||||
} from '@/runtime/runtime-rpc-client'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import {
|
||||
hydrateOwnerWorktreeVisibilityDefaults,
|
||||
readRuntimeWorktreeVisibilityDefaults
|
||||
} from './worktree-visibility-owner-settings'
|
||||
|
||||
const runtimeCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
markRuntimeEnvironmentCompatible('env-1')
|
||||
runtimeCall.mockReset()
|
||||
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
|
||||
})
|
||||
|
||||
describe('runtime worktree visibility defaults', () => {
|
||||
it('reads defaults from the selected runtime owner', async () => {
|
||||
runtimeCall.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { settings: { worktreeVisibilityDefaults: { external: 'show' } } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(readRuntimeWorktreeVisibilityDefaults('env-1')).resolves.toEqual({
|
||||
external: 'show'
|
||||
})
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ selector: 'env-1', method: 'settings.get' })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses compatibility fallback semantics when the host omits the field', async () => {
|
||||
runtimeCall.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { settings: {} },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(readRuntimeWorktreeVisibilityDefaults('env-1')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('preserves cached support state when the host is temporarily unavailable', async () => {
|
||||
runtimeCall.mockRejectedValue(new Error('offline'))
|
||||
|
||||
const hydrated = await hydrateOwnerWorktreeVisibilityDefaults(
|
||||
{
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
worktreeVisibilityDefaults: { external: 'hide' }
|
||||
} as GlobalSettings,
|
||||
{ 'runtime:env-1': { external: 'show' } }
|
||||
)
|
||||
|
||||
expect(hydrated.settings.worktreeVisibilityDefaults).toEqual({ external: 'show' })
|
||||
expect(hydrated.supportedRuntimeEnvironmentId).toBe('env-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toRuntimeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { GlobalSettings, WorktreeVisibilityDefaults } from '../../../../shared/types'
|
||||
|
||||
export type WorktreeVisibilityDefaultsByHost = Partial<
|
||||
Record<ExecutionHostId, WorktreeVisibilityDefaults | null>
|
||||
>
|
||||
|
||||
export async function readRuntimeWorktreeVisibilityDefaults(
|
||||
environmentId: string
|
||||
): Promise<WorktreeVisibilityDefaults | null | undefined> {
|
||||
try {
|
||||
const result = await callRuntimeRpc<{ settings: Partial<GlobalSettings> }>(
|
||||
{ kind: 'environment', environmentId },
|
||||
'settings.get',
|
||||
undefined,
|
||||
{ timeoutMs: 15_000, reuseRecentCompatibilityFailure: true }
|
||||
)
|
||||
const external = result.settings.worktreeVisibilityDefaults?.external
|
||||
return external === 'hide' || external === 'show' ? { external } : null
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function hydrateOwnerWorktreeVisibilityDefaults(
|
||||
settings: GlobalSettings,
|
||||
defaultsByHost: WorktreeVisibilityDefaultsByHost
|
||||
): Promise<{
|
||||
settings: GlobalSettings
|
||||
defaultsByHost: WorktreeVisibilityDefaultsByHost
|
||||
supportedRuntimeEnvironmentId: string | null
|
||||
}> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
if (target.kind !== 'environment') {
|
||||
return {
|
||||
settings,
|
||||
supportedRuntimeEnvironmentId: null,
|
||||
defaultsByHost: {
|
||||
...defaultsByHost,
|
||||
[LOCAL_EXECUTION_HOST_ID]: settings.worktreeVisibilityDefaults ?? { external: 'hide' }
|
||||
}
|
||||
}
|
||||
}
|
||||
const hostId = toRuntimeExecutionHostId(target.environmentId)
|
||||
const localDefaults =
|
||||
defaultsByHost[LOCAL_EXECUTION_HOST_ID] ?? settings.worktreeVisibilityDefaults
|
||||
const ownerDefaultsByHost = localDefaults
|
||||
? { ...defaultsByHost, [LOCAL_EXECUTION_HOST_ID]: localDefaults }
|
||||
: defaultsByHost
|
||||
const defaults = await readRuntimeWorktreeVisibilityDefaults(target.environmentId)
|
||||
if (defaults) {
|
||||
return {
|
||||
settings: { ...settings, worktreeVisibilityDefaults: defaults },
|
||||
defaultsByHost: { ...ownerDefaultsByHost, [hostId]: defaults },
|
||||
supportedRuntimeEnvironmentId: target.environmentId
|
||||
}
|
||||
}
|
||||
if (defaults === undefined) {
|
||||
const cached = ownerDefaultsByHost[hostId]
|
||||
if (cached) {
|
||||
return {
|
||||
settings: { ...settings, worktreeVisibilityDefaults: cached },
|
||||
defaultsByHost: ownerDefaultsByHost,
|
||||
supportedRuntimeEnvironmentId: target.environmentId
|
||||
}
|
||||
}
|
||||
const { worktreeVisibilityDefaults: _unavailable, ...settingsWithoutDefaults } = settings
|
||||
return {
|
||||
settings: settingsWithoutDefaults as GlobalSettings,
|
||||
defaultsByHost: ownerDefaultsByHost,
|
||||
supportedRuntimeEnvironmentId: null
|
||||
}
|
||||
}
|
||||
const { worktreeVisibilityDefaults: _unsupported, ...settingsWithoutDefaults } = settings
|
||||
return {
|
||||
settings: settingsWithoutDefaults as GlobalSettings,
|
||||
defaultsByHost: { ...ownerDefaultsByHost, [hostId]: null },
|
||||
supportedRuntimeEnvironmentId: null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { AppState } from '../types'
|
||||
import { markRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import { persistVisibilityAwareSettings } from './worktree-visibility-settings-write'
|
||||
|
||||
it('preserves the active runtime default during unrelated local settings writes', async () => {
|
||||
const currentSettings = {
|
||||
activeRuntimeEnvironmentId: 'env-1',
|
||||
worktreeVisibilityDefaults: { external: 'show' }
|
||||
} as GlobalSettings
|
||||
let state = {
|
||||
settings: currentSettings,
|
||||
worktreeVisibilityDefaultsByHost: { 'runtime:env-1': { external: 'show' } }
|
||||
} as unknown as AppState
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
settings: {
|
||||
set: vi.fn().mockResolvedValue({
|
||||
pluginSystemEnabled: true,
|
||||
worktreeVisibilityDefaults: { external: 'hide' }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await persistVisibilityAwareSettings({
|
||||
normalizedUpdates: { pluginSystemEnabled: true },
|
||||
currentSettings,
|
||||
supportedRuntimeEnvironmentId: 'env-1',
|
||||
set: (updater) => {
|
||||
state = { ...state, ...updater(state) }
|
||||
}
|
||||
})
|
||||
|
||||
expect(state.settings?.worktreeVisibilityDefaults).toEqual({ external: 'show' })
|
||||
})
|
||||
|
||||
it('reclassifies worktrees after changing the host visibility default', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
settings: {
|
||||
set: vi.fn().mockResolvedValue({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
}
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
const fetchAllWorktrees = vi.fn().mockResolvedValue(undefined)
|
||||
store.setState({ fetchAllWorktrees })
|
||||
|
||||
await store.getState().updateSettings({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
|
||||
expect(fetchAllWorktrees).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not restore a stale owner after its visibility write resolves', async () => {
|
||||
markRuntimeEnvironmentCompatible('env-a')
|
||||
let resolveUpdate!: (value: unknown) => void
|
||||
const update = new Promise((resolve) => (resolveUpdate = resolve))
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
call: vi.fn().mockReturnValue(update)
|
||||
}
|
||||
}
|
||||
})
|
||||
const currentSettings = {
|
||||
activeRuntimeEnvironmentId: 'env-a',
|
||||
worktreeVisibilityDefaults: { external: 'hide' }
|
||||
} as GlobalSettings
|
||||
let state = {
|
||||
settings: currentSettings,
|
||||
worktreeVisibilityDefaultsByHost: {}
|
||||
} as unknown as AppState
|
||||
const write = persistVisibilityAwareSettings({
|
||||
normalizedUpdates: { worktreeVisibilityDefaults: { external: 'show' } },
|
||||
currentSettings,
|
||||
supportedRuntimeEnvironmentId: 'env-a',
|
||||
set: (updater) => {
|
||||
state = { ...state, ...updater(state) }
|
||||
}
|
||||
})
|
||||
state = { ...state, settings: { activeRuntimeEnvironmentId: 'env-b' } as GlobalSettings }
|
||||
resolveUpdate({
|
||||
ok: true,
|
||||
result: { settings: { worktreeVisibilityDefaults: { external: 'show' } } },
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
await write
|
||||
|
||||
expect(state.settings?.activeRuntimeEnvironmentId).toBe('env-b')
|
||||
expect(state.worktreeVisibilityDefaultsByHost['runtime:env-a']).toEqual({ external: 'show' })
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
toRuntimeExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
export async function persistVisibilityAwareSettings(args: {
|
||||
normalizedUpdates: Partial<GlobalSettings>
|
||||
currentSettings: GlobalSettings | null
|
||||
supportedRuntimeEnvironmentId: string | null
|
||||
set: (updater: (state: AppState) => Partial<AppState>) => void
|
||||
}): Promise<void> {
|
||||
const { normalizedUpdates, currentSettings, supportedRuntimeEnvironmentId, set } = args
|
||||
const target = getActiveRuntimeTarget(currentSettings)
|
||||
if ('worktreeVisibilityDefaults' in normalizedUpdates && target.kind === 'environment') {
|
||||
const { worktreeVisibilityDefaults, ...localUpdates } = normalizedUpdates
|
||||
const localSettings =
|
||||
Object.keys(localUpdates).length > 0
|
||||
? ((await window.api.settings.set(localUpdates)) as GlobalSettings)
|
||||
: currentSettings
|
||||
let nextSettings = localSettings
|
||||
if (target.environmentId === supportedRuntimeEnvironmentId) {
|
||||
const result = await callRuntimeRpc<{ settings: Partial<GlobalSettings> }>(
|
||||
target,
|
||||
'settings.update',
|
||||
{ worktreeVisibilityDefaults },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
nextSettings = {
|
||||
...localSettings,
|
||||
worktreeVisibilityDefaults: result.settings.worktreeVisibilityDefaults
|
||||
} as GlobalSettings
|
||||
}
|
||||
set((state) => {
|
||||
const currentTarget = getActiveRuntimeTarget(state.settings)
|
||||
const stillFocused =
|
||||
currentTarget.kind === 'environment' && currentTarget.environmentId === target.environmentId
|
||||
return {
|
||||
settings: stillFocused ? nextSettings : state.settings,
|
||||
worktreeVisibilityDefaultsByHost:
|
||||
target.environmentId === supportedRuntimeEnvironmentId &&
|
||||
nextSettings?.worktreeVisibilityDefaults
|
||||
? {
|
||||
...state.worktreeVisibilityDefaultsByHost,
|
||||
[toRuntimeExecutionHostId(target.environmentId)]:
|
||||
nextSettings.worktreeVisibilityDefaults
|
||||
}
|
||||
: state.worktreeVisibilityDefaultsByHost
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
const nextSettings = await window.api.settings.set(normalizedUpdates)
|
||||
set((state) => ({
|
||||
settings: (() => {
|
||||
const persisted = (nextSettings as GlobalSettings | undefined) ?? state.settings
|
||||
if (!persisted || target.kind !== 'environment') {
|
||||
return persisted
|
||||
}
|
||||
const defaults =
|
||||
state.worktreeVisibilityDefaultsByHost[toRuntimeExecutionHostId(target.environmentId)] ??
|
||||
currentSettings?.worktreeVisibilityDefaults
|
||||
if (target.environmentId === supportedRuntimeEnvironmentId && defaults) {
|
||||
return { ...persisted, worktreeVisibilityDefaults: defaults }
|
||||
}
|
||||
const { worktreeVisibilityDefaults: _unsupported, ...settingsWithoutDefaults } = persisted
|
||||
return settingsWithoutDefaults as GlobalSettings
|
||||
})(),
|
||||
...('worktreeVisibilityDefaults' in normalizedUpdates
|
||||
? {
|
||||
worktreeVisibilityDefaultsByHost: {
|
||||
...state.worktreeVisibilityDefaultsByHost,
|
||||
[LOCAL_EXECUTION_HOST_ID]: (nextSettings as GlobalSettings | undefined)
|
||||
?.worktreeVisibilityDefaults ??
|
||||
normalizedUpdates.worktreeVisibilityDefaults ?? { external: 'hide' }
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}))
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
getSettingsFocusedExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
@@ -6222,12 +6223,29 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
|
||||
const restoredSessionOwnersChanged =
|
||||
survivingRestoredSessionOwners !== s.restoredRuntimeHostIdByWorkspaceSessionKey
|
||||
let survivingVisibilityDefaults = s.worktreeVisibilityDefaultsByHost
|
||||
for (const environmentId of removed) {
|
||||
const hostId = toRuntimeExecutionHostId(environmentId)
|
||||
if (hostId in survivingVisibilityDefaults) {
|
||||
if (survivingVisibilityDefaults === s.worktreeVisibilityDefaultsByHost) {
|
||||
survivingVisibilityDefaults = { ...survivingVisibilityDefaults }
|
||||
}
|
||||
delete survivingVisibilityDefaults[hostId]
|
||||
}
|
||||
}
|
||||
const visibilityDefaultsChanged =
|
||||
survivingVisibilityDefaults !== s.worktreeVisibilityDefaultsByHost
|
||||
const visibilitySupportChanged = removed.has(
|
||||
s.worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId ?? ''
|
||||
)
|
||||
if (
|
||||
!reposChanged &&
|
||||
!setupsChanged &&
|
||||
!worktreesChanged &&
|
||||
!detectedChanged &&
|
||||
!restoredSessionOwnersChanged &&
|
||||
!visibilityDefaultsChanged &&
|
||||
!visibilitySupportChanged &&
|
||||
removedWorktreeIds.size === 0
|
||||
) {
|
||||
return s
|
||||
@@ -6252,6 +6270,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
...(restoredSessionOwnersChanged
|
||||
? { restoredRuntimeHostIdByWorkspaceSessionKey: survivingRestoredSessionOwners }
|
||||
: {}),
|
||||
...(visibilityDefaultsChanged
|
||||
? { worktreeVisibilityDefaultsByHost: survivingVisibilityDefaults }
|
||||
: {}),
|
||||
...(visibilitySupportChanged
|
||||
? { worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null }
|
||||
: {}),
|
||||
...(rowsChanged ? { sortEpoch: s.sortEpoch + 1 } : {}),
|
||||
// Why: mirror validateRepoScopedUi so a filtered/active sidebar can't reference a purged repo id.
|
||||
...(reposChanged
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getRepoOwnerWorktreeVisibilityDefaults } from './worktree-visibility-defaults-by-host'
|
||||
|
||||
describe('getRepoOwnerWorktreeVisibilityDefaults', () => {
|
||||
it('resolves defaults from each repository owner', () => {
|
||||
const settings = { worktreeVisibilityDefaults: { external: 'hide' as const } }
|
||||
const defaultsByHost = {
|
||||
local: { external: 'hide' as const },
|
||||
'runtime:show-host': { external: 'show' as const },
|
||||
'runtime:legacy-host': null
|
||||
}
|
||||
|
||||
expect(
|
||||
getRepoOwnerWorktreeVisibilityDefaults(
|
||||
{ executionHostId: 'runtime:show-host' },
|
||||
settings,
|
||||
defaultsByHost
|
||||
)
|
||||
).toEqual({ external: 'show' })
|
||||
expect(
|
||||
getRepoOwnerWorktreeVisibilityDefaults({ connectionId: 'ssh-box' }, settings, defaultsByHost)
|
||||
).toEqual({ external: 'hide' })
|
||||
expect(
|
||||
getRepoOwnerWorktreeVisibilityDefaults(
|
||||
{ executionHostId: 'runtime:legacy-host' },
|
||||
settings,
|
||||
defaultsByHost
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { GlobalSettings, Repo, WorktreeVisibilityDefaults } from '../../../shared/types'
|
||||
|
||||
export function getRepoOwnerWorktreeVisibilityDefaults(
|
||||
repo: Pick<Repo, 'connectionId' | 'executionHostId'>,
|
||||
settings: Pick<GlobalSettings, 'worktreeVisibilityDefaults'> | null | undefined,
|
||||
defaultsByHost: Partial<Record<ExecutionHostId, WorktreeVisibilityDefaults | null>>
|
||||
): WorktreeVisibilityDefaults | undefined {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
if (parseExecutionHostId(hostId)?.kind === 'runtime') {
|
||||
return defaultsByHost[hostId] ?? undefined
|
||||
}
|
||||
return defaultsByHost.local ?? settings?.worktreeVisibilityDefaults
|
||||
}
|
||||
@@ -1124,6 +1124,93 @@ describe('web settings preload API', () => {
|
||||
expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }])
|
||||
}, 15_000)
|
||||
|
||||
it('hydrates and updates external-worktree defaults owned by a paired runtime', async () => {
|
||||
const runtimeCalls: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
|
||||
runtimeCalls.push({ method, params })
|
||||
return Promise.resolve({
|
||||
id: `call-${runtimeCalls.length}`,
|
||||
ok: true,
|
||||
result: {
|
||||
settings: {
|
||||
worktreeVisibilityDefaults: {
|
||||
external: method === 'settings.update' ? 'show' : 'hide'
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
globals.storage.setItem(
|
||||
'orca.web.settings.v1',
|
||||
JSON.stringify({ worktreeVisibilityDefaults: { external: 'show' } })
|
||||
)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
expect((await globals.window.api.settings.get()).worktreeVisibilityDefaults).toEqual({
|
||||
external: 'hide'
|
||||
})
|
||||
expect(
|
||||
JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}').worktreeVisibilityDefaults
|
||||
).toEqual({ external: 'show' })
|
||||
expect(
|
||||
(
|
||||
await globals.window.api.settings.set({
|
||||
worktreeVisibilityDefaults: { external: 'show' }
|
||||
})
|
||||
).worktreeVisibilityDefaults
|
||||
).toEqual({ external: 'show' })
|
||||
expect(runtimeCalls).toEqual([
|
||||
{ method: 'settings.get', params: undefined },
|
||||
{
|
||||
method: 'settings.update',
|
||||
params: { worktreeVisibilityDefaults: { external: 'show' } }
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not send the additive visibility field to an older paired runtime', async () => {
|
||||
const runtimeCalls: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
|
||||
runtimeCalls.push({ method, params })
|
||||
return Promise.resolve({
|
||||
id: `call-${runtimeCalls.length}`,
|
||||
ok: true,
|
||||
result: { settings: {} },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
expect((await globals.window.api.settings.get()).worktreeVisibilityDefaults).toBeUndefined()
|
||||
const settings = await globals.window.api.settings.set({
|
||||
worktreeVisibilityDefaults: { external: 'show' }
|
||||
})
|
||||
|
||||
expect(settings.worktreeVisibilityDefaults).toBeUndefined()
|
||||
expect(runtimeCalls).toEqual([{ method: 'settings.get', params: undefined }])
|
||||
})
|
||||
|
||||
it('hydrates new worktree card style from a paired runtime', async () => {
|
||||
const runtimeCalls: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
StatsSummary,
|
||||
Worktree,
|
||||
WorktreeLineage,
|
||||
WorktreeVisibilityDefaults,
|
||||
WorkspaceLineage,
|
||||
WorkspaceSessionPatch,
|
||||
WorkspaceSessionState
|
||||
@@ -179,6 +180,8 @@ export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024
|
||||
const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000
|
||||
|
||||
let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment()
|
||||
let worktreeVisibilityDefaultsRuntimeEnvironmentId: string | null = null
|
||||
let worktreeVisibilityDefaultsRuntimeValue: WorktreeVisibilityDefaults | null = null
|
||||
let activeClient: WebRuntimeClient | null = null
|
||||
let activeClientEnvironmentId: string | null = null
|
||||
const manuallyDisconnectedEnvironmentIds = new Set<string>()
|
||||
@@ -682,10 +685,24 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
settings: {
|
||||
get: async () => getRuntimeBackedStoredSettings(),
|
||||
// Why: localStorage-backed settings are synchronous, so the pre-hydration kill-switch read works the same as desktop.
|
||||
getSync: () => getStoredSettings(),
|
||||
getSync: () => settingsForActiveVisibilityOwner(getStoredSettings()),
|
||||
set: async (updates) => {
|
||||
const sanitizedUpdates = { ...updates }
|
||||
const runtimeEnvironment = requireActiveEnvironmentOrNull()
|
||||
delete sanitizedUpdates.activeRuntimeEnvironmentId
|
||||
if (
|
||||
'worktreeVisibilityDefaults' in sanitizedUpdates &&
|
||||
runtimeEnvironment &&
|
||||
runtimeEnvironment.id !== worktreeVisibilityDefaultsRuntimeEnvironmentId
|
||||
) {
|
||||
delete sanitizedUpdates.worktreeVisibilityDefaults
|
||||
}
|
||||
if ('worktreeVisibilityDefaults' in sanitizedUpdates) {
|
||||
sanitizedUpdates.worktreeVisibilityDefaults = {
|
||||
...getStoredSettings().worktreeVisibilityDefaults,
|
||||
...sanitizedUpdates.worktreeVisibilityDefaults
|
||||
}
|
||||
}
|
||||
if ('computerAwakeMode' in sanitizedUpdates) {
|
||||
Object.assign(
|
||||
sanitizedUpdates,
|
||||
@@ -720,7 +737,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
preserveAutoRenameBranchFromWorkUpdate: 'autoRenameBranchFromWork' in sanitizedUpdates
|
||||
})
|
||||
writeStoredSettings(next)
|
||||
return syncRuntimeBackedSettings(sanitizedUpdates, next)
|
||||
return settingsForActiveVisibilityOwner(
|
||||
await syncRuntimeBackedSettings(sanitizedUpdates, next)
|
||||
)
|
||||
},
|
||||
setActiveRuntimeEnvironmentPreference: async ({ environmentId }) => {
|
||||
const requestedEnvironmentId = environmentId?.trim() || null
|
||||
@@ -3807,6 +3826,18 @@ async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
|
||||
15_000
|
||||
)
|
||||
const runtimeSettings: Partial<GlobalSettings> = {}
|
||||
worktreeVisibilityDefaultsRuntimeEnvironmentId = null
|
||||
worktreeVisibilityDefaultsRuntimeValue = null
|
||||
if (
|
||||
result.settings.worktreeVisibilityDefaults?.external === 'hide' ||
|
||||
result.settings.worktreeVisibilityDefaults?.external === 'show'
|
||||
) {
|
||||
worktreeVisibilityDefaultsRuntimeValue = {
|
||||
...result.settings.worktreeVisibilityDefaults,
|
||||
external: result.settings.worktreeVisibilityDefaults.external
|
||||
}
|
||||
worktreeVisibilityDefaultsRuntimeEnvironmentId = requireActiveEnvironment().id
|
||||
}
|
||||
if (typeof result.settings.experimentalNewWorktreeCardStyle === 'boolean') {
|
||||
runtimeSettings.experimentalNewWorktreeCardStyle =
|
||||
result.settings.experimentalNewWorktreeCardStyle
|
||||
@@ -3832,13 +3863,28 @@ async function getRuntimeBackedStoredSettings(): Promise<GlobalSettings> {
|
||||
}
|
||||
const next = mergeSettings(local, runtimeSettings)
|
||||
writeStoredSettings(next)
|
||||
return next
|
||||
return settingsForActiveVisibilityOwner(next)
|
||||
} catch {
|
||||
// Why: unpaired/offline web clients keep a local settings fallback.
|
||||
return local
|
||||
return settingsForActiveVisibilityOwner(local)
|
||||
}
|
||||
}
|
||||
|
||||
function settingsForActiveVisibilityOwner(settings: GlobalSettings): GlobalSettings {
|
||||
const environment = requireActiveEnvironmentOrNull()
|
||||
if (!environment) {
|
||||
return settings
|
||||
}
|
||||
if (
|
||||
environment.id === worktreeVisibilityDefaultsRuntimeEnvironmentId &&
|
||||
worktreeVisibilityDefaultsRuntimeValue
|
||||
) {
|
||||
return { ...settings, worktreeVisibilityDefaults: worktreeVisibilityDefaultsRuntimeValue }
|
||||
}
|
||||
const { worktreeVisibilityDefaults: _unsupported, ...supportedSettings } = settings
|
||||
return supportedSettings as GlobalSettings
|
||||
}
|
||||
|
||||
async function syncRuntimeBackedSettings(
|
||||
updates: Partial<GlobalSettings>,
|
||||
localNext: GlobalSettings
|
||||
@@ -3847,6 +3893,14 @@ async function syncRuntimeBackedSettings(
|
||||
return localNext
|
||||
}
|
||||
const runtimeUpdates: Partial<GlobalSettings> = {}
|
||||
if (
|
||||
updates.worktreeVisibilityDefaults?.external === 'hide' ||
|
||||
updates.worktreeVisibilityDefaults?.external === 'show'
|
||||
) {
|
||||
runtimeUpdates.worktreeVisibilityDefaults = {
|
||||
external: updates.worktreeVisibilityDefaults.external
|
||||
}
|
||||
}
|
||||
if (typeof updates.experimentalNewWorktreeCardStyle === 'boolean') {
|
||||
runtimeUpdates.experimentalNewWorktreeCardStyle = updates.experimentalNewWorktreeCardStyle
|
||||
}
|
||||
@@ -3875,6 +3929,12 @@ async function syncRuntimeBackedSettings(
|
||||
)
|
||||
const runtimeSettings = { ...result.settings }
|
||||
delete runtimeSettings.activeRuntimeEnvironmentId
|
||||
const external = runtimeSettings.worktreeVisibilityDefaults?.external
|
||||
if (external === 'hide' || external === 'show') {
|
||||
worktreeVisibilityDefaultsRuntimeEnvironmentId = requireActiveEnvironment().id
|
||||
worktreeVisibilityDefaultsRuntimeValue = { external }
|
||||
}
|
||||
delete runtimeSettings.worktreeVisibilityDefaults
|
||||
const next = mergeSettings(localNext, runtimeSettings)
|
||||
writeStoredSettings(next)
|
||||
return next
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
} from '../../../shared/protocol-version'
|
||||
|
||||
const fakeSockets: FakeWebSocket[] = []
|
||||
@@ -83,7 +84,8 @@ describe('WebRuntimeClient', () => {
|
||||
deviceToken: 'token',
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { normalizeRuntimePathSeparators } from './cross-platform-path'
|
||||
import type { ExternalWorktreeVisibility, Repo } from './types'
|
||||
import type { ExternalWorktreeVisibility, Repo, WorktreeVisibilityDefaults } from './types'
|
||||
|
||||
export const EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT = Date.UTC(2026, 4, 23)
|
||||
export const UNKNOWN_EXTERNAL_WORKTREE_PARENT_PATH = 'Unknown location'
|
||||
@@ -60,14 +60,59 @@ export function isLegacyRepoForExternalWorktreeVisibility(repo: Repo): boolean {
|
||||
|
||||
export function effectiveExternalWorktreeVisibility(
|
||||
repo: Pick<Repo, 'externalWorktreeVisibility'>,
|
||||
isLegacyRepoForVisibility: boolean
|
||||
isLegacyRepoForVisibility: boolean,
|
||||
defaults?: WorktreeVisibilityDefaults
|
||||
): ExternalWorktreeVisibility {
|
||||
if (repo.externalWorktreeVisibility) {
|
||||
return repo.externalWorktreeVisibility
|
||||
}
|
||||
if (defaults?.external === 'show' || defaults?.external === 'hide') {
|
||||
return defaults.external
|
||||
}
|
||||
return isLegacyRepoForVisibility ? 'show' : 'hide'
|
||||
}
|
||||
|
||||
export function normalizeWorktreeVisibilityDefaults(
|
||||
value: unknown
|
||||
): WorktreeVisibilityDefaults | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const external = (value as { external?: unknown }).external
|
||||
return external === 'show' || external === 'hide'
|
||||
? { ...(value as WorktreeVisibilityDefaults), external }
|
||||
: undefined
|
||||
}
|
||||
|
||||
export function migrateExternalWorktreeVisibilityDefaults(
|
||||
repos: readonly Repo[],
|
||||
value: unknown
|
||||
): {
|
||||
repos: Repo[]
|
||||
defaults: WorktreeVisibilityDefaults
|
||||
changed: boolean
|
||||
} {
|
||||
const defaults = normalizeWorktreeVisibilityDefaults(value)
|
||||
if (defaults) {
|
||||
return { repos: [...repos], defaults, changed: false }
|
||||
}
|
||||
const migratedRepos = repos.map((repo) => {
|
||||
if (
|
||||
repo.kind === 'folder' ||
|
||||
repo.externalWorktreeVisibility !== undefined ||
|
||||
!isLegacyRepoForExternalWorktreeVisibility(repo)
|
||||
) {
|
||||
return repo
|
||||
}
|
||||
return {
|
||||
...repo,
|
||||
externalWorktreeVisibility: 'show' as const,
|
||||
externalWorktreeVisibilityLegacy: true
|
||||
}
|
||||
})
|
||||
return { repos: migratedRepos, defaults: { external: 'hide' }, changed: true }
|
||||
}
|
||||
|
||||
export function effectiveAgentWorktreeVisibility(
|
||||
repo: Pick<Repo, 'agentWorktreeVisibility'>
|
||||
): ExternalWorktreeVisibility {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
import type { RuntimeStatus } from './runtime-types'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
} from './protocol-version'
|
||||
// Re-export so existing value importers of `RemoteRuntimeClientError` are
|
||||
// unaffected; the class lives in a ws-free module so type-only consumers
|
||||
@@ -139,7 +140,8 @@ async function sendRemoteRuntimeRequestOnSocket<TResult>(
|
||||
deviceToken: pairing.deviceToken,
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
const pendingRequest = {
|
||||
@@ -511,7 +513,8 @@ export async function subscribeRemoteRuntimeRequest<TResult>(
|
||||
deviceToken: pairing.deviceToken,
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
return await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connection'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
} from './protocol-version'
|
||||
|
||||
type TestServer = {
|
||||
@@ -63,7 +64,8 @@ describe('RemoteRuntimeRequestConnection', () => {
|
||||
expect.objectContaining({
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
)
|
||||
@@ -114,7 +116,8 @@ async function createServer(): Promise<TestServer> {
|
||||
deviceToken: 'device-token',
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
authenticated = true
|
||||
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
} from './protocol-version'
|
||||
|
||||
type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready'
|
||||
@@ -212,7 +213,8 @@ export class RemoteRuntimeRequestConnection {
|
||||
deviceToken: this.pairing.deviceToken,
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
}),
|
||||
sharedKey
|
||||
|
||||
@@ -20,13 +20,9 @@ import { getRemoteRuntimeRequestAdmissionEvidence } from './remote-runtime-prepa
|
||||
import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection'
|
||||
import * as sharedControlProtocol from './remote-runtime-shared-control-protocol'
|
||||
import { isRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
} from './protocol-version'
|
||||
import * as protocolCapabilities from './protocol-version'
|
||||
|
||||
const TEST_PROJECT_PATH = path.join('tmp', 'project')
|
||||
|
||||
type TestServer = {
|
||||
pairing: PairingOffer
|
||||
requests: { id: string; method: string; params?: unknown }[]
|
||||
@@ -66,8 +62,9 @@ describe('RemoteRuntimeSharedControlConnection', () => {
|
||||
type: 'e2ee_auth',
|
||||
deviceToken: 'device-token',
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
protocolCapabilities.SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
protocolCapabilities.AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
protocolCapabilities.WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
expect(server.requests.map((request) => request.method)).toEqual([
|
||||
|
||||
@@ -2,7 +2,8 @@ import { parseAuthenticatedFrame, parseReadyFrame } from './remote-runtime-reque
|
||||
import type { RemoteRuntimeClientError } from './remote-runtime-client-error'
|
||||
import {
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
} from './protocol-version'
|
||||
import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch'
|
||||
import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol'
|
||||
@@ -43,7 +44,8 @@ export function handleSharedControlTextFrame(args: {
|
||||
deviceToken: args.deviceToken,
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
})
|
||||
return
|
||||
|
||||
@@ -123,6 +123,11 @@ export type WorktreeVisibilitySourcePreferences = {
|
||||
custom?: Record<string, ExternalWorktreeVisibility>
|
||||
}
|
||||
|
||||
export type WorktreeVisibilityDefaults = {
|
||||
/** Default for worktrees Orca did not create. Future source-specific defaults extend this shape. */
|
||||
external?: ExternalWorktreeVisibility
|
||||
}
|
||||
|
||||
export type ProjectProviderIdentity = {
|
||||
provider: 'github'
|
||||
owner: string
|
||||
@@ -2801,6 +2806,8 @@ export type AgentDashboardMode = 'in-window' | 'popout'
|
||||
|
||||
export type GlobalSettings = {
|
||||
workspaceDir: string
|
||||
/** Host-owned defaults used when a repository has no explicit visibility override. */
|
||||
worktreeVisibilityDefaults?: WorktreeVisibilityDefaults
|
||||
/** Per-host overrides keyed by ExecutionHostId. Effective value for a
|
||||
* host-varying setting is `host override ?? client default`. */
|
||||
hostSettingOverrides?: Partial<Record<ExecutionHostId, HostSettingOverrides>>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -184,7 +184,10 @@ export function toDetectedWorktree(args: {
|
||||
repo: Repo
|
||||
worktree: Worktree
|
||||
meta?: WorktreeMeta
|
||||
settings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
|
||||
settings: Pick<
|
||||
GlobalSettings,
|
||||
'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory' | 'worktreeVisibilityDefaults'
|
||||
>
|
||||
knownOrcaLayouts: OrcaWorkspaceLayout[]
|
||||
isLegacyRepoForVisibility?: boolean
|
||||
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
|
||||
@@ -211,6 +214,7 @@ export function toDetectedWorktree(args: {
|
||||
isLegacyRepoForVisibility,
|
||||
isSelectedCheckout: selectedCheckout,
|
||||
importedExternalWorktreePaths: args.repo.importedExternalWorktreePaths,
|
||||
visibilityDefaults: args.settings.worktreeVisibilityDefaults,
|
||||
visibilitySource
|
||||
})
|
||||
|
||||
@@ -229,6 +233,7 @@ export function shouldShowWorktree(args: {
|
||||
repo: Repo
|
||||
isLegacyRepoForVisibility: boolean
|
||||
isSelectedCheckout: boolean
|
||||
visibilityDefaults?: GlobalSettings['worktreeVisibilityDefaults']
|
||||
importedExternalWorktreePaths?: readonly string[] | undefined
|
||||
visibilitySource?: ReturnType<WorktreeVisibilitySourceMatcher>
|
||||
}): boolean {
|
||||
@@ -254,7 +259,13 @@ export function shouldShowWorktree(args: {
|
||||
if (args.ownership === 'unknown-legacy' && args.isLegacyRepoForVisibility) {
|
||||
return true
|
||||
}
|
||||
return effectiveExternalWorktreeVisibility(args.repo, args.isLegacyRepoForVisibility) === 'show'
|
||||
return (
|
||||
effectiveExternalWorktreeVisibility(
|
||||
args.repo,
|
||||
args.isLegacyRepoForVisibility,
|
||||
args.visibilityDefaults
|
||||
) === 'show'
|
||||
)
|
||||
}
|
||||
|
||||
export function applyMetadataFallbackVisibility(detected: DetectedWorktree): DetectedWorktree {
|
||||
|
||||
Reference in New Issue
Block a user