diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index d1b182108..0ccc0fcbd 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 => { - const repo = store.getRepo(args.repoId) + async ( + _event, + args: { repoId: string; hostId?: ExecutionHostId } + ): Promise => { + 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 { - 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') diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 9f4dea40f..c40e4ad06 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 06397b341..a11a00127 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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 } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9151a2268..3aa7782f3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1020,12 +1020,21 @@ export type PreloadApi = { getDefaultCreateProjectParent: () => Promise onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void getGitUsername: (args: { repoId: string }) => Promise - getBaseRefDefault: (args: { repoId: string }) => Promise - searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise + getBaseRefDefault: (args: { + repoId: string + hostId?: ExecutionHostId + }) => Promise + searchBaseRefs: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }) => Promise searchBaseRefDetails: (args: { repoId: string query: string limit?: number + hostId?: ExecutionHostId }) => Promise onChanged: (callback: () => void) => () => void } @@ -2242,7 +2251,7 @@ export type PreloadApi = { worktreePath: string command: string }) => Promise - 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 + writeIssueCommand: (args: { + repoId: string + content: string + hostId?: ExecutionHostId + }) => Promise } ephemeralVm: { listRecipes: (args: { repoId: string }) => Promise<{ diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f0436caf..8342d8b10 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -598,16 +598,23 @@ const api = { getGitUsername: (args: { repoId: string }): Promise => ipcRenderer.invoke('repos:getGitUsername', args), - getBaseRefDefault: (args: { repoId: string }): Promise => - ipcRenderer.invoke('repos:getBaseRefDefault', args), + getBaseRefDefault: (args: { + repoId: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:getBaseRefDefault', args), - searchBaseRefs: (args: { repoId: string; query: string; limit?: number }): Promise => - ipcRenderer.invoke('repos:searchBaseRefs', args), + searchBaseRefs: (args: { + repoId: string + query: string + limit?: number + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('repos:searchBaseRefs', args), searchBaseRefDetails: (args: { repoId: string query: string limit?: number + hostId?: ExecutionHostId }): Promise => 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 => - ipcRenderer.invoke('hooks:writeIssueCommand', args) + writeIssueCommand: (args: { + repoId: string + content: string + hostId?: ExecutionHostId + }): Promise => ipcRenderer.invoke('hooks:writeIssueCommand', args) }, ephemeralVm: { diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index 23d6d347e..b0c0b6e72 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -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 => { 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 diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index c10516dfd..1ee7cfe19 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -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(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 => { 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 diff --git a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx index 557de4db4..43be0d1f6 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupActions.tsx @@ -43,7 +43,7 @@ type RepositoryHostSetupActionsProps = { setupState: 'not-set-up' setupMethod: 'provisioned' }) => Promise - 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('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) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx index 2ebc332f7..665ef0bcc 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -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 () => { diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index f149567f6..4506d04a0 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -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(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')}