mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
Keep an unplaceable row owner out of desktop Open in launches (#26797)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<ShellOpenExternalEditorResult, { ok: true }>,
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 (
|
||||
<DropdownMenuItem
|
||||
@@ -372,6 +246,7 @@ export function WorktreeOpenInSubMenu({
|
||||
worktreePath,
|
||||
connectionId,
|
||||
runtimeEnvironmentId,
|
||||
ownerUnresolved,
|
||||
disabled
|
||||
}: WorktreeOpenInMenuItemsProps): React.JSX.Element {
|
||||
return (
|
||||
@@ -389,6 +264,7 @@ export function WorktreeOpenInSubMenu({
|
||||
worktreePath={worktreePath}
|
||||
connectionId={connectionId}
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
ownerUnresolved={ownerUnresolved}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -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, 'id' | 'path' | 'hostId'>
|
||||
worktree: Pick<Worktree, 'id' | 'path' | 'hostId' | 'runtimeOwnerEnvironmentId'>
|
||||
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 (
|
||||
<WorktreeOpenInSubMenu
|
||||
worktreePath={worktree.path}
|
||||
connectionId={connectionId}
|
||||
runtimeEnvironmentId={runtimeEnvironmentId}
|
||||
runtimeEnvironmentId={owner.runtimeEnvironmentId}
|
||||
ownerUnresolved={owner.ownerUnresolved}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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<ShellOpenExternalEditorResult, { ok: true }>,
|
||||
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.'
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,19 @@ export type ExternalEditorOpenCapability =
|
||||
|
||||
export function getExternalEditorOpenCapability(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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()) {
|
||||
|
||||
@@ -4,9 +4,15 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function isLocalPathOpenBlocked(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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()
|
||||
|
||||
@@ -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<string>; hasAmbiguousOwner: boolean } {
|
||||
const environmentIds = new Set<string>()
|
||||
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<string>()
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<Worktree, 'id' | 'hostId' | 'runtimeOwnerEnvironmentId'>
|
||||
): 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
|
||||
}
|
||||
Reference in New Issue
Block a user