diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx index b1e9ab67da5..3f0d8be76f9 100644 --- a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.test.tsx @@ -329,4 +329,27 @@ describe('WorktreeOpenInMenu', () => { metadata: 'Local only' }) }) + + it('marks an unresolved owner local-only and never launches the desktop copy', async () => { + const editor = { id: 'zed', label: 'Zed', target: 'external-editor', command: 'zed' } as const + const fileManager = { id: 'file-manager', label: 'Finder', target: 'file-manager' } as const + for (const entry of [editor, fileManager]) { + expect(getOpenInEntryAvailability(entry, mockState.settings, null, null, true)).toEqual({ + disabled: true, + metadata: 'Local only' + }) + } + for (const target of ['external-editor', 'file-manager'] as const) { + await openWorktreePath({ + target, + worktreePath: '/srv/worktree', + connectionId: 'ssh-1', + runtimeEnvironmentId: null, + ownerUnresolved: true, + command: 'code' + }) + } + expect(openInExternalEditorMock).not.toHaveBeenCalled() + expect(openInFileManagerMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx index 791fd8dbbb3..983d5773e91 100644 --- a/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react' import { ExternalLink, FolderOpen } from 'lucide-react' -import { toast } from 'sonner' import { DropdownMenuItem, DropdownMenuSeparator, @@ -14,7 +13,7 @@ import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label' import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog' import { getExternalEditorOpenCapability } from '@/lib/external-editor-open-capability' import { NO_OPEN_IN_APPLICATIONS } from '@/lib/open-in-application-selection' -import type { ShellOpenExternalEditorResult } from '../../../../shared/shell-open-types' +import { showOpenFailureToast } from './worktree-open-failure-toast' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { OpenInApplication } from '../../../../shared/ui-chrome-types' import { translate } from '@/i18n/i18n' @@ -25,6 +24,7 @@ type WorktreeOpenInMenuItemsProps = { worktreePath: string connectionId?: string | null runtimeEnvironmentId?: string | null + ownerUnresolved?: boolean disabled?: boolean labelPrefix?: string } @@ -55,10 +55,15 @@ export function getOpenInEntryAvailability( entry: OpenInMenuEntry, settings: Pick | null | undefined, connectionId?: string | null, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId?: string | null, + ownerUnresolved?: boolean ): { disabled: boolean; metadata?: string } { if (entry.target === 'file-manager') { - const disabled = isLocalPathOpenBlocked(settings, { connectionId, runtimeEnvironmentId }) + const disabled = isLocalPathOpenBlocked(settings, { + connectionId, + runtimeEnvironmentId, + ownerUnresolved + }) return disabled ? { disabled: true, @@ -69,7 +74,8 @@ export function getOpenInEntryAvailability( const capability = getExternalEditorOpenCapability(settings, { connectionId, command: entry.command, - runtimeEnvironmentId + runtimeEnvironmentId, + ownerUnresolved }) if (!capability.allowed) { return { @@ -85,152 +91,6 @@ export function getOpenInEntryAvailability( : { disabled: false } } -function showOpenFailureToast( - result: Exclude, - remote: boolean -): void { - if (result.reason === 'remote-runtime-unsupported') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeOpenInMenu.remoteRuntimeUnsupported', - 'Opening this path in a local app is not available.' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.remoteRuntimeUnsupportedDetail', - 'Switch to a local or SSH workspace, then try again.' - ) - } - ) - return - } - if (result.reason === 'ssh-target-not-found') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeOpenInMenu.sshTargetNotFound', - 'SSH host is no longer available.' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.sshTargetNotFoundDetail', - 'Refresh workspaces or reconnect the host, then try again.' - ) - } - ) - return - } - if (result.reason === 'ssh-target-invalid') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeOpenInMenu.sshTargetInvalid', - 'SSH host configuration is incomplete.' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.sshTargetInvalidDetail', - 'Edit or reconnect the SSH host, then try again.' - ) - } - ) - return - } - if (result.reason === 'ssh-alias-required') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeOpenInMenu.sshAliasRequired', - 'VS Code needs an SSH config alias for this host.' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.sshAliasRequiredDetail', - 'Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.', - { host: result.host, port: result.port } - ) - } - ) - return - } - if (result.reason === 'remote-editor-unsupported') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeOpenInMenu.remoteEditorUnsupported', - 'This app cannot open SSH workspaces.' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.remoteEditorUnsupportedDetail', - 'Choose VS Code or use the app locally.' - ) - } - ) - return - } - if (result.reason === 'not-absolute') { - toast.error( - remote - ? translate( - 'auto.components.sidebar.WorktreeOpenInMenu.remotePathInvalid', - 'Path is not valid for the SSH host.' - ) - : translate( - 'auto.components.sidebar.WorktreeOpenInMenu.f387af445b', - 'Workspace path is not a valid local path.' - ), - remote - ? { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.remotePathInvalidDetail', - 'Refresh the workspace before trying again.' - ) - } - : undefined - ) - return - } - if (result.reason === 'not-found') { - toast.error( - translate( - 'auto.components.sidebar.WorktreeOpenInMenu.3921d3d9a5', - 'Workspace folder was not found.' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.0bed8727db', - 'It may have been moved or deleted. Refresh workspaces or remove it from Orca.' - ) - } - ) - return - } - if (remote) { - toast.error( - translate( - 'auto.components.sidebar.WorktreeOpenInMenu.remoteLaunchFailed', - 'Could not open the path in VS Code.' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.remoteLaunchFailedDetail', - 'Check the VS Code command configured on this machine.' - ) - } - ) - return - } - toast.error( - translate( - 'auto.components.sidebar.WorktreeOpenInMenu.9a5381eb09', - 'Could not open workspace folder.' - ), - { - description: translate( - 'auto.components.sidebar.WorktreeOpenInMenu.bd0e8159f8', - 'Check the editor command or file manager configuration on this machine.' - ) - } - ) -} - function stopMenuPropagation(event: React.SyntheticEvent): void { event.stopPropagation() } @@ -250,6 +110,7 @@ export async function openWorktreePath(args: { worktreePath: string connectionId?: string | null runtimeEnvironmentId?: string | null + ownerUnresolved?: boolean command?: string }): Promise { const settings = useAppStore.getState().settings @@ -257,7 +118,8 @@ export async function openWorktreePath(args: { if ( isLocalPathOpenBlocked(settings, { connectionId: args.connectionId ?? null, - runtimeEnvironmentId: args.runtimeEnvironmentId + runtimeEnvironmentId: args.runtimeEnvironmentId, + ownerUnresolved: args.ownerUnresolved }) ) { showLocalPathOpenBlockedToast() @@ -267,7 +129,8 @@ export async function openWorktreePath(args: { const capability = getExternalEditorOpenCapability(settings, { connectionId: args.connectionId, command: args.command, - runtimeEnvironmentId: args.runtimeEnvironmentId + runtimeEnvironmentId: args.runtimeEnvironmentId, + ownerUnresolved: args.ownerUnresolved }) if (!capability.allowed) { if (capability.reason === 'remote-runtime') { @@ -295,16 +158,24 @@ export async function openWorktreePath(args: { function useOpenInWorktreePath({ worktreePath, connectionId, - runtimeEnvironmentId + runtimeEnvironmentId, + ownerUnresolved }: WorktreeOpenInMenuItemsProps): ( target: 'file-manager' | 'external-editor', command?: string ) => Promise { return useCallback( async (target, command) => { - await openWorktreePath({ target, worktreePath, connectionId, runtimeEnvironmentId, command }) + await openWorktreePath({ + target, + worktreePath, + connectionId, + runtimeEnvironmentId, + ownerUnresolved, + command + }) }, - [connectionId, runtimeEnvironmentId, worktreePath] + [connectionId, ownerUnresolved, runtimeEnvironmentId, worktreePath] ) } @@ -312,13 +183,15 @@ export function WorktreeOpenInMenuItems({ worktreePath, connectionId, runtimeEnvironmentId, + ownerUnresolved, disabled, labelPrefix = '' }: WorktreeOpenInMenuItemsProps): React.JSX.Element { const openInWorktreePath = useOpenInWorktreePath({ worktreePath, connectionId, - runtimeEnvironmentId + runtimeEnvironmentId, + ownerUnresolved }) const openInApplications = useAppStore( (s) => s.settings?.openInApplications ?? NO_OPEN_IN_APPLICATIONS @@ -334,7 +207,8 @@ export function WorktreeOpenInMenuItems({ entry, settings, connectionId, - runtimeEnvironmentId + runtimeEnvironmentId, + ownerUnresolved ) return ( diff --git a/src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx b/src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx index 9466c241e86..36aaecfc625 100644 --- a/src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx @@ -1,7 +1,7 @@ import type React from 'react' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { resolveWorktreeOperationRouteForHost } from '@/lib/worktree-operation-route' +import { resolveWorktreeRowOpenInRuntimeOwner } from '@/lib/worktree-row-open-in-owner' import type { Worktree } from '../../../../shared/worktree/types' import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu' @@ -11,23 +11,18 @@ export function WorktreeRowOpenInSubMenu({ connectionId, disabled }: { - worktree: Pick + worktree: Pick connectionId: string | null disabled?: boolean }): React.JSX.Element { - // Why: resolved only while the menu is open, not per row on every store update. An inactive - // row can name its host before the catalog has it, so the explicit host wins. - const runtimeEnvironmentId = useAppStore((s) => - worktree.hostId - ? (resolveWorktreeOperationRouteForHost(s, worktree.id, worktree.hostId) - ?.runtimeEnvironmentId ?? null) - : getRuntimeEnvironmentIdForWorktree(s, worktree.id) - ) + // Why: resolved only while the menu is open, not per row on every store update. + const owner = useAppStore(useShallow((s) => resolveWorktreeRowOpenInRuntimeOwner(s, worktree))) return ( ) diff --git a/src/renderer/src/components/sidebar/worktree-open-failure-toast.ts b/src/renderer/src/components/sidebar/worktree-open-failure-toast.ts new file mode 100644 index 00000000000..70c661a5599 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-open-failure-toast.ts @@ -0,0 +1,150 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import type { ShellOpenExternalEditorResult } from '../../../../shared/shell-open-types' + +/** Actionable toast for a refused or failed Open in launch. */ +export function showOpenFailureToast( + result: Exclude, + remote: boolean +): void { + if (result.reason === 'remote-runtime-unsupported') { + toast.error( + translate( + 'auto.components.sidebar.WorktreeOpenInMenu.remoteRuntimeUnsupported', + 'Opening this path in a local app is not available.' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.remoteRuntimeUnsupportedDetail', + 'Switch to a local or SSH workspace, then try again.' + ) + } + ) + return + } + if (result.reason === 'ssh-target-not-found') { + toast.error( + translate( + 'auto.components.sidebar.WorktreeOpenInMenu.sshTargetNotFound', + 'SSH host is no longer available.' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.sshTargetNotFoundDetail', + 'Refresh workspaces or reconnect the host, then try again.' + ) + } + ) + return + } + if (result.reason === 'ssh-target-invalid') { + toast.error( + translate( + 'auto.components.sidebar.WorktreeOpenInMenu.sshTargetInvalid', + 'SSH host configuration is incomplete.' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.sshTargetInvalidDetail', + 'Edit or reconnect the SSH host, then try again.' + ) + } + ) + return + } + if (result.reason === 'ssh-alias-required') { + toast.error( + translate( + 'auto.components.sidebar.WorktreeOpenInMenu.sshAliasRequired', + 'VS Code needs an SSH config alias for this host.' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.sshAliasRequiredDetail', + 'Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.', + { host: result.host, port: result.port } + ) + } + ) + return + } + if (result.reason === 'remote-editor-unsupported') { + toast.error( + translate( + 'auto.components.sidebar.WorktreeOpenInMenu.remoteEditorUnsupported', + 'This app cannot open SSH workspaces.' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.remoteEditorUnsupportedDetail', + 'Choose VS Code or use the app locally.' + ) + } + ) + return + } + if (result.reason === 'not-absolute') { + toast.error( + remote + ? translate( + 'auto.components.sidebar.WorktreeOpenInMenu.remotePathInvalid', + 'Path is not valid for the SSH host.' + ) + : translate( + 'auto.components.sidebar.WorktreeOpenInMenu.f387af445b', + 'Workspace path is not a valid local path.' + ), + remote + ? { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.remotePathInvalidDetail', + 'Refresh the workspace before trying again.' + ) + } + : undefined + ) + return + } + if (result.reason === 'not-found') { + toast.error( + translate( + 'auto.components.sidebar.WorktreeOpenInMenu.3921d3d9a5', + 'Workspace folder was not found.' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.0bed8727db', + 'It may have been moved or deleted. Refresh workspaces or remove it from Orca.' + ) + } + ) + return + } + if (remote) { + toast.error( + translate( + 'auto.components.sidebar.WorktreeOpenInMenu.remoteLaunchFailed', + 'Could not open the path in VS Code.' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.remoteLaunchFailedDetail', + 'Check the VS Code command configured on this machine.' + ) + } + ) + return + } + toast.error( + translate( + 'auto.components.sidebar.WorktreeOpenInMenu.9a5381eb09', + 'Could not open workspace folder.' + ), + { + description: translate( + 'auto.components.sidebar.WorktreeOpenInMenu.bd0e8159f8', + 'Check the editor command or file manager configuration on this machine.' + ) + } + ) +} diff --git a/src/renderer/src/lib/external-editor-open-capability.ts b/src/renderer/src/lib/external-editor-open-capability.ts index 84729364f35..10e02436068 100644 --- a/src/renderer/src/lib/external-editor-open-capability.ts +++ b/src/renderer/src/lib/external-editor-open-capability.ts @@ -7,9 +7,19 @@ export type ExternalEditorOpenCapability = export function getExternalEditorOpenCapability( settings: Pick | null | undefined, - context: { connectionId?: string | null; command?: string; runtimeEnvironmentId?: string | null } + context: { + connectionId?: string | null + command?: string + runtimeEnvironmentId?: string | null + /** The path's owner could not be placed; a null runtime then proves nothing about locality. */ + ownerUnresolved?: boolean + } ): ExternalEditorOpenCapability { - if (settings?.activeRuntimeEnvironmentId?.trim() || context.runtimeEnvironmentId?.trim()) { + if ( + context.ownerUnresolved || + settings?.activeRuntimeEnvironmentId?.trim() || + context.runtimeEnvironmentId?.trim() + ) { return { allowed: false, reason: 'remote-runtime' } } if (!context.connectionId?.trim()) { diff --git a/src/renderer/src/lib/local-path-open-guard.ts b/src/renderer/src/lib/local-path-open-guard.ts index a560cf8e393..dd621c38fa1 100644 --- a/src/renderer/src/lib/local-path-open-guard.ts +++ b/src/renderer/src/lib/local-path-open-guard.ts @@ -4,9 +4,15 @@ import { translate } from '@/i18n/i18n' export function isLocalPathOpenBlocked( settings: Pick | null | undefined, - context?: { connectionId?: string | null; runtimeEnvironmentId?: string | null } + context?: { + connectionId?: string | null + runtimeEnvironmentId?: string | null + /** The path's owner could not be placed; a null runtime then proves nothing about locality. */ + ownerUnresolved?: boolean + } ): boolean { return Boolean( + context?.ownerUnresolved || settings?.activeRuntimeEnvironmentId?.trim() || context?.connectionId?.trim() || context?.runtimeEnvironmentId?.trim() diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index a321d2412aa..98132ebe54c 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -134,6 +134,45 @@ export function resolveWorktreeOperationRouteForHost( return resolution.kind === 'resolved' ? resolution.route : null } +/** + * Strict variant of the host-qualified resolver for callers that treat a null runtime as local: + * rival HUBs projecting the same host report `ambiguous` instead of a transport-less route. + */ +export function resolveStrictWorktreeOperationRouteResultForHost( + state: WorktreeOperationRouteState, + worktreeId: string, + executionHostId: ExecutionHostId +): WorktreeOperationRouteResolution { + const host = parseExecutionHostId(executionHostId) + if (!host) { + return { kind: 'missing' } + } + if (host.kind === 'ssh') { + const hubOwners = collectHubOwnerEnvironmentIds(state, worktreeId, host.id) + if (hubOwners.hasAmbiguousOwner || hubOwners.environmentIds.size > 1) { + return { kind: 'ambiguous' } + } + } + return { kind: 'resolved', route: resolveSelectedHostRoute(state, worktreeId, host) } +} + +function collectHubOwnerEnvironmentIds( + state: WorktreeOperationRouteState, + worktreeId: string, + executionHostId: ExecutionHostId +): { environmentIds: Set; hasAmbiguousOwner: boolean } { + const environmentIds = new Set() + let hasAmbiguousOwner = false + for (const owner of ownerRecordsOnHost(state, worktreeId, executionHostId)) { + const resolution = resolveExactWorktreeRoute(state, owner) + hasAmbiguousOwner ||= resolution.kind === 'ambiguous' + if (resolution.kind === 'resolved' && resolution.route.runtimeEnvironmentId) { + environmentIds.add(resolution.route.runtimeEnvironmentId) + } + } + return { environmentIds, hasAmbiguousOwner } +} + /** * An authoritative host selection already names the target, so only the transport has to be * recovered — and only for `ssh:`, which a paired HUB can proxy. Rival HUBs projecting the same @@ -151,13 +190,7 @@ function resolveSelectedHostRoute( if (selectedHost.kind !== 'ssh') { return { executionHostId: selectedHost.id, runtimeEnvironmentId: null } } - const environmentIds = new Set() - for (const owner of ownerRecordsOnHost(state, worktreeId, selectedHost.id)) { - const resolution = resolveExactWorktreeRoute(state, owner) - if (resolution.kind === 'resolved' && resolution.route.runtimeEnvironmentId) { - environmentIds.add(resolution.route.runtimeEnvironmentId) - } - } + const { environmentIds } = collectHubOwnerEnvironmentIds(state, worktreeId, selectedHost.id) const environmentId = environmentIds.values().next().value return { executionHostId: selectedHost.id, diff --git a/src/renderer/src/lib/worktree-row-open-in-owner.test.ts b/src/renderer/src/lib/worktree-row-open-in-owner.test.ts new file mode 100644 index 00000000000..4aa5111af74 --- /dev/null +++ b/src/renderer/src/lib/worktree-row-open-in-owner.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../../shared/worktree/types' +import { resolveWorktreeRowOpenInRuntimeOwner } from './worktree-row-open-in-owner' +import { getExternalEditorOpenCapability } from './external-editor-open-capability' +import { isLocalPathOpenBlocked } from './local-path-open-guard' + +const WORKTREE_ID = 'repo-1::/srv/worktree' + +function worktree(hostId: Worktree['hostId'], runtimeOwnerEnvironmentId?: string): Worktree { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only id/repoId/hostId/runtimeOwnerEnvironmentId. + return { + id: WORKTREE_ID, + repoId: 'repo-1', + path: '/srv/worktree', + hostId, + runtimeOwnerEnvironmentId + } as Worktree +} + +// Two paired servers provisioned from one profile publish the same id and SSH host. +const rivalHubState = { + settings: { activeRuntimeEnvironmentId: null }, + worktreesByRepo: { + 'repo-1': [ + worktree('ssh:same-private-target', 'hub-a'), + worktree('ssh:same-private-target', 'hub-b') + ] + } +} + +describe('resolveWorktreeRowOpenInRuntimeOwner', () => { + it('keeps the clicked row stamped owner when rival HUBs share its SSH host', () => { + expect( + resolveWorktreeRowOpenInRuntimeOwner( + rivalHubState, + worktree('ssh:same-private-target', 'hub-b') + ) + ).toEqual({ runtimeEnvironmentId: 'hub-b', ownerUnresolved: false }) + }) + + it('reports an unstamped row on a rival-HUB host as unresolved, never as desktop-owned', () => { + const owner = resolveWorktreeRowOpenInRuntimeOwner( + rivalHubState, + worktree('ssh:same-private-target') + ) + expect(owner).toEqual({ runtimeEnvironmentId: null, ownerUnresolved: true }) + const context = { connectionId: null, command: 'code', ...owner } + expect(getExternalEditorOpenCapability(rivalHubState.settings, context)).toEqual({ + allowed: false, + reason: 'remote-runtime' + }) + expect(isLocalPathOpenBlocked(rivalHubState.settings, context)).toBe(true) + }) + + it('recovers a single HUB owner for an unstamped row on its SSH host', () => { + expect( + resolveWorktreeRowOpenInRuntimeOwner( + { worktreesByRepo: { 'repo-1': [worktree('ssh:hub-private-target', 'hub-a')] } }, + worktree('ssh:hub-private-target') + ) + ).toEqual({ runtimeEnvironmentId: 'hub-a', ownerUnresolved: false }) + }) + + it('keeps a direct SSH row resolved with no runtime', () => { + expect( + resolveWorktreeRowOpenInRuntimeOwner( + { + repos: [{ id: 'repo-1', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }], + worktreesByRepo: { 'repo-1': [worktree('ssh:ssh-1')] } + }, + worktree('ssh:ssh-1') + ) + ).toEqual({ runtimeEnvironmentId: null, ownerUnresolved: false }) + }) + + it('keeps a local row resolved as desktop-owned', () => { + expect(resolveWorktreeRowOpenInRuntimeOwner({}, worktree('local'))).toEqual({ + runtimeEnvironmentId: null, + ownerUnresolved: false + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-row-open-in-owner.ts b/src/renderer/src/lib/worktree-row-open-in-owner.ts new file mode 100644 index 00000000000..5c48a8e3d6c --- /dev/null +++ b/src/renderer/src/lib/worktree-row-open-in-owner.ts @@ -0,0 +1,44 @@ +import type { Worktree } from '../../../shared/worktree/types' +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' +import { + resolveStrictWorktreeOperationRouteResultForHost, + type WorktreeOperationRouteState +} from './worktree-operation-route' +import { routeForOwner } from './worktree-owner-route' + +export type WorktreeRowOpenInOwner = { + runtimeEnvironmentId: string | null + /** The store cannot place the row; a null runtime then must not read as desktop ownership. */ + ownerUnresolved: boolean +} + +const UNRESOLVED: WorktreeRowOpenInOwner = { runtimeEnvironmentId: null, ownerUnresolved: true } + +function resolved(runtimeEnvironmentId: string | null): WorktreeRowOpenInOwner { + return { runtimeEnvironmentId, ownerUnresolved: false } +} + +/** Runtime that owns one clicked sidebar row, for the desktop Open in launch guards. */ +export function resolveWorktreeRowOpenInRuntimeOwner( + state: WorktreeOperationRouteState & WorktreeRuntimeOwnerState, + worktree: Pick +): WorktreeRowOpenInOwner { + // Why: the row's stamped owner is exact; rival HUBs can share its host id, so never re-derive it. + if (worktree.runtimeOwnerEnvironmentId?.trim()) { + const runtimeEnvironmentId = routeForOwner(worktree)?.runtimeEnvironmentId + return runtimeEnvironmentId ? resolved(runtimeEnvironmentId) : UNRESOLVED + } + if (!worktree.hostId) { + return resolved(getRuntimeEnvironmentIdForWorktree(state, worktree.id)) + } + // Why: an inactive row can name its host before the catalog has it, so the explicit host wins. + const resolution = resolveStrictWorktreeOperationRouteResultForHost( + state, + worktree.id, + worktree.hostId + ) + return resolution.kind === 'resolved' + ? resolved(resolution.route.runtimeEnvironmentId) + : UNRESOLVED +}