fix(settings): render Projects settings per-project across hosts (#8566) (#8650)

* fix(settings): collapse Projects list to one row + pane per project (#8566)

Settings and its nav enumerated repo rows, so a project set up on multiple
execution hosts (local + a Remote Orca Server, or the same repo cloned on two
machines) rendered two nav rows + two panes that collided (duplicate React
key/DOM id) or mirrored. Derive the Settings list from the project projection
(repos-only, deterministic) so nav, panes, and the Cmd+J palette all collapse
to one entry per project. Deep-link repoId targets resolve to the project's
representative section.

* fix(settings): switch project host in place, host-scoped edits + deep links (#8566)

Add an ephemeral per-project host selection driven by the pane's "Available
Hosts" switcher, so the single collapsed pane shows the selected host's setup
(path, worktree base, runtime, fork-sync, hooks, source-control AI). Route
edits to the selected host by threading an optional hostId through updateRepo
(mirrors removeProject's host routing), fixing the same-id/self-pair case where
a local + runtime share one repo id. Couple Settings deep links to the switcher
so host-specific subsection anchors resolve, load hooks for the selected host,
and make pane-level Remove Project remove every host setup.

* fix(settings): isolate selected project host state

* fix(settings): review follow-ups for per-project Projects pane (#8566)

- Remove Project copy now states it removes the project on all
  configured hosts (the button removes every host setup); new catalog
  key translated across all 5 locales
- ensureHooksConfirmed forwards hostId to readRuntimeIssueCommand so
  the issue-command trust read resolves duplicate repo ids the same way
  as its sibling checkRuntimeHooks call
- translate the multi-host "N hosts" nav description
- extract removeSettingsProjectFromAllHosts with unit tests; drop the
  now-unused getRuntimeTargetIdentity
This commit is contained in:
Brennan Benson 2026-07-14 15:00:52 -07:00 committed by GitHub
parent 59a7fffcd6
commit a5cea59933
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1198 additions and 261 deletions

View File

@ -87,7 +87,12 @@ import type { RepoMethod } from '../../shared/telemetry-events'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import {
getRepoExecutionHostId,
normalizeExecutionHostId,
parseExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import {
assertFolderWorkspacePathUsable,
@ -2430,8 +2435,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle(
'repos:getBaseRefDefault',
async (_event, args: { repoId: string }): Promise<BaseRefDefaultResult> => {
const repo = store.getRepo(args.repoId)
async (
_event,
args: { repoId: string; hostId?: ExecutionHostId }
): Promise<BaseRefDefaultResult> => {
const repo = getRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
// Why: folder-mode repos have no git state to resolve a base ref from.
// Return null + 0 so the renderer can decline to use a fabricated default
@ -2507,14 +2515,20 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.handle(
'repos:searchBaseRefs',
async (_event, args: { repoId: string; query: string; limit?: number }) => {
async (
_event,
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
) => {
return (await searchBaseRefDetailsForRepo(store, args)).map((entry) => entry.refName)
}
)
ipcMain.handle(
'repos:searchBaseRefDetails',
async (_event, args: { repoId: string; query: string; limit?: number }) => {
async (
_event,
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
) => {
return searchBaseRefDetailsForRepo(store, args)
}
)
@ -2522,9 +2536,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
async function searchBaseRefDetailsForRepo(
store: Store,
args: { repoId: string; query: string; limit?: number }
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
): Promise<BaseRefSearchResult[]> {
const repo = store.getRepo(args.repoId)
const repo = getRepoForExecutionHost(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return []
}
@ -2602,6 +2616,23 @@ async function searchBaseRefDetailsForRepo(
return searchBaseRefDetails(repo.path, args.query, limit)
}
function getRepoForExecutionHost(
store: Store,
repoId: string,
hostId?: ExecutionHostId
): Repo | null {
if (!hostId) {
return store.getRepo(repoId) ?? null
}
// Why: repo ids can collide across local and SSH hosts; base-ref reads must
// use the same host selected by the Settings pane as the subsequent write.
return (
store
.getRepos()
.find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) ?? null
)
}
function notifyReposChanged(mainWindow: BrowserWindow): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:changed')

View File

@ -8428,6 +8428,45 @@ describe('registerWorktreeHandlers', () => {
expect(fsProvider.writeFile).not.toHaveBeenCalled()
})
it('reads an issue-command override from the requested host when repo ids collide', async () => {
const localRepo = {
id: 'repo-shared',
path: '/local/repo',
displayName: 'local',
badgeColor: '#000',
addedAt: 0
}
const sshRepo = {
...localRepo,
path: '/remote/repo',
displayName: 'ssh',
connectionId: 'conn-1'
}
const fsProvider = {
readFile: vi.fn(async (filePath: string) => {
if (filePath.endsWith('/.orca/issue-command')) {
return { content: 'remote command\n', isBinary: false }
}
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
})
}
store.getRepos.mockReturnValue([localRepo, sshRepo])
store.getRepo.mockReturnValue(localRepo)
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
await expect(
handlers['hooks:readIssueCommand'](null, {
repoId: 'repo-shared',
hostId: 'ssh:conn-1'
})
).resolves.toMatchObject({
localContent: 'remote command',
effectiveContent: 'remote command',
source: 'local'
})
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.orca/issue-command')
})
it('creates remote .gitignore only when it is missing while writing SSH issue commands', async () => {
const repo = {
id: 'repo-ssh',

View File

@ -2248,74 +2248,77 @@ export function registerWorktreeHandlers(
)
})
ipcMain.handle('hooks:readIssueCommand', async (_event, args: { repoId: string }) => {
const repo = store.getRepo(args.repoId)
if (!repo || isFolderRepo(repo)) {
return {
status: 'ok',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: '',
source: 'none' as const
}
}
if (repo.connectionId) {
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
ipcMain.handle(
'hooks:readIssueCommand',
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return {
status: 'error',
status: 'ok',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: issueCommandPath,
localFilePath: '',
source: 'none' as const
}
}
if (repo.connectionId) {
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
const fsProvider = getSshFilesystemProvider(repo.connectionId)
if (!fsProvider) {
return {
status: 'error',
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: issueCommandPath,
source: 'none' as const
}
}
let status: 'ok' | 'error' = 'ok'
let localContent: string | null = null
let sharedContent: string | null = null
try {
const result = await fsProvider.readFile(issueCommandPath)
localContent = result.isBinary ? null : result.content.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
let status: 'ok' | 'error' = 'ok'
let localContent: string | null = null
let sharedContent: string | null = null
try {
const result = await fsProvider.readFile(issueCommandPath)
localContent = result.isBinary ? null : result.content.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
}
}
try {
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
sharedContent = result.isBinary
? null
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
}
}
const effectiveContent = localContent ?? sharedContent
return {
status: localContent ? 'ok' : status,
localContent,
sharedContent,
effectiveContent,
localFilePath: issueCommandPath,
source: localContent
? ('local' as const)
: sharedContent
? ('shared' as const)
: ('none' as const)
}
}
try {
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
sharedContent = result.isBinary
? null
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
} catch (error) {
if (!isENOENT(error)) {
status = 'error'
}
}
const effectiveContent = localContent ?? sharedContent
return {
status: localContent ? 'ok' : status,
localContent,
sharedContent,
effectiveContent,
localFilePath: issueCommandPath,
source: localContent
? ('local' as const)
: sharedContent
? ('shared' as const)
: ('none' as const)
}
return readIssueCommand(repo.path)
}
return readIssueCommand(repo.path)
})
)
ipcMain.handle(
'hooks:writeIssueCommand',
async (_event, args: { repoId: string; content: string }) => {
const repo = store.getRepo(args.repoId)
async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => {
const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId)
if (!repo || isFolderRepo(repo)) {
return
}

View File

@ -1020,12 +1020,21 @@ export type PreloadApi = {
getDefaultCreateProjectParent: () => Promise<string>
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
getGitUsername: (args: { repoId: string }) => Promise<string>
getBaseRefDefault: (args: { repoId: string }) => Promise<BaseRefDefaultResult>
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise<string[]>
getBaseRefDefault: (args: {
repoId: string
hostId?: ExecutionHostId
}) => Promise<BaseRefDefaultResult>
searchBaseRefs: (args: {
repoId: string
query: string
limit?: number
hostId?: ExecutionHostId
}) => Promise<string[]>
searchBaseRefDetails: (args: {
repoId: string
query: string
limit?: number
hostId?: ExecutionHostId
}) => Promise<BaseRefSearchResult[]>
onChanged: (callback: () => void) => () => void
}
@ -2242,7 +2251,7 @@ export type PreloadApi = {
worktreePath: string
command: string
}) => Promise<WorktreeSetupLaunch>
readIssueCommand: (args: { repoId: string }) => Promise<{
readIssueCommand: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{
status?: 'ok' | 'error'
localContent: string | null
sharedContent: string | null
@ -2250,7 +2259,11 @@ export type PreloadApi = {
localFilePath: string
source: 'local' | 'shared' | 'none'
}>
writeIssueCommand: (args: { repoId: string; content: string }) => Promise<void>
writeIssueCommand: (args: {
repoId: string
content: string
hostId?: ExecutionHostId
}) => Promise<void>
}
ephemeralVm: {
listRecipes: (args: { repoId: string }) => Promise<{

View File

@ -598,16 +598,23 @@ const api = {
getGitUsername: (args: { repoId: string }): Promise<string> =>
ipcRenderer.invoke('repos:getGitUsername', args),
getBaseRefDefault: (args: { repoId: string }): Promise<BaseRefDefaultResult> =>
ipcRenderer.invoke('repos:getBaseRefDefault', args),
getBaseRefDefault: (args: {
repoId: string
hostId?: ExecutionHostId
}): Promise<BaseRefDefaultResult> => ipcRenderer.invoke('repos:getBaseRefDefault', args),
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }): Promise<string[]> =>
ipcRenderer.invoke('repos:searchBaseRefs', args),
searchBaseRefs: (args: {
repoId: string
query: string
limit?: number
hostId?: ExecutionHostId
}): Promise<string[]> => ipcRenderer.invoke('repos:searchBaseRefs', args),
searchBaseRefDetails: (args: {
repoId: string
query: string
limit?: number
hostId?: ExecutionHostId
}): Promise<BaseRefSearchResult[]> => ipcRenderer.invoke('repos:searchBaseRefDetails', args),
onChanged: (callback: () => void): (() => void) => {
@ -2639,6 +2646,7 @@ const api = {
readIssueCommand: (args: {
repoId: string
hostId?: ExecutionHostId
}): Promise<{
status?: 'ok' | 'error'
localContent: string | null
@ -2648,8 +2656,11 @@ const api = {
source: 'local' | 'shared' | 'none'
}> => ipcRenderer.invoke('hooks:readIssueCommand', args),
writeIssueCommand: (args: { repoId: string; content: string }): Promise<void> =>
ipcRenderer.invoke('hooks:writeIssueCommand', args)
writeIssueCommand: (args: {
repoId: string
content: string
hostId?: ExecutionHostId
}): Promise<void> => ipcRenderer.invoke('hooks:writeIssueCommand', args)
},
ephemeralVm: {

View File

@ -10,9 +10,11 @@ import {
} from '@/runtime/runtime-repo-client'
import { isRuntimeRepoRefSearchQueryWithinLimit } from '@/runtime/runtime-repo-search-bounds'
import { translate } from '@/i18n/i18n'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
type BaseRefPickerProps = {
repoId: string
hostId?: ExecutionHostId
currentBaseRef?: string
onSelect: (ref: string) => void
onUsePrimary?: () => void
@ -20,13 +22,20 @@ type BaseRefPickerProps = {
export function BaseRefPicker({
repoId,
hostId,
currentBaseRef,
onSelect,
onUsePrimary
}: BaseRefPickerProps): React.JSX.Element {
const activeRuntimeEnvironmentId = useAppStore((state) =>
const focusedRuntimeEnvironmentId = useAppStore((state) =>
getRuntimeEnvironmentIdForRepo(state, repoId)
)
const selectedHost = parseExecutionHostId(hostId)
const activeRuntimeEnvironmentId = hostId
? selectedHost?.kind === 'runtime'
? selectedHost.environmentId
: null
: focusedRuntimeEnvironmentId
// Why: null until the IPC resolves (or when the repo has no default base ref
// available). We avoid seeding with 'origin/main' because that would display
// a fabricated default in repos that don't actually have origin/main.
@ -64,7 +73,11 @@ export function BaseRefPicker({
const loadDefaultBaseRef = async (): Promise<void> => {
try {
const result = await getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId)
const result = await getRuntimeRepoBaseRefDefault(
{ activeRuntimeEnvironmentId },
repoId,
hostId
)
if (!stale) {
setDefaultBaseRef(result.defaultBaseRef)
setRemoteCount(result.remoteCount)
@ -90,7 +103,7 @@ export function BaseRefPicker({
return () => {
stale = true
}
}, [activeRuntimeEnvironmentId, repoId])
}, [activeRuntimeEnvironmentId, hostId, repoId])
useEffect(() => {
if (!isRuntimeRepoRefSearchQueryWithinLimit(baseRefQuery)) {
@ -109,7 +122,13 @@ export function BaseRefPicker({
setIsSearchingBaseRefs(true)
const timer = window.setTimeout(() => {
void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 20)
void searchRuntimeRepoBaseRefs(
{ activeRuntimeEnvironmentId },
repoId,
trimmedQuery,
20,
hostId
)
.then((results) => {
if (!stale) {
setBaseRefResults(results)
@ -132,7 +151,7 @@ export function BaseRefPicker({
stale = true
window.clearTimeout(timer)
}
}, [activeRuntimeEnvironmentId, baseRefQuery, repoId])
}, [activeRuntimeEnvironmentId, baseRefQuery, hostId, repoId])
const effectiveBaseRef = currentBaseRef ?? defaultBaseRef

View File

@ -1,6 +1,6 @@
/* eslint-disable max-lines -- Why: the script editor, advanced/Command Source disclosure, issue-command override, and YAML state surfaces share tightly coupled state and persistence; splitting them across files would scatter prop drilling. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: repository hook saves and issue-command overrides synchronize debounced persistence state with external repo settings. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type {
HookCommandSourcePolicy,
OrcaHooks,
@ -24,6 +24,7 @@ import { getRepositoryLocalCommandsSectionId } from './repository-settings-targe
import { matchesSettingsSearch } from './settings-search'
import { translate } from '@/i18n/i18n'
import { getRepositoryHookScriptTextareaRows } from '@/lib/script-textarea-rows'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
type RepositoryHooksSectionProps = {
repo: Repo
@ -749,8 +750,15 @@ export function RepositoryHooksSection({
// Why: this component uses the lightweight translate() helper; subscribe here
// so render-time option/copy builders refresh when the UI language changes.
useTranslation()
const settings = useAppStore((s) => s.settings)
const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery)
const selectedHostId = getRepoExecutionHostId(repo)
const repoHostIdentity = `${selectedHostId}\0${repo.id}`
const hookRuntimeSettings = useMemo(() => {
const parsedHost = parseExecutionHostId(selectedHostId)
return {
activeRuntimeEnvironmentId: parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null
}
}, [selectedHostId])
const yamlState = yamlHooks
? 'loaded'
: hasHooksFile
@ -764,7 +772,7 @@ export function RepositoryHooksSection({
)
const hookSettingsDraftRef = useRef(hookSettingsDraft)
hookSettingsDraftRef.current = hookSettingsDraft
const localCommandsRepoIdRef = useRef(repo.id)
const localCommandsRepoIdentityRef = useRef(repoHostIdentity)
const localCommandsDraftDirtyRef = useRef(false)
const localCommandsAutosaveTimerRef = useRef<number | null>(null)
const persistRef = useRef(onUpdateHookSettings)
@ -878,7 +886,7 @@ export function RepositoryHooksSection({
// dirty draft through the previous repo's captured updater.
useEffect(() => {
const next = getHookSettingsDraft(repo.hookSettings)
const isSameRepo = localCommandsRepoIdRef.current === repo.id
const isSameRepo = localCommandsRepoIdentityRef.current === repoHostIdentity
if (isSameRepo) {
localCommandsPersistForRepoRef.current = onUpdateHookSettings
@ -889,11 +897,17 @@ export function RepositoryHooksSection({
}
flushScriptDraft(localCommandsPersistForRepoRef.current)
localCommandsRepoIdRef.current = repo.id
localCommandsRepoIdentityRef.current = repoHostIdentity
localCommandsPersistForRepoRef.current = onUpdateHookSettings
hookSettingsDraftRef.current = next
setHookSettingsDraft(next)
}, [flushScriptDraft, onUpdateHookSettings, repo.id, repo.hookSettings, syncHookSettingsDraft])
}, [
flushScriptDraft,
onUpdateHookSettings,
repo.hookSettings,
repoHostIdentity,
syncHookSettingsDraft
])
useEffect(() => {
let cancelled = false
@ -903,7 +917,9 @@ export function RepositoryHooksSection({
setHasSharedIssueCommand(false)
setIssueCommandSaveError(null)
void readRuntimeIssueCommand(settings, repoId)
// Why: the pane can show a host other than the globally focused runtime;
// route both runtime RPC and local/SSH IPC by the selected repo owner.
void readRuntimeIssueCommand(hookRuntimeSettings, repoId, selectedHostId)
.then((result) => {
if (cancelled) {
return
@ -925,18 +941,20 @@ export function RepositoryHooksSection({
cancelled = true
const draft = issueCommandDraftRef.current.trim()
if (draft !== lastCommittedIssueCommandRef.current) {
void writeRuntimeIssueCommand(settings, repoId, draft).catch((err) => {
console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', err)
})
void writeRuntimeIssueCommand(hookRuntimeSettings, repoId, draft, selectedHostId).catch(
(err) => {
console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', err)
}
)
}
}
}, [repo.id, settings])
}, [hookRuntimeSettings, repo.id, repoHostIdentity, selectedHostId])
const commitIssueCommand = useCallback(async (): Promise<void> => {
const trimmed = issueCommandDraft.trim()
setIssueCommandDraft(trimmed)
try {
await writeRuntimeIssueCommand(settings, repo.id, trimmed)
await writeRuntimeIssueCommand(hookRuntimeSettings, repo.id, trimmed, selectedHostId)
lastCommittedIssueCommandRef.current = trimmed
setIssueCommandSaveError(null)
} catch (err) {
@ -945,7 +963,7 @@ export function RepositoryHooksSection({
setIssueCommandSaveError(message)
toast.error(message)
}
}, [issueCommandDraft, repo.id, settings])
}, [hookRuntimeSettings, issueCommandDraft, repo.id, selectedHostId])
const sharedSetupScript = yamlHooks?.scripts.setup
const sharedArchiveScript = yamlHooks?.scripts.archive

View File

@ -43,7 +43,7 @@ type RepositoryHostSetupActionsProps = {
setupState: 'not-set-up'
setupMethod: 'provisioned'
}) => Promise<ProjectHostSetupCreateResult | null>
onOpenSetup: (repoId: string) => void
onSetupReady: (hostId: ExecutionHostId) => void
}
type SetupStep = 'choose' | 'existing' | 'clone' | 'planned'
@ -55,7 +55,7 @@ export function RepositoryHostSetupActions({
setupProjectExistingFolder,
setupProjectClone,
createProjectHostSetup,
onOpenSetup
onSetupReady
}: RepositoryHostSetupActionsProps): React.JSX.Element | null {
const [isOpen, setIsOpen] = useState(false)
const [step, setStep] = useState<SetupStep>('choose')
@ -102,7 +102,7 @@ export function RepositoryHostSetupActions({
})
if (result) {
resetFlow()
onOpenSetup(result.repo.id)
onSetupReady(setupTargetHostId)
}
} finally {
setIsSettingUp(false)
@ -129,7 +129,7 @@ export function RepositoryHostSetupActions({
})
if (result) {
resetFlow()
onOpenSetup(result.repo.id)
onSetupReady(setupTargetHostId)
}
} finally {
setIsCloning(false)

View File

@ -147,9 +147,10 @@ describe('RepositoryHostSetupsSection', () => {
expect(container.textContent).toContain(LOCAL_HOST_LABEL)
})
it('opens the selected host setup settings pane through the setup repo id', () => {
it('selects the host in place instead of navigating to a separate repo pane', () => {
const openSettingsPage = vi.fn()
const openSettingsTarget = vi.fn()
const setSettingsProjectHostSelection = vi.fn()
const localRepo = makeRepo({
id: 'local-repo',
displayName: 'Orca',
@ -181,7 +182,8 @@ describe('RepositoryHostSetupsSection', () => {
})
],
openSettingsPage,
openSettingsTarget
openSettingsTarget,
setSettingsProjectHostSelection
})
renderSection(localRepo)
@ -196,8 +198,13 @@ describe('RepositoryHostSetupsSection', () => {
openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(openSettingsPage).toHaveBeenCalledTimes(1)
expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' })
// The single project pane switches host in place — no navigation.
expect(setSettingsProjectHostSelection).toHaveBeenCalledWith(
'github:stablyai/orca',
toSshExecutionHostId('openclaw 2')
)
expect(openSettingsPage).not.toHaveBeenCalled()
expect(openSettingsTarget).not.toHaveBeenCalled()
})
it('removes independent setup metadata instead of opening an empty repo target', async () => {
@ -264,6 +271,7 @@ describe('RepositoryHostSetupsSection', () => {
it('sets up the project on another known host from an existing folder path', async () => {
const openSettingsPage = vi.fn()
const openSettingsTarget = vi.fn()
const setSettingsProjectHostSelection = vi.fn()
const setupProjectExistingFolder = vi.fn().mockResolvedValue({
project: makeProject({ id: 'github:stablyai/orca' }),
setup: makeSetup({
@ -300,6 +308,7 @@ describe('RepositoryHostSetupsSection', () => {
sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]),
openSettingsPage,
openSettingsTarget,
setSettingsProjectHostSelection,
setupProjectExistingFolder
})
@ -327,13 +336,18 @@ describe('RepositoryHostSetupsSection', () => {
kind: 'git',
displayName: 'Orca'
})
expect(openSettingsPage).toHaveBeenCalledTimes(1)
expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' })
expect(setSettingsProjectHostSelection).toHaveBeenCalledWith(
'github:stablyai/orca',
'ssh:openclaw%202'
)
expect(openSettingsPage).not.toHaveBeenCalled()
expect(openSettingsTarget).not.toHaveBeenCalled()
})
it('clones the project onto another known host from settings', async () => {
const openSettingsPage = vi.fn()
const openSettingsTarget = vi.fn()
const setSettingsProjectHostSelection = vi.fn()
const setupProjectClone = vi.fn().mockResolvedValue({
project: makeProject({ id: 'github:stablyai/orca' }),
setup: makeSetup({
@ -370,6 +384,7 @@ describe('RepositoryHostSetupsSection', () => {
sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]),
openSettingsPage,
openSettingsTarget,
setSettingsProjectHostSelection,
setupProjectClone
})
@ -402,8 +417,12 @@ describe('RepositoryHostSetupsSection', () => {
destination: '/home/alice',
displayName: 'Orca'
})
expect(openSettingsPage).toHaveBeenCalledTimes(1)
expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' })
expect(setSettingsProjectHostSelection).toHaveBeenCalledWith(
'github:stablyai/orca',
'ssh:openclaw%202'
)
expect(openSettingsPage).not.toHaveBeenCalled()
expect(openSettingsTarget).not.toHaveBeenCalled()
})
it('creates pending setup metadata for a known host without requiring a path', async () => {

View File

@ -1,5 +1,9 @@
import { useMemo, useState } from 'react'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
getExecutionHostLabel,
getRepoExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import type { Repo } from '../../../../shared/types'
@ -30,8 +34,9 @@ export function RepositoryHostSetupsSection({
searchQuery,
searchEntries
}: RepositoryHostSetupsSectionProps): React.JSX.Element | null {
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
const setSettingsProjectHostSelection = useAppStore(
(state) => state.setSettingsProjectHostSelection
)
const setupProjectExistingFolder = useAppStore((state) => state.setupProjectExistingFolder)
const setupProjectClone = useAppStore((state) => state.setupProjectClone)
const createProjectHostSetup = useAppStore((state) => state.createProjectHostSetup)
@ -67,8 +72,9 @@ export function RepositoryHostSetupsSection({
const projectHostSetupProjection = useAppStore((state) =>
getProjectHostSetupProjectionFromState(state)
)
const selectedHostId = getRepoExecutionHostId(repo)
const selectedProjectHostSetup = projectHostSetupProjection.setups.find(
(setup) => setup.repoId === repo.id
(setup) => setup.repoId === repo.id && setup.hostId === selectedHostId
)
const projectHostSetups = selectedProjectHostSetup
? projectHostSetupProjection.setups.filter(
@ -82,11 +88,14 @@ export function RepositoryHostSetupsSection({
})
const hostOptionById = new Map(hostOptions.map((option) => [option.id, option]))
const [deletingSetupId, setDeletingSetupId] = useState<string | null>(null)
const openSetup = (repoId: string) => {
openSettingsPage()
openSettingsTarget({ pane: 'repo', repoId })
const projectId = selectedProjectHostSetup?.projectId
// Why: the single project pane switches host in place — set the ephemeral
// per-project selection instead of navigating to a separate repo section.
const selectHost = (hostId: ExecutionHostId) => {
if (projectId) {
setSettingsProjectHostSelection(projectId, hostId)
}
}
if (
(projectHostSetups.length <= 1 && setupHostOptions.length === 0) ||
(!forceVisible && !matchesSettingsSearch(searchQuery, searchEntries))
@ -116,12 +125,12 @@ export function RepositoryHostSetupsSection({
{translate('auto.components.settings.RepositoryPane.viewingHost', 'Viewing host')}
</span>
<Select
value={repo.id}
onValueChange={(repoId) => {
if (repoId === repo.id) {
value={selectedHostId}
onValueChange={(hostId) => {
if (hostId === selectedHostId) {
return
}
openSetup(repoId)
selectHost(hostId as ExecutionHostId)
}}
>
<SelectTrigger className="h-8 w-44 min-w-0 text-xs">
@ -129,7 +138,7 @@ export function RepositoryHostSetupsSection({
</SelectTrigger>
<SelectContent>
{openableProjectHostSetups.map((setup) => (
<SelectItem key={setup.id} value={setup.repoId}>
<SelectItem key={setup.hostId} value={setup.hostId}>
<span className="block min-w-0 truncate">
{hostOptionById.get(setup.hostId)?.label ??
getExecutionHostLabel(setup.hostId)}
@ -150,12 +159,12 @@ export function RepositoryHostSetupsSection({
</div>
<div className="divide-y divide-border rounded-md border border-border">
{projectHostSetups.map((setup) => {
const isCurrentSetup = setup.repoId === repo.id
const isCurrentSetup = setup.hostId === selectedHostId
const canOpenSetup = setup.repoId.trim().length > 0
const canRemoveSetup = !canOpenSetup && deletingSetupId !== setup.id
return (
<div
key={setup.id}
key={setup.hostId}
className={cn(
'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',
isCurrentSetup ? 'bg-muted/30' : ''
@ -189,7 +198,7 @@ export function RepositoryHostSetupsSection({
variant="outline"
size="sm"
onClick={() => {
openSetup(setup.repoId)
selectHost(setup.hostId)
}}
>
{translate('auto.components.settings.RepositoryPane.openSetup', 'Open')}
@ -221,7 +230,7 @@ export function RepositoryHostSetupsSection({
setupProjectExistingFolder={setupProjectExistingFolder}
setupProjectClone={setupProjectClone}
createProjectHostSetup={createProjectHostSetup}
onOpenSetup={openSetup}
onSetupReady={selectHost}
/>
) : null}
</SearchableSetting>

View File

@ -8,9 +8,8 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { RepoIconGlyph, getRepoLucideIconOptions } from '../repo/repo-icon'
import { useAppStore } from '@/store'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
import { useMountedRef } from '@/hooks/useMountedRef'
import { RepositoryIconColorSection } from './RepositoryIconColorSection'
import { RepositoryIconTabs } from './RepositoryIconTabs'
@ -33,9 +32,9 @@ export function RepositoryIconPicker({
const mountedRef = useMountedRef()
// Why: resolve this repo's upstream/avatar on the host that owns it, not the
// focused runtime.
const activeRuntimeEnvironmentId = useAppStore((state) =>
getRuntimeEnvironmentIdForRepo(state, repo.id)
)
const selectedHost = parseExecutionHostId(getRepoExecutionHostId(repo))
const activeRuntimeEnvironmentId =
selectedHost?.kind === 'runtime' ? selectedHost.environmentId : null
const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : null
const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : ''
const selectedBadgeColor = normalizeRepoBadgeColor(repo.badgeColor) ?? DEFAULT_REPO_BADGE_COLOR

View File

@ -7,6 +7,7 @@ import type {
RepoHookSettings
} from '../../../../shared/types'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
@ -47,7 +48,11 @@ type RepositoryPaneProps = {
hasHooksFile: boolean
hooksInspectionReady: boolean
mayNeedUpdate: boolean
updateRepo: (repoId: string, updates: RepositoryPaneRepoUpdate) => void
updateRepo: (
repoId: string,
updates: RepositoryPaneRepoUpdate,
options?: { hostId?: ExecutionHostId }
) => void
removeProject: (repoId: string) => void
project?: Project | null
isLocalWindowsProject?: boolean
@ -76,6 +81,16 @@ export function RepositoryPane({
updateProject
}: RepositoryPaneProps): React.JSX.Element {
const isFolder = isFolderRepo(repo)
// Why: this pane renders the switcher-selected host's repo row. Bind every
// edit to that host so identity/host-specific writes land on the selected
// host, not findRepoForHost's focused-host fallback (the same-id/self-pair
// case where local and a runtime share one repo id).
const selectedHostId = getRepoExecutionHostId(repo)
const updateSelectedRepo = useCallback(
(repoId: string, updates: RepositoryPaneRepoUpdate) =>
updateRepo(repoId, updates, { hostId: selectedHostId }),
[updateRepo, selectedHostId]
)
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const settings = useAppStore((state) => state.settings)
const runtimeSessionSummary = useAppStore(
@ -119,7 +134,7 @@ export function RepositoryPane({
}
const updateSelectedRepoHookSettings = (nextSettings: RepoHookSettings) => {
updateRepo(repo.id, {
updateSelectedRepo(repo.id, {
hookSettings: nextSettings
})
}
@ -229,8 +244,8 @@ export function RepositoryPane({
'Remove Project'
)}
description={translate(
'auto.components.settings.RepositoryPane.170624bdfb',
'Remove this project from Orca.'
'auto.components.settings.RepositoryPane.removeProjectAllHosts',
'Remove this project from Orca on all configured hosts.'
)}
keywords={[repo.displayName, 'delete', 'project', 'repository']}
className="absolute top-0 right-0 z-10 w-auto max-w-none"
@ -273,7 +288,7 @@ export function RepositoryPane({
id={`repo-display-name-${repo.id}`}
repoId={repo.id}
storeValue={repo.displayName}
onTextChange={(text) => updateRepo(repo.id, { displayName: text })}
onTextChange={(text) => updateSelectedRepo(repo.id, { displayName: text })}
className="h-9 text-sm"
/>
</SearchableSetting>
@ -298,7 +313,7 @@ export function RepositoryPane({
id={getRepositoryIconSectionId(repo.id)}
forceVisible={forceFullPaneForRepoMatch}
>
<RepositoryIconPicker repo={repo} updateRepo={updateRepo} />
<RepositoryIconPicker repo={repo} updateRepo={updateSelectedRepo} />
</SearchableSetting>
{!isFolder ? (
@ -327,14 +342,14 @@ export function RepositoryPane({
<RepositoryForkSyncSection
repo={repo}
updateRepo={updateRepo}
updateRepo={updateSelectedRepo}
forceVisible={forceFullPaneForRepoMatch}
/>
<RepositoryWorktreeDefaultsSection
repo={repo}
settings={settings}
updateRepo={updateRepo}
updateRepo={updateSelectedRepo}
forceVisible={forceFullPaneForRepoMatch}
/>
</>
@ -347,13 +362,13 @@ export function RepositoryPane({
<RepositorySourceControlAiSection
key="source-control-ai"
repo={repo}
updateRepo={updateRepo}
updateRepo={updateSelectedRepo}
/>
) : null,
!isFolder &&
!repo.connectionId &&
(forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, symlinkEntries)) ? (
<WorktreeSymlinksSection key="symlinks" repo={repo} updateRepo={updateRepo} />
<WorktreeSymlinksSection key="symlinks" repo={repo} updateRepo={updateSelectedRepo} />
) : null,
!isFolder &&
(forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, sparsePresetEntries)) ? (

View File

@ -5,6 +5,7 @@ import { BaseRefPicker } from './BaseRefPicker'
import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput'
import { SearchableSetting } from './SearchableSetting'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
type RepositoryWorktreeDefaultsUpdate = Pick<Repo, 'worktreeBasePath' | 'worktreeBaseRef'>
@ -41,6 +42,7 @@ export function RepositoryWorktreeDefaultsSection({
</Label>
<BaseRefPicker
repoId={repo.id}
hostId={getRepoExecutionHostId(repo)}
currentBaseRef={repo.worktreeBaseRef}
onSelect={(ref) => updateRepo(repo.id, { worktreeBaseRef: ref })}
onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })}

View File

@ -1,9 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
deriveNeededRepoIds,
deriveNeededSectionIds,
getRuntimeTargetIdentity
} from './settings-load-performance'
import { deriveNeededSectionIds } from './settings-load-performance'
describe('Settings load-performance helpers', () => {
it('keeps only eager and active sections mounted for empty search on first paint', () => {
@ -66,18 +62,4 @@ describe('Settings load-performance helpers', () => {
expect(needed.has('repo-a')).toBe(true)
})
it('scopes repo hook checks to needed repo sections only', () => {
const neededRepoIds = deriveNeededRepoIds(
[{ id: 'a' }, { id: 'b' }, { id: 'c' }],
new Set(['general', 'repo-b'])
)
expect(neededRepoIds).toEqual(['b'])
})
it('normalizes runtime target identity for cache invalidation keys', () => {
expect(getRuntimeTargetIdentity({ activeRuntimeEnvironmentId: null })).toBe('local')
expect(getRuntimeTargetIdentity({ activeRuntimeEnvironmentId: ' env-1 ' })).toBe('env-1')
})
})

View File

@ -10,7 +10,7 @@ import {
type MutableRefObject
} from 'react'
import { toast } from 'sonner'
import type { GlobalSettings, OrcaHooks } from '../../../../shared/types'
import type { GlobalSettings, OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types'
import type { SpeechModelState } from '../../../../shared/speech-types'
import type {
SourceControlAiSettings,
@ -29,7 +29,11 @@ import {
mergeFontSuggestions
} from './SettingsConstants'
import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId
} from '../../../../shared/execution-host'
import { GeneralPane } from './GeneralPane'
import { BrowserPane } from './BrowserPane'
import { AppearancePane } from './AppearancePane'
@ -96,14 +100,18 @@ import {
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import {
deriveNeededRepoIds,
deriveNeededSectionIds,
getInitialMountedSectionIds,
getRuntimeTargetIdentity
} from './settings-load-performance'
import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance'
import { translate } from '@/i18n/i18n'
import { getProjectHostSetupProjectionFromState } from '../../store/selectors'
import { getRepoHostIdentity } from '../../store/slices/repo-host-identity'
import {
buildRepoIdToHostSelection,
buildRepoIdToRepresentative,
buildSettingsProjectList,
getSettingsProjectHostRepo,
removeSettingsProjectFromAllHosts,
resolveSettingsTargetRepoId
} from './settings-project-list'
const DevToolsPane = import.meta.env.DEV
? lazy(() => import('./DevToolsPane').then((module) => ({ default: module.DevToolsPane })))
@ -157,9 +165,16 @@ const SETTINGS_NAV_GROUP_BY_ID = new Map<string, SettingsNavGroupDefinition>(
const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm'
const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200
function getSettingsSectionId(pane: SettingsNavTarget, repoId: string | null): string {
function getSettingsSectionId(
pane: SettingsNavTarget,
repoId: string | null,
repoIdToRepresentative: Map<string, string>
): string {
if (pane === 'repo' && repoId) {
return `repo-${repoId}`
// Why: a `{pane:'repo', repoId}` target can name any host's repo row, but
// Settings now renders one collapsed pane per project — resolve to that
// project's representative section so the deep link lands.
return `repo-${repoIdToRepresentative.get(repoId) ?? repoId}`
}
return pane
}
@ -283,12 +298,36 @@ function Settings(): React.JSX.Element {
const removeProject = useAppStore((s) => s.removeProject)
const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget)
const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget)
const settingsProjectHostSelection = useAppStore((s) => s.settingsProjectHostSelection)
const setSettingsProjectHostSelection = useAppStore((s) => s.setSettingsProjectHostSelection)
const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery)
const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery)
const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery)
const modelStates = useAppStore((s) => s.modelStates)
const refreshModelStates = useAppStore((s) => s.refreshModelStates)
// Why: collapse repo rows into one entry per project (derived from repos so it
// matches the nav metadata exactly) — the source of truth for the pane list.
const settingsProjectList = useMemo(() => buildSettingsProjectList(repos), [repos])
const repoIdToRepresentative = useMemo(
() => buildRepoIdToRepresentative(settingsProjectList),
[settingsProjectList]
)
// Why: lets a deep-link's repoId select the owning project's host so
// host-specific subsection anchors exist under the now-selected host.
const repoIdToHostSelection = useMemo(
() => buildRepoIdToHostSelection(settingsProjectList),
[settingsProjectList]
)
// Why: the pane-level "Remove Project" removes the whole project (every host
// setup), not just the selected host — the per-host remove lives inside
// "Available Hosts".
const removeProjectAllHosts = useCallback(
(setups: readonly ProjectHostSetup[]): Promise<void> =>
removeSettingsProjectFromAllHosts(setups, removeProject),
[removeProject]
)
const [repoHooksMap, setRepoHooksMap] = useState<
Record<string, { hasHooks: boolean; hooks: OrcaHooks | null; mayNeedUpdate: boolean }>
>({})
@ -348,7 +387,6 @@ function Settings(): React.JSX.Element {
const pendingScrollTargetRef = useRef<string | null>(null)
const pendingSubsectionScrollFrameRef = useRef<number | null>(null)
const repoHooksRequestSeqRef = useRef(0)
const repoHooksRuntimeIdentityRef = useRef<string>('local')
const shortcutsEscapeConfirmUntilRef = useRef(0)
const sourceControlAiWriteQueueRef = useRef<Promise<void>>(Promise.resolve())
@ -499,8 +537,6 @@ function Settings(): React.JSX.Element {
}
}, [refreshModelStates, showDesktopOnlySettings])
const runtimeTargetIdentity = getRuntimeTargetIdentity(settings)
useEffect(() => {
const hasVisibleOverlay = (): boolean =>
Array.from(
@ -615,8 +651,22 @@ function Settings(): React.JSX.Element {
const paneSectionId = getSettingsSectionId(
settingsNavigationTarget.pane as SettingsNavTarget,
settingsNavigationTarget.repoId
settingsNavigationTarget.repoId,
repoIdToRepresentative
)
// Why: couple the deep link to the in-pane host switcher before scrolling —
// select the target repo's host so its host-specific subsection anchor
// (e.g. `repo-<remoteId>-source-control-ai`) renders and the scroll lands.
const targetRepoId = resolveSettingsTargetRepoId(
settingsNavigationTarget,
repoIdToHostSelection.keys()
)
if (targetRepoId) {
const hostSelection = repoIdToHostSelection.get(targetRepoId)
if (hostSelection) {
setSettingsProjectHostSelection(hostSelection.projectId, hostSelection.hostId)
}
}
pendingNavSectionRef.current = paneSectionId
pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId
// Why: Appearance nests status-bar controls under a collapsed accordion;
@ -640,7 +690,14 @@ function Settings(): React.JSX.Element {
// scroll effect runs even when the visible section set is otherwise stable.
setPendingNavRequestTick((tick) => tick + 1)
clearSettingsTarget()
}, [clearSettingsTarget, settings, settingsNavigationTarget])
}, [
clearSettingsTarget,
repoIdToHostSelection,
repoIdToRepresentative,
setSettingsProjectHostSelection,
settings,
settingsNavigationTarget
])
// Why: only recompute scrollback mode when the row value actually changes,
// not on every unrelated settings mutation.
@ -805,71 +862,80 @@ function Settings(): React.JSX.Element {
setMountedSectionIds(neededSectionIds)
}
const neededRepoIds = useMemo(
() => deriveNeededRepoIds(repos, neededSectionIds),
[neededSectionIds, repos]
)
// Why: each mounted project pane renders its SELECTED host's repo, so hooks
// must load for that repo id — not the representative id parsed from the
// section string (they differ when a non-default host is selected).
const neededRepos = useMemo(() => {
const reposByHostIdentity = new Map<string, Repo>()
for (const settingsProject of settingsProjectList) {
if (!neededSectionIds.has(`repo-${settingsProject.representativeRepoId}`)) {
continue
}
const repo = getSettingsProjectHostRepo(
settingsProject,
repos,
settingsProjectHostSelection[settingsProject.projectId]
)
if (repo) {
reposByHostIdentity.set(getRepoHostIdentity(repo), repo)
}
}
return [...reposByHostIdentity.values()]
}, [neededSectionIds, repos, settingsProjectHostSelection, settingsProjectList])
useEffect(() => {
const repoIdSet = new Set(repos.map((repo) => repo.id))
const repoHostIdentitySet = new Set(repos.map(getRepoHostIdentity))
setRepoHooksMap((previous) => {
const next = Object.fromEntries(
Object.entries(previous).filter(([repoId]) => repoIdSet.has(repoId))
Object.entries(previous).filter(([identity]) => repoHostIdentitySet.has(identity))
) as Record<string, { hasHooks: boolean; hooks: OrcaHooks | null; mayNeedUpdate: boolean }>
return Object.keys(next).length === Object.keys(previous).length ? previous : next
})
}, [repos])
useEffect(() => {
if (repoHooksRuntimeIdentityRef.current !== runtimeTargetIdentity) {
repoHooksRuntimeIdentityRef.current = runtimeTargetIdentity
repoHooksRequestSeqRef.current += 1
setRepoHooksMap({})
}
}, [runtimeTargetIdentity])
useEffect(() => {
if (neededRepoIds.length === 0) {
if (neededRepos.length === 0) {
return
}
let stale = false
const requestSeq = ++repoHooksRequestSeqRef.current
const repoById = new Map(repos.map((repo) => [repo.id, repo] as const))
const liveRepoHostIdentities = new Set(repos.map(getRepoHostIdentity))
void Promise.all(
neededRepoIds.map(async (repoId) => {
const repo = repoById.get(repoId)
if (!repo) {
return
}
neededRepos.map(async (repo) => {
const repoHostIdentity = getRepoHostIdentity(repo)
if (isFolderRepo(repo)) {
setRepoHooksMap((previous) => {
if (previous[repoId]) {
if (previous[repoHostIdentity]) {
return previous
}
return {
...previous,
[repoId]: { hasHooks: false, hooks: null, mayNeedUpdate: false }
[repoHostIdentity]: { hasHooks: false, hooks: null, mayNeedUpdate: false }
}
})
return
}
try {
const hostId = getRepoExecutionHostId(repo)
const parsedHost = parseExecutionHostId(hostId)
const result = await checkRuntimeHooks(
runtimeTargetIdentity === 'local'
? { activeRuntimeEnvironmentId: null }
: { activeRuntimeEnvironmentId: runtimeTargetIdentity },
repoId
{
activeRuntimeEnvironmentId:
parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null
},
repo.id,
hostId
)
if (stale || requestSeq !== repoHooksRequestSeqRef.current) {
return
}
setRepoHooksMap((previous) => {
if (!repos.some((entry) => entry.id === repoId)) {
if (!liveRepoHostIdentities.has(repoHostIdentity)) {
return previous
}
return { ...previous, [repoId]: result }
return { ...previous, [repoHostIdentity]: result }
})
} catch {
// Keep last known value on transient failures.
@ -877,15 +943,15 @@ function Settings(): React.JSX.Element {
return
}
setRepoHooksMap((previous) => {
if (!repos.some((entry) => entry.id === repoId)) {
if (!liveRepoHostIdentities.has(repoHostIdentity)) {
return previous
}
if (previous[repoId]) {
if (previous[repoHostIdentity]) {
return previous
}
return {
...previous,
[repoId]: { hasHooks: false, hooks: null, mayNeedUpdate: false }
[repoHostIdentity]: { hasHooks: false, hooks: null, mayNeedUpdate: false }
}
})
}
@ -895,7 +961,7 @@ function Settings(): React.JSX.Element {
return () => {
stale = true
}
}, [neededRepoIds, repos, runtimeTargetIdentity])
}, [neededRepos, repos])
useEffect(() => {
const scrollTargetId = pendingScrollTargetRef.current
@ -1646,32 +1712,47 @@ function Settings(): React.JSX.Element {
) : null}
</SettingsSection>
{repos.map((repo) => {
const repoSectionId = `repo-${repo.id}`
const repoHooksState = repoHooksMap[repo.id]
const project = projectByRepoId.get(repo.id) ?? null
{settingsProjectList.map((settingsProject) => {
const repoSectionId = `repo-${settingsProject.representativeRepoId}`
// Why: render the switcher-selected host's repo row (validated
// against live setups) so identity edits and host-specific
// settings follow the "Available Hosts" selection.
const repo = getSettingsProjectHostRepo(
settingsProject,
repos,
settingsProjectHostSelection[settingsProject.projectId]
)
if (!repo) {
return null
}
const repoHostIdentity = getRepoHostIdentity(repo)
const repoHooksState = repoHooksMap[repoHostIdentity]
const project = projectByRepoId.get(repo.id) ?? settingsProject.project
return (
<SettingsSection
key={repo.id}
key={repoSectionId}
id={repoSectionId}
title={translate(
'auto.components.settings.Settings.3bf149e873',
'Project Settings > {{value0}}',
{ value0: repo.displayName }
{ value0: project.displayName }
)}
description={repo.path}
searchEntries={getSectionSearchEntries(repoSectionId)}
>
{isSectionMounted(repoSectionId) ? (
// Why: same-id hosts otherwise reuse drafts and effects
// from the previously selected host inside this pane.
<RepositoryPane
key={repoHostIdentity}
repo={repo}
yamlHooks={repoHooksState?.hooks ?? null}
hasHooksFile={repoHooksState?.hasHooks ?? false}
hooksInspectionReady={Boolean(repoHooksState)}
mayNeedUpdate={repoHooksState?.mayNeedUpdate ?? false}
updateRepo={updateRepo}
removeProject={removeProject}
removeProject={() => void removeProjectAllHosts(settingsProject.setups)}
project={project}
isLocalWindowsProject={
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID &&

View File

@ -11,8 +11,8 @@ import {
getWorktreeSymlinkPathFilterState,
type WorktreeSymlinkPathSuggestion
} from './worktree-symlink-path-filter'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
type WorktreeSymlinksSectionProps = {
repo: Repo
@ -32,10 +32,11 @@ export function WorktreeSymlinksSection({
}: WorktreeSymlinksSectionProps): React.JSX.Element {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
const paths = repo.symlinkPaths ?? EMPTY_WORKTREE_SYMLINK_PATHS
const useLocalDirectorySuggestions = !activeRuntimeEnvironmentId?.trim()
// Why: the pane may show a non-focused runtime host; only inspect the local
// filesystem when the switcher-selected repo is actually local.
const useLocalDirectorySuggestions = getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
const directorySuggestionKey = `${repo.path}\n${repo.connectionId ?? ''}`
const [directorySuggestions, setDirectorySuggestions] = useState<DirectorySuggestionState>(
() => ({

View File

@ -1,13 +1,5 @@
import type { GlobalSettings } from '../../../../shared/types'
const EAGER_SECTION_IDS = new Set(['general'])
export function getRuntimeTargetIdentity(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): string {
return settings?.activeRuntimeEnvironmentId?.trim() || 'local'
}
export function deriveNeededSectionIds(args: {
navSectionIds: string[]
mountedSectionIds: Set<string>
@ -37,13 +29,6 @@ export function deriveNeededSectionIds(args: {
return next
}
export function deriveNeededRepoIds(
repos: readonly { id: string }[],
neededSectionIds: Set<string>
): string[] {
return repos.map((repo) => repo.id).filter((repoId) => neededSectionIds.has(`repo-${repoId}`))
}
export function getInitialMountedSectionIds(): Set<string> {
return new Set(EAGER_SECTION_IDS)
}

View File

@ -0,0 +1,257 @@
import { describe, expect, it, vi } from 'vitest'
import type { ProjectHostSetup, Repo } from '../../../../shared/types'
import {
buildRepoIdToHostSelection,
buildRepoIdToRepresentative,
buildSettingsProjectList,
getSettingsProjectHostRepo,
getSettingsProjectRepresentativeRepoId,
removeSettingsProjectFromAllHosts,
resolveEffectiveProjectHost,
resolveSettingsTargetRepoId
} from './settings-project-list'
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo {
return {
path: `/repos/${overrides.id}`,
displayName: overrides.id,
badgeColor: '#000',
addedAt: 0,
...overrides
} satisfies Repo
}
function makeSetup(
overrides: Partial<ProjectHostSetup> & Pick<ProjectHostSetup, 'hostId'>
): ProjectHostSetup {
return {
id: `${overrides.hostId}:${overrides.repoId ?? 'r'}`,
projectId: 'p',
repoId: overrides.repoId ?? 'r',
path: '/repo',
displayName: 'r',
setupState: 'ready',
setupMethod: 'legacy-repo',
createdAt: 0,
updatedAt: 0,
...overrides
} satisfies ProjectHostSetup
}
const gitRemote = {
canonicalKey: 'gitlab.com/acme/app',
remoteName: 'origin',
remoteUrl: 'git@gitlab.com:acme/app.git'
}
describe('buildSettingsProjectList', () => {
it('collapses a git same-remote pair on two hosts (different ids) into one project', () => {
const repos: Repo[] = [
makeRepo({ id: 'local-1', gitRemoteIdentity: gitRemote }),
makeRepo({
id: 'remote-9',
gitRemoteIdentity: gitRemote,
executionHostId: 'runtime:home-mac'
})
]
const projects = buildSettingsProjectList(repos)
expect(projects).toHaveLength(1)
expect(projects[0].setups).toHaveLength(2)
// Representative is the local host's repo.
expect(projects[0].representativeRepoId).toBe('local-1')
})
it('collapses a folder with the same id on local + runtime into one project', () => {
const repos: Repo[] = [
makeRepo({ id: 'folder-x', kind: 'folder' }),
makeRepo({ id: 'folder-x', kind: 'folder', executionHostId: 'runtime:home-mac' })
]
const projects = buildSettingsProjectList(repos)
expect(projects).toHaveLength(1)
expect(projects[0].setups).toHaveLength(2)
expect(projects[0].representativeRepoId).toBe('folder-x')
})
it('keeps the representative stable when an unrelated host is removed', () => {
const withRuntime: Repo[] = [
makeRepo({ id: 'local-1', gitRemoteIdentity: gitRemote }),
makeRepo({
id: 'remote-9',
gitRemoteIdentity: gitRemote,
executionHostId: 'runtime:home-mac'
})
]
const localOnly: Repo[] = [makeRepo({ id: 'local-1', gitRemoteIdentity: gitRemote })]
expect(buildSettingsProjectList(withRuntime)[0].representativeRepoId).toBe(
buildSettingsProjectList(localOnly)[0].representativeRepoId
)
})
})
describe('getSettingsProjectRepresentativeRepoId', () => {
it('prefers the local host setup', () => {
const setups = [
makeSetup({ hostId: 'runtime:home-mac', repoId: 'aaa' }),
makeSetup({ hostId: 'local', repoId: 'zzz' })
]
expect(getSettingsProjectRepresentativeRepoId(setups)).toBe('zzz')
})
it('falls back to the lowest repoId when there is no local setup', () => {
const setups = [
makeSetup({ hostId: 'runtime:home-mac', repoId: 'zzz' }),
makeSetup({ hostId: 'ssh:box', repoId: 'aaa' })
]
expect(getSettingsProjectRepresentativeRepoId(setups)).toBe('aaa')
})
})
describe('resolveEffectiveProjectHost', () => {
const setups = [
makeSetup({ hostId: 'local', repoId: 'local-1' }),
makeSetup({ hostId: 'runtime:home-mac', repoId: 'remote-9' })
]
it('keeps a valid stored selection', () => {
expect(resolveEffectiveProjectHost(setups, 'runtime:home-mac')).toBe('runtime:home-mac')
})
it('falls back to local when the stored host no longer exists', () => {
expect(resolveEffectiveProjectHost(setups, 'runtime:gone')).toBe('local')
})
it('falls back to the first ready setup when there is no local host', () => {
const remoteSetups = [
makeSetup({ hostId: 'ssh:box', repoId: 'a', setupState: 'not-set-up' }),
makeSetup({ hostId: 'runtime:home-mac', repoId: 'b', setupState: 'ready' })
]
expect(resolveEffectiveProjectHost(remoteSetups, 'runtime:gone')).toBe('runtime:home-mac')
})
it('returns undefined when there are no setups', () => {
expect(resolveEffectiveProjectHost([], 'local')).toBeUndefined()
})
})
describe('deep-link resolution', () => {
const repos: Repo[] = [
makeRepo({ id: 'local-1', gitRemoteIdentity: gitRemote }),
makeRepo({ id: 'remote-9', gitRemoteIdentity: gitRemote, executionHostId: 'runtime:home-mac' })
]
const projects = buildSettingsProjectList(repos)
it('maps every host repoId to the representative section (getSettingsSectionId resolver)', () => {
const map = buildRepoIdToRepresentative(projects)
expect(map.get('remote-9')).toBe('local-1')
expect(map.get('local-1')).toBe('local-1')
})
it('maps a repoId to its owning project + host for selection', () => {
const map = buildRepoIdToHostSelection(projects)
expect(map.get('remote-9')).toEqual({
projectId: projects[0].projectId,
hostId: 'runtime:home-mac'
})
})
it('parses a repoId from a host-specific subsection sectionId', () => {
const repoIds = [...buildRepoIdToHostSelection(projects).keys()]
expect(
resolveSettingsTargetRepoId(
{ repoId: null, sectionId: 'repo-remote-9-source-control-ai' },
repoIds
)
).toBe('remote-9')
})
it('prefers an explicit target repoId over the sectionId', () => {
expect(
resolveSettingsTargetRepoId({ repoId: 'local-1', sectionId: 'repo-remote-9-icon' }, [
'local-1',
'remote-9'
])
).toBe('local-1')
})
it('disambiguates repo ids where one is a prefix of another (longest match wins)', () => {
expect(
resolveSettingsTargetRepoId({ repoId: null, sectionId: 'repo-app-2-icon' }, ['app', 'app-2'])
).toBe('app-2')
})
it('resolves the remote host repo row when a remote host is selected', () => {
const hostSelection = buildRepoIdToHostSelection(projects).get('remote-9')
expect(getSettingsProjectHostRepo(projects[0], repos, hostSelection?.hostId)?.id).toBe(
'remote-9'
)
})
it('defaults to the local host repo row when no host is selected', () => {
expect(getSettingsProjectHostRepo(projects[0], repos, undefined)?.id).toBe('local-1')
})
it('distinguishes same-id repo rows by execution host', () => {
const sameIdRepos = [
makeRepo({ id: 'same-repo', gitRemoteIdentity: gitRemote }),
makeRepo({
id: 'same-repo',
gitRemoteIdentity: gitRemote,
executionHostId: 'runtime:home-mac',
path: '/remote/repo'
})
]
const sameIdProjects = buildSettingsProjectList(sameIdRepos)
expect(
getSettingsProjectHostRepo(sameIdProjects[0], sameIdRepos, 'runtime:home-mac')?.path
).toBe('/remote/repo')
})
})
describe('removeSettingsProjectFromAllHosts', () => {
it('removes every host setup with its own hostId and skips setups without a repo row', async () => {
const removeProject = vi.fn().mockResolvedValue(undefined)
const setups = [
makeSetup({ hostId: 'local', repoId: 'local-1' }),
makeSetup({ hostId: 'ssh:box', repoId: ' ' }),
makeSetup({ hostId: 'runtime:home-mac', repoId: 'remote-9' })
]
await removeSettingsProjectFromAllHosts(setups, removeProject)
expect(removeProject.mock.calls).toEqual([
['local-1', { hostId: 'local' }],
['remote-9', { hostId: 'runtime:home-mac' }]
])
})
it('awaits each host removal before starting the next', async () => {
let resolveFirst: (() => void) | undefined
const removeProject = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirst = resolve
})
)
.mockResolvedValue(undefined)
const setups = [
makeSetup({ hostId: 'local', repoId: 'local-1' }),
makeSetup({ hostId: 'runtime:home-mac', repoId: 'remote-9' })
]
const pending = removeSettingsProjectFromAllHosts(setups, removeProject)
await Promise.resolve()
expect(removeProject).toHaveBeenCalledTimes(1)
resolveFirst?.()
await pending
expect(removeProject).toHaveBeenCalledTimes(2)
})
})

View File

@ -0,0 +1,194 @@
import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
export type SettingsProject = {
projectId: string
project: Project
setups: ProjectHostSetup[]
representativeRepoId: string
}
/**
* Which repo row identifies a project's single Settings nav row + pane. Pure
* over the project's setups so nav and panes derive the same id. Prefers the
* `local` host (the user's own machine) and otherwise the lowest repoId, so the
* id is stable unless that exact repo row is removed.
*/
export function getSettingsProjectRepresentativeRepoId(
setups: readonly ProjectHostSetup[]
): string {
const localSetup = setups.find(
(setup) => setup.hostId === LOCAL_EXECUTION_HOST_ID && setup.repoId.trim().length > 0
)
if (localSetup) {
return localSetup.repoId
}
let lowest = ''
for (const setup of setups) {
const repoId = setup.repoId.trim()
if (repoId.length > 0 && (lowest === '' || repoId < lowest)) {
lowest = repoId
}
}
return lowest
}
/**
* Collapses repo rows into one entry per project so Settings renders per
* project, matching the rest of the app. Derived from repos alone (not the
* persisted projects/setups) so the nav and pane lists agree exactly.
*/
export function buildSettingsProjectList(repos: readonly Repo[]): SettingsProject[] {
const projection = projectHostSetupProjectionFromRepos(repos)
const setupsByProjectId = new Map<string, ProjectHostSetup[]>()
for (const setup of projection.setups) {
const projectSetups = setupsByProjectId.get(setup.projectId)
if (projectSetups) {
projectSetups.push(setup)
} else {
setupsByProjectId.set(setup.projectId, [setup])
}
}
return projection.projects.map((project) => {
// Why: Settings metadata is rebuilt as repos refresh across hosts; index
// setups once so many projects do not turn each refresh into an O(n²) scan.
const setups = setupsByProjectId.get(project.id) ?? []
return {
projectId: project.id,
project,
setups,
representativeRepoId: getSettingsProjectRepresentativeRepoId(setups)
}
})
}
/**
* The host whose settings the project pane should show. Validates the stored
* selection against the live setups so a disconnected/removed host never leaves
* the pane rendering off a dangling hostId: falls back to local, then the first
* ready setup, then the first setup.
*/
export function resolveEffectiveProjectHost(
setups: readonly ProjectHostSetup[],
selectedHostId: ExecutionHostId | undefined
): ExecutionHostId | undefined {
if (setups.length === 0) {
return undefined
}
if (selectedHostId && setups.some((setup) => setup.hostId === selectedHostId)) {
return selectedHostId
}
const localSetup = setups.find((setup) => setup.hostId === LOCAL_EXECUTION_HOST_ID)
if (localSetup) {
return localSetup.hostId
}
const readySetup = setups.find((setup) => setup.setupState === 'ready')
return (readySetup ?? setups[0]).hostId
}
/** Maps every host's repoId to its project's representative repoId, so a
* `{pane:'repo', repoId}` deep link resolves to the collapsed pane. */
export function buildRepoIdToRepresentative(
projects: readonly SettingsProject[]
): Map<string, string> {
const map = new Map<string, string>()
for (const settingsProject of projects) {
for (const setup of settingsProject.setups) {
if (setup.repoId.trim().length > 0) {
map.set(setup.repoId, settingsProject.representativeRepoId)
}
}
}
return map
}
/** Maps each host's repoId to its owning project + host, so a deep link can
* select that host in the pane's "Available Hosts" switcher. */
export function buildRepoIdToHostSelection(
projects: readonly SettingsProject[]
): Map<string, { projectId: string; hostId: ExecutionHostId }> {
const map = new Map<string, { projectId: string; hostId: ExecutionHostId }>()
for (const settingsProject of projects) {
for (const setup of settingsProject.setups) {
if (setup.repoId.trim().length > 0 && !map.has(setup.repoId)) {
map.set(setup.repoId, { projectId: settingsProject.projectId, hostId: setup.hostId })
}
}
}
return map
}
/**
* The repo row a Settings deep link points at, from either an explicit repoId
* or a `repo-<id>-<subsection>` sectionId. repo ids can contain hyphens, so the
* sectionId is matched against known ids with the longest match winning.
*/
export function resolveSettingsTargetRepoId(
target: { repoId: string | null; sectionId?: string },
repoIds: Iterable<string>
): string | null {
if (target.repoId) {
return target.repoId
}
const sectionId = target.sectionId
if (!sectionId || !sectionId.startsWith('repo-')) {
return null
}
let best: string | null = null
for (const repoId of repoIds) {
if (sectionId === `repo-${repoId}` || sectionId.startsWith(`repo-${repoId}-`)) {
if (best === null || repoId.length > best.length) {
best = repoId
}
}
}
return best
}
/**
* Removes a project's setup on every host it exists on. Sequential so each
* host's teardown + projection recompute don't interleave; setups without a
* repo row (planned/not-set-up hosts) have nothing to remove.
*/
export async function removeSettingsProjectFromAllHosts(
setups: readonly ProjectHostSetup[],
removeProject: (repoId: string, options: { hostId: ExecutionHostId }) => Promise<void>
): Promise<void> {
for (const setup of setups) {
if (setup.repoId.trim().length > 0) {
await removeProject(setup.repoId, { hostId: setup.hostId })
}
}
}
/**
* The repo row the project pane should render for the given host selection.
* Shared by the pane and the hooks-loading effect so they always agree on which
* host's repo (id + host) is mounted critical in the same-id/self-pair case.
*/
export function getSettingsProjectHostRepo(
settingsProject: SettingsProject,
repos: readonly Repo[],
selectedHostId: ExecutionHostId | undefined
): Repo | undefined {
const effectiveHostId = resolveEffectiveProjectHost(settingsProject.setups, selectedHostId)
if (!effectiveHostId) {
return undefined
}
const effectiveSetup =
settingsProject.setups.find((setup) => setup.hostId === effectiveHostId) ??
settingsProject.setups[0]
return (
repos.find(
(repo) =>
repo.id === effectiveSetup.repoId && getRepoExecutionHostId(repo) === effectiveHostId
) ??
repos.find((repo) => repo.id === effectiveSetup.repoId) ??
repos.find((repo) => repo.id === settingsProject.representativeRepoId)
)
}

View File

@ -181,6 +181,42 @@ describe('settings navigation metadata', () => {
expect(ids({ isDev: true, isWebClient: true })).not.toContain('dev')
})
it('renders one repo nav section per project even across execution hosts', () => {
const gitRemote = {
canonicalKey: 'gitlab.com/acme/app',
remoteName: 'origin',
remoteUrl: 'git@gitlab.com:acme/app.git'
}
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
isWebClient: false,
repos: [
{
id: 'local-1',
path: '/a',
displayName: 'App',
badgeColor: '#000',
addedAt: 0,
gitRemoteIdentity: gitRemote
},
{
id: 'remote-9',
path: '/b',
displayName: 'App',
badgeColor: '#000',
addedAt: 0,
gitRemoteIdentity: gitRemote,
executionHostId: 'runtime:home-mac'
}
]
})
const repoSections = sections.filter((section) => section.id.startsWith('repo-'))
expect(repoSections).toHaveLength(1)
expect(repoSections[0].id).toBe('repo-local-1')
})
it('keeps macOS permissions mac-only', () => {
expect(ids({ isMac: false })).not.toContain('developer-permissions')
expect(ids({ isMac: true })).toContain('developer-permissions')

View File

@ -71,6 +71,7 @@ import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-s
import { getStatsPaneSearchEntries } from '@/components/stats/stats-search'
import { getExperimentalPaneSearchEntries } from '@/components/settings/experimental-search'
import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search'
import { buildSettingsProjectList } from '@/components/settings/settings-project-list'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
getWindowsTerminalCapabilityOwnerKey,
@ -130,6 +131,12 @@ export function buildSettingsNavigationMetadata({
const runtimeEnvironmentsSearchEntry = isWebClient
? getWebRuntimeEnvironmentsSearchEntry()
: getRuntimeEnvironmentsSearchEntry()
const reposById = new Map<string, Repo>()
for (const repo of repos) {
if (!reposById.has(repo.id)) {
reposById.set(repo.id, repo)
}
}
return [
// Why: this array's order must mirror SETTINGS_NAV_GROUPS so the Settings
@ -537,16 +544,30 @@ export function buildSettingsNavigationMetadata({
searchEntries: getExperimentalPaneSearchEntries(),
group: 'experimental'
},
...repos.map((repo) => ({
id: `repo-${repo.id}`,
title: repo.displayName,
description: `${getRepoKindLabel(repo)}${repo.path}`,
icon: SlidersHorizontal,
searchEntries: getRepositoryPaneSearchEntries(repo, {
windowsRuntimeSupported: isWindowsTerminalHost
}),
group: 'repositories'
}))
// Why: one nav row per project, not per repo row — a project set up on
// multiple hosts (local + a Remote Orca Server, or two clones) collapses to
// a single entry. Derived from repos alone so this list matches the panes.
...buildSettingsProjectList(repos).map(({ project, representativeRepoId, setups }) => {
const representativeRepo = reposById.get(representativeRepoId) ?? repos[0]
const hostSummary =
setups.length > 1
? translate(
'auto.hooks.useSettingsNavigationMetadata.projectHostsSummary',
'{{value0}} hosts',
{ value0: setups.length }
)
: (setups[0]?.path ?? representativeRepo.path)
return {
id: `repo-${representativeRepoId}`,
title: project.displayName,
description: `${getRepoKindLabel(project)}${hostSummary}`,
icon: SlidersHorizontal,
searchEntries: getRepositoryPaneSearchEntries(representativeRepo, {
windowsRuntimeSupported: isWindowsTerminalHost
}),
group: 'repositories'
}
})
]
}

View File

@ -699,7 +699,8 @@
"devSearchKeywordToast": "toast",
"devSearchKeywordSonner": "sonner",
"devSearchKeywordError": "error",
"devSearchKeywordNotification": "notification"
"devSearchKeywordNotification": "notification",
"projectHostsSummary": "{{value0}} hosts"
},
"useAppMenuPaste": {
"pasteTooLarge": "Paste is too large."
@ -6127,7 +6128,7 @@
"26fef02bf3": "Project Icon",
"c7ef4415de": "Display Name",
"b0a0c14a1c": "Project-specific display details for the sidebar and tabs.",
"170624bdfb": "Remove this project from Orca.",
"removeProjectAllHosts": "Remove this project from Orca on all configured hosts.",
"0909e5d650": "Remove Project",
"ee5a290616": "Opened as folder. Git features are unavailable for this workspace.",
"323debba71": "Type:",

View File

@ -699,7 +699,8 @@
"devSearchKeywordToast": "toast",
"devSearchKeywordSonner": "sonner",
"devSearchKeywordError": "error",
"devSearchKeywordNotification": "notification"
"devSearchKeywordNotification": "notification",
"projectHostsSummary": "{{value0}} hosts"
},
"useAppMenuPaste": {
"pasteTooLarge": "El contenido pegado es demasiado grande."
@ -6090,7 +6091,7 @@
"26fef02bf3": "Icono de proyecto",
"c7ef4415de": "Nombre para mostrar",
"b0a0c14a1c": "Detalles de visualización específicos del proyecto para la barra lateral y las pestañas.",
"170624bdfb": "Eliminar este proyecto de Orca.",
"removeProjectAllHosts": "Eliminar este proyecto de Orca en todos los hosts configurados.",
"0909e5d650": "Eliminar proyecto",
"ee5a290616": "Abierto como carpeta. Las funciones de Git no están disponibles para este espacio de trabajo.",
"323debba71": "Tipo:",

View File

@ -699,7 +699,8 @@
"devSearchKeywordToast": "toast",
"devSearchKeywordSonner": "sonner",
"devSearchKeywordError": "error",
"devSearchKeywordNotification": "notification"
"devSearchKeywordNotification": "notification",
"projectHostsSummary": "{{value0}} 台のホスト"
},
"useAppMenuPaste": {
"pasteTooLarge": "貼り付け内容が大きすぎます。"
@ -6112,7 +6113,7 @@
"26fef02bf3": "プロジェクトアイコン",
"c7ef4415de": "表示名",
"b0a0c14a1c": "サイドバーとタブのプロジェクト固有の表示詳細。",
"170624bdfb": "このプロジェクトを Orca から削除します。",
"removeProjectAllHosts": "このプロジェクトを、設定済みのすべてのホストの Orca から削除します。",
"0909e5d650": "プロジェクトの削除",
"ee5a290616": "フォルダーとして開きます。このワークスペースでは Git 機能を使用できません。",
"323debba71": "タイプ:",

View File

@ -699,7 +699,8 @@
"devSearchKeywordToast": "toast",
"devSearchKeywordSonner": "sonner",
"devSearchKeywordError": "error",
"devSearchKeywordNotification": "notification"
"devSearchKeywordNotification": "notification",
"projectHostsSummary": "호스트 {{value0}}개"
},
"useAppMenuPaste": {
"pasteTooLarge": "붙여넣기 내용이 너무 큽니다."
@ -6075,7 +6076,7 @@
"26fef02bf3": "프로젝트 아이콘",
"c7ef4415de": "표시 이름",
"b0a0c14a1c": "사이드바 및 탭에 대한 프로젝트별 표시 세부 정보입니다.",
"170624bdfb": "Orca에서 이 프로젝트를 제거하세요.",
"removeProjectAllHosts": "구성된 모든 호스트의 Orca에서 이 프로젝트를 제거합니다.",
"0909e5d650": "프로젝트 제거",
"ee5a290616": "폴더로 열렸습니다. 이 워크스페이스에서는 Git 기능을 사용할 수 없습니다.",
"323debba71": "유형:",

View File

@ -699,7 +699,8 @@
"devSearchKeywordToast": "toast",
"devSearchKeywordSonner": "sonner",
"devSearchKeywordError": "error",
"devSearchKeywordNotification": "notification"
"devSearchKeywordNotification": "notification",
"projectHostsSummary": "{{value0}} 个主机"
},
"useAppMenuPaste": {
"pasteTooLarge": "粘贴内容过大。"
@ -6075,7 +6076,7 @@
"26fef02bf3": "项目图标",
"c7ef4415de": "显示名称",
"b0a0c14a1c": "侧边栏和选项卡的项目特定显示详细信息。",
"170624bdfb": "从 Orca 中删除该项目。",
"removeProjectAllHosts": "从所有已配置主机的 Orca 中删除该项目。",
"0909e5d650": "删除项目",
"ee5a290616": "作为文件夹打开。 Git 功能对此工作区不可用。",
"323debba71": "类型:",

View File

@ -368,6 +368,26 @@ describe('ensureHooksConfirmed', () => {
expect(pending).toHaveLength(0)
})
it('forwards the explicit host to issueCommand inspection when repo ids collide', async () => {
const { state } = createTestState({
repos: [
{ id: 'repo-1', displayName: 'Local Row' },
{ id: 'repo-1', displayName: 'SSH Row', connectionId: 'server' }
]
} as unknown as Partial<AppState>)
readIssueCommandMock.mockResolvedValue({
source: 'local',
sharedContent: null,
localContent: 'user content',
effectiveContent: 'user content',
localFilePath: ''
})
await ensureHooksConfirmed(state, 'repo-1', 'issueCommand', 'ssh:server')
expect(readIssueCommandMock).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'ssh:server' })
})
it('fails closed when issueCommand inspection reports an error status', async () => {
const { state, pending } = createTestState()
readIssueCommandMock.mockResolvedValue({

View File

@ -101,9 +101,12 @@ export async function ensureHooksConfirmed(
try {
if (scriptKind === 'issueCommand') {
// Local overrides are user-owned; only shared orca.yaml commands need repo trust.
// Why: hostId disambiguates duplicate repo ids on the local IPC path,
// matching the checkRuntimeHooks call below.
const result = await readRuntimeIssueCommand(
settingsForHookRepoOwner(state, repoId, hostId),
repoId
repoId,
hostId
)
if (result.source === 'local') {
return 'run'

View File

@ -108,4 +108,35 @@ describe('runtime hooks client', () => {
expect(hooksReadIssueCommand).not.toHaveBeenCalled()
expect(hooksWriteIssueCommand).not.toHaveBeenCalled()
})
it('forwards an explicit SSH host to local hook IPC', async () => {
hooksCheck.mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false })
hooksReadIssueCommand.mockResolvedValue({
localContent: null,
sharedContent: null,
effectiveContent: null,
localFilePath: '',
source: 'none'
})
await checkRuntimeHooks({ activeRuntimeEnvironmentId: null }, 'same-repo', 'ssh:server')
await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'same-repo', 'ssh:server')
await writeRuntimeIssueCommand(
{ activeRuntimeEnvironmentId: null },
'same-repo',
'Fix it',
'ssh:server'
)
expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'ssh:server' })
expect(hooksReadIssueCommand).toHaveBeenCalledWith({
repoId: 'same-repo',
hostId: 'ssh:server'
})
expect(hooksWriteIssueCommand).toHaveBeenCalledWith({
repoId: 'same-repo',
content: 'Fix it',
hostId: 'ssh:server'
})
})
})

View File

@ -54,11 +54,12 @@ export async function inspectRuntimeSetupScriptImports(
export async function readRuntimeIssueCommand(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string
repoId: string,
hostId?: ExecutionHostId
): Promise<IssueCommandReadResult> {
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return window.api.hooks.readIssueCommand({ repoId })
return window.api.hooks.readIssueCommand({ repoId, ...(hostId ? { hostId } : {}) })
}
return callRuntimeRpc<IssueCommandReadResult>(
target,
@ -71,11 +72,12 @@ export async function readRuntimeIssueCommand(
export async function writeRuntimeIssueCommand(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string,
content: string
content: string,
hostId?: ExecutionHostId
): Promise<void> {
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
await window.api.hooks.writeIssueCommand({ repoId, content })
await window.api.hooks.writeIssueCommand({ repoId, content, ...(hostId ? { hostId } : {}) })
return
}
await callRuntimeRpc(

View File

@ -1,19 +1,26 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { searchRuntimeRepoBaseRefDetails, searchRuntimeRepoBaseRefs } from './runtime-repo-client'
import {
getRuntimeRepoBaseRefDefault,
searchRuntimeRepoBaseRefDetails,
searchRuntimeRepoBaseRefs
} from './runtime-repo-client'
const getBaseRefDefault = vi.fn()
const searchBaseRefs = vi.fn()
const searchBaseRefDetails = vi.fn()
const runtimeCall = vi.fn()
beforeEach(() => {
getBaseRefDefault.mockReset()
searchBaseRefs.mockReset()
searchBaseRefDetails.mockReset()
runtimeCall.mockReset()
vi.stubGlobal('window', {
api: {
repos: {
getBaseRefDefault,
searchBaseRefs,
searchBaseRefDetails
},
@ -51,4 +58,23 @@ describe('runtime repo client search bounds', () => {
expect(searchBaseRefDetails).not.toHaveBeenCalled()
expect(runtimeCall).not.toHaveBeenCalled()
})
it('forwards the selected SSH host to base-ref IPC', async () => {
getBaseRefDefault.mockResolvedValue({ defaultBaseRef: 'origin/main', remoteCount: 1 })
searchBaseRefs.mockResolvedValue(['origin/main'])
await getRuntimeRepoBaseRefDefault(null, 'same-repo', 'ssh:server')
await searchRuntimeRepoBaseRefs(null, 'same-repo', 'main', 20, 'ssh:server')
expect(getBaseRefDefault).toHaveBeenCalledWith({
repoId: 'same-repo',
hostId: 'ssh:server'
})
expect(searchBaseRefs).toHaveBeenCalledWith({
repoId: 'same-repo',
query: 'main',
limit: 20,
hostId: 'ssh:server'
})
})
})

View File

@ -2,6 +2,7 @@ import type { BaseRefSearchResult, GlobalSettings } from '../../../shared/types'
import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result'
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
import { isRuntimeRepoRefSearchQueryWithinLimit } from './runtime-repo-search-bounds'
import type { ExecutionHostId } from '../../../shared/execution-host'
export type RuntimeRepoBaseRefDefault = {
defaultBaseRef: string | null
@ -10,11 +11,12 @@ export type RuntimeRepoBaseRefDefault = {
export async function getRuntimeRepoBaseRefDefault(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string
repoId: string,
hostId?: ExecutionHostId
): Promise<RuntimeRepoBaseRefDefault> {
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return window.api.repos.getBaseRefDefault({ repoId })
return window.api.repos.getBaseRefDefault({ repoId, ...(hostId ? { hostId } : {}) })
}
return callRuntimeRpc<RuntimeRepoBaseRefDefault>(
target,
@ -28,14 +30,15 @@ export async function searchRuntimeRepoBaseRefs(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string,
query: string,
limit: number
limit: number,
hostId?: ExecutionHostId
): Promise<string[]> {
if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) {
return []
}
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return window.api.repos.searchBaseRefs({ repoId, query, limit })
return window.api.repos.searchBaseRefs({ repoId, query, limit, ...(hostId ? { hostId } : {}) })
}
const result = await callRuntimeRpc<{ refs: string[]; truncated: boolean }>(
target,
@ -50,14 +53,20 @@ export async function searchRuntimeRepoBaseRefDetails(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
repoId: string,
query: string,
limit: number
limit: number,
hostId?: ExecutionHostId
): Promise<BaseRefSearchResult[]> {
if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) {
return []
}
const target = getActiveRuntimeTarget(settings)
if (target.kind !== 'environment') {
return window.api.repos.searchBaseRefDetails({ repoId, query, limit })
return window.api.repos.searchBaseRefDetails({
repoId,
query,
limit,
...(hostId ? { hostId } : {})
})
}
const result = await callRuntimeRpc<{
refs: string[]

View File

@ -133,6 +133,62 @@ describe('repo slice host identity routing', () => {
)
})
it('updateRepo with an explicit hostId routes to that host, not the focused one', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-explicit-update',
ok: true,
result: { repo: { ...remoteDuplicate, displayName: 'Remote via host' } },
_meta: { runtimeId: 'runtime-remote' }
})
const store = createTestStore()
// Focus is local (no active runtime env); without the explicit hostId this
// would route to the focused (local) row.
store.setState({ repos: [localDuplicate, remoteDuplicate] })
await store
.getState()
.updateRepo('same-repo', { displayName: 'Remote via host' }, { hostId: 'runtime:env-1' })
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'repo.update',
params: { repo: 'same-repo', updates: { displayName: 'Remote via host' } },
timeoutMs: 15_000
})
expect(reposUpdate).not.toHaveBeenCalled()
expect(store.getState().repos).toEqual([
localDuplicate,
{ ...remoteDuplicate, displayName: 'Remote via host' }
])
})
it('updateRepo with an explicit local hostId stays local even when a runtime is focused', async () => {
// The self-pair case: a repo id exists on both local and a focused runtime.
// An explicit local hostId must route to local IPC, not the runtime RPC.
reposUpdate.mockResolvedValue(undefined)
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
repos: [localDuplicate, remoteDuplicate]
})
await store
.getState()
.updateRepo('same-repo', { displayName: 'Local via host' }, { hostId: 'local' })
expect(reposUpdate).toHaveBeenCalledWith({
repoId: 'same-repo',
updates: { displayName: 'Local via host' }
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'repo.update' })
)
expect(store.getState().repos).toEqual([
{ ...localDuplicate, displayName: 'Local via host' },
remoteDuplicate
])
})
it('keeps queued focused-host repo updates pinned when focus changes', async () => {
const firstUpdate = deferred<{
id: string

View File

@ -1497,7 +1497,13 @@ export type RepoSlice = {
// id exists on multiple hosts; without it the focused host is assumed.
removeProject: (projectId: string, options?: { hostId?: ExecutionHostId }) => Promise<void>
updateProject: (projectId: string, updates: ProjectUpdate) => Promise<boolean>
updateRepo: (projectId: string, updates: RepoUpdate) => Promise<boolean>
// options.hostId targets a specific host's repo row + RPC target when the same
// repo id exists on multiple hosts; without it the focused host is assumed.
updateRepo: (
projectId: string,
updates: RepoUpdate,
options?: { hostId?: ExecutionHostId }
) => Promise<boolean>
setActiveRepo: (projectId: string | null) => void
reorderRepos: (orderedIds: string[]) => Promise<void>
}
@ -2978,14 +2984,22 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
},
updateRepo: async (projectId, updates) => {
updateRepo: async (projectId, updates, options) => {
const updateRepoChains = getRepoUpdateChains(get)
const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings })
// Why: pass options.hostId so a duplicate repo id across hosts resolves to the
// intended row instead of findRepoForHost's settings-focused fallback.
const ownerRepo = findRepoForHost(get().repos, projectId, {
settings: get().settings,
hostId: options?.hostId
})
if (!ownerRepo) {
return false
}
// Why: an explicit hostId is authoritative — treat it as an explicit host so
// routing goes to that host's target (local IPC or its runtime RPC) rather
// than the currently-focused runtime, which is the same-id/self-pair case.
const ownerHasExplicitHost = Boolean(
ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim()
options?.hostId || ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim()
)
const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo)
const ownerTarget = ownerHasExplicitHost

View File

@ -984,6 +984,20 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().visibleWorkspaceHostIds).toBeNull()
})
it('tracks the per-project settings host selection without persisting it', () => {
const setUI = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
const store = createUIStore()
store.getState().setSettingsProjectHostSelection('git:acme/app', 'runtime:home-mac')
expect(store.getState().settingsProjectHostSelection).toEqual({
'git:acme/app': 'runtime:home-mac'
})
// Ephemeral: never written through the UI persistence pipeline.
expect(setUI).not.toHaveBeenCalled()
})
it('persists workspace host scope changes', () => {
const setUI = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', { api: { ui: { set: setUI } } })

View File

@ -771,6 +771,14 @@ export type UISlice = {
} | null
openSettingsTarget: (target: NonNullable<UISlice['settingsNavigationTarget']>) => void
clearSettingsTarget: () => void
/**
* Which host the Projects Settings pane shows for each project, keyed by
* projectId. Set by the pane's "Available Hosts" switcher. Ephemeral on
* purpose never persisted, so a reload reopens on the project's effective
* host rather than a possibly-dangling selection.
*/
settingsProjectHostSelection: Record<string, ExecutionHostId>
setSettingsProjectHostSelection: (projectId: string, hostId: ExecutionHostId) => void
/**
* One-shot Appearance accordion to expand for nested Settings deep links
* (e.g. Usage percentages lives under Window & Sidebar). Cleared when
@ -1533,6 +1541,20 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
settingsNavigationTarget: null,
openSettingsTarget: (target) => set({ settingsNavigationTarget: target }),
clearSettingsTarget: () => set({ settingsNavigationTarget: null }),
settingsProjectHostSelection: {},
// Why: renderer-only, never persisted — no window.api.ui.set here and this
// field is intentionally absent from the debounced UI writer in App.tsx.
setSettingsProjectHostSelection: (projectId, hostId) =>
set((s) =>
s.settingsProjectHostSelection[projectId] === hostId
? s
: {
settingsProjectHostSelection: {
...s.settingsProjectHostSelection,
[projectId]: hostId
}
}
),
appearanceAccordionDeepLink: null,
setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }),
clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }),