diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 51c26f8c5ab..5b77ce7c9df 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -121,6 +121,7 @@ import { buildRepoIdToRepresentative, buildSettingsProjectList, getSettingsProjectHostRepo, + getSettingsTargetHostSelection, removeSettingsProjectFromAllHosts, resolveSettingsTargetRepoId } from './settings-project-list' @@ -642,9 +643,21 @@ function Settings(): React.JSX.Element { repoIdToHostSelection.keys() ) if (targetRepoId) { - const hostSelection = repoIdToHostSelection.get(targetRepoId) + const hostSelection = settingsNavigationTarget.hostId + ? getSettingsTargetHostSelection( + settingsProjectList, + targetRepoId, + settingsNavigationTarget.hostId + ) + : repoIdToHostSelection.get(targetRepoId) if (hostSelection) { - setSettingsProjectHostSelection(hostSelection.projectId, hostSelection.hostId) + setSettingsProjectHostSelection( + hostSelection.projectId, + hostSelection.hostId, + 'setupId' in hostSelection && typeof hostSelection.setupId === 'string' + ? hostSelection.setupId + : undefined + ) } } pendingNavSectionRef.current = paneSectionId @@ -678,6 +691,7 @@ function Settings(): React.JSX.Element { repoIdToRepresentative, setSettingsProjectHostSelection, settings, + settingsProjectList, settingsNavigationTarget ]) diff --git a/src/renderer/src/components/settings/settings-project-list.test.ts b/src/renderer/src/components/settings/settings-project-list.test.ts index 8ff85d054a7..6aca1301421 100644 --- a/src/renderer/src/components/settings/settings-project-list.test.ts +++ b/src/renderer/src/components/settings/settings-project-list.test.ts @@ -6,6 +6,7 @@ import { buildSettingsProjectList, getSettingsProjectHostRepo, getSettingsProjectRepresentativeRepoId, + getSettingsTargetHostSelection, removeSettingsProjectFromAllHosts, resolveEffectiveProjectHost, resolveSettingsTargetRepoId @@ -159,6 +160,26 @@ describe('deep-link resolution', () => { }) }) + it('uses an explicit host when same-id repo rows collide', () => { + const sameIdProjects = buildSettingsProjectList([ + makeRepo({ id: 'same-repo', gitRemoteIdentity: gitRemote }), + makeRepo({ + id: 'same-repo', + gitRemoteIdentity: gitRemote, + executionHostId: 'ssh:server', + connectionId: 'server', + path: '/remote/repo' + }) + ]) + + expect(getSettingsTargetHostSelection(sameIdProjects, 'same-repo', 'ssh:server')).toEqual( + expect.objectContaining({ + projectId: sameIdProjects[0].projectId, + hostId: 'ssh:server' + }) + ) + }) + it('parses a repoId from a host-specific subsection sectionId', () => { const repoIds = [...buildRepoIdToHostSelection(projects).keys()] expect( diff --git a/src/renderer/src/components/settings/settings-project-list.ts b/src/renderer/src/components/settings/settings-project-list.ts index 517ad7bb140..2eb6a567600 100644 --- a/src/renderer/src/components/settings/settings-project-list.ts +++ b/src/renderer/src/components/settings/settings-project-list.ts @@ -123,6 +123,22 @@ export function buildRepoIdToHostSelection( return map } +export function getSettingsTargetHostSelection( + projects: readonly SettingsProject[], + repoId: string, + hostId: ExecutionHostId +): { projectId: string; hostId: ExecutionHostId; setupId: string } | null { + for (const settingsProject of projects) { + const setup = settingsProject.setups.find( + (candidate) => candidate.repoId === repoId && candidate.hostId === hostId + ) + if (setup) { + return { projectId: settingsProject.projectId, hostId, setupId: setup.id } + } + } + return null +} + /** * The repo row a Settings deep link points at, from either an explicit repoId * or a `repo--` sectionId. repo ids can contain hyphens, so the diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts index f01e7909a2e..aa3dc5bc07d 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest' import { + findSetupScriptPromptRepo, getRenderedSetupScriptPromptState, markSetupScriptPromptSaved } from './setup-script-prompt-render-state' import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' import { getRepoHostIdentityForParts } from '@/store/slices/repo-host-identity' +import type { Repo } from '../../../../shared/types' function repoIdentity(repoId: string, hostId: string): string { return getRepoHostIdentityForParts(repoId, hostId) @@ -24,6 +26,64 @@ function prompt( } } +describe('findSetupScriptPromptRepo', () => { + it('uses the active direct-SSH worktree host when repo ids collide', () => { + const local: Repo = { + id: 'same-repo', + path: '/local', + displayName: 'Local', + badgeColor: '#000', + addedAt: 1 + } + const ssh: Repo = { + ...local, + path: '/ssh', + displayName: 'SSH', + connectionId: 'server', + executionHostId: 'ssh:server' + } + + expect( + findSetupScriptPromptRepo({ + repos: [local, ssh], + activeRepoId: 'same-repo', + activeWorktree: { repoId: 'same-repo', hostId: 'ssh:server' }, + settings: { activeRuntimeEnvironmentId: null } + }) + ).toBe(ssh) + }) + + it('uses the runtime owner for a relayed SSH worktree', () => { + const directSsh: Repo = { + id: 'same-repo', + path: '/direct', + displayName: 'Direct SSH', + badgeColor: '#000', + addedAt: 1, + connectionId: 'private' + } + const runtime: Repo = { + ...directSsh, + path: '/runtime', + displayName: 'Runtime', + executionHostId: 'runtime:hub' + } + + expect( + findSetupScriptPromptRepo({ + repos: [directSsh, runtime], + activeRepoId: 'same-repo', + activeWorktree: { + repoId: 'same-repo', + hostId: 'ssh:private', + runtimeOwnerEnvironmentId: 'hub' + }, + settings: { activeRuntimeEnvironmentId: null } + }) + ).toBe(runtime) + }) +}) + describe('getRenderedSetupScriptPromptState', () => { it('uses the current inspection when it belongs to the active repo and host', () => { const current = prompt('repo-local', 'local') diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx index bc4a63869c9..159115b79e0 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx @@ -20,6 +20,7 @@ import { showSavedInProjectSettingsToast } from './SetupScriptPromptToast' import { openSetupScriptSettings } from './open-setup-script-settings' import { trackSetupScriptPromptExposure } from './setup-script-prompt-exposure-telemetry' import { + findSetupScriptPromptRepo, getRenderedSetupScriptPromptState, markSetupScriptPromptSaved, type LastVisibleSetupScriptPrompt, @@ -27,13 +28,16 @@ import { } from './setup-script-prompt-render-state' import { useSetupScriptPromptRevalidation } from './useSetupScriptPromptRevalidation' import { translate } from '@/i18n/i18n' -import { findRepoForHost, getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { useWorktreeById } from '@/store/selectors' function SetupScriptPromptCard(): React.JSX.Element | null { const sidebarOpen = useAppStore((s) => s.sidebarOpen) const repos = useAppStore((s) => s.repos) const activeRepoId = useAppStore((s) => s.activeRepoId) + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const activeWorktree = useWorktreeById(activeWorktreeId) const settings = useAppStore((s) => s.settings) const updateRepo = useAppStore((s) => s.updateRepo) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -49,8 +53,8 @@ function SetupScriptPromptCard(): React.JSX.Element | null { const mountedRef = useMountedRef() const activeRepo = useMemo( - () => (activeRepoId ? findRepoForHost(repos, activeRepoId, { settings }) : null), - [activeRepoId, repos, settings] + () => findSetupScriptPromptRepo({ repos, activeRepoId, activeWorktree, settings }), + [activeRepoId, activeWorktree, repos, settings] ) const activeRepoHostIdentity = activeRepo ? getRepoHostIdentity(activeRepo) : null const isDismissed = activeRepoHostIdentity @@ -98,14 +102,14 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }, [activeRepo, inspectionRetryKey, isDismissed, settings, sidebarOpen]) const openLocalCommandSettings = useCallback( - (repoId: string) => { + (repoId: string, hostId: ReturnType) => openSetupScriptSettings({ repoId, + hostId, setSettingsSearchQuery, openSettingsTarget, openSettingsPage - }) - }, + }), [openSettingsPage, openSettingsTarget, setSettingsSearchQuery] ) @@ -162,7 +166,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { }) ) } - openLocalCommandSettings(activeRepo.id) + openLocalCommandSettings(activeRepo.id, getRepoExecutionHostId(activeRepo)) }, [activeRepo, activeRepoHostIdentity, openLocalCommandSettings, promptState]) const handleDismiss = useCallback(() => { @@ -249,7 +253,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { markSetupScriptPromptSaved(current, importedRepoHostIdentity) ) showSavedInProjectSettingsToast({ - onOpenSettings: () => openLocalCommandSettings(importedRepoId), + onOpenSettings: () => openLocalCommandSettings(importedRepoId, importedHostId), description: translate( 'auto.components.sidebar.SetupScriptPromptCard.a49196d538', 'Runs when Orca creates a new worktree.' @@ -262,7 +266,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { setPromptState((current) => markSetupScriptPromptSaved(current, importedRepoHostIdentity)) const skippedCount = candidate.unsupportedFields?.length ?? 0 showSavedInProjectSettingsToast({ - onOpenSettings: () => openLocalCommandSettings(importedRepoId), + onOpenSettings: () => openLocalCommandSettings(importedRepoId, importedHostId), description: skippedCount > 0 ? `${skippedCount} unsupported field${skippedCount === 1 ? '' : 's'} skipped. Saved the setup command.` @@ -385,9 +389,7 @@ function SetupScriptPromptCard(): React.JSX.Element | null { !renderedPromptState.hasEffectiveSetup && renderedPromptState.repoHostIdentity === activeRepoHostIdentity ) { - lastVisiblePromptRef.current = { - state: renderedPromptState - } + lastVisiblePromptRef.current = { state: renderedPromptState } } const isInspectionError = renderedPromptState.status === 'error' diff --git a/src/renderer/src/components/sidebar/open-setup-script-settings.ts b/src/renderer/src/components/sidebar/open-setup-script-settings.ts index e9918b502dc..e6d2db44319 100644 --- a/src/renderer/src/components/sidebar/open-setup-script-settings.ts +++ b/src/renderer/src/components/sidebar/open-setup-script-settings.ts @@ -1,18 +1,26 @@ import { getRepositoryLocalCommandsSectionId } from '@/components/settings/repository-settings-targets' +import type { ExecutionHostId } from '../../../../shared/execution-host' export function openSetupScriptSettings(input: { repoId: string + hostId: ExecutionHostId setSettingsSearchQuery: (query: string) => void - openSettingsTarget: (target: { pane: 'repo'; repoId: string; sectionId: string }) => void + openSettingsTarget: (target: { + pane: 'repo' + repoId: string + hostId: ExecutionHostId + sectionId: string + }) => void openSettingsPage: () => void }): void { - const { openSettingsPage, openSettingsTarget, repoId, setSettingsSearchQuery } = input + const { hostId, openSettingsPage, openSettingsTarget, repoId, setSettingsSearchQuery } = input // Why: imported setup commands are local repo settings; a stale Settings // search should not hide the exact editor this action opens. setSettingsSearchQuery('') openSettingsTarget({ pane: 'repo', repoId, + hostId, sectionId: getRepositoryLocalCommandsSectionId(repoId) }) openSettingsPage() diff --git a/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts index 0b9d600c4d5..0e80337118d 100644 --- a/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts +++ b/src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts @@ -1,4 +1,7 @@ +import type { GlobalSettings, Repo, Worktree } from '../../../../shared/types' import type { SetupScriptPromptInspection } from '@/lib/setup-script-prompt' +import { findRepoForHost } from '@/store/slices/repo-host-identity' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' export type SetupScriptPromptState = SetupScriptPromptInspection & { repoHostIdentity: string @@ -8,6 +11,30 @@ export type LastVisibleSetupScriptPrompt = { state: SetupScriptPromptState } +export function findSetupScriptPromptRepo(input: { + repos: readonly Repo[] + activeRepoId: string | null + activeWorktree: Pick | null + settings: Pick | null +}): Repo | null { + const { activeRepoId, activeWorktree, repos, settings } = input + if (!activeRepoId) { + return null + } + // Why: runtime-relayed SSH worktrees expose their transport host separately from the repo catalog owner. + const runtimeOwnerEnvironmentId = activeWorktree?.runtimeOwnerEnvironmentId?.trim() + const activeWorktreeHostId = + activeWorktree?.repoId === activeRepoId + ? runtimeOwnerEnvironmentId + ? toRuntimeExecutionHostId(runtimeOwnerEnvironmentId) + : activeWorktree.hostId + : undefined + return findRepoForHost(repos, activeRepoId, { + settings, + ...(activeWorktreeHostId ? { hostId: activeWorktreeHostId } : {}) + }) +} + export function markSetupScriptPromptSaved( current: SetupScriptPromptState | null, savedRepoHostIdentity: string diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 9197153fde5..bf3d208630e 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -744,6 +744,7 @@ export type UISlice = { settingsNavigationTarget: { pane: SettingsNavTarget repoId: string | null + hostId?: ExecutionHostId sectionId?: string intent?: 'add-quick-command' | 'add-remote-orca-server' | 'add-ssh-host' } | null