From 996924081935cc67e94ccf609d9eded3f689b666 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 5 Oct 2026 02:22:10 -0700 Subject: [PATCH 1/5] fix(lint): distinguish internal hook confirmation from browser dialogs --- src/renderer/src/lib/worktree-creation-hook-preparation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/lib/worktree-creation-hook-preparation.ts b/src/renderer/src/lib/worktree-creation-hook-preparation.ts index a8ad413d6c9c..4132e3fa8985 100644 --- a/src/renderer/src/lib/worktree-creation-hook-preparation.ts +++ b/src/renderer/src/lib/worktree-creation-hook-preparation.ts @@ -14,7 +14,7 @@ export async function prepareWorktreeCreationHooks( } const isCancelled = (): boolean => !useAppStore.getState().pendingWorktreeCreations[creationId] - const confirm = (kind: 'setup' | 'vmRecipe'): Promise<'run' | 'skip'> => + const confirmHook = (kind: 'setup' | 'vmRecipe'): Promise<'run' | 'skip'> => ensureHooksConfirmed( useAppStore.getState, request.repoId, @@ -23,7 +23,7 @@ export async function prepareWorktreeCreationHooks( undefined, isCancelled ) - const trustDecision = await confirm('setup') + const trustDecision = await confirmHook('setup') if (isCancelled()) { return null } @@ -48,7 +48,7 @@ export async function prepareWorktreeCreationHooks( } if (preparation.confirmVmRecipe) { - const decision = await confirm('vmRecipe') + const decision = await confirmHook('vmRecipe') if (isCancelled()) { return null } From d314158b097d6489e4d317f66b69e4e2f78da808 Mon Sep 17 00:00:00 2001 From: Chen Date: Sun, 27 Sep 2026 13:43:01 +0800 Subject: [PATCH 2/5] fix(editor): preserve file owner across host focus --- .../lib/editor-file-operation-owner.test.ts | 23 +++++++++++ .../src/lib/editor-file-operation-owner.ts | 20 ++++++++- .../store/slices/editor-tab-placement.test.ts | 41 +++++++++++++++++++ .../editor/tabs/workspace-editor-item.ts | 29 +++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/editor-file-operation-owner.test.ts b/src/renderer/src/lib/editor-file-operation-owner.test.ts index 43e257419db4..a272f739b44b 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -93,6 +93,29 @@ describe('editor file operation owner', () => { expect(context.settings?.activeRuntimeEnvironmentId).toBe('hub-a') }) + it('keeps an unstamped local worktree local while another host is focused', () => { + useAppStore.setState({ + repos: [{ id: 'repo' } as never], + worktreesByRepo: { + repo: [{ id: worktreeId, repoId: 'repo', path: '/local/repo' } as never] + }, + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: 'runtime:hub-b' + }) + + const provenance = captureEditorFileOperationProvenance( + useAppStore.getState(), + worktreeId, + undefined, + false + ) + + expect(provenance.generation.route).toEqual({ + executionHostId: 'local', + runtimeEnvironmentId: null + }) + }) + it('rejects an open tab after the same environment id is re-paired', () => { useAppStore.getState().setRuntimeEnvironments([runtimeEnvironment('hub-a', 1)]) useAppStore.setState({ diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index 0c9bcdfdef40..3df9fd3f36d8 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -23,6 +23,8 @@ export type EditorFileOperationProvenance = { type EditorOwnerState = Pick< AppState, | 'settings' + | 'activeWorktreeId' + | 'activeWorkspaceExecutionHostId' | 'repos' | 'worktreesByRepo' | 'detectedWorktreesByRepo' @@ -66,7 +68,7 @@ export function captureEditorFileOperationProvenance( : ('local' as const), runtimeEnvironmentId: hintedRuntimeEnvironmentId } - : resolveWorktreeOperationRoute(state, worktreeId) + : resolveLegacyEditorFileRoute(state, worktreeId) if (!route || (ownerHintProvided && (ownerHint?.trim() || null) !== route.runtimeEnvironmentId)) { throw new Error(OWNER_CHANGED_MESSAGE) } @@ -78,6 +80,22 @@ export function captureEditorFileOperationProvenance( } } +function resolveLegacyEditorFileRoute( + state: EditorOwnerState, + worktreeId: string +): WorktreeOperationRoute | null { + // Why: a focused workspace selects where the UI is looking, not which host owns a file. + return resolveWorktreeOperationRoute( + { + ...state, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + settings: { ...state.settings, activeRuntimeEnvironmentId: null } + }, + worktreeId + ) +} + export function assertEditorFileOperationCurrent( state: EditorOwnerState, worktreeId: string, diff --git a/src/renderer/src/store/slices/editor-tab-placement.test.ts b/src/renderer/src/store/slices/editor-tab-placement.test.ts index 39ee3d9a9021..7033a9fb82c0 100644 --- a/src/renderer/src/store/slices/editor-tab-placement.test.ts +++ b/src/renderer/src/store/slices/editor-tab-placement.test.ts @@ -125,6 +125,47 @@ describe('createEditorSlice floating editor activation', () => { }) }) +describe('createEditorSlice editor host ownership', () => { + it('keeps an editor tab on its local file owner while another host is focused', () => { + const store = createEditorTabsStore() + store.setState({ + repos: [{ id: 'repo-1' } as never], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' } as never] + }, + activeWorktreeId: 'wt-1', + activeWorkspaceExecutionHostId: 'runtime:hub-b', + settings: { activeRuntimeEnvironmentId: 'hub-b' } as never + } as Partial) + + store.getState().openFile({ + filePath: '/repo/local.ts', + relativePath: 'local.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + + expect(store.getState().openFiles).toContainEqual( + expect.objectContaining({ + filePath: '/repo/local.ts', + runtimeEnvironmentId: null, + operationProvenance: expect.objectContaining({ + generation: expect.objectContaining({ + route: { executionHostId: 'local', runtimeEnvironmentId: null } + }) + }) + }) + ) + expect( + store + .getState() + .unifiedTabsByWorktree['wt-1']?.find((tab) => tab.entityId === '/repo/local.ts') + ?.executionHostId + ).toBe('local') + }) +}) + describe('createEditorSlice split-group editor routing', () => { function openSourceFile( store: StoreApi, diff --git a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts index e26a2526975d..b5d1ab3c9865 100644 --- a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts +++ b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts @@ -1,4 +1,10 @@ import type { AppState } from '../../../types' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' import type { EditorSlice } from '../types/editor-slice' import type { EditorTabSelection, OpenFile } from '../types/open-file' import { resolveEditorOpenTargetGroupId } from './editor-open-target-group' @@ -34,16 +40,39 @@ export function openWorkspaceEditorItem( return existing.id } } + // Why: editable files carry an owner captured at open time; virtual diff and review tabs retain their existing host selection. + const executionHostId = + contentType === 'editor' ? resolveEditorItemExecutionHostId(state, fileId, worktreeId) : null const created = state.createUnifiedTab?.(worktreeId, contentType, { entityId: fileId, label, isPreview, + ...(executionHostId ? { executionHostId } : {}), ...(resolvedGroupId ? { targetGroupId: resolvedGroupId } : {}), ...(selection === 'none' ? { activate: false } : {}), ...(selection === 'background' ? { recordFocus: false } : {}) }) return created?.id ?? fileId } + +function resolveEditorItemExecutionHostId( + state: Pick, + fileId: string, + worktreeId: string +): ExecutionHostId | null { + const file = state.openFiles.find( + (candidate) => candidate.id === fileId && candidate.worktreeId === worktreeId + ) + if (!file) { + return null + } + return ( + file.operationProvenance?.generation.route.executionHostId ?? + (file.externalSshTargetId ? toSshExecutionHostId(file.externalSshTargetId) : null) ?? + (file.runtimeEnvironmentId ? toRuntimeExecutionHostId(file.runtimeEnvironmentId) : null) ?? + LOCAL_EXECUTION_HOST_ID + ) +} export function getReplaceablePreviewFileId( state: Pick, worktreeId: string, From cad7903fbe01ed3d9df50832d304b5890c19c2f4 Mon Sep 17 00:00:00 2001 From: Chen Date: Sun, 27 Sep 2026 14:48:34 +0800 Subject: [PATCH 3/5] fix(editor): retain legacy runtime owner fallback --- src/renderer/src/lib/editor-file-operation-owner.test.ts | 3 ++- src/renderer/src/lib/editor-file-operation-owner.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/lib/editor-file-operation-owner.test.ts b/src/renderer/src/lib/editor-file-operation-owner.test.ts index a272f739b44b..c6d2d5ea7e8c 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -100,7 +100,8 @@ describe('editor file operation owner', () => { repo: [{ id: worktreeId, repoId: 'repo', path: '/local/repo' } as never] }, activeWorktreeId: worktreeId, - activeWorkspaceExecutionHostId: 'runtime:hub-b' + activeWorkspaceExecutionHostId: 'runtime:hub-b', + settings: { activeRuntimeEnvironmentId: null } as never }) const provenance = captureEditorFileOperationProvenance( diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index 3df9fd3f36d8..2b0cdbcfdcd9 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -89,8 +89,7 @@ function resolveLegacyEditorFileRoute( { ...state, activeWorktreeId: null, - activeWorkspaceExecutionHostId: null, - settings: { ...state.settings, activeRuntimeEnvironmentId: null } + activeWorkspaceExecutionHostId: null }, worktreeId ) From 7a89e6fdb1abb7853c7d54ffffa033f9b733b2c2 Mon Sep 17 00:00:00 2001 From: Chen Date: Sun, 27 Sep 2026 17:23:29 +0800 Subject: [PATCH 4/5] test(editor): isolate file owner focus coverage --- src/renderer/src/lib/editor-file-operation-owner.test.ts | 7 ++++--- .../src/store/slices/editor-tab-placement.test.ts | 9 +++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/lib/editor-file-operation-owner.test.ts b/src/renderer/src/lib/editor-file-operation-owner.test.ts index c6d2d5ea7e8c..e2b8629a1edc 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -6,6 +6,7 @@ import { getEditorFileOperationContext } from './editor-file-operation-owner' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { makeWorktree, TEST_REPO } from '../store/slices/store-test-helpers' const worktreeId = 'repo::/remote/repo' @@ -95,13 +96,13 @@ describe('editor file operation owner', () => { it('keeps an unstamped local worktree local while another host is focused', () => { useAppStore.setState({ - repos: [{ id: 'repo' } as never], + repos: [{ ...TEST_REPO, id: 'repo', path: '/local/repo', displayName: 'Local repo' }], worktreesByRepo: { - repo: [{ id: worktreeId, repoId: 'repo', path: '/local/repo' } as never] + repo: [makeWorktree({ id: worktreeId, repoId: 'repo', path: '/local/repo' })] }, activeWorktreeId: worktreeId, activeWorkspaceExecutionHostId: 'runtime:hub-b', - settings: { activeRuntimeEnvironmentId: null } as never + settings: { ...useAppStore.getState().settings, activeRuntimeEnvironmentId: null } }) const provenance = captureEditorFileOperationProvenance( diff --git a/src/renderer/src/store/slices/editor-tab-placement.test.ts b/src/renderer/src/store/slices/editor-tab-placement.test.ts index 7033a9fb82c0..b7768417ab73 100644 --- a/src/renderer/src/store/slices/editor-tab-placement.test.ts +++ b/src/renderer/src/store/slices/editor-tab-placement.test.ts @@ -1,6 +1,7 @@ import type { StoreApi } from 'zustand/vanilla' import { describe, expect, it, vi } from 'vitest' import { createEditorStore, createEditorTabsStore } from './editor-slice-test-harness' +import { makeWorktree, TEST_REPO } from './store-test-helpers' import type { AppState } from '../types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' @@ -129,14 +130,14 @@ describe('createEditorSlice editor host ownership', () => { it('keeps an editor tab on its local file owner while another host is focused', () => { const store = createEditorTabsStore() store.setState({ - repos: [{ id: 'repo-1' } as never], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/repo', displayName: 'Repo' }], worktreesByRepo: { - 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' } as never] + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo' })] }, activeWorktreeId: 'wt-1', activeWorkspaceExecutionHostId: 'runtime:hub-b', - settings: { activeRuntimeEnvironmentId: 'hub-b' } as never - } as Partial) + settings: { ...store.getState().settings, activeRuntimeEnvironmentId: null } + }) store.getState().openFile({ filePath: '/repo/local.ts', From 64a234aaa5fbf4bbe538811a77d4ae14e6fceea2 Mon Sep 17 00:00:00 2001 From: Neil Date: Sun, 4 Oct 2026 22:54:32 -0700 Subject: [PATCH 5/5] fix(editor): preserve folder ownership through host focus changes Reuse the existing file host precedence for editable tab creation. Cover local, runtime, and SSH read/save ownership while UI focus changes. --- ...editor-file-host-focus.operations.test.tsx | 180 ++++++++++++++++ .../lib/editor-file-operation-owner.test.ts | 3 +- .../src/lib/editor-file-operation-owner.ts | 2 +- .../src/lib/editor-file-owner-focus.test.ts | 197 ++++++++++++++++++ .../src/lib/unified-tab-host-ownership.ts | 26 ++- .../store/slices/editor-tab-placement.test.ts | 3 +- .../editor/tabs/workspace-editor-item.ts | 36 +--- tests/e2e/editor-file-host-focus.spec.ts | 109 ++++++++++ 8 files changed, 511 insertions(+), 45 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-file-host-focus.operations.test.tsx create mode 100644 src/renderer/src/lib/editor-file-owner-focus.test.ts create mode 100644 tests/e2e/editor-file-host-focus.spec.ts diff --git a/src/renderer/src/components/editor/editor-file-host-focus.operations.test.tsx b/src/renderer/src/components/editor/editor-file-host-focus.operations.test.tsx new file mode 100644 index 000000000000..d31388601402 --- /dev/null +++ b/src/renderer/src/components/editor/editor-file-host-focus.operations.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom +import { act, useLayoutEffect, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import type { OpenFile } from '@/store/slices/editor' +import type * as RuntimeFileClient from '@/runtime/runtime-file-client' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { FileContent } from './editor-panel-content-types' +import { createEditorSaveQueue } from './editor-save-queue' +import { + useEditorPanelFileContentLoader, + type EditorPanelFileContentLoader +} from './useEditorPanelFileContentLoader' + +const calls = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() })) +vi.mock('@/runtime/runtime-file-client', async (importOriginal) => ({ + ...(await importOriginal()), + readRuntimeFileContent: calls.read, + writeRuntimeFile: calls.write +})) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function ReadProbe({ + file, + ready +}: { + file: OpenFile + ready: (load: EditorPanelFileContentLoader) => void +}) { + const [, setFileContents] = useState>({}) + const load = useEditorPanelFileContentLoader({ + fileLoadRetryAttemptsRef: useRef({}), + fileReadGenerationCounterRef: useRef(0), + fileReadGenerationRef: useRef({}), + openFilesRef: useRef([file]), + outstandingFileReadsRef: useRef({}), + setFileContents + }) + useLayoutEffect(() => { + ready(load) + }, [load, ready]) + return null +} + +afterEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + calls.read.mockReset() + calls.write.mockReset() +}) + +describe('editor operations across host focus changes', () => { + it.each([ + { host: 'local', environment: null, connection: undefined }, + { host: 'runtime:file-host', environment: 'file-host', connection: undefined }, + { host: 'ssh:file-target', environment: null, connection: 'file-target' } + ] satisfies { + host: ExecutionHostId + environment: string | null + connection: string | undefined + }[])('keeps delayed read and save on $host', async ({ host, environment, connection }) => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + useAppStore.setState(useAppStore.getInitialState(), true) + const worktreeId = 'repo1::/workspace/repo' + useAppStore.setState({ + repos: [{ ...TEST_REPO, path: '/workspace/repo', connectionId: connection }], + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/workspace/repo', + ...(host === 'local' ? {} : { hostId: host }), + ...(environment ? { runtimeOwnerEnvironmentId: environment } : {}) + }) + ] + }, + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: null }), + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: 'runtime:other-host', + sshConnectionStates: new Map([ + [ + 'file-target', + { + targetId: 'file-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 7 + } + ] + ]), + recordFeatureInteraction: vi.fn() + }) + const fileId = useAppStore.getState().openFile({ + filePath: '/workspace/repo/file.txt', + relativePath: 'file.txt', + worktreeId, + language: 'plaintext', + mode: 'edit' + }) + const file = useAppStore.getState().openFiles.find((entry) => entry.id === fileId) + if (!file) { + throw new Error('Expected the opened file') + } + const read = deferred() + const write = deferred() + calls.read.mockReturnValueOnce(read.promise) + calls.write.mockReturnValueOnce(write.promise) + const container = document.body.appendChild(document.createElement('div')) + const root = createRoot(container) + const queue = createEditorSaveQueue(useAppStore) + const probe: { load?: EditorPanelFileContentLoader } = {} + try { + act(() => + root.render( + { + probe.load = load + }} + /> + ) + ) + const load = probe.load + if (!load) { + throw new Error('Expected the content loader') + } + const pendingRead = load(file.filePath, file.id, worktreeId, file.relativePath) + await vi.waitFor(() => expect(calls.read).toHaveBeenCalledTimes(1)) + useAppStore.setState({ + activeWorkspaceExecutionHostId: 'runtime:third-host', + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: 'third-host' }) + }) + expect(calls.read.mock.calls[0]?.[0]).toMatchObject({ + settings: { activeRuntimeEnvironmentId: environment }, + connectionId: connection + }) + await act(async () => { + read.resolve({ content: 'original', isBinary: false }) + await pendingRead + }) + useAppStore.getState().setEditorDraft(fileId, 'saved draft') + useAppStore.getState().markFileDirty(fileId, true) + const pendingSave = queue.queueSave(file, 'saved draft') + await vi.waitFor(() => expect(calls.write).toHaveBeenCalledTimes(1)) + useAppStore.setState({ activeWorkspaceExecutionHostId: 'runtime:fourth-host' }) + expect(calls.write.mock.calls[0]?.[0]).toMatchObject({ + settings: { activeRuntimeEnvironmentId: environment }, + ...(connection ? { connectionId: connection } : {}), + expectedExecutionHostId: connection ? host : 'local' + }) + expect(calls.write.mock.calls[0]?.slice(1, 3)).toEqual([file.filePath, 'saved draft']) + write.resolve() + await pendingSave + expect(useAppStore.getState().openFiles.find((entry) => entry.id === fileId)?.isDirty).toBe( + false + ) + expect( + useAppStore + .getState() + .unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.entityId === fileId) + ?.executionHostId + ).toBe(host) + } finally { + queue.dispose() + act(() => root.unmount()) + container.remove() + } + }) +}) diff --git a/src/renderer/src/lib/editor-file-operation-owner.test.ts b/src/renderer/src/lib/editor-file-operation-owner.test.ts index e2b8629a1edc..794038d20529 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -7,6 +7,7 @@ import { } from './editor-file-operation-owner' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { makeWorktree, TEST_REPO } from '../store/slices/store-test-helpers' +import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture' const worktreeId = 'repo::/remote/repo' @@ -102,7 +103,7 @@ describe('editor file operation owner', () => { }, activeWorktreeId: worktreeId, activeWorkspaceExecutionHostId: 'runtime:hub-b', - settings: { ...useAppStore.getState().settings, activeRuntimeEnvironmentId: null } + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: null }) }) const provenance = captureEditorFileOperationProvenance( diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index 2b0cdbcfdcd9..9a9d23791281 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -132,7 +132,7 @@ function resolveCurrentEditorRoute( // Why: ordinary folder workspaces have no published worktree row, so re-resolve their live // folder owner after preserving the explicit-owner fail-closed contract above (#10251). if (parseWorkspaceKey(worktreeId)?.type === 'folder') { - return resolveWorktreeOperationRoute(state, worktreeId) + return resolveLegacyEditorFileRoute(state, worktreeId) } return isWorktreePublished(state, worktreeId) ? provenance.generation.route : null } diff --git a/src/renderer/src/lib/editor-file-owner-focus.test.ts b/src/renderer/src/lib/editor-file-owner-focus.test.ts new file mode 100644 index 000000000000..41f45babbc9d --- /dev/null +++ b/src/renderer/src/lib/editor-file-owner-focus.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTestStore, makeWorktree, TEST_REPO } from '../store/slices/store-test-helpers' +import { + assertEditorFileOperationCurrent, + captureEditorFileOperationProvenance, + getEditorFileOperationContext +} from './editor-file-operation-owner' +import { resolveWorktreeOperationRoute } from './worktree-operation-route' +import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture' + +const worktreeId = 'repo1::/workspace/repo' + +function focusedStore() { + const store = createTestStore() + store.setState({ + repos: [{ ...TEST_REPO, path: '/workspace/repo' }], + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/workspace/repo' })] + }, + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: null }), + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: 'runtime:other-host', + recordFeatureInteraction: vi.fn() + }) + return store +} + +describe('editor file ownership across host focus', () => { + it('opens an unstamped local file with local operation and tab ownership', () => { + const store = focusedStore() + const fileId = store.getState().openFile({ + filePath: '/workspace/repo/local.ts', + relativePath: 'local.ts', + worktreeId, + language: 'typescript', + mode: 'edit' + }) + const file = store.getState().openFiles.find((candidate) => candidate.id === fileId) + if (!file) { + throw new Error('Expected the opened file') + } + + expect(getEditorFileOperationContext(store.getState(), file, '/workspace/repo')).toMatchObject({ + expectedExecutionHostId: 'local', + settings: { activeRuntimeEnvironmentId: null } + }) + expect( + store.getState().unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.entityId === fileId) + ?.executionHostId + ).toBe('local') + }) + + it('keeps an explicit remote file and its editable tab on the captured host', () => { + const store = focusedStore() + store.setState({ + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/workspace/repo', + hostId: 'runtime:file-host', + runtimeOwnerEnvironmentId: 'file-host' + }) + ] + } + }) + const fileId = store.getState().openFile({ + filePath: '/workspace/repo/remote.ts', + relativePath: 'remote.ts', + worktreeId, + language: 'typescript', + mode: 'edit' + }) + expect( + store.getState().unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.entityId === fileId) + ?.executionHostId + ).toBe('runtime:file-host') + }) + + it('preserves the legacy global runtime fallback independently of workspace focus', () => { + const store = focusedStore() + store.setState({ + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: 'legacy-host' }), + runtimeEnvironments: [ + { + id: 'legacy-host', + name: 'Legacy host', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + preferredEndpointId: 'ws', + endpoints: [{ id: 'ws', kind: 'websocket', label: 'WebSocket', endpoint: 'ws://host' }] + } + ] + }) + expect( + captureEditorFileOperationProvenance(store.getState(), worktreeId, undefined, false) + .generation.route + ).toEqual({ executionHostId: 'runtime:legacy-host', runtimeEnvironmentId: 'legacy-host' }) + }) + + it('keeps a direct SSH file on its target instead of the focused runtime', () => { + const store = focusedStore() + store.setState({ + repos: [{ ...TEST_REPO, connectionId: 'file-target' }], + worktreesByRepo: { + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'ssh:file-target' })] + }, + sshConnectionStates: new Map([ + [ + 'file-target', + { + targetId: 'file-target', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 7 + } + ] + ]) + }) + const fileId = store.getState().openFile({ + filePath: '/workspace/repo/ssh.ts', + relativePath: 'ssh.ts', + worktreeId, + language: 'typescript', + mode: 'edit' + }) + const file = store.getState().openFiles.find((candidate) => candidate.id === fileId) + if (!file) { + throw new Error('Expected the opened file') + } + expect(getEditorFileOperationContext(store.getState(), file, '/workspace/repo')).toMatchObject({ + connectionId: 'file-target', + expectedExecutionHostId: 'ssh:file-target', + expectedSshConnectionGeneration: 7, + settings: { activeRuntimeEnvironmentId: null } + }) + expect( + store.getState().unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.entityId === fileId) + ?.executionHostId + ).toBe('ssh:file-target') + }) + + it('keeps generic workspace routing on the selected host', () => { + const store = focusedStore() + expect(resolveWorktreeOperationRoute(store.getState(), worktreeId)).toEqual({ + executionHostId: 'runtime:other-host', + runtimeEnvironmentId: 'other-host' + }) + }) + + it('keeps a local folder file local through capture and revalidation after focus changes', () => { + const store = focusedStore() + const folderId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const folderKey = `folder:${folderId}` + store.setState({ + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + folderWorkspaces: [ + { + id: folderId, + projectGroupId: 'group', + name: 'Folder', + folderPath: '/workspace/folder', + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + ] + }) + const provenance = captureEditorFileOperationProvenance( + store.getState(), + folderKey, + undefined, + false + ) + store.setState({ + activeWorktreeId: folderKey, + activeWorkspaceExecutionHostId: 'runtime:other-host' + }) + + expect(assertEditorFileOperationCurrent(store.getState(), folderKey, provenance)).toEqual({ + executionHostId: 'local', + runtimeEnvironmentId: null + }) + }) +}) diff --git a/src/renderer/src/lib/unified-tab-host-ownership.ts b/src/renderer/src/lib/unified-tab-host-ownership.ts index 9eeac23c1232..c2d34653706e 100644 --- a/src/renderer/src/lib/unified-tab-host-ownership.ts +++ b/src/renderer/src/lib/unified-tab-host-ownership.ts @@ -75,20 +75,18 @@ export function isOpenFileOwnedByWorktree( if (file.worktreeId !== worktree.id) { return false } - const operationHost = file.operationProvenance?.generation.route.executionHostId - if (operationHost) { - return isExecutionHostAliasForWorktree(operationHost, worktree) - } - if (file.externalSshTargetId) { - return isExecutionHostAliasForWorktree(toSshExecutionHostId(file.externalSshTargetId), worktree) - } - if (file.runtimeEnvironmentId) { - return isExecutionHostAliasForWorktree( - toRuntimeExecutionHostId(file.runtimeEnvironmentId), - worktree - ) - } - return isExecutionHostAliasForWorktree(LOCAL_EXECUTION_HOST_ID, worktree) + return isExecutionHostAliasForWorktree(getOpenFileExecutionHostId(file), worktree) +} + +export function getOpenFileExecutionHostId( + file: Pick +): ExecutionHostId { + return ( + file.operationProvenance?.generation.route.executionHostId ?? + (file.externalSshTargetId ? toSshExecutionHostId(file.externalSshTargetId) : null) ?? + (file.runtimeEnvironmentId ? toRuntimeExecutionHostId(file.runtimeEnvironmentId) : null) ?? + LOCAL_EXECUTION_HOST_ID + ) } export function hasOpenFileExecutionHostEvidence( diff --git a/src/renderer/src/store/slices/editor-tab-placement.test.ts b/src/renderer/src/store/slices/editor-tab-placement.test.ts index b7768417ab73..ecc1122fa5bd 100644 --- a/src/renderer/src/store/slices/editor-tab-placement.test.ts +++ b/src/renderer/src/store/slices/editor-tab-placement.test.ts @@ -4,6 +4,7 @@ import { createEditorStore, createEditorTabsStore } from './editor-slice-test-ha import { makeWorktree, TEST_REPO } from './store-test-helpers' import type { AppState } from '../types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture' const { toastErrorMock } = vi.hoisted(() => ({ toastErrorMock: vi.fn() @@ -136,7 +137,7 @@ describe('createEditorSlice editor host ownership', () => { }, activeWorktreeId: 'wt-1', activeWorkspaceExecutionHostId: 'runtime:hub-b', - settings: { ...store.getState().settings, activeRuntimeEnvironmentId: null } + settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: null }) }) store.getState().openFile({ diff --git a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts index b5d1ab3c9865..938ac0e75947 100644 --- a/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts +++ b/src/renderer/src/store/slices/editor/tabs/workspace-editor-item.ts @@ -1,10 +1,5 @@ import type { AppState } from '../../../types' -import { - LOCAL_EXECUTION_HOST_ID, - toRuntimeExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' +import { getOpenFileExecutionHostId } from '@/lib/unified-tab-host-ownership' import type { EditorSlice } from '../types/editor-slice' import type { EditorTabSelection, OpenFile } from '../types/open-file' import { resolveEditorOpenTargetGroupId } from './editor-open-target-group' @@ -41,38 +36,23 @@ export function openWorkspaceEditorItem( } } // Why: editable files carry an owner captured at open time; virtual diff and review tabs retain their existing host selection. - const executionHostId = - contentType === 'editor' ? resolveEditorItemExecutionHostId(state, fileId, worktreeId) : null + const file = + contentType === 'editor' + ? state.openFiles.find( + (candidate) => candidate.id === fileId && candidate.worktreeId === worktreeId + ) + : undefined const created = state.createUnifiedTab?.(worktreeId, contentType, { entityId: fileId, label, isPreview, - ...(executionHostId ? { executionHostId } : {}), + ...(file ? { executionHostId: getOpenFileExecutionHostId(file) } : {}), ...(resolvedGroupId ? { targetGroupId: resolvedGroupId } : {}), ...(selection === 'none' ? { activate: false } : {}), ...(selection === 'background' ? { recordFocus: false } : {}) }) return created?.id ?? fileId } - -function resolveEditorItemExecutionHostId( - state: Pick, - fileId: string, - worktreeId: string -): ExecutionHostId | null { - const file = state.openFiles.find( - (candidate) => candidate.id === fileId && candidate.worktreeId === worktreeId - ) - if (!file) { - return null - } - return ( - file.operationProvenance?.generation.route.executionHostId ?? - (file.externalSshTargetId ? toSshExecutionHostId(file.externalSshTargetId) : null) ?? - (file.runtimeEnvironmentId ? toRuntimeExecutionHostId(file.runtimeEnvironmentId) : null) ?? - LOCAL_EXECUTION_HOST_ID - ) -} export function getReplaceablePreviewFileId( state: Pick, worktreeId: string, diff --git a/tests/e2e/editor-file-host-focus.spec.ts b/tests/e2e/editor-file-host-focus.spec.ts new file mode 100644 index 000000000000..6b4ee978bd01 --- /dev/null +++ b/tests/e2e/editor-file-host-focus.spec.ts @@ -0,0 +1,109 @@ +import { randomUUID } from 'node:crypto' +import { readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { getActiveWorktreeContext } from './helpers/markdown-editor-fixture' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import type { Worktree } from '../../src/shared/worktree/types' + +test('keeps a local editor file on its owner while another host is selected', async ({ + orcaPage, + registerPostElectronShutdownCleanup +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = path.join(context.rootPath, `host-focus-${randomUUID()}.txt`) + await writeFile(filePath, 'LOCAL_CONTENT', 'utf8') + registerPostElectronShutdownCleanup(() => rm(filePath, { force: true })) + + // Controlled legacy owner records; reads and saves use the actual local filesystem. + const fileId = await orcaPage.evaluate( + ({ worktreeId, filePath }) => { + const store = window.__store + if (!store) { + throw new Error('Editor store unavailable') + } + const state = store.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((row) => row.id === worktreeId) + const settings = state.settings + if (!worktree || !settings) { + throw new Error('Workspace unavailable') + } + const withoutOwner = (row: T) => + row.id === worktreeId + ? { ...row, hostId: undefined, runtimeOwnerEnvironmentId: undefined } + : row + store.setState({ + repos: state.repos.map((repo) => + repo.id === worktree.repoId + ? { ...repo, connectionId: null, executionHostId: null } + : repo + ), + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, rows]) => [ + repoId, + rows.map(withoutOwner) + ]) + ), + detectedWorktreesByRepo: Object.fromEntries( + Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => [ + repoId, + { ...result, worktrees: result.worktrees.map(withoutOwner) } + ]) + ), + settings: { ...settings, activeRuntimeEnvironmentId: null, editorAutoSave: false }, + activeWorkspaceExecutionHostId: 'runtime:focus-fixture' + }) + return store.getState().openFile({ + filePath, + relativePath: filePath.split(/[\\/]/).pop() ?? filePath, + worktreeId, + language: 'plaintext', + mode: 'edit' + }) + }, + { worktreeId: context.worktreeId, filePath } + ) + + try { + await expect + .poll(() => orcaPage.evaluate(() => window.__monacoEditorE2E?.snapshot().valueTail), { + timeout: 25_000 + }) + .toBe('LOCAL_CONTENT') + } finally { + await orcaPage.screenshot({ + path: testInfo.outputPath('opened-file.png'), + animations: 'disabled' + }) + } + await expect + .poll(() => + orcaPage.evaluate( + ({ fileId, worktreeId }) => { + const state = window.__store?.getState() + const file = state?.openFiles.find((row) => row.id === fileId) + const tab = state?.unifiedTabsByWorktree[worktreeId]?.find( + (row) => row.entityId === fileId + ) + return { owner: file?.runtimeEnvironmentId, tabHost: tab?.executionHostId } + }, + { fileId, worktreeId: context.worktreeId } + ) + ) + .toEqual({ owner: null, tabHost: 'local' }) + + await orcaPage.evaluate(() => + window.__store?.setState({ activeWorkspaceExecutionHostId: 'local' }) + ) + const editor = orcaPage.locator('.monaco-editor').first() + await editor.click() + await orcaPage.keyboard.press('ControlOrMeta+End') + await orcaPage.keyboard.type('_SAVED_ON_LOCAL') + await orcaPage.keyboard.press('ControlOrMeta+s') + await expect.poll(() => readFile(filePath, 'utf8')).toBe('LOCAL_CONTENT_SAVED_ON_LOCAL') + await orcaPage.screenshot({ path: testInfo.outputPath('saved-file.png'), animations: 'disabled' }) +})