From 7de3710dbed7466ebf0ff7edc84738ce9906c4f7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 11:23:18 -0700 Subject: [PATCH] refactor(files): file reads and edits route by the file's owner (#27278) * refactor(files): file reads and edits route by the file's owner The runtime file clients took a settings object and turned its Active Server field into a connection, so any caller that passed raw settings silently read or wrote on the focused server. They now take `target: RuntimeClientTarget`, and every caller derives it from the owner: the tab's stamped owner, else its worktree's owner (getRuntimeTargetForFileOwner / getRuntimeTargetForWorktreeOwner), the file-explorer operation owner, or an already captured owner id. Behavior: single-host (local-only) users see no change. An editor tab opened without an owner stamp, a terminal link with no PTY owner, an OS file drop and a notebook link now go to the workspace's owner instead of whichever server is focused when the read happens. Removes getEditorFileDropSettingsForWorktree and the native-chat settings clone cache; settingsForWorktreeOperationRoute loses its editor caller. * fix(files): file owner targets come from the rows, never the focused server getRuntimeTargetForWorktreeOwner/getRuntimeTargetForFileOwner move to lib/file-owner-runtime-target.ts and resolve through the focus-free resolveOwner. A workspace with no row yet stays on this app (tabs and drops stamp a server owner when one exists); rows that disagree return null, and callers with an error path refuse via requireRuntimeTargetForFileOwner. * test: keep the editor content-state fixture within the line limit * fix(files): an unresolved document owner blocks local image reads A null owner target became runtimeContext undefined, which image readers take as a plain local file, so a server-owned document's images were read from this machine. It is now runtimeContext null, the readers' do-not-load sentinel, in the rich editor and the preview, and prewarm skips it. * fix(files): read the image node view's storage through a named type --------- Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 118 ++---------------- config/owner-routing-baseline.txt | 27 ---- .../navigate/navigate-browser-page-url.ts | 3 +- .../doc-preview-document-actions.test.ts | 20 +++ .../src/components/editor/EditorPanel.tsx | 17 ++- .../editor/ExternalFileChangeBanner.tsx | 8 +- .../ExternalFileChangeCompareDialog.tsx | 16 ++- .../MarkdownPreview.image-access.test.tsx | 21 +++- ...nPreview.link-routing.interaction.test.tsx | 21 +++- ...rkdownPreview.toc-visibility-gate.test.tsx | 21 +++- .../components/editor/RichMarkdownEditor.tsx | 12 +- .../components/editor/csv/CsvPagedViewer.tsx | 5 +- .../editor/csv/csv-paged-preview.test.ts | 2 +- .../editor/csv/useCsvPagedPreview.test.tsx | 4 +- ...or-dropped-file-open.escaping-link.test.ts | 17 ++- .../editor/editor-dropped-file-open.test.ts | 26 ++-- .../editor/editor-dropped-file-open.ts | 38 ++---- .../editor-restored-tab-conflict-scan.ts | 16 ++- .../markdown-document-list-request.test.ts | 6 +- .../editor/markdown-document-list-request.ts | 2 +- .../editor/markdown-preview-link-actions.ts | 7 +- .../markdown-preview-local-images.test.ts | 4 +- .../editor/markdown-preview-local-images.ts | 8 +- ...ed-editor-workspace-runtime-owner.test.tsx | 2 +- .../rich-markdown-async-image-paste.test.ts | 9 +- .../rich-markdown-editor-click-routing.ts | 3 - .../rich-markdown-editor-config.test.ts | 2 +- .../editor/rich-markdown-editor-config.ts | 12 +- .../editor/rich-markdown-extensions.ts | 9 +- .../rich-markdown-image-context.test.ts | 58 +++++++++ .../editor/rich-markdown-image-context.ts | 62 ++++++--- .../editor/rich-markdown-image-insert.ts | 7 +- ...arkdown-large-text-paste.selection.test.ts | 2 +- .../editor/rich-markdown-paste-image.ts | 7 +- ...itor-group-file-drop-owner.remote.test.tsx | 4 +- .../use-editor-group-file-drop-owner.test.tsx | 17 ++- .../use-markdown-preview-source-foundation.ts | 31 +++-- .../useEditorPanelContentState.test.tsx | 16 ++- .../editor/useEditorPanelFileContentLoader.ts | 29 +++-- ...torPanelRemoteSiblingContentState.test.tsx | 19 +++ .../editor/useLocalImageSrc.test.ts | 14 +-- .../src/components/editor/useLocalImageSrc.ts | 6 +- .../useMarkdownDocuments.navigation.test.ts | 19 ++- .../useMarkdownDocuments.ownership.test.tsx | 9 +- .../components/editor/useMarkdownDocuments.ts | 21 +++- .../editor/useMarkdownDocuments.watch.test.ts | 21 +++- .../useRichMarkdownEditorInstance.test.tsx | 2 +- .../useRichMarkdownProgrammaticSync.test.ts | 3 +- .../editor/useRichMarkdownProgrammaticSync.ts | 16 +-- .../useRichMarkdownReconcileRoundTrip.ts | 7 +- .../FloatingTerminalPanel.bounds.test.tsx | 2 +- .../use-floating-terminal-create-actions.ts | 5 +- .../NativeChatAgentMessageRow.test.tsx | 4 +- .../NativeChatImageAttachmentPreview.test.tsx | 2 +- .../NativeChatImageAttachmentPreview.tsx | 3 +- .../NativeChatTranscriptChrome.test.tsx | 2 +- .../native-chat-file-link-existence.test.ts | 7 +- .../native-chat-file-link-existence.ts | 5 +- .../native-chat-image-runtime-context.test.ts | 6 +- .../native-chat-image-runtime-context.ts | 48 +++---- ...en-file-list-remote-catalog.react.test.tsx | 5 +- .../quick-open-file-list.react.test.tsx | 6 +- .../src/components/quick-open-file-list.ts | 6 +- .../quick-open-file-navigation.test.ts | 14 +-- .../components/quick-open-file-navigation.ts | 6 +- .../quick-open-recent-validation.test.ts | 2 +- .../FileExplorerRow.actions.test.tsx | 4 +- .../file-explorer-deletion-editors.ts | 4 +- .../file-explorer-directory-listing.ts | 2 +- .../file-explorer-instant-dir-toggle.test.ts | 2 +- ...file-explorer-operation-generation.test.ts | 2 +- .../file-explorer-operation-owner.ts | 16 +-- .../use-file-explorer-tree-pane-state.ts | 11 +- .../right-sidebar/useFileDeletion.ts | 8 +- .../right-sidebar/useFileDuplicate.ts | 2 +- .../right-sidebar/useFileExplorerHandlers.ts | 3 +- .../useFileExplorerImport.remote.test.tsx | 4 +- .../right-sidebar/useFileExplorerImport.ts | 2 +- .../useFileExplorerInlineInput.ts | 14 +-- .../right-sidebar/useFileExplorerMoveDrop.ts | 2 +- .../right-sidebar/useFileExplorerTree.ts | 2 +- .../useFileSearchPanel.lifecycle.test.tsx | 2 +- .../useFileSearchRunner.test.tsx | 6 +- .../right-sidebar/useFileSearchRunner.ts | 9 +- .../McpConfigSection.host-owner.test.tsx | 6 +- .../components/settings/McpConfigSection.tsx | 3 +- .../settings/mcp-config-inspection.ts | 2 +- .../tab-bar/tab-create-entry-action.test.ts | 2 +- .../tab-bar/tab-create-entry-local-path.ts | 10 +- .../terminal-drop-handler.test.ts | 6 +- ...erminal-drop-runtime-catalog-owner.test.ts | 2 +- .../terminal-file-link-target.test.ts | 2 +- .../terminal-file-open-routing.ts | 9 +- .../terminal-file-path-mapping.ts | 22 +++- .../terminal-host-workspace-file.ts | 12 +- ...minal-link-remote-runtime-ssh-open.test.ts | 8 +- .../terminal-native-file-drop-destination.ts | 2 - .../terminal-native-file-drop.ts | 8 +- .../terminal-pane-element-file-drop.test.tsx | 2 +- .../terminal-path-existence-batch.test.ts | 12 +- .../terminal-path-existence-batch.ts | 5 +- .../terminal-worktree-path-link.test.ts | 12 +- .../terminal-worktree-path-link.ts | 5 +- .../composer-state/attachment-drop-state.ts | 11 +- ...editor-external-watch-disk-verification.ts | 5 +- .../hooks/editor-runtime-file-watch.test.ts | 4 +- .../src/hooks/editor-runtime-file-watch.ts | 3 +- .../src/lib/create-untitled-markdown.test.ts | 20 +-- .../src/lib/create-untitled-markdown.ts | 16 +-- .../lib/editor-file-operation-owner.test.ts | 2 +- .../src/lib/editor-file-operation-owner.ts | 9 +- .../lib/execute-open-editor-path-move.test.ts | 2 +- .../src/lib/file-owner-runtime-target.test.ts | 106 ++++++++++++++++ .../src/lib/file-owner-runtime-target.ts | 58 +++++++++ .../src/lib/file-search-result-owner.test.ts | 10 +- .../src/lib/file-search-result-owner.ts | 6 +- .../lib/floating-workspace-tab-creation.ts | 2 +- ...loating-workspace-terminal-actions.test.ts | 4 +- src/renderer/src/lib/local-file-access.ts | 13 +- .../lib/markdown-document-templates.test.ts | 6 +- .../src/lib/markdown-document-templates.ts | 6 +- src/renderer/src/lib/rename-file.ts | 2 +- .../src/lib/user-opened-local-path.test.ts | 2 +- .../src/lib/workspace-file-host-routing.ts | 9 +- .../src/runtime/mobile-markdown-bridge.ts | 4 +- .../remote-host-file-open-repro.test.ts | 17 ++- .../src/runtime/runtime-client-target.ts | 5 + .../runtime-file-client-download.test.ts | 12 +- ...untime-file-client-external-import.test.ts | 12 +- ...ime-file-client-mutation-ownership.test.ts | 22 ++-- ...runtime-file-client-search-listing.test.ts | 48 +++---- .../src/runtime/runtime-file-client-types.ts | 8 +- .../runtime/runtime-file-client-watch.test.ts | 20 +-- .../src/runtime/runtime-file-client.test.ts | 52 ++++---- .../src/runtime/runtime-file-import-client.ts | 3 +- ...ntime-file-import-pairing-revision.test.ts | 4 +- .../runtime/runtime-file-metadata-client.ts | 4 +- .../runtime/runtime-file-mutation-client.ts | 4 +- .../runtime/runtime-file-range-client.test.ts | 14 ++- .../src/runtime/runtime-file-range-client.ts | 6 +- .../src/runtime/runtime-file-read-client.ts | 13 +- .../src/runtime/runtime-file-routing.ts | 21 ++-- .../src/runtime/runtime-file-search-client.ts | 10 +- .../src/runtime/runtime-file-watch-client.ts | 8 +- .../runtime-legacy-recent-freshness.test.ts | 2 +- .../runtime-path-existence-batch.test.ts | 2 +- ...untime-path-existence-error-parity.test.ts | 4 +- .../runtime-path-existence-pairing.test.ts | 2 +- ...ntime-path-existence-queue-pairing.test.ts | 4 +- ...e-quick-open-version-compatibility.test.ts | 20 +-- .../runtime/use-worktree-runtime-target.ts | 26 +++- .../editor/actions/markdown-link-action.ts | 5 +- .../actions/markdown-preview-actions.ts | 2 +- 153 files changed, 1090 insertions(+), 734 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-image-context.test.ts create mode 100644 src/renderer/src/lib/file-owner-runtime-target.test.ts create mode 100644 src/renderer/src/lib/file-owner-runtime-target.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index a82f0220b48..b377f24e894 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -10,7 +10,6 @@ 1 src/renderer/src/components/LinearIssueWorkspace.tsx 1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx 1 src/renderer/src/components/NativeChatResumeOnRestartModal.tsx -2 src/renderer/src/components/QuickOpen.tsx 1 src/renderer/src/components/activity/ActivityPrototypePage.tsx 1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx 3 src/renderer/src/components/activity/activity-thread-actions.ts @@ -31,54 +30,23 @@ 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts -2 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts +1 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.ts 1 src/renderer/src/components/browser-pane/workspace-doc/workspace-doc-page-pane.tsx 2 src/renderer/src/components/cmd-j/quick-action-context.ts -1 src/renderer/src/components/editor/EditorAutosaveController.tsx -2 src/renderer/src/components/editor/EditorContent.tsx -1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx -1 src/renderer/src/components/editor/EditorEditFileSurface.tsx -1 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx -2 src/renderer/src/components/editor/EditorPanel.tsx -4 src/renderer/src/components/editor/ExternalFileChangeBanner.tsx -3 src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx -1 src/renderer/src/components/editor/MarkdownPreview.tsx 1 src/renderer/src/components/editor/MonacoGutterContextMenu.tsx -4 src/renderer/src/components/editor/RichMarkdownEditor.tsx 1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx 4 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts 1 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts -2 src/renderer/src/components/editor/csv/CsvPagedViewer.tsx -1 src/renderer/src/components/editor/csv/csv-file-content.ts 1 src/renderer/src/components/editor/csv/csv-link-routing.ts 2 src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts -7 src/renderer/src/components/editor/editor-dropped-file-open.ts -1 src/renderer/src/components/editor/editor-header-file-rename.ts 1 src/renderer/src/components/editor/editor-model-lifetime-fixture.ts -6 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts -1 src/renderer/src/components/editor/editor-save-queue.ts -1 src/renderer/src/components/editor/local-image-src-reader.ts -3 src/renderer/src/components/editor/markdown-document-list-request.ts -2 src/renderer/src/components/editor/markdown-preview-link-actions.ts -1 src/renderer/src/components/editor/markdown-preview-local-images.ts +1 src/renderer/src/components/editor/markdown-preview-link-actions.ts 1 src/renderer/src/components/editor/markdown-preview-system-link-action.ts 1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts -1 src/renderer/src/components/editor/rich-markdown-editor-config.ts -2 src/renderer/src/components/editor/rich-markdown-image-context.ts -4 src/renderer/src/components/editor/rich-markdown-image-insert.ts -1 src/renderer/src/components/editor/rich-markdown-paste-handler.ts -3 src/renderer/src/components/editor/rich-markdown-paste-image.ts -1 src/renderer/src/components/editor/use-markdown-preview-components.tsx -1 src/renderer/src/components/editor/use-markdown-preview-foundation.ts -1 src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts 4 src/renderer/src/components/editor/useEditorPanelContentState.ts 5 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts -7 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts -1 src/renderer/src/components/editor/useLocalImagePick.ts -5 src/renderer/src/components/editor/useLocalImageSrc.ts -5 src/renderer/src/components/editor/useMarkdownDocuments.ts -1 src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts -1 src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts +1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +1 src/renderer/src/components/editor/useMarkdownDocuments.ts 2 src/renderer/src/components/feature-wall/ConnectIntegrationsList.tsx 1 src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx 1 src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx @@ -94,10 +62,8 @@ 1 src/renderer/src/components/native-chat-resume-tree.test-support.ts 2 src/renderer/src/components/native-chat/NativeChatComposer.tsx 1 src/renderer/src/components/native-chat/NativeChatResolvedView.tsx -1 src/renderer/src/components/native-chat/NativeChatTranscriptChrome.tsx 1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx 1 src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts -4 src/renderer/src/components/native-chat/native-chat-file-link-existence.ts 1 src/renderer/src/components/native-chat/native-chat-file-link.ts 1 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts 1 src/renderer/src/components/native-chat/native-chat-observed-send.ts @@ -105,10 +71,7 @@ 2 src/renderer/src/components/native-chat/native-chat-runtime-owner.ts 6 src/renderer/src/components/native-chat/native-chat-runtime-send.ts 2 src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts -2 src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts -1 src/renderer/src/components/native-chat/use-native-chat-file-link-existence.tsx 6 src/renderer/src/components/native-chat/use-native-chat-interactive-send.ts -1 src/renderer/src/components/native-chat/use-native-chat-mention-files.ts 2 src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts 4 src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts 2 src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -118,13 +81,10 @@ 2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts 1 src/renderer/src/components/ports/WorkspacePortScanner.tsx -2 src/renderer/src/components/quick-open-file-list-test-harness.ts -6 src/renderer/src/components/quick-open-file-list.ts -4 src/renderer/src/components/quick-open-file-navigation.ts -2 src/renderer/src/components/quick-open-recent-validation.ts +1 src/renderer/src/components/quick-open-file-list-test-harness.ts 3 src/renderer/src/components/repo/CreateFromPicker.tsx 1 src/renderer/src/components/right-sidebar/ChecksPanel.tsx -3 src/renderer/src/components/right-sidebar/FileExplorer.tsx +2 src/renderer/src/components/right-sidebar/FileExplorer.tsx 1 src/renderer/src/components/right-sidebar/FileExplorerFilesTreePane.tsx 1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx 1 src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts @@ -136,9 +96,7 @@ 3 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-git-status-effects.tsx 3 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-state.tsx -2 src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts -1 src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts -4 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +1 src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts 1 src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx 1 src/renderer/src/components/right-sidebar/git-status-file-watch-refresh.ts 5 src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -173,21 +131,10 @@ 2 src/renderer/src/components/right-sidebar/use-create-pull-request-field-generation.ts 2 src/renderer/src/components/right-sidebar/use-create-pull-request-field-seeding.ts 1 src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts -1 src/renderer/src/components/right-sidebar/use-file-explorer-name-filter.ts -2 src/renderer/src/components/right-sidebar/use-file-explorer-node-commands.ts -6 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +2 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts 3 src/renderer/src/components/right-sidebar/use-git-status-upstream-ref-watch.ts -3 src/renderer/src/components/right-sidebar/useFileDeletion.ts -1 src/renderer/src/components/right-sidebar/useFileDuplicate.ts -2 src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts -2 src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts -4 src/renderer/src/components/right-sidebar/useFileExplorerImport.ts -3 src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts -1 src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts -2 src/renderer/src/components/right-sidebar/useFileExplorerTree.ts -2 src/renderer/src/components/right-sidebar/useFileExplorerWatch.ts 4 src/renderer/src/components/right-sidebar/useFileSearchPanel.ts -4 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +1 src/renderer/src/components/right-sidebar/useFileSearchRunner.ts 1 src/renderer/src/components/right-sidebar/useFileSearchScope.ts 2 src/renderer/src/components/right-sidebar/useGitStatusPolling.ts 3 src/renderer/src/components/settings/BaseRefPicker.tsx @@ -197,7 +144,6 @@ 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/IntegrationsPane.tsx 3 src/renderer/src/components/settings/LinearAgentSkillPane.tsx -1 src/renderer/src/components/settings/McpConfigSection.tsx 1 src/renderer/src/components/settings/RepositoryForkSyncSection.tsx 2 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -213,7 +159,6 @@ 3 src/renderer/src/components/settings/cli-source-control-integration-cards.tsx 3 src/renderer/src/components/settings/jira-integration-card.tsx 1 src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx -1 src/renderer/src/components/settings/mcp-config-inspection.ts 1 src/renderer/src/components/settings/provider-account-scope.ts 1 src/renderer/src/components/settings/settings-persistence-test-fixture.ts 2 src/renderer/src/components/settings/settings-setup-guide-progress.ts @@ -279,12 +224,8 @@ 1 src/renderer/src/components/status-bar/use-status-bar-controller.ts 1 src/renderer/src/components/status-bar/use-workspace-space-git-refresh-action.ts 1 src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts -1 src/renderer/src/components/tab-bar/EditorFileTab.tsx 1 src/renderer/src/components/tab-bar/TabBar.tsx -2 src/renderer/src/components/tab-bar/TabBarCreateEntry.tsx 3 src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts -2 src/renderer/src/components/tab-bar/tab-create-entry-action.ts -3 src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts 1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts 4 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts 2 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts @@ -298,26 +239,14 @@ 1 src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts 1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts 2 src/renderer/src/components/terminal-pane/terminal-drop-runtime-owner.ts -8 src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts -1 src/renderer/src/components/terminal-pane/terminal-file-link-hit-testing.ts -2 src/renderer/src/components/terminal-pane/terminal-file-link-target.ts -6 src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts -3 src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts 1 src/renderer/src/components/terminal-pane/terminal-fit-restore.ts -2 src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts 1 src/renderer/src/components/terminal-pane/terminal-input-host-platform.ts -2 src/renderer/src/components/terminal-pane/terminal-link-handlers.ts 1 src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts -1 src/renderer/src/components/terminal-pane/terminal-link-provider-buffer-fixtures.ts -1 src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts 2 src/renderer/src/components/terminal-pane/terminal-pane-close-admission.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts -1 src/renderer/src/components/terminal-pane/terminal-pane-pane-links.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-execution.ts 1 src/renderer/src/components/terminal-pane/terminal-pane-paste-listeners.ts -3 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts -2 src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-controller.ts 1 src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts @@ -327,7 +256,6 @@ 1 src/renderer/src/components/terminal/running-terminal-close-guard.ts 1 src/renderer/src/components/terminal/terminal-tab-create.ts 1 src/renderer/src/components/terminal/window-close-running-work.ts -2 src/renderer/src/components/use-quick-open-interaction.ts 2 src/renderer/src/components/use-task-page-source-availability.ts 2 src/renderer/src/components/use-task-page-store-bindings.ts 2 src/renderer/src/components/use-terminal-activation-actions.ts @@ -339,7 +267,7 @@ 2 src/renderer/src/components/use-worktree-jump-palette-store-state.ts 2 src/renderer/src/hooks/automation-dispatch-handler.ts 1 src/renderer/src/hooks/automation-dispatch-workspace.ts -4 src/renderer/src/hooks/composer-state/attachment-drop-state.ts +1 src/renderer/src/hooks/composer-state/attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-attachment-drop-state.ts 1 src/renderer/src/hooks/composer-state/composer-navigation-actions.ts 2 src/renderer/src/hooks/composer-state/composer-source-state.ts @@ -350,9 +278,7 @@ 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/composer-state/workspace-identity-state.ts -1 src/renderer/src/hooks/editor-external-watch-disk-verification.ts 2 src/renderer/src/hooks/editor-external-watch-targets.ts -1 src/renderer/src/hooks/editor-runtime-file-watch.ts 1 src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts 1 src/renderer/src/hooks/ipc-events-terminal-create-test-harness.ts 6 src/renderer/src/hooks/ipc-events-test-harness.ts @@ -363,7 +289,7 @@ 2 src/renderer/src/hooks/ipc-events/tab-lifecycle-ipc-bridge.ts 3 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts 1 src/renderer/src/hooks/useActiveProjectSkillRuntime.ts -2 src/renderer/src/hooks/useEditorExternalWatch.ts +1 src/renderer/src/hooks/useEditorExternalWatch.ts 1 src/renderer/src/hooks/useInstalledAgentSkills.ts 1 src/renderer/src/hooks/useIpcEvents.ts 1 src/renderer/src/hooks/useIssueMetadata.ts @@ -393,7 +319,6 @@ 1 src/renderer/src/lib/execute-open-editor-path-move.ts 1 src/renderer/src/lib/execution-host-facts.ts 6 src/renderer/src/lib/file-preview.ts -1 src/renderer/src/lib/file-search-result-owner.ts 2 src/renderer/src/lib/folder-workspace-runtime-owner.ts 1 src/renderer/src/lib/github-pr-start-point.ts 1 src/renderer/src/lib/host-mirrored-pane-liveness.ts @@ -407,14 +332,11 @@ 1 src/renderer/src/lib/launch-work-item-direct-preflight.ts 2 src/renderer/src/lib/launch-work-item-direct.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts -3 src/renderer/src/lib/local-file-access.ts 2 src/renderer/src/lib/local-preflight-context.ts -1 src/renderer/src/lib/markdown-document-templates.ts 1 src/renderer/src/lib/new-workspace.ts 1 src/renderer/src/lib/open-agent-message-sender.ts 2 src/renderer/src/lib/provider-runtime-context.ts 1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts -2 src/renderer/src/lib/rename-file.ts 4 src/renderer/src/lib/repo-runtime-owner.ts 2 src/renderer/src/lib/resolve-owner.ts 1 src/renderer/src/lib/runtime-session-mirror-owners.ts @@ -425,11 +347,9 @@ 1 src/renderer/src/lib/terminal-tab-owner-activation.ts 4 src/renderer/src/lib/terminal-worktree-route.ts 3 src/renderer/src/lib/unread-badge-count-selector.ts -1 src/renderer/src/lib/user-opened-local-path.ts 2 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts 1 src/renderer/src/lib/workspace-browser-tab-close.ts 4 src/renderer/src/lib/workspace-browser-tab-open.ts -3 src/renderer/src/lib/workspace-file-host-routing.ts 1 src/renderer/src/lib/workspace-tab-palette-activation.ts 1 src/renderer/src/lib/workspace-terminal-host-authority.ts 1 src/renderer/src/lib/worktree-activation-pty-inventory.ts @@ -444,17 +364,7 @@ 3 src/renderer/src/lib/worktree-runtime-owner.ts 1 src/renderer/src/lib/worktree-sort-order-host-split.ts 1 src/renderer/src/runtime/close-mirrored-editor-tab.ts -3 src/renderer/src/runtime/mobile-markdown-bridge.ts 1 src/renderer/src/runtime/runtime-client-target.ts -2 src/renderer/src/runtime/runtime-file-import-client.ts -3 src/renderer/src/runtime/runtime-file-metadata-client.ts -8 src/renderer/src/runtime/runtime-file-mutation-client.ts -2 src/renderer/src/runtime/runtime-file-range-client.ts -10 src/renderer/src/runtime/runtime-file-read-client.ts -6 src/renderer/src/runtime/runtime-file-routing.ts -5 src/renderer/src/runtime/runtime-file-search-client.ts -1 src/renderer/src/runtime/runtime-file-upload-client.ts -1 src/renderer/src/runtime/runtime-file-watch-client.ts 2 src/renderer/src/runtime/runtime-git-client-context.ts 5 src/renderer/src/runtime/runtime-git-diff-client.ts 5 src/renderer/src/runtime/runtime-git-generation-client.ts @@ -469,7 +379,6 @@ 8 src/renderer/src/runtime/runtime-linear-client.ts 5 src/renderer/src/runtime/runtime-linear-issue-mutations.ts 12 src/renderer/src/runtime/runtime-linear-project-client.ts -1 src/renderer/src/runtime/runtime-path-existence-batch.ts 3 src/renderer/src/runtime/runtime-repo-client.ts 3 src/renderer/src/runtime/runtime-terminal-inspection.ts 1 src/renderer/src/runtime/runtime-terminal-stream.ts @@ -527,12 +436,11 @@ 4 src/renderer/src/store/slices/editor/actions/git-remote-push-pull.ts 1 src/renderer/src/store/slices/editor/actions/git-remote-status.ts 8 src/renderer/src/store/slices/editor/actions/git-remote-sync.ts -2 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts +1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts 1 src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts 1 src/renderer/src/store/slices/editor/actions/open-file-action.ts 1 src/renderer/src/store/slices/editor/actions/open-file-apply.ts -2 src/renderer/src/store/slices/editor/create-editor-slice.ts -1 src/renderer/src/store/slices/editor/tabs/untitled-file-cleanup.ts +1 src/renderer/src/store/slices/editor/create-editor-slice.ts 1 src/renderer/src/store/slices/github-cache-key.ts 1 src/renderer/src/store/slices/github-slice-test-harness.ts 1 src/renderer/src/store/slices/hosted-review-cache-identity.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index cadca974e08..9cb19d92b22 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -2,22 +2,10 @@ # getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file. # This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead. # Prune after removing sites: pnpm check:owner-routing-ratchet --prune -1 src/renderer/src/components/editor/EditorPanel.tsx -1 src/renderer/src/components/editor/ExternalFileChangeBanner.tsx -1 src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx 1 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts -2 src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts -1 src/renderer/src/components/editor/markdown-preview-link-actions.ts -1 src/renderer/src/components/editor/rich-markdown-image-context.ts -1 src/renderer/src/components/editor/rich-markdown-image-insert.ts -1 src/renderer/src/components/editor/rich-markdown-paste-image.ts -1 src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts 1 src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts -1 src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts -2 src/renderer/src/components/editor/useMarkdownDocuments.ts 1 src/renderer/src/components/linear-api-key-dialog.tsx 1 src/renderer/src/components/ports/WorkspacePortScanner.tsx -1 src/renderer/src/components/quick-open-file-navigation.ts 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx @@ -27,9 +15,6 @@ 1 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts 1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts 1 src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts -1 src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts -1 src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts -1 src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/hooks/use-active-skill-discovery-runtime-target.ts @@ -43,19 +28,8 @@ 1 src/renderer/src/lib/github-pr-start-point.ts 1 src/renderer/src/lib/launch-agent-background-session.ts 1 src/renderer/src/lib/launch-worktree-background-terminals.ts -1 src/renderer/src/lib/local-file-access.ts 1 src/renderer/src/lib/structured-agent-session-tab-activation.ts -1 src/renderer/src/lib/workspace-file-host-routing.ts 1 src/renderer/src/lib/worktree-creation-flow-startup.ts -1 src/renderer/src/runtime/mobile-markdown-bridge.ts -1 src/renderer/src/runtime/runtime-file-import-client.ts -1 src/renderer/src/runtime/runtime-file-metadata-client.ts -1 src/renderer/src/runtime/runtime-file-mutation-client.ts -1 src/renderer/src/runtime/runtime-file-range-client.ts -2 src/renderer/src/runtime/runtime-file-read-client.ts -4 src/renderer/src/runtime/runtime-file-routing.ts -4 src/renderer/src/runtime/runtime-file-search-client.ts -1 src/renderer/src/runtime/runtime-file-watch-client.ts 1 src/renderer/src/runtime/runtime-git-client-context.ts 5 src/renderer/src/runtime/runtime-git-diff-client.ts 5 src/renderer/src/runtime/runtime-git-generation-client.ts @@ -85,7 +59,6 @@ 1 src/renderer/src/store/repos/repo-update.ts 1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 1 src/renderer/src/store/slices/diff-comment-persistence.ts -1 src/renderer/src/store/slices/editor/actions/markdown-link-action.ts 1 src/renderer/src/store/slices/hosted-review.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts index a3ba85c5a35..fdf57e706c5 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -22,6 +22,7 @@ import type { } from '../describe-page/browser-page-types' import type { MutableRefObject } from 'react' import { statUserOpenedPath } from '@/lib/user-opened-local-path' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' export type NavigateBrowserPageToUrlArgs = { url: string @@ -100,7 +101,7 @@ export function navigateBrowserPageToUrl({ // Why getKnownWorktreeById: folder and floating workspaces are absent from allWorktrees(). const activeWorktree = store.getKnownWorktreeById(worktreeId) const fileContext: RuntimeFileOperationArgs = { - settings: store.settings, + target: requireRuntimeTargetForFileOwner(store, worktreeId, undefined), worktreeId, worktreePath: activeWorktree?.path, connectionId: undefined diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts index 3842d9d9545..485595bcb9f 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-document-actions.test.ts @@ -27,6 +27,26 @@ vi.mock('@/lib/connection-owner-resolution', () => ({ vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => mocks.worktreeRuntimeOwnerId })) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined + ) => { + const owner = + runtimeEnvironmentId === undefined ? mocks.worktreeRuntimeOwnerId : runtimeEnvironmentId + return owner + ? { kind: 'environment' as const, environmentId: owner } + : { kind: 'local' as const } + } + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => mocks.connectionIdForWorkspace, getConnectionIdForFile: () => mocks.connectionIdForFile diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 118b14a42bb..938c1386806 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -4,7 +4,7 @@ import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' import { canShowWorkspaceFileBrowserAction, openFilePreviewToSide } from '@/lib/file-preview' import { getEditorHeaderCopyState } from './editor-header' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { exportActiveMarkdownToPdf } from './export-active-markdown' import type { EditorToggleValue } from './EditorViewToggle' import { EditorPanelShell } from './EditorPanelShell' @@ -142,6 +142,16 @@ function EditorPanelInner({ closeRenameDialog, handleRenameConfirm } = useUntitledFileRename({ openFiles, clearUntitled }) + // Why not local: an unresolved owner must not offer this machine's folder picker either. + const renameDialogFileOffClient = useAppStore( + (s) => + !!renameDialogFile && + getRuntimeTargetForFileOwner( + s, + renameDialogFile.worktreeId, + renameDialogFile.runtimeEnvironmentId + )?.kind !== 'local' + ) useMarkdownPreviewShortcut({ activeFile, panelRef, openMarkdownPreview }) @@ -279,10 +289,7 @@ function EditorPanelInner({ ) } const disableRenameBrowse = Boolean( - settingsForRuntimeOwner( - settings, - renameDialogFile?.runtimeEnvironmentId - )?.activeRuntimeEnvironmentId?.trim() || + renameDialogFileOffClient || (renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null) ) const markdownDocumentStateFileId = diff --git a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx index 71a11eaa775..be943fb5c8e 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeBanner.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button' import { getConnectionIdForFile } from '@/lib/connection-context' import { editorTabFileAccess } from '@/lib/local-file-access' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import type { OpenFile } from '@/store/slices/editor' @@ -87,8 +87,12 @@ export function keepTabEditsOverExternalChange(file: OpenFile): void { // the dismissed conflict from the stale signature on every launch. // Best-effort: a failed read leaves the old signature, which can only // re-surface the banner — never lose data. + const target = getRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId) + if (!target) { + return + } void readRuntimeFileContent({ - settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + target, filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, diff --git a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx index 26cd9fbe95f..c3a506e351e 100644 --- a/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx +++ b/src/renderer/src/components/editor/ExternalFileChangeCompareDialog.tsx @@ -14,7 +14,10 @@ import { getConnectionIdForFile } from '@/lib/connection-context' import { editorTabFileAccess } from '@/lib/local-file-access' import { detectLanguage } from '@/lib/language-detect' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + FILE_OWNER_UNRESOLVED_MESSAGE, + getRuntimeTargetForFileOwner +} from '@/lib/file-owner-runtime-target' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { translate } from '@/i18n/i18n' @@ -57,8 +60,17 @@ export function ExternalFileChangeCompareDialog({ setDiskState({ kind: 'loading' }) // Why: read at open time — the banner can be minutes old and the agent // may have written again since; the comparison must show current disk. + const target = getRuntimeTargetForFileOwner( + useAppStore.getState(), + file.worktreeId, + file.runtimeEnvironmentId + ) + if (!target) { + setDiskState({ kind: 'error', message: FILE_OWNER_UNRESOLVED_MESSAGE }) + return + } void readRuntimeFileContent({ - settings: settingsForRuntimeOwner(useAppStore.getState().settings, file.runtimeEnvironmentId), + target, filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, diff --git a/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx index 7e815d20f43..a8a579c29b3 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.image-access.test.tsx @@ -39,8 +39,25 @@ vi.mock('@/store', () => { return { useAppStore } }) vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (settings: unknown) => settings +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) +vi.mock('@/runtime/use-worktree-runtime-target', () => ({ + useFileOwnerRuntimeTarget: (_worktreeId: unknown, runtimeEnvironmentId?: string | null) => + runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) diff --git a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx index e244a50865a..d22e47bcdf8 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx @@ -59,8 +59,25 @@ vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: (_worktrees: unknown, id: string) => worktreeLookup.value.find((worktree) => worktree.id === id) ?? null })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (settings: unknown) => settings +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) +vi.mock('@/runtime/use-worktree-runtime-target', () => ({ + useFileOwnerRuntimeTarget: (_worktreeId: unknown, runtimeEnvironmentId?: string | null) => + runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: statRuntimePathMock diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx index 645d1f556d6..f6fb46d38ce 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx @@ -45,8 +45,25 @@ vi.mock('@/store', () => { return { useAppStore } }) vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (settings: unknown) => settings +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) +vi.mock('@/runtime/use-worktree-runtime-target', () => ({ + useFileOwnerRuntimeTarget: (_worktreeId: unknown, runtimeEnvironmentId?: string | null) => + runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 6678cda3569..86624380c3b 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useFileOwnerRuntimeTarget } from '@/runtime/use-worktree-runtime-target' import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment } from '../../../../shared/diff-comment-types' import { useAppStore } from '@/store' @@ -56,8 +57,10 @@ export default function RichMarkdownEditor({ headerSlot }: RichMarkdownEditorProps): React.JSX.Element { const rootRef = useRef(null) - const settings = useAppStore((s) => s.settings) - const richMarkdownSpellcheckEnabled = settings?.richMarkdownSpellcheckEnabled ?? true + const richMarkdownSpellcheckEnabled = useAppStore( + (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true + ) + const runtimeTarget = useFileOwnerRuntimeTarget(worktreeId, runtimeEnvironmentId) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const activateMarkdownLink = useAppStore((s) => s.activateMarkdownLink) const addDiffComment = useAppStore((s) => s.addDiffComment) @@ -200,7 +203,7 @@ export default function RichMarkdownEditor({ runtimeEnvironmentId, isMac, richMarkdownSpellcheckEnabled, - settings, + runtimeTarget, activateMarkdownLink, rootRef, editorRef, @@ -296,8 +299,7 @@ export default function RichMarkdownEditor({ baseCanonicalRef, markdownDocuments, rootRef, - runtimeEnvironmentId, - settings, + runtimeTarget, slashMenuSetter: menu.setSlashMenu, worktreeId, worktreeRoot diff --git a/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx b/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx index 968a2420e28..65b558ad3ce 100644 --- a/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx +++ b/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx @@ -11,6 +11,7 @@ import { EditorFileLoadErrorView } from '../EditorFileLoadErrorView' import { useCsvColumnWidths } from './useCsvColumnWidths' import { buildFileViewPreferenceKey } from '../file-view-preference-storage' import { openCsvHttpLink } from './csv-link-routing' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' export default function CsvPagedViewer({ file, @@ -97,7 +98,7 @@ function CsvPagedTable({ preferenceKey ?? buildFileViewPreferenceKey({ worktreeId: file.readArgs.worktreeId ?? '', - runtimeEnvironmentId: file.readArgs.settings?.activeRuntimeEnvironmentId, + runtimeEnvironmentId: runtimeTargetEnvironmentId(file.readArgs.target), externalSshTargetId: file.readArgs.connectionId, filePath: file.readArgs.filePath }), @@ -124,7 +125,7 @@ function CsvPagedTable({ openCsvHttpLink(url, event, { filePath: file.readArgs.filePath, worktreeId: file.readArgs.worktreeId, - runtimeEnvironmentId: file.readArgs.settings?.activeRuntimeEnvironmentId, + runtimeEnvironmentId: runtimeTargetEnvironmentId(file.readArgs.target), connectionId: file.readArgs.connectionId ?? null }) } diff --git a/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts b/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts index 8e873a8a79a..0787baf6609 100644 --- a/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts +++ b/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts @@ -15,7 +15,7 @@ vi.mock('./csv-preview-worker-client', () => ({ })) const snapshot = { size: 1024 * 1024, mtime: 1, isDirectory: false } -const file = { readArgs: { settings: null, filePath: '/repo/a.csv' }, snapshot } +const file = { readArgs: { target: { kind: 'local' as const }, filePath: '/repo/a.csv' }, snapshot } beforeEach(() => { vi.resetAllMocks() mocks.stat.mockResolvedValue(snapshot) diff --git a/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx b/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx index 8f9cf3349ab..fceac36d094 100644 --- a/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx +++ b/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx @@ -39,7 +39,7 @@ it('keeps the new viewport loader alive when a canceled delimiter scan rejects l ]) ) const file = { - readArgs: { settings: null, filePath: '/repo/a.csv' }, + readArgs: { target: { kind: 'local' as const }, filePath: '/repo/a.csv' }, snapshot: { size: 20, mtime: 1, isDirectory: false } } const { result, rerender } = renderHook(({ delimiter }) => useCsvPagedPreview(file, delimiter), { @@ -79,7 +79,7 @@ it('coalesces rapid scrolls and releases rows outside the latest viewport', asyn ) ) const file = { - readArgs: { settings: null, filePath: '/repo/a.csv' }, + readArgs: { target: { kind: 'local' as const }, filePath: '/repo/a.csv' }, snapshot: { size: 80, mtime: 1, isDirectory: false } } const { result } = renderHook(() => useCsvPagedPreview(file, ',')) diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts b/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts index 02198a89201..6f04f5e23ab 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts @@ -11,9 +11,20 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/lib/ssh-mutation-expectation', () => ({ captureWorktreeSshMutationExpectation: () => ({}) })) -vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: () => null -})) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + _runtimeEnvironmentId: string | null | undefined + ) => ({ kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) vi.mock('@/lib/user-opened-local-path', () => ({ statUserOpenedPath: mocks.statUserOpenedPath })) vi.mock('@/store', () => ({ useAppStore: { diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.test.ts b/src/renderer/src/components/editor/editor-dropped-file-open.test.ts index 5409651b666..452e6096982 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.test.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.test.ts @@ -1,30 +1,28 @@ import { describe, expect, it } from 'vitest' import { getEditorFileDropOperationContext, - getEditorFileDropSettingsForWorktree, shouldUploadRemoteEditorFileDrop } from './editor-dropped-file-open' +import { getRuntimeTargetForWorktreeOwner } from '@/lib/file-owner-runtime-target' describe('shouldUploadRemoteEditorFileDrop', () => { it('does not upload editor drops for local workspaces', () => { - expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, null)).toBe(false) + expect(shouldUploadRemoteEditorFileDrop({ kind: 'local' }, null)).toBe(false) }) it('uploads editor drops while a runtime environment is active', () => { - expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: 'env-1' }, null)).toBe( - true - ) + expect( + shouldUploadRemoteEditorFileDrop({ kind: 'environment', environmentId: 'env-1' }, null) + ).toBe(true) }) it('uploads editor drops for SSH workspaces', () => { - expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe( - true - ) + expect(shouldUploadRemoteEditorFileDrop({ kind: 'local' }, 'ssh-1')).toBe(true) }) it('uses the worktree owner runtime instead of the focused runtime', () => { expect( - getEditorFileDropSettingsForWorktree( + getRuntimeTargetForWorktreeOwner( { settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }], @@ -32,12 +30,12 @@ describe('shouldUploadRemoteEditorFileDrop', () => { }, 'wt-1' ) - ).toEqual({ activeRuntimeEnvironmentId: 'owner-runtime' }) + ).toEqual({ kind: 'environment', environmentId: 'owner-runtime' }) }) it('keeps explicit local worktree editor drops local while a runtime is focused', () => { expect( - getEditorFileDropSettingsForWorktree( + getRuntimeTargetForWorktreeOwner( { settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], @@ -45,7 +43,7 @@ describe('shouldUploadRemoteEditorFileDrop', () => { }, 'wt-1' ) - ).toEqual({ activeRuntimeEnvironmentId: null }) + ).toEqual({ kind: 'local' }) }) it('builds file operation context from the worktree owner instead of global focus', () => { @@ -61,7 +59,7 @@ describe('shouldUploadRemoteEditorFileDrop', () => { undefined ) ).toEqual({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repos/repo-1', connectionId: undefined @@ -81,7 +79,7 @@ describe('shouldUploadRemoteEditorFileDrop', () => { 'ssh-1' ) ).toEqual({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/home/orca/repo-1', connectionId: 'ssh-1' diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.ts b/src/renderer/src/components/editor/editor-dropped-file-open.ts index a07b57b658c..1d253dbf0ee 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.ts @@ -4,39 +4,27 @@ import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-lin import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { joinPath } from '@/lib/path' -import { - getRuntimeEnvironmentIdForWorktree, - type WorktreeRuntimeOwnerState -} from '@/lib/worktree-runtime-owner' +import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner' import { importExternalPathsToRuntime, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { + runtimeTargetEnvironmentId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { translate } from '@/i18n/i18n' import { captureWorktreeSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { statUserOpenedPath } from '@/lib/user-opened-local-path' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace' - -export function getEditorFileDropSettingsForWorktree( - store: WorktreeRuntimeOwnerState, - worktreeId: string -): Pick { - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(store, worktreeId) - // Why: OS drops target the selected worktree. Use that worktree's host owner - // so a focused runtime cannot hijack local/SSH editor drops. - return { - ...store.settings, - activeRuntimeEnvironmentId: runtimeEnvironmentId - } -} +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' export function shouldUploadRemoteEditorFileDrop( - settings: Pick | null | undefined, + target: RuntimeClientTarget, connectionId: string | null | undefined ): boolean { - return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim()) + return target.kind === 'environment' || Boolean(connectionId?.trim()) } export function getEditorFileDropOperationContext( @@ -46,7 +34,8 @@ export function getEditorFileDropOperationContext( connectionId: string | undefined ): RuntimeFileOperationArgs { return { - settings: getEditorFileDropSettingsForWorktree(store, worktreeId), + // Why: OS drops target the selected worktree's owner, never the focused server. + target: requireRuntimeTargetForFileOwner(store, worktreeId, undefined), worktreeId, worktreePath, connectionId @@ -89,7 +78,7 @@ function sameEditorFileDropOwner(a: EditorFileDropContext, b: EditorFileDropCont return ( a.worktreePath === b.worktreePath && a.connectionId === b.connectionId && - left.settings?.activeRuntimeEnvironmentId === right.settings?.activeRuntimeEnvironmentId && + runtimeTargetEnvironmentId(left.target) === runtimeTargetEnvironmentId(right.target) && left.expectedExecutionHostId === right.expectedExecutionHostId && left.expectedSshTargetId === right.expectedSshTargetId && left.expectedSshConnectionGeneration === right.expectedSshConnectionGeneration @@ -149,9 +138,8 @@ async function openEditorFileDropPaths( const store = useAppStore.getState() const { worktreeId, groupId } = destination const groupOptions = groupId ? { targetGroupId: groupId } : null - const dropSettings = fileContext.settings - const runtimeEnvironmentId = dropSettings?.activeRuntimeEnvironmentId ?? null - if (shouldUploadRemoteEditorFileDrop(dropSettings, connectionId)) { + const runtimeEnvironmentId = runtimeTargetEnvironmentId(fileContext.target) + if (shouldUploadRemoteEditorFileDrop(fileContext.target, connectionId)) { if (!worktreePath) { toast.error( translate( diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index 72d44520981..2caf51e322e 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -5,7 +5,10 @@ import type { OpenFile } from '@/store/slices/editor' import { getConnectionIdForFile } from '@/lib/connection-context' import { editorTabFileAccess } from '@/lib/local-file-access' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner +} from '@/lib/file-owner-runtime-target' import { canAutoSaveOpenFile } from './editor-autosave' import { getDiskBaselineSignature } from './diff-content-signature' import { markFileChangedOnDisk } from './editor-changed-on-disk-mark' @@ -40,8 +43,13 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { // Only local/SSH paths can be probed: for runtime-owned files window.api.fs would stat the client path and misreport it as gone. const probeFileMissing = async (file: OpenFile): Promise => { - const settings = settingsForRuntimeOwner(store.getState().settings, file.runtimeEnvironmentId) - if (settings?.activeRuntimeEnvironmentId?.trim()) { + const owner = getRuntimeTargetForFileOwner( + store.getState(), + file.worktreeId, + file.runtimeEnvironmentId + ) + // Why `!owner`: with no single owner, a client stat cannot prove the file gone. + if (owner?.kind !== 'local') { return false } try { @@ -65,7 +73,7 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { try { const state = store.getState() const result = await readRuntimeFileContent({ - settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + target: requireRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId), filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts index d9a56114ea0..062aec9ed98 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.test.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.ts @@ -8,7 +8,7 @@ import { function context(overrides: Partial = {}): RuntimeFileOperationArgs { return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'worktree-1', worktreePath: '/repo', ...overrides @@ -171,7 +171,9 @@ describe('shared Markdown document list requests', () => { it('keeps runtime routes isolated and encodes unusual paths losslessly', () => { const local = context() const ssh = context({ connectionId: 'ssh-1' }) - const runtime = context({ settings: { activeRuntimeEnvironmentId: ' runtime-1 ' } }) + const runtime = context({ + target: { kind: 'environment' as const, environmentId: ' runtime-1 ' } + }) const unusualRoot = '/repo\nwith-newline' const windowsRoot = 'C:\\repo\\docs' const uncRoot = '\\\\server\\share\\repo' diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts index f88406704c8..dc311fc327b 100644 --- a/src/renderer/src/components/editor/markdown-document-list-request.ts +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -27,7 +27,7 @@ export function getMarkdownDocumentListRequestKey( rootPath: string ): string { return JSON.stringify([ - context.settings?.activeRuntimeEnvironmentId?.trim() ?? '', + context.target.kind === 'environment' ? context.target.environmentId : '', context.connectionId ?? '', context.worktreeId ?? '', context.worktreePath ?? '', diff --git a/src/renderer/src/components/editor/markdown-preview-link-actions.ts b/src/renderer/src/components/editor/markdown-preview-link-actions.ts index f966a02bd88..7e1fd5a6254 100644 --- a/src/renderer/src/components/editor/markdown-preview-link-actions.ts +++ b/src/renderer/src/components/editor/markdown-preview-link-actions.ts @@ -6,7 +6,7 @@ import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/loc import { getLinkSourceLocalOpenOwner } from '@/lib/link-source-local-open-owner' import { openHttpLink } from '@/lib/http-link-routing' import { translate } from '@/i18n/i18n' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { statRuntimePath } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' @@ -171,8 +171,9 @@ export async function handleMarkdownPreviewLinkClick({ try { const stats = await statRuntimePath( { - settings: settingsForRuntimeOwner( - useAppStore.getState().settings, + target: requireRuntimeTargetForFileOwner( + useAppStore.getState(), + targetWorktree.id, resolvedSourceRuntimeEnvironmentId ), worktreeId: targetWorktree.id, diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.test.ts b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts index 5c5e54b21c6..b8bcb8ec90f 100644 --- a/src/renderer/src/components/editor/markdown-preview-local-images.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-local-images.test.ts @@ -130,7 +130,7 @@ describe('extractMarkdownPreviewLocalImageCandidates', () => { '/repo/docs/readme.md', { runtimeContext: { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' } @@ -141,7 +141,7 @@ describe('extractMarkdownPreviewLocalImageCandidates', () => { '/repo/docs/readme.md', { runtimeContext: { - settings: { activeRuntimeEnvironmentId: 'env-2' }, + target: { kind: 'environment' as const, environmentId: 'env-2' }, worktreeId: 'wt-1', worktreePath: '/repo' } diff --git a/src/renderer/src/components/editor/markdown-preview-local-images.ts b/src/renderer/src/components/editor/markdown-preview-local-images.ts index c366c3bc76a..51d6631dcc4 100644 --- a/src/renderer/src/components/editor/markdown-preview-local-images.ts +++ b/src/renderer/src/components/editor/markdown-preview-local-images.ts @@ -31,7 +31,8 @@ type MarkdownImageAstNode = { type ExtractLocalImageCandidatesOptions = { connectionId?: string | null limit?: number - runtimeContext?: MarkdownImageRuntimeContext + /** `null`: the owner is unresolved, so nothing is read. */ + runtimeContext?: MarkdownImageRuntimeContext | null } type PrewarmMarkdownPreviewLocalImagesOptions = ExtractLocalImageCandidatesOptions & { @@ -70,7 +71,7 @@ export function extractMarkdownPreviewLocalImageCandidates( options: ExtractLocalImageCandidatesOptions = {} ): MarkdownPreviewLocalImageCandidate[] { const limit = Math.max(0, options.limit ?? MARKDOWN_PREVIEW_LOCAL_IMAGE_PREWARM_LIMIT) - if (limit === 0 || !markdown.includes('!')) { + if (limit === 0 || options.runtimeContext === null || !markdown.includes('!')) { return [] } @@ -98,7 +99,8 @@ export function extractMarkdownPreviewLocalImageCandidates( const cacheKey = getLocalImageCacheKey( absolutePath, options.connectionId, - options.runtimeContext, + // Null returned early above. + options.runtimeContext ?? undefined, documentResourceAccess(filePath) ) if (seenCacheKeys.has(cacheKey)) { diff --git a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx index a410a99492a..661608108a6 100644 --- a/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx +++ b/src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.test.tsx @@ -125,7 +125,7 @@ describe('a restored tab whose workspace moved to its managed server', () => { await act(async () => load!(FILE_PATH, file.id, WORKTREE, 'README.md')) expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(1) expect(mocks.readRuntimeFileContent.mock.calls[0]?.[0]).toMatchObject({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: WORKTREE }) expect(fileContents[file.id]).toMatchObject({ content: '# repo' }) diff --git a/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts b/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts index aba0e4b9d26..74c4e599b98 100644 --- a/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-async-image-paste.test.ts @@ -18,7 +18,7 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionId: vi.fn(() => null) vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn(() => ({ - settings: null, + target: { kind: 'local' as const }, folderWorkspaces: [], worktreesByRepo: {}, openFiles: [] @@ -460,7 +460,7 @@ describe('rich Markdown asynchronous image paste', () => { for (const phase of ['saving', 'importing']) { const editor = richEditor() const runtimeContext: RichMarkdownImageRuntimeContext = { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: null @@ -491,7 +491,10 @@ describe('rich Markdown asynchronous image paste', () => { } else if (field === 'root') { nextContext.runtimeContext.worktreePath = '/other-repo' } else if (field === 'runtime') { - nextContext.runtimeContext.settings = { activeRuntimeEnvironmentId: 'other-runtime' } + nextContext.runtimeContext.target = { + kind: 'environment', + environmentId: 'other-runtime' + } } else if (field === 'connection') { nextContext.runtimeContext.connectionId = 'other-connection' } else { diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts index b009be2cd90..16a2a682458 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.ts @@ -11,7 +11,6 @@ import { } from '@/lib/workspace-browser-tab-open' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { getLinkSourceLocalOpenOwner } from '@/lib/link-source-local-open-owner' -import type { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { absolutePathToFileUri as toFileUrlForOsEscape, resolveMarkdownLinkTarget @@ -40,8 +39,6 @@ export type ActivateMarkdownLink = ( } ) => void | Promise -export type RichMarkdownRuntimeSettings = Parameters[0] - type RichMarkdownEditorClickRoutingOptions = { activateMarkdownLink: ActivateMarkdownLink editorRef: MutableRefObject diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts index 8f03d9cf5b2..c71e11f137f 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -51,7 +51,7 @@ function createConfigParams(overrides: Partial = {}): Editor runtimeEnvironmentId: null, isMac: false, richMarkdownSpellcheckEnabled: true, - settings: { activeRuntimeEnvironmentId: null }, + runtimeTarget: { kind: 'local' }, activateMarkdownLink: vi.fn(), rootRef: ref(null), editorRef: ref(null), diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 606e8555754..c96102fc7f2 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -19,8 +19,7 @@ import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuat import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { handleRichMarkdownEditorClick, - type ActivateMarkdownLink, - type RichMarkdownRuntimeSettings + type ActivateMarkdownLink } from './rich-markdown-editor-click-routing' import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler' import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' @@ -28,6 +27,7 @@ import { createRichMarkdownImageResolverContext, setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { getRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' import type { MutableRefObject, Dispatch, SetStateAction } from 'react' import type { DiffComment } from '../../../../shared/diff-comment-types' @@ -49,7 +49,8 @@ export type EditorConfigParams = { runtimeEnvironmentId?: string | null isMac: boolean richMarkdownSpellcheckEnabled: boolean - settings: RichMarkdownRuntimeSettings + /** The document owner's transport, for image reads. */ + runtimeTarget: RuntimeClientTarget | null activateMarkdownLink: ActivateMarkdownLink rootRef: MutableRefObject editorRef: MutableRefObject @@ -103,7 +104,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE runtimeEnvironmentId, isMac, richMarkdownSpellcheckEnabled, - settings, + runtimeTarget, activateMarkdownLink, rootRef, editorRef, @@ -247,8 +248,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot }) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index e317ee0d0e8..890017c889f 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -44,7 +44,10 @@ import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight' import { RichMarkdownTaskList } from './rich-markdown-task-list' import { createCachedLowlight } from './rich-markdown-lowlight-cache' import { documentResourceAccess } from '@/lib/local-file-access' -import { resolveRichMarkdownImageUrl } from './rich-markdown-image-context' +import { + readRichMarkdownImageRuntimeContext, + resolveRichMarkdownImageUrl +} from './rich-markdown-image-context' const lowlight = createCachedLowlight(createLowlight(common)) @@ -143,9 +146,7 @@ export function createRichMarkdownExtensions({ releaseImageLease?.() releaseImageLease = undefined const fp = this.storage.filePath as string - const runtimeContext = this.storage.runtimeContext as - | RuntimeFileOperationArgs - | undefined + const runtimeContext = readRichMarkdownImageRuntimeContext(this.storage) const contextVersionAtLoad = getImageContextVersion(this.storage) if (src && fp) { const access = documentResourceAccess(fp) diff --git a/src/renderer/src/components/editor/rich-markdown-image-context.test.ts b/src/renderer/src/components/editor/rich-markdown-image-context.test.ts new file mode 100644 index 00000000000..0459a0fb1c2 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-image-context.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createRichMarkdownImageResolverContext, + readRichMarkdownImageRuntimeContext +} from './rich-markdown-image-context' +import { prewarmMarkdownPreviewLocalImages } from './markdown-preview-local-images' +import { loadLocalImageSrc, resetLocalImageSrcStateForTests } from './useLocalImageSrc' + +vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) + +describe('image reads for a document whose owner is unresolved', () => { + afterEach(() => { + resetLocalImageSrcStateForTests() + vi.unstubAllGlobals() + }) + + it('never reads the image from this machine', async () => { + const readFile = vi.fn(async () => ({ content: '', isBinary: true })) + vi.stubGlobal('window', { ...window, api: { fs: { readFile } } }) + const context = createRichMarkdownImageResolverContext({ + filePath: '/srv/repo/doc.md', + runtimeTarget: null, + worktreeId: 'repo::/srv/repo', + worktreeRoot: '/srv/repo' + }) + // `undefined` would mean a plain local file; an unresolved owner must block instead. + expect(context.runtimeContext).toBeNull() + // The editor's image node view reads the stored context back; null must survive that. + expect( + readRichMarkdownImageRuntimeContext({ runtimeContext: context.runtimeContext }) + ).toBeNull() + + await expect( + loadLocalImageSrc('./shot.png', '/srv/repo/doc.md', undefined, context.runtimeContext) + ).resolves.toBeNull() + const loadImage = vi.fn(async () => null) + prewarmMarkdownPreviewLocalImages('![shot](./shot.png)', '/srv/repo/doc.md', { + runtimeContext: null, + loadImage + }) + await Promise.resolve() + expect(loadImage).not.toHaveBeenCalled() + expect(readFile).not.toHaveBeenCalled() + }) + + it('keeps no workspace distinct from an unresolved owner', () => { + expect( + createRichMarkdownImageResolverContext({ + filePath: '/notes/doc.md', + runtimeTarget: { kind: 'local' }, + worktreeId: '', + worktreeRoot: null + }).runtimeContext + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-image-context.ts b/src/renderer/src/components/editor/rich-markdown-image-context.ts index b01d2236b45..ff84266e819 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-context.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-context.ts @@ -1,6 +1,6 @@ import type { Editor } from '@tiptap/core' import { getConnectionId } from '@/lib/connection-context' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' export type RichMarkdownImageRuntimeContext = Omit & { @@ -10,11 +10,10 @@ export type RichMarkdownImageRuntimeContext = Omit - runtimeContext?: RichMarkdownImageRuntimeContext + /** `null`: the owner is unresolved, so local images must not load; `undefined`: no workspace. */ + runtimeContext?: RichMarkdownImageRuntimeContext | null } -export type RichMarkdownImageResolverSettings = Parameters[0] - type RichMarkdownImageUrls = Record function isRichMarkdownImageUrls(value: unknown): value is RichMarkdownImageUrls { @@ -40,10 +39,30 @@ type RichMarkdownImageStorage = { filePath: string imageUrls?: Record reloadListeners?: Set<() => void> - runtimeContext?: RichMarkdownImageRuntimeContext + runtimeContext?: RichMarkdownImageRuntimeContext | null } } +/** The image node view's storage; its context is validated on read. */ +export type RichMarkdownImageNodeStorage = { runtimeContext?: unknown } + +/** The stored image context; `null` (unresolved owner) is kept so readers refuse instead of going local. */ +export function readRichMarkdownImageRuntimeContext( + storage: RichMarkdownImageNodeStorage +): RichMarkdownImageRuntimeContext | null | undefined { + const value = storage.runtimeContext + if (value === null) { + return null + } + return isRichMarkdownImageRuntimeContext(value) ? value : undefined +} + +function isRichMarkdownImageRuntimeContext( + value: unknown +): value is RichMarkdownImageRuntimeContext { + return typeof value === 'object' && value !== null && 'target' in value && 'worktreeId' in value +} + export function getRichMarkdownImageResolverContextVersion(editor: Editor): number { const image: unknown = 'image' in editor.storage ? editor.storage.image : null if (!image || typeof image !== 'object') { @@ -56,29 +75,30 @@ export function getRichMarkdownImageResolverContextVersion(editor: Editor): numb export function createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot }: { filePath: string externalSshTargetId?: string - runtimeEnvironmentId?: string | null - settings: RichMarkdownImageResolverSettings + /** The document owner's transport; `null` while unresolved, which blocks local image reads. */ + runtimeTarget: RuntimeClientTarget | null worktreeId: string worktreeRoot: string | null }): RichMarkdownImageResolverContext { return { filePath, - runtimeContext: worktreeRoot - ? { - settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), - worktreeId, - worktreePath: worktreeRoot, - connectionId: getConnectionId(worktreeId), - expectedExternalSshTargetId: externalSshTargetId - } - : undefined + runtimeContext: !worktreeRoot + ? undefined + : runtimeTarget + ? { + target: runtimeTarget, + worktreeId, + worktreePath: worktreeRoot, + connectionId: getConnectionId(worktreeId), + expectedExternalSshTargetId: externalSshTargetId + } + : null } } @@ -117,7 +137,11 @@ function getRichMarkdownImageContextSignature(context: RichMarkdownImageResolver return [ context.filePath, JSON.stringify(context.imageUrls ?? {}), - context.runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client', + context.runtimeContext === null + ? 'unresolved' + : context.runtimeContext?.target.kind === 'environment' + ? context.runtimeContext.target.environmentId + : 'client', context.runtimeContext?.connectionId ?? 'local', context.runtimeContext?.expectedExternalSshTargetId ?? '', context.runtimeContext?.worktreeId ?? 'unknown-worktree', diff --git a/src/renderer/src/components/editor/rich-markdown-image-insert.ts b/src/renderer/src/components/editor/rich-markdown-image-insert.ts index 1f6f020dfdb..a040bb9f86b 100644 --- a/src/renderer/src/components/editor/rich-markdown-image-insert.ts +++ b/src/renderer/src/components/editor/rich-markdown-image-insert.ts @@ -6,7 +6,7 @@ import { useAppStore } from '@/store' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' import { editorTabDocumentFolderAccess } from '@/lib/local-file-access' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { translate } from '@/i18n/i18n' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' @@ -46,7 +46,7 @@ export async function insertRichMarkdownImageFromPath({ worktreeId && parsedWorkspace?.type !== 'folder' ? getEditorFileOperationContext(state, { worktreeId, runtimeEnvironmentId }, worktreePath) : { - settings: settingsForRuntimeOwner(state.settings, runtimeEnvironmentId), + target: requireRuntimeTargetForFileOwner(state, worktreeId, runtimeEnvironmentId), worktreeId, worktreePath, connectionId, @@ -57,8 +57,7 @@ export async function insertRichMarkdownImageFromPath({ ? captureDirectSshMutationExpectation(state, connectionId, runtimeEnvironmentId) : {}) } - const settings = fileContext.settings - if (settings?.activeRuntimeEnvironmentId?.trim() && !worktreePath) { + if (fileContext.target.kind === 'environment' && !worktreePath) { toast.error( translate( 'auto.components.editor.useLocalImagePick.91d835dc88', diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts index e871e7e02ed..839c625d56d 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.selection.test.ts @@ -185,7 +185,7 @@ describe('large Markdown paste selection during yields', () => { ...context, runtimeContext: { connectionId: 'another-target', - settings: null, + target: { kind: 'local' as const }, worktreeId: 'workspace', worktreePath: '/repo' } diff --git a/src/renderer/src/components/editor/rich-markdown-paste-image.ts b/src/renderer/src/components/editor/rich-markdown-paste-image.ts index 676014b14be..09607bf3e90 100644 --- a/src/renderer/src/components/editor/rich-markdown-paste-image.ts +++ b/src/renderer/src/components/editor/rich-markdown-paste-image.ts @@ -2,7 +2,7 @@ import type { Editor } from '@tiptap/react' import { toast } from 'sonner' import { getConnectionId } from '@/lib/connection-context' import { useAppStore } from '@/store' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { extractIpcErrorMessage } from './rich-markdown-ipc-error-message' import { insertRichMarkdownImageFromPath } from './rich-markdown-image-insert' import { captureRichMarkdownImageInsertionTarget } from './rich-markdown-image-insertion-target' @@ -73,8 +73,9 @@ async function saveClipboardImageForMarkdownPaste( worktreeId: string | null, runtimeEnvironmentId?: string | null ): Promise { - const settings = settingsForRuntimeOwner(useAppStore.getState().settings, runtimeEnvironmentId) - const hasRuntimeOwner = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) + const hasRuntimeOwner = + requireRuntimeTargetForFileOwner(useAppStore.getState(), worktreeId, runtimeEnvironmentId) + .kind === 'environment' // Why: runtime-owned notes use runtime-side clipboard import; routing this // temp save through SSH would put the source file on the wrong machine. const connectionId = hasRuntimeOwner ? undefined : (getConnectionId(worktreeId) ?? undefined) diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx index 9d13093bb61..92c5988e146 100644 --- a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx @@ -77,7 +77,7 @@ describe('editor group OS file drops on remote workspaces', () => { worktreeId: 'wt-ssh', worktreePath: SSH_PATH, connectionId: 'ssh-1', - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, expectedExecutionHostId: 'ssh:ssh-1', expectedSshTargetId: 'ssh-1', expectedSshConnectionGeneration: 3 @@ -99,7 +99,7 @@ describe('editor group OS file drops on remote workspaces', () => { await waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1)) expect(mocks.importPaths.mock.calls[0][0]).toMatchObject({ worktreeId: 'wt-runtime', - settings: { activeRuntimeEnvironmentId: 'owner-runtime' } + target: { kind: 'environment', environmentId: 'owner-runtime' } }) expect(mocks.importPaths.mock.calls[0][2]).toBe(joinPath(RUNTIME_PATH, '.orca/drops')) expect(mocks.openFile).toHaveBeenCalledWith( diff --git a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx index a33abd776bf..55fa5d0b2d3 100644 --- a/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx +++ b/src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx @@ -34,9 +34,20 @@ vi.mock('@/lib/ssh-mutation-expectation', () => ({ return { expectedExecutionHostId: 'local' } } })) -vi.mock('@/lib/worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: () => null -})) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + _runtimeEnvironmentId: string | null | undefined + ) => ({ kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) vi.mock('@/lib/user-opened-local-path', () => ({ statUserOpenedPath: mocks.stat })) vi.mock('@/store', () => ({ useAppStore: { diff --git a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts index 433bd6c4a31..480c78b4930 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts @@ -3,7 +3,7 @@ import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolu import { computeEditorFontSize } from '@/lib/editor-font-zoom' import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' import { isMarkdownComment } from '@/lib/diff-comment-compat' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { useFileOwnerRuntimeTarget } from '@/runtime/use-worktree-runtime-target' import { useAppStore } from '@/store' import { exceedsMarkdownRichModeSizeLimit } from './markdown-rich-size-limit' import { prewarmMarkdownPreviewLocalImages } from './markdown-preview-local-images' @@ -124,23 +124,28 @@ export function useMarkdownPreviewSourceFoundation({ ), [allDiffComments, sourceRelativePath] ) - const settings = useAppStore((s) => s.settings) + const sourceRuntimeTarget = useFileOwnerRuntimeTarget( + sourceRoutingWorktreeId, + resolvedSourceRuntimeEnvironmentId + ) const imageRuntimeContext = useMemo( () => - sourceRoutingWorktreeId && worktreeRoot - ? { - settings: settingsForRuntimeOwner(settings, resolvedSourceRuntimeEnvironmentId), - worktreeId: sourceRoutingWorktreeId, - worktreePath: worktreeRoot, - connectionId: sourceConnectionId, - expectedExternalSshTargetId: sourceOpenFile?.externalSshTargetId - } - : undefined, + !sourceRoutingWorktreeId || !worktreeRoot + ? undefined + : sourceRuntimeTarget + ? { + target: sourceRuntimeTarget, + worktreeId: sourceRoutingWorktreeId, + worktreePath: worktreeRoot, + connectionId: sourceConnectionId, + expectedExternalSshTargetId: sourceOpenFile?.externalSshTargetId + } + : // Why null, not undefined: undefined reads as a plain local file and would read here. + null, [ - settings, + sourceRuntimeTarget, sourceConnectionId, sourceOpenFile?.externalSshTargetId, - resolvedSourceRuntimeEnvironmentId, sourceRoutingWorktreeId, worktreeRoot ] diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 3ce87d778fc..6c4b955ba13 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -20,9 +20,9 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/runtime/runtime-file-client', () => ({ getRuntimeFileReadScope: vi.fn( ( - settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined, + target: { kind: 'local' } | { kind: 'environment'; environmentId: string }, connectionId?: string - ) => connectionId ?? settings?.activeRuntimeEnvironmentId ?? null + ) => connectionId ?? (target.kind === 'environment' ? target.environmentId : null) ), readRuntimeFileContent: mocks.readRuntimeFileContent, subscribeRuntimeFileChanges: vi.fn() @@ -47,6 +47,18 @@ vi.mock('@/lib/runtime-workspace-file-route', () => ({ findWorkspaceFileRoute: vi.fn(() => null) })) +// Fixture owners: `repo-runtime::` workspaces live on runtime-1, everything else here is local. +vi.mock('@/lib/file-owner-runtime-target', () => { + const ownerTarget = (_s: unknown, worktreeId?: string, owner?: string | null) => { + const id = owner === undefined && worktreeId?.startsWith('repo-runtime::') ? 'runtime-1' : owner + return id ? { kind: 'environment', environmentId: id } : { kind: 'local' } + } + return { + getRuntimeTargetForFileOwner: ownerTarget, + requireRuntimeTargetForFileOwner: ownerTarget + } +}) + vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index a5389a91c1a..a6df9203175 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -5,7 +5,9 @@ import { useAppStore } from '@/store' import { getDiskBaselineSignature } from './diff-content-signature' import { getRuntimeFileReadScope } from '@/runtime/runtime-file-client' import { readEditorCsvFileContent } from './csv/csv-file-content' -import { RuntimeRpcCallError, settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route' import { selectWorktreeHostConnectionPhase } from '@/lib/worktree-host-connection-phase' import { @@ -92,11 +94,6 @@ export function useEditorPanelFileContentLoader({ const resolvedConnectionId = getConnectionIdForFile(worktreeId ?? null, filePath) const connectionId = resolvedConnectionId ?? undefined const restoredOpenFile = openFilesRef.current.find((file) => file.id === id) - const activeSettings = useAppStore.getState().settings - const readSettings = settingsForRuntimeOwner( - activeSettings, - restoredOpenFile?.runtimeEnvironmentId - ) const isLiveTailLogTab = restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true // Why: these tabs name a file on this client, so the worktree's SSH or runtime owner must @@ -104,6 +101,16 @@ export function useEditorPanelFileContentLoader({ const isClientLocalTab = restoredOpenFile ? isClientLocalReadOnlyTab(restoredOpenFile) : false + const readTarget: RuntimeClientTarget = + isClientLocalTab && restoredOpenFile?.runtimeEnvironmentId === undefined + ? { kind: 'local' } + : requireRuntimeTargetForFileOwner( + useAppStore.getState(), + worktreeId, + restoredOpenFile?.runtimeEnvironmentId + ) + const readOwnerEnvironmentId = + readTarget.kind === 'environment' ? readTarget.environmentId : undefined readConnectionId = connectionId let readWorktreeId = worktreeId let readRelativePath = restoredOpenFile?.relativePath ?? relativePath @@ -145,7 +152,7 @@ export function useEditorPanelFileContentLoader({ if ( !isClientLocalTab && resolvedConnectionId === undefined && - !readSettings?.activeRuntimeEnvironmentId?.trim() && + !readOwnerEnvironmentId && !isWorktreeConnectionResolved(worktreeId ?? null) ) { // Why: the backing repo hasn't hydrated yet (SSH still connecting), so @@ -160,9 +167,7 @@ export function useEditorPanelFileContentLoader({ const externalSshOwnerId = restoredOpenFile.externalSshTargetId?.trim() || (isClientLocalTab ? undefined : connectionId) - const runtimeEnvironmentId = isClientLocalTab - ? undefined - : readSettings?.activeRuntimeEnvironmentId?.trim() + const runtimeEnvironmentId = isClientLocalTab ? undefined : readOwnerEnvironmentId if (isClientLocalTab) { readConnectionId = undefined } else { @@ -190,7 +195,7 @@ export function useEditorPanelFileContentLoader({ } } } - const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) + const readScope = getRuntimeFileReadScope(readTarget, readConnectionId) const access = restoredOpenFile ? editorTabFileAccess(useAppStore.getState(), restoredOpenFile) : undefined @@ -213,7 +218,7 @@ export function useEditorPanelFileContentLoader({ if (!pending) { const promise = readEditorCsvFileContent( { - settings: readSettings, + target: readTarget, filePath, relativePath: readRelativePath, worktreeId: readWorktreeId, diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx index 2627969dacb..0dad110e8ec 100644 --- a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -52,6 +52,25 @@ vi.mock('@/lib/worktree-runtime-owner', () => ({ getExplicitRuntimeEnvironmentIdForWorktree: vi.fn(() => null) })) +// These fixtures' workspaces live on runtime-1, so an unstamped tab resolves there. +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined + ) => + runtimeEnvironmentId === null + ? { kind: 'local' as const } + : { kind: 'environment' as const, environmentId: runtimeEnvironmentId ?? 'runtime-1' } + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) + vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) vi.mock('./useEditorPanelExternalContentEvents', () => ({ diff --git a/src/renderer/src/components/editor/useLocalImageSrc.test.ts b/src/renderer/src/components/editor/useLocalImageSrc.test.ts index d4b0cdcb173..6dd8c92d0a5 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.test.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.test.ts @@ -105,17 +105,17 @@ afterEach(() => { describe('getLocalImageCacheKey', () => { it('scopes local markdown image cache entries by runtime owner', () => { const localKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }) const remoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/repo' }) const otherRemoteKey = getLocalImageCacheKey('/repo/docs/logo.png', null, { - settings: { activeRuntimeEnvironmentId: 'env-2' }, + target: { kind: 'environment' as const, environmentId: 'env-2' }, worktreeId: 'wt-1', worktreePath: '/repo' }) @@ -365,7 +365,7 @@ describe('loadLocalImageSrc', () => { await expect( loadLocalImageSrc('diagram.png', '/repo/docs/readme.md', null, { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -373,7 +373,7 @@ describe('loadLocalImageSrc', () => { ).resolves.toBe('blob:runtime-one') await expect( loadLocalImageSrc('diagram.png', '/repo/docs/readme.md', null, { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-2', worktreePath: '/repo', connectionId: 'ssh-2' @@ -388,7 +388,7 @@ describe('loadLocalImageSrc', () => { await expect( loadLocalImageSrc('diagram.png', '/tmp/readme.md', null, { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-2', @@ -454,7 +454,7 @@ describe('useLocalImageSrc runtime owner', () => { function sshOwner(overrides: Partial = {}): RuntimeFileOperationArgs { return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', diff --git a/src/renderer/src/components/editor/useLocalImageSrc.ts b/src/renderer/src/components/editor/useLocalImageSrc.ts index f256b173552..f82ac8cfac0 100644 --- a/src/renderer/src/components/editor/useLocalImageSrc.ts +++ b/src/renderer/src/components/editor/useLocalImageSrc.ts @@ -17,6 +17,7 @@ import { subscribeToLocalImageCacheInvalidation, unpinLocalImageCache } from './local-image-src-cache' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' export function getLocalImageCacheKey( absolutePath: string, @@ -26,8 +27,9 @@ export function getLocalImageCacheKey( }, access?: LocalFileAccess ): string { - const runtimeEnvironmentId = - runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client' + const runtimeEnvironmentId = runtimeContext + ? (runtimeTargetEnvironmentId(runtimeContext.target) ?? 'client') + : 'client' return [ runtimeEnvironmentId, runtimeContext?.connectionId ?? connectionId ?? 'local', diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts b/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts index d2d328c5777..e99dc37f823 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.navigation.test.ts @@ -33,9 +33,20 @@ vi.mock('@/store', () => ({ })) vi.mock('@/lib/connection-context', () => ({ getConnectionIdFromState: () => runtimeConnectionId })) vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: runtime.stat })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (_settings: unknown, owner: string | null | undefined) => ({ owner }) -})) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) vi.mock('./markdown-document-list-request', () => ({ requestSharedMarkdownDocumentList: runtime.list })) @@ -225,7 +236,7 @@ describe('Markdown document navigation', () => { expect(runtime.stat).toHaveBeenCalledWith( { - settings: { owner: 'runtime-owner' }, + target: { kind: 'environment', environmentId: 'runtime-owner' }, worktreeId: 'wt', worktreePath: '/repo', connectionId: 'ssh-owner' diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx b/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx index 854d3ab925b..a086e9062e2 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ownership.test.tsx @@ -85,7 +85,10 @@ it('waits for the retained editor to take its managed workspace owner before lis await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) expect(mocks.list).toHaveBeenCalledTimes(1) expect(mocks.list).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'env-1' }, worktreeId }), + expect.objectContaining({ + target: { kind: 'environment', environmentId: 'env-1' }, + worktreeId + }), '/remote/repo', { requireFresh: false } ) @@ -122,7 +125,7 @@ it('uses an explicit server owner even before its desktop repository catalog is await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) expect(mocks.list).toHaveBeenCalledTimes(1) expect(mocks.list).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'env-1' } }), + expect.objectContaining({ target: { kind: 'environment', environmentId: 'env-1' } }), '/remote/repo', { requireFresh: false } ) @@ -173,7 +176,7 @@ it('waits for ownership migration in a non-git folder workspace too', async () = await waitFor(() => expect(result.current.markdownDocuments).toEqual([document])) expect(mocks.list).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'folder:notes' }), '/notes', diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 3e5b287d396..259a7166ffa 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -5,7 +5,10 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { getConnectionIdFromState } from '@/lib/connection-context' import { statRuntimePath } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner +} from '@/lib/file-owner-runtime-target' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { createMarkdownDocumentIndex, @@ -81,9 +84,14 @@ export function useMarkdownDocuments( return } const state = useAppStore.getState() - const settings = settingsForRuntimeOwner(state.settings, activeFile.runtimeEnvironmentId) + const target = getRuntimeTargetForFileOwner( + state, + worktreeId, + activeFile.runtimeEnvironmentId + ) // The content loader reowns retained tabs before metadata may use their new host. if ( + !target || findRestoredEditorWorkspaceRuntimeOwner( state, { @@ -95,7 +103,7 @@ export function useMarkdownDocuments( }, worktreeId ) || - (connectionId === undefined && !settings?.activeRuntimeEnvironmentId?.trim()) + (connectionId === undefined && target.kind === 'local') ) { return } @@ -105,7 +113,7 @@ export function useMarkdownDocuments( try { const documents = await requestSharedMarkdownDocumentList( { - settings, + target, worktreeId, worktreePath, connectionId: connectionId ?? undefined @@ -161,8 +169,9 @@ export function useMarkdownDocuments( try { const stats = await statRuntimePath( { - settings: settingsForRuntimeOwner( - useAppStore.getState().settings, + target: requireRuntimeTargetForFileOwner( + useAppStore.getState(), + worktreeId, activeFile.runtimeEnvironmentId ), worktreeId, diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts b/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts index 107504763d7..4058b8ea315 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.watch.test.ts @@ -24,11 +24,20 @@ vi.mock('@/runtime/runtime-file-client', () => ({ listRuntimeMarkdownDocuments: runtime.list, statRuntimePath: runtime.stat })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - settingsForRuntimeOwner: (_settings: unknown, owner: string | null | undefined) => ({ - activeRuntimeEnvironmentId: owner - }) -})) +vi.mock('@/lib/file-owner-runtime-target', () => { + const getRuntimeTargetForFileOwner = ( + _state: unknown, + _worktreeId: string | null | undefined, + owner: string | null | undefined + ) => (owner ? { kind: 'environment' as const, environmentId: owner } : { kind: 'local' as const }) + return { + FILE_OWNER_UNRESOLVED_MESSAGE: 'unresolved', + getRuntimeTargetForFileOwner, + requireRuntimeTargetForFileOwner: getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner: (state: unknown, worktreeId: string | null | undefined) => + getRuntimeTargetForFileOwner(state, worktreeId, undefined) + } +}) const toastError = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ toast: { error: toastError } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -147,7 +156,7 @@ describe('Markdown metadata from the existing worktree watcher', () => { await settle() expect(controllers[0].markdownDocuments).toEqual([target]) expect(runtime.list).toHaveBeenLastCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null } }), + expect.objectContaining({ target: { kind: 'local' } }), '/repo' ) }) diff --git a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx index 967facaedbb..900dda7e80e 100644 --- a/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx +++ b/src/renderer/src/components/editor/useRichMarkdownEditorInstance.test.tsx @@ -33,7 +33,7 @@ function createParams(content = ''): EditorConfigParams { worktreeRoot: '/repo', isMac: false, richMarkdownSpellcheckEnabled: true, - settings: {} as EditorConfigParams['settings'], + runtimeTarget: { kind: 'local' }, activateMarkdownLink: vi.fn(), rootRef: { current: null }, editorRef: { current: null }, diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts index c136095b8b0..744a9b66ddd 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.test.ts @@ -70,8 +70,7 @@ describe('useRichMarkdownProgrammaticSync external-reload baseline adoption (#60 baseCanonicalRef, markdownDocuments: undefined, rootRef: { current: null }, - runtimeEnvironmentId: null, - settings: null, + runtimeTarget: { kind: 'local' }, slashMenuSetter: vi.fn(), worktreeId: 'w1', worktreeRoot: null diff --git a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts index 6094570d39e..1719183d297 100644 --- a/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/useRichMarkdownProgrammaticSync.ts @@ -8,9 +8,9 @@ import { normalizeEmptyListItems } from './rich-markdown-normalize' import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands' import { createRichMarkdownImageResolverContext, - setRichMarkdownImageResolverContext, - type RichMarkdownImageResolverSettings + setRichMarkdownImageResolverContext } from './rich-markdown-image-context' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' type RichMarkdownProgrammaticSyncOptions = { @@ -27,8 +27,7 @@ type RichMarkdownProgrammaticSyncOptions = { baseCanonicalRef: MutableRefObject markdownDocuments?: MarkdownDocument[] rootRef: MutableRefObject - runtimeEnvironmentId?: string | null - settings: RichMarkdownImageResolverSettings + runtimeTarget: RuntimeClientTarget | null slashMenuSetter: Dispatch> worktreeId: string worktreeRoot: string | null @@ -54,8 +53,7 @@ export function useRichMarkdownProgrammaticSync({ baseCanonicalRef, markdownDocuments, rootRef, - runtimeEnvironmentId, - settings, + runtimeTarget, slashMenuSetter, worktreeId, worktreeRoot @@ -71,8 +69,7 @@ export function useRichMarkdownProgrammaticSync({ createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot }) @@ -85,8 +82,7 @@ export function useRichMarkdownProgrammaticSync({ externalSshTargetId, filePath, isApplyingProgrammaticUpdateRef, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot ]) diff --git a/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts index 26a9844d84e..b024cc89bec 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReconcileRoundTrip.ts @@ -1,8 +1,8 @@ import { useRef, type MutableRefObject } from 'react' -import { useAppStore } from '@/store' import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer' import { createRichMarkdownImageResolverContext } from './rich-markdown-image-context' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' +import { useFileOwnerRuntimeTarget } from '@/runtime/use-worktree-runtime-target' type ReconcileRoundTripParams = { htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext @@ -27,7 +27,7 @@ export function useRichMarkdownReconcileRoundTrip({ worktreeId, worktreeRoot }: ReconcileRoundTripParams): MutableRefObject<(markdown: string) => string | null> { - const settings = useAppStore((s) => s.settings) + const runtimeTarget = useFileOwnerRuntimeTarget(worktreeId, runtimeEnvironmentId) const ref = useRef<(markdown: string) => string | null>(() => null) ref.current = (markdown) => serializeRichMarkdownForReconcile(markdown, { @@ -35,8 +35,7 @@ export function useRichMarkdownReconcileRoundTrip({ imageResolverContext: createRichMarkdownImageResolverContext({ filePath, externalSshTargetId, - runtimeEnvironmentId, - settings, + runtimeTarget, worktreeId, worktreeRoot }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx index bb1ba9561bc..ef28d09f407 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx @@ -690,7 +690,7 @@ describe('FloatingTerminalPanel markdown editor', () => { '/tmp/orca/floating-notes', FLOATING_TERMINAL_WORKTREE_ID, undefined, - { activeRuntimeEnvironmentId: null } + { kind: 'local' } ) expect(mocks.openFile).toHaveBeenCalledWith( expect.objectContaining({ filePath: '/tmp/orca/floating-notes/untitled.md' }), diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 9a29a47e95e..51e9ece5675 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -13,8 +13,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' -const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } type FloatingTerminalCreateActionsInput = Pick< FloatingTerminalPanelStoreState, @@ -97,7 +98,7 @@ export function useFloatingTerminalCreateActions({ markdownCwd, FLOATING_TERMINAL_WORKTREE_ID, getConnectionId(FLOATING_TERMINAL_WORKTREE_ID) ?? undefined, - LOCAL_RUNTIME_SETTINGS + LOCAL_TARGET ) if (!fileInfo) { return diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx index 4e8a13fae3b..e44f2892ae3 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -44,7 +44,9 @@ function renderUserMessage(source?: AgentMessageSource, worktreeId: string | nul expandSignal={false} onScrollMessageToTop={vi.fn()} runtimeContext={ - worktreeId ? { settings: null, worktreeId, worktreePath: '/repo' } : undefined + worktreeId + ? { target: { kind: 'local' as const }, worktreeId, worktreePath: '/repo' } + : undefined } /> ) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index b8d9400235e..f023b17f9c4 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -82,7 +82,7 @@ describe('NativeChatImageAttachmentPreview', () => { for (const call of mocks.useLocalImageSrc.mock.calls) { expect(call[3]).toEqual({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: null, worktreePath: null }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 80c4ea7a4df..e011cfebef3 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -13,6 +13,7 @@ import type { NativeChatComposerImageAttachment } from './NativeChatComposerFiel import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { chatImageAccess } from '@/lib/local-file-access' import { LoadingSpinner } from '@/components/LoadingSpinner' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' type Props = { attachment: NativeChatComposerImageAttachment @@ -151,7 +152,7 @@ function NativeChatImageThumbnail({ () => readEnvironmentId ? { - settings: { activeRuntimeEnvironmentId: readEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(readEnvironmentId), worktreeId: null, worktreePath: null } diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx index e8ea88d317d..593ee255d89 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptChrome.test.tsx @@ -17,7 +17,7 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true function runtimeContext(worktreeId: string): RuntimeFileOperationArgs { return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId, worktreePath: `/repo/${worktreeId}`, expectedExecutionHostId: 'local' diff --git a/src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts index 51a2ec04ace..0cee65f9cff 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link-existence.test.ts @@ -24,7 +24,7 @@ vi.mock('@/components/terminal-pane/terminal-file-link-target', async (importOri absolutePath: /^[\\/]{2}/.test(link.pathText) ? link.pathText : `/repo/${link.pathText}`, line: null, column: null, - fileContext: { sourceHostResolved: connection.sourceHostResolved }, + fileContext: { target: { kind: 'local' }, sourceHostResolved: connection.sourceHostResolved }, isRemoteRuntimePath: false, cacheKey: link.pathText, isKnownWorktreeRoot: link.pathText === 'ROOT' @@ -33,7 +33,10 @@ vi.mock('@/components/terminal-pane/terminal-file-link-target', async (importOri vi.mock('@/components/terminal-pane/terminal-file-path-mapping', async (importOriginal) => ({ ...(await importOriginal()), - getTerminalFileContext: () => ({ sourceHostResolved: connection.currentOwnerResolved }) + getTerminalFileContext: () => ({ + target: { kind: 'local' }, + sourceHostResolved: connection.currentOwnerResolved + }) })) const host = { cwd: '/repo', worktreeId: 'wt-1', worktreePath: '/repo', connectionId: null } diff --git a/src/renderer/src/components/native-chat/native-chat-file-link-existence.ts b/src/renderer/src/components/native-chat/native-chat-file-link-existence.ts index c95b5878368..fa37b9a30b0 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link-existence.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link-existence.ts @@ -15,6 +15,7 @@ import { type FileLinkTarget } from '@/components/terminal-pane/terminal-file-link-target' import { getTerminalFileContext } from '@/components/terminal-pane/terminal-file-path-mapping' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' // Why: a host that could not answer is retried a few times, then waits for the next recheck. export const UNVERIFIABLE_RETRY_DELAYS_MS = [2_000, 10_000, 30_000] as const @@ -131,8 +132,8 @@ export function createNativeChatFileLinkExistence( if ( !current.sourceHostResolved || current.connectionId !== target.fileContext.connectionId || - current.settings?.activeRuntimeEnvironmentId !== - target.fileContext.settings?.activeRuntimeEnvironmentId + runtimeTargetEnvironmentId(current.target) !== + runtimeTargetEnvironmentId(target.fileContext.target) ) { throw new Error('The workspace host changed or could not be determined') } diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts index d91663efea7..3dde9761162 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts @@ -64,7 +64,7 @@ describe('resolveNativeChatImageRuntimeContext', () => { const second = resolveNativeChatImageRuntimeContext(storeState, 'tab-1') expect(first).not.toBeNull() - expect(second?.settings).toBe(first?.settings) + expect(second?.target).toBe(first?.target) expect(shallow(second, first)).toBe(true) }) @@ -91,7 +91,7 @@ describe('resolveNativeChatImageRuntimeContext', () => { worktreeId: 'wt-1', worktreePath: '/repo/worktree', expectedExecutionHostId: 'local', - settings: { activeRuntimeEnvironmentId: 'owner-a' } + target: { kind: 'environment' as const, environmentId: 'owner-a' } }) }) @@ -136,7 +136,7 @@ describe('resolveNativeChatImageRuntimeContext', () => { worktreeId: FLOATING_TERMINAL_WORKTREE_ID, worktreePath: '/home/me/pinned', expectedExecutionHostId: 'local', - settings: { activeRuntimeEnvironmentId: null } + target: { kind: 'local' as const } }) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts index b6ca7df8ec9..72b21596232 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts @@ -2,7 +2,10 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { - settingsForWorktreeOperationRoute, + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' +import { resolveWorktreeOperationRouteResult, type WorktreeOperationRouteResolution } from '@/lib/worktree-operation-route' @@ -70,38 +73,21 @@ const FLOATING_WORKSPACE_ROUTE: WorktreeOperationRouteResolution = { route: { executionHostId: 'local', runtimeEnvironmentId: null } } -// Route settings are cloned for the runtime operation contract. Reuse that -// clone while the store's source settings and selected runtime are unchanged so -// consumers do not treat an unrelated store update as a new image owner. -const settingsBySource = new WeakMap>() +// Why stable: consumers compare by identity, so an unrelated store update must not read as a +// new image owner. +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } +const environmentTargets = new Map() -function stableSettingsForRoute( - settings: AppState['settings'], - runtimeEnvironmentId: string | null -): AppState['settings'] { - if (!settings) { - return settingsForWorktreeOperationRoute(settings, { - executionHostId: null, - runtimeEnvironmentId - }) +function stableTargetForRoute(runtimeEnvironmentId: string | null): RuntimeClientTarget { + if (!runtimeEnvironmentId) { + return LOCAL_TARGET } - const source = settings as object - let byRuntime = settingsBySource.get(source) - if (!byRuntime) { - byRuntime = new Map() - settingsBySource.set(source, byRuntime) + let target = environmentTargets.get(runtimeEnvironmentId) + if (!target) { + target = runtimeTargetForOwnerEnvironment(runtimeEnvironmentId) + environmentTargets.set(runtimeEnvironmentId, target) } - const cacheKey = runtimeEnvironmentId ?? '' - const cached = byRuntime.get(cacheKey) - if (cached) { - return cached - } - const resolved = settingsForWorktreeOperationRoute(settings, { - executionHostId: null, - runtimeEnvironmentId - }) - byRuntime.set(cacheKey, resolved) - return resolved + return target } export function resolveNativeChatImageRuntimeContext( @@ -139,7 +125,7 @@ export function resolveNativeChatImageRuntimeContext( return null } const context: RuntimeFileOperationArgs = { - settings: stableSettingsForRoute(state.settings, route.runtimeEnvironmentId), + target: stableTargetForRoute(route.runtimeEnvironmentId), worktreeId: linkContext.worktreeId, worktreePath, expectedExecutionHostId: host.kind === 'ssh' ? host.id : 'local' diff --git a/src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx b/src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx index 95e46d2faea..cd44734578b 100644 --- a/src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list-remote-catalog.react.test.tsx @@ -57,7 +57,10 @@ describe.each(['paired-detected', 'legacy-ssh'] as const)('%s file catalog', (ow expect.objectContaining({ worktreeId: WORKTREE_ID, worktreePath: '/srv/worktree', - settings: { activeRuntimeEnvironmentId: owner === 'paired-detected' ? 'hub' : null }, + target: + owner === 'paired-detected' + ? { kind: 'environment', environmentId: 'hub' } + : { kind: 'local' }, connectionId: owner === 'legacy-ssh' ? 'box' : undefined }), expect.objectContaining({ rootPath: '/srv/worktree' }) diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index b7e9070cef5..716b425ad93 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -98,7 +98,7 @@ describe('useRuntimeFileListForWorktree', () => { worktreeId: workspaceKey, worktreePath: '/srv/platform', connectionId: 'ssh-1', - settings: expect.objectContaining({ activeRuntimeEnvironmentId: null }) + target: { kind: 'local' } }), { includeIgnored: true, @@ -170,7 +170,7 @@ describe('useRuntimeFileListForWorktree', () => { expect(searchRuntimeFilePathsMock).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: workspaceKey, worktreePath: '/srv/platform' }), @@ -300,7 +300,7 @@ describe('useRuntimeFileListForWorktree', () => { expect(searchRuntimeFilePathsMock).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'wt-remote', worktreePath: '/srv/remote' }), diff --git a/src/renderer/src/components/quick-open-file-list.ts b/src/renderer/src/components/quick-open-file-list.ts index 4217b51f40c..ffc880c1b4b 100644 --- a/src/renderer/src/components/quick-open-file-list.ts +++ b/src/renderer/src/components/quick-open-file-list.ts @@ -38,6 +38,7 @@ import { getFileExplorerOwnerUnresolvedMessage, getFileExplorerOperationRoute } from '@/components/right-sidebar/file-explorer-operation-owner' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export type RuntimeFileListState = { files: string[] @@ -116,7 +117,8 @@ export function useRuntimeFileListForWorktree({ const operationRoute = getFileExplorerOperationRoute(operationOwner) const operationRouteAvailable = operationRoute !== null const connectionId = operationRoute?.connectionId - const runtimeEnvironmentId = operationRoute?.settings.activeRuntimeEnvironmentId ?? null + const runtimeEnvironmentId = + operationOwner.kind === 'runtime' ? operationOwner.environmentId : null const activeTargetStatus = useAppStore((state) => connectionId ? state.sshConnectionStates.get(connectionId)?.status : undefined ) @@ -189,7 +191,7 @@ export function useRuntimeFileListForWorktree({ const requestAbortController = new AbortController() const requestOperationOwner = operationOwnerRef.current const requestContext = { - settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), worktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/quick-open-file-navigation.test.ts b/src/renderer/src/components/quick-open-file-navigation.test.ts index 8e671882079..47d7deb5514 100644 --- a/src/renderer/src/components/quick-open-file-navigation.test.ts +++ b/src/renderer/src/components/quick-open-file-navigation.test.ts @@ -2,10 +2,10 @@ import { beforeEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { const route: { current: { - settings: { activeRuntimeEnvironmentId: string | null } + target: { kind: 'local' } | { kind: 'environment'; environmentId: string } connectionId: string | undefined } - } = { current: { settings: { activeRuntimeEnvironmentId: null }, connectionId: undefined } } + } = { current: { target: { kind: 'local' }, connectionId: undefined } } return { stat: vi.fn(), authorize: vi.fn(), @@ -40,10 +40,6 @@ vi.mock('./right-sidebar/file-explorer-operation-owner', () => ({ }) })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - getActiveRuntimeTarget: (settings: { activeRuntimeEnvironmentId: string | null }) => - settings.activeRuntimeEnvironmentId - ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } - : { kind: 'local' }, callRuntimeRpc: mocks.rpc })) vi.mock('@/store/slices/editor/focus/editor-focus-reveal', () => ({ @@ -53,7 +49,7 @@ import { openQuickOpenFile } from './quick-open-file-navigation' beforeEach(() => { vi.clearAllMocks() - mocks.route.current = { settings: { activeRuntimeEnvironmentId: null }, connectionId: undefined } + mocks.route.current = { target: { kind: 'local' }, connectionId: undefined } mocks.stat.mockResolvedValue({ isDirectory: false }) mocks.assertCurrent.mockReset() vi.stubGlobal('window', { api: { fs: { authorizeExternalPath: mocks.authorize } } }) @@ -129,7 +125,7 @@ it('probes SSH paths only on their owning host', async () => { }) it('resolves paired paths on the host and never falls back to client filesystem access', async () => { - mocks.route.current.settings.activeRuntimeEnvironmentId = 'env-a' + mocks.route.current.target = { kind: 'environment', environmentId: 'env-a' } mocks.rpc.mockResolvedValue({ exists: true, isDirectory: false, @@ -189,7 +185,7 @@ it.each(['C:\\repo\\file.ts', '\\\\server\\share\\file.ts'])( ) it('resolves literal colon names on a paired host before treating the suffix as navigation', async () => { - mocks.route.current.settings.activeRuntimeEnvironmentId = 'env-a' + mocks.route.current.target = { kind: 'environment', environmentId: 'env-a' } mocks.rpc.mockResolvedValueOnce({ exists: true, isDirectory: false, diff --git a/src/renderer/src/components/quick-open-file-navigation.ts b/src/renderer/src/components/quick-open-file-navigation.ts index 6610987db66..96fe801d7df 100644 --- a/src/renderer/src/components/quick-open-file-navigation.ts +++ b/src/renderer/src/components/quick-open-file-navigation.ts @@ -4,7 +4,7 @@ import { detectLanguage } from '@/lib/language-detect' import { joinPath, getRelativePathInsideRoot } from '@/lib/path' import { useAppStore } from '@/store' import { isMissingRuntimePathError } from '@/runtime/runtime-file-client' -import { getActiveRuntimeTarget, callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import type { RuntimeTerminalPathResolution } from '../../../shared/runtime-file-contracts' import { resolveWslRepoWorktreeBasePath } from '../../../shared/wsl-paths' @@ -36,7 +36,7 @@ export async function openQuickOpenFile( } assertCurrent() const route = guard.route - const target = getActiveRuntimeTarget(route.settings) + const target = route.target const resolvePastedPath = (path: string): string => target.kind !== 'environment' && !route.connectionId && @@ -125,7 +125,7 @@ export async function openQuickOpenFile( filePath, relativePath, worktreeId, - runtimeEnvironmentId: route.settings.activeRuntimeEnvironmentId, + runtimeEnvironmentId: target.kind === 'environment' ? target.environmentId : null, ...(route.connectionId && getRelativePathInsideRoot(filePath, root) === null ? { externalSshTargetId: route.connectionId } : {}), diff --git a/src/renderer/src/components/quick-open-recent-validation.test.ts b/src/renderer/src/components/quick-open-recent-validation.test.ts index 3606cb3d28f..1486189efd8 100644 --- a/src/renderer/src/components/quick-open-recent-validation.test.ts +++ b/src/renderer/src/components/quick-open-recent-validation.test.ts @@ -20,7 +20,7 @@ beforeEach(() => { vi.clearAllMocks() }) const context = { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt', worktreePath: '/repo' } diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx index 3d419c45227..08eab39b9e6 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx @@ -89,7 +89,7 @@ describe('FileExplorerRow collapse folder action', () => { it('shows remote download only for desktop SSH rows and file-like Remote Host rows', () => { const runtimeContext = { - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + target: { kind: 'environment' as const, environmentId: 'runtime-1' }, worktreeId: 'wt-1', worktreePath: '/repo' } @@ -290,7 +290,7 @@ describe('FileExplorerRow collapse folder action', () => { it('downloads Remote Host rows through the runtime download path', async () => { const runtimeContext = { - settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + target: { kind: 'environment' as const, environmentId: 'runtime-1' }, worktreeId: 'wt-1', worktreePath: '/repo' } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts b/src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts index f35c2e8c2de..94ea2e823b3 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-deletion-editors.ts @@ -3,6 +3,7 @@ import type { OpenFile } from '@/store/slices/editor' import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' import type { FileExplorerOperationRoute } from './file-explorer-operation-owner' import { isPathEqualOrDescendant } from './file-explorer-paths' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' export type FileExplorerDeletionEditor = Pick< OpenFile, @@ -29,8 +30,7 @@ export function getFileExplorerDeletionEditors( const owner = getEditorFileOperationContext(state, file, null) // Identical paths on different hosts must not trigger another editor's save or close. return ( - (owner.settings?.activeRuntimeEnvironmentId?.trim() || null) === - (route.settings.activeRuntimeEnvironmentId?.trim() || null) && + runtimeTargetEnvironmentId(owner.target) === runtimeTargetEnvironmentId(route.target) && owner.expectedExecutionHostId === route.expectedExecutionHostId && owner.connectionId === route.connectionId ) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts index f42108ff430..e8b04d367bc 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-directory-listing.ts @@ -49,7 +49,7 @@ export async function readFileExplorerDirectory( } const entries = await readRuntimeDirectory( { - settings: route.settings, + target: route.target, worktreeId: activeWorktreeId, worktreePath, connectionId: route.connectionId diff --git a/src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts index 3ba85c87e28..f4b2a802943 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-instant-dir-toggle.test.ts @@ -11,7 +11,7 @@ import type { TreeNode } from './file-explorer-types' vi.mock('./file-explorer-operation-owner', () => ({ getFileExplorerOwnerUnresolvedMessage: () => 'unresolved', - requireMatchingFileExplorerOperationRoute: () => ({ settings: {} }) + requireMatchingFileExplorerOperationRoute: () => ({ target: { kind: 'local' } }) })) vi.mock('@/store', () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts index 75b82277e66..88b41e66524 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-generation.test.ts @@ -41,7 +41,7 @@ describe('file explorer operation generations', () => { expect( getFileExplorerOperationOwnerFromState(useAppStore.getState(), FLOATING_TERMINAL_WORKTREE_ID) ).toEqual(owner) - expect(guard.route.settings.activeRuntimeEnvironmentId).toBeNull() + expect(guard.route.target).toEqual({ kind: 'local' }) expect(guard.route.expectedExecutionHostId).toBe('local') expect(() => guard.assertCurrent()).not.toThrow() }) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts index 2c52ad62895..9c6358c9b62 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -11,8 +11,9 @@ import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { translate } from '@/i18n/i18n' import { getExplicitRuntimeEnvironmentIdForWorktree, - getSettingsForWorktreeRuntimeOwner + getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { FileExplorerOperationOwner } from './file-explorer-types' import { getFloatingWorkspaceOperationRoute, @@ -24,7 +25,7 @@ import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operatio const ownerUnresolved = (): Error => new Error(getFileExplorerOwnerUnresolvedMessage()) export type FileExplorerOperationRoute = { - settings: { activeRuntimeEnvironmentId: string | null } + target: RuntimeClientTarget connectionId?: string expectedExecutionHostId?: 'local' | `ssh:${string}` expectedSshTargetId?: string @@ -82,13 +83,12 @@ export function getFileExplorerOperationOwnerFromState( if (connectionId === undefined && explicitRuntimeEnvironmentId === null) { return { kind: 'unresolved' } } - const settings = getSettingsForWorktreeRuntimeOwner(state, worktreeId) - // Why: inferred SSH ownership outranks global runtime focus, but an explicit + // Why: inferred SSH ownership outranks a projected runtime owner, but an explicit // workspace runtime still owns its files. const runtimeEnvironmentId = connectionId && explicitRuntimeEnvironmentId === null ? null - : settings.activeRuntimeEnvironmentId?.trim() + : getRuntimeEnvironmentIdForWorktree(state, worktreeId)?.trim() if (runtimeEnvironmentId) { return { kind: 'runtime', @@ -114,19 +114,19 @@ export function getFileExplorerOperationRoute( switch (owner.kind) { case 'local': return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, expectedExecutionHostId: 'local' } case 'ssh': return { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, connectionId: owner.connectionId, expectedExecutionHostId: toSshExecutionHostId(owner.connectionId) } case 'runtime': { const host = parseExecutionHostId(owner.executionHostId) return { - settings: { activeRuntimeEnvironmentId: owner.environmentId }, + target: { kind: 'environment', environmentId: owner.environmentId }, ...(host?.kind === 'ssh' ? { expectedExecutionHostId: host.id } : { expectedExecutionHostId: 'local' as const }) diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts index 2a50566fc84..ae251541dbd 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts @@ -20,6 +20,7 @@ import { useFileExplorerTreeLoadEffects } from './use-file-explorer-tree-load-ef import { useFileExplorerWatch } from './useFileExplorerWatch' import type { useFileExplorerSelection } from './useFileExplorerSelection' import type { useFileExplorerTree } from './useFileExplorerTree' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' type UseFileExplorerTreePaneStateParams = { onRevealOutsideRoot?: () => void @@ -127,7 +128,7 @@ export function useFileExplorerTreePaneState({ ? s.sshConnectionStates.get(connectionId)?.supportsFolderDownload === true : false }) - const activeRuntimeEnvironmentId = useAppStore((s) => + const ownerRuntimeEnvironmentId = useAppStore((s) => getRuntimeEnvironmentIdForWorktree(s, activeWorktreeId) ) const toggleDir = useAppStore((s) => s.toggleDir) @@ -138,15 +139,15 @@ export function useFileExplorerTreePaneState({ const runtimeDownloadContext = useMemo( () => - activeRuntimeEnvironmentId && activeWorktreeId && worktreePath + ownerRuntimeEnvironmentId && activeWorktreeId && worktreePath ? { - settings: { activeRuntimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(ownerRuntimeEnvironmentId), worktreeId: activeWorktreeId, worktreePath, connectionId: activeRepo?.connectionId ?? undefined } : null, - [activeRepo?.connectionId, activeRuntimeEnvironmentId, activeWorktreeId, worktreePath] + [activeRepo?.connectionId, ownerRuntimeEnvironmentId, activeWorktreeId, worktreePath] ) const isWindows = useMemo(() => navigator.userAgent.includes('Windows'), []) @@ -249,7 +250,7 @@ export function useFileExplorerTreePaneState({ const handlers = useFileExplorerHandlers({ activeWorktreeId, - runtimeEnvironmentId: activeRuntimeEnvironmentId, + runtimeEnvironmentId: ownerRuntimeEnvironmentId, openFile, makePreviewFilePermanent, toggleDir: hasNameFilter ? handleToggleNameFilterDir : toggleDir, diff --git a/src/renderer/src/components/right-sidebar/useFileDeletion.ts b/src/renderer/src/components/right-sidebar/useFileDeletion.ts index 47f895aaecd..05e78656192 100644 --- a/src/renderer/src/components/right-sidebar/useFileDeletion.ts +++ b/src/renderer/src/components/right-sidebar/useFileDeletion.ts @@ -130,7 +130,7 @@ export function useFileDeletion({ const state = useAppStore.getState() const worktree = activeWorktreeId ? state.getKnownWorktreeById(activeWorktreeId) : null const fileContext = { - settings: operationRoute.settings, + target: operationRoute.target, worktreeId: activeWorktreeId, worktreePath: worktree?.path ?? null, connectionId: operationRoute.connectionId, @@ -147,7 +147,7 @@ export function useFileDeletion({ if (!node.isDirectory) { try { const rf = await readRuntimeFileContent({ - settings: fileContext.settings, + target: fileContext.target, filePath: node.path, relativePath: node.relativePath, worktreeId: activeWorktreeId ?? undefined, @@ -172,7 +172,7 @@ export function useFileDeletion({ await writeRuntimeFile( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, node.path, @@ -185,7 +185,7 @@ export function useFileDeletion({ await deleteRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, node.path, diff --git a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts index 1b7a19f9ca6..63d53366cb7 100644 --- a/src/renderer/src/components/right-sidebar/useFileDuplicate.ts +++ b/src/renderer/src/components/right-sidebar/useFileDuplicate.ts @@ -49,7 +49,7 @@ export function useFileDuplicate({ return } const context = { - settings: operationGuard.route.settings, + target: operationGuard.route.target, worktreeId: activeWorktreeId, worktreePath, connectionId: operationGuard.route.connectionId, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts index 3464763cf55..5ae97cf1248 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerHandlers.ts @@ -11,6 +11,7 @@ import { getFileExplorerOwnerUnresolvedMessage, requireMatchingFileExplorerOperationRoute } from './file-explorer-operation-owner' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' type UseFileExplorerHandlersParams = { activeWorktreeId: string | null @@ -133,7 +134,7 @@ export async function activateFileExplorerNode(args: { let fileRuntimeEnvironmentId: string | null try { const route = requireMatchingFileExplorerOperationRoute(activeWorktreeId, node.operationOwner) - fileRuntimeEnvironmentId = route.settings.activeRuntimeEnvironmentId?.trim() || null + fileRuntimeEnvironmentId = runtimeTargetEnvironmentId(route.target) } catch { toast.error(getFileExplorerOwnerUnresolvedMessage()) return diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx index fd1b68d8a2f..11a76e8fd31 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.remote.test.tsx @@ -89,7 +89,7 @@ describe('file explorer OS file drops on remote workspaces', () => { worktreeId: 'wt-ssh', worktreePath: SSH_ROOT, connectionId: 'ssh-1', - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, expectedExecutionHostId: 'ssh:ssh-1', expectedSshTargetId: 'ssh-1', expectedSshConnectionGeneration: 3 @@ -104,7 +104,7 @@ describe('file explorer OS file drops on remote workspaces', () => { await waitFor(() => expect(mocks.importPaths).toHaveBeenCalledTimes(1)) expect(mocks.importPaths.mock.calls[0][0]).toMatchObject({ worktreeId: 'wt-runtime', - settings: { activeRuntimeEnvironmentId: 'owner-runtime' } + target: { kind: 'environment', environmentId: 'owner-runtime' } }) expect(mocks.importPaths.mock.calls[0][2]).toBe(joinPath(RUNTIME_ROOT, 'src')) }) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts index 537647ac635..165a2ecdfed 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts @@ -102,7 +102,7 @@ export function useFileExplorerImport({ guard.assertCurrent() const { results } = await importExternalPathsToRuntime( { - settings: guard.route.settings, + target: guard.route.target, worktreeId: destination.worktreeId, worktreePath: destination.worktreePath, connectionId: guard.route.connectionId, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts index ed197871579..61726c47563 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts @@ -14,6 +14,7 @@ import { captureFileExplorerOperationGuard, getFileExplorerOperationOwner } from './file-explorer-operation-owner' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' type UseFileExplorerInlineInputParams = { activeWorktreeId: string | null @@ -138,7 +139,7 @@ export function useFileExplorerInlineInput({ ) const operationRoute = operationGuard.route const fileContext = { - settings: operationRoute.settings, + target: operationRoute.target, worktreeId: activeWorktreeId, worktreePath, connectionId: operationRoute.connectionId, @@ -160,7 +161,7 @@ export function useFileExplorerInlineInput({ await deleteRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, fullPath, @@ -173,7 +174,7 @@ export function useFileExplorerInlineInput({ await createRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, fullPath, @@ -189,7 +190,7 @@ export function useFileExplorerInlineInput({ await deleteRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, fullPath @@ -201,7 +202,7 @@ export function useFileExplorerInlineInput({ await createRuntimePath( { ...fileContext, - settings: currentRoute.settings, + target: currentRoute.target, connectionId: currentRoute.connectionId }, fullPath, @@ -213,8 +214,7 @@ export function useFileExplorerInlineInput({ } await refreshDir(inlineInput.parentPath) if (inlineInput.type === 'file') { - const runtimeEnvironmentId = - fileContext.settings.activeRuntimeEnvironmentId?.trim() || null + const runtimeEnvironmentId = runtimeTargetEnvironmentId(fileContext.target) openFile( { filePath: fullPath, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts b/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts index b917353240b..6cde5043b4e 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerMoveDrop.ts @@ -58,7 +58,7 @@ export function useFileExplorerMoveDrop({ const operationGuard = captureFileExplorerOperationGuard(activeWorktreeId, operationOwner) const operationRoute = operationGuard.route const fileContext = { - settings: operationRoute.settings, + target: operationRoute.target, worktreeId: activeWorktreeId, worktreePath, connectionId: operationRoute.connectionId, diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index 0bea31f44c6..b6b8605e2b0 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -195,7 +195,7 @@ export function useFileExplorerTree( } return statUserOpenedPath( { - settings: route.settings, + target: route.target, worktreeId: activeWorktreeId, worktreePath, connectionId: route.connectionId diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx index 7c02e1a8e69..c7d9bd65d3e 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.lifecycle.test.tsx @@ -121,7 +121,7 @@ it('invalidates completed results and errors when the execution owner changes at ) await act(async () => vi.advanceTimersByTimeAsync(300)) expect(search).toHaveBeenCalledTimes(2) - expect(search.mock.calls[1][0].settings.activeRuntimeEnvironmentId).toBe('remote-a') + expect(search.mock.calls[1][0].target).toEqual({ kind: 'environment', environmentId: 'remote-a' }) expect( useAppStore.getState().fileSearchStateByWorktree['folder:a'].resultOwner?.executionHostId ).toBe('runtime:remote-a') diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx index 8bee124361c..b183f717d8e 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.test.tsx @@ -84,7 +84,7 @@ describe('useFileSearchRunner result ownership', () => { expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'search-runtime-a' }, + target: { kind: 'environment', environmentId: 'search-runtime-a' }, worktreeId }), expect.any(Object), @@ -116,7 +116,7 @@ describe('useFileSearchRunner result ownership', () => { await finishSearch(hook.result.current.executeSearch) expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null }, worktreeId }), + expect.objectContaining({ target: { kind: 'local' }, worktreeId }), expect.any(Object), expect.any(AbortSignal) ) @@ -148,7 +148,7 @@ describe('useFileSearchRunner result ownership', () => { expect(mocks.searchRuntimeFiles).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, worktreeId, connectionId: 'ssh-target' }), diff --git a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts index 23571567d98..c4c6069f185 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchRunner.ts @@ -17,6 +17,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { SearchResult } from '../../../../shared/code-search-types' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' const SEARCH_DEBOUNCE_MS = 300 const SEARCH_MAX_RESULTS = 2000 @@ -95,10 +96,9 @@ export function useFileSearchRunner({ excludePattern: currentSearchState?.excludePattern || undefined }) ) { - const runtimeSettings = { activeRuntimeEnvironmentId: runtimeEnvironmentId } updateActiveSearchState({ results: createEmptyRuntimeFileSearchResult(), - resultOwner: createFileSearchResultOwner(activeWorktreeId, runtimeSettings, { + resultOwner: createFileSearchResultOwner(activeWorktreeId, runtimeEnvironmentId, { rootPath: worktreePath, executionHostId }), @@ -116,8 +116,7 @@ export function useFileSearchRunner({ searchTimerRef.current = setTimeout(async () => { searchTimerRef.current = null // Why: results can outlive the selected worktree; clicks must reuse the route that produced them. - const runtimeSettings = { activeRuntimeEnvironmentId: runtimeEnvironmentId } - const resultOwner = createFileSearchResultOwner(activeWorktreeId, runtimeSettings, { + const resultOwner = createFileSearchResultOwner(activeWorktreeId, runtimeEnvironmentId, { rootPath: worktreePath, executionHostId }) @@ -145,7 +144,7 @@ export function useFileSearchRunner({ } const results = await searchRuntimeFiles( { - settings: runtimeSettings, + target: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), worktreeId: activeWorktreeId, worktreePath, connectionId diff --git a/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx b/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx index d09d7dd0a12..d67aeb81def 100644 --- a/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.host-owner.test.tsx @@ -75,7 +75,7 @@ it('inspects and creates a managed-host repo config on that host, never the desk await waitFor(() => expect(runtimeFiles.readRuntimeDirectory).toHaveBeenCalled()) expect(runtimeFiles.readRuntimeDirectory).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'host-a' }, + target: { kind: 'environment', environmentId: 'host-a' }, worktreeId, worktreePath: root }), @@ -89,7 +89,7 @@ it('inspects and creates a managed-host repo config on that host, never the desk await waitFor(() => expect(runtimeFiles.writeRuntimeFile).toHaveBeenCalled()) expect(runtimeFiles.writeRuntimeFile).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: 'host-a' } }), + expect.objectContaining({ target: { kind: 'environment', environmentId: 'host-a' } }), `${root}/.mcp.json`, expect.any(String) ) @@ -109,7 +109,7 @@ it('keeps a desktop repo on the desktop while a managed host is focused', async await waitFor(() => expect(runtimeFiles.readRuntimeDirectory).toHaveBeenCalled()) expect(runtimeFiles.readRuntimeDirectory).toHaveBeenCalledWith( - expect.objectContaining({ settings: { activeRuntimeEnvironmentId: null } }), + expect.objectContaining({ target: { kind: 'local' } }), root ) }) diff --git a/src/renderer/src/components/settings/McpConfigSection.tsx b/src/renderer/src/components/settings/McpConfigSection.tsx index 162a780c426..be54a2b6be3 100644 --- a/src/renderer/src/components/settings/McpConfigSection.tsx +++ b/src/renderer/src/components/settings/McpConfigSection.tsx @@ -33,6 +33,7 @@ import { loadMcpConfigInspections } from './mcp-config-inspection' import { translate } from '@/i18n/i18n' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' import { writeRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' type McpConfigSectionProps = { repo: Repo @@ -100,7 +101,7 @@ export function McpConfigSection({ repo }: McpConfigSectionProps): React.JSX.Ele const targetHostId = getWorktreeExecutionHostId(targetWorktree, repo) const fileContext = useMemo( (): RuntimeFileOperationArgs => ({ - settings: { activeRuntimeEnvironmentId: runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(runtimeEnvironmentId), worktreeId: targetWorktreeId, worktreePath: targetRootPath, connectionId diff --git a/src/renderer/src/components/settings/mcp-config-inspection.ts b/src/renderer/src/components/settings/mcp-config-inspection.ts index ff917414466..3e5e1baced3 100644 --- a/src/renderer/src/components/settings/mcp-config-inspection.ts +++ b/src/renderer/src/components/settings/mcp-config-inspection.ts @@ -77,7 +77,7 @@ export async function loadMcpConfigInspections( try { const result = await readRuntimeFileContent({ - settings: context.settings, + target: context.target, worktreeId: context.worktreeId ?? undefined, filePath: absolutePath, relativePath: candidate.relativePath, diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index 93d007adf31..221208bc655 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -26,7 +26,7 @@ describe('openTabEntryWithOperations', () => { groupId: 'group-1', worktreePath: '/repo', runtimeContext: { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts index be2dddee268..a66d9ea1339 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-local-path.ts @@ -5,6 +5,7 @@ import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-e import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import type { useAppStore } from '@/store' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' type TabEntryAbsolutePathOwnerState = Pick< ReturnType, @@ -23,12 +24,11 @@ type TabEntryAbsolutePathOwnerState = Pick< > export function isTabEntryAbsolutePathAllowed( - context: Pick + context: Pick ): boolean { - // Why: file-operation contexts pin `settings` to the route owner, never to the focused server. return !isLocalPathOpenBlocked( getLocalPathOpenOwnerForRoute({ - runtimeEnvironmentId: context.settings?.activeRuntimeEnvironmentId, + runtimeEnvironmentId: runtimeTargetEnvironmentId(context.target), connectionId: context.connectionId }) ) @@ -69,9 +69,7 @@ export function getTabEntryFileOperationContext( getFolderWorkspaceConnectionId(state, workspaceKey.folderWorkspaceId) === null ) { return { - settings: state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: null } - : { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' }, worktreeId, worktreePath, expectedExecutionHostId: 'local' diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index d0c416bf27b..2602ef07df2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -159,7 +159,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo', expectedExecutionHostId: 'local', @@ -262,7 +262,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment', environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '//server/share/repo', expectedExecutionHostId: 'local', @@ -326,7 +326,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.importExternalPathsToRuntime).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + target: { kind: 'environment', environmentId: 'owner-runtime' }, worktreeId: 'wt-1', worktreePath: '/remote/repo', expectedExecutionHostId: 'local', diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts index df4ace370da..d468f9c69f4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts @@ -167,7 +167,7 @@ describe.each(['native', 'internal'])('%s runtime terminal catalog ownership', ( if (lane === 'native') { expect(mocks.importPaths).toHaveBeenCalledExactlyOnceWith( { - settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + target: { kind: 'environment', environmentId: 'owner-runtime' }, worktreeId: workspaceId, worktreePath: '/owner/workspace', expectedExecutionHostId: executionHostId, diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts index 6c1d1a3d038..15d6d5f8bca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-target.test.ts @@ -15,7 +15,7 @@ const routing = vi.hoisted(() => { vi.mock('./terminal-file-path-mapping', async (importOriginal) => ({ ...(await importOriginal()), getTerminalFileContext: (worktreeId: string, worktreePath: string) => ({ - settings: null, + target: { kind: 'local' }, worktreeId, worktreePath, connectionId: routing.connectionId diff --git a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts index 582fc02a864..3596fd9eeea 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-open-routing.ts @@ -23,7 +23,7 @@ import { } from '../../../../shared/execution-host' import { statUserOpenedPath } from '@/lib/user-opened-local-path' import { - CLIENT_LOCAL_FILE_SETTINGS, + CLIENT_LOCAL_FILE_TARGET, resolveHostWorkspaceFile, type HostWorkspaceFile } from './terminal-host-workspace-file' @@ -36,6 +36,7 @@ export { terminalPathWslDistro, type TerminalFileContext } from './terminal-file-path-mapping' +import { runtimeTargetEnvironmentId } from '@/runtime/runtime-client-target' export type FileOpenFailure = { /** `missing` is a verified absence; `unverifiable` means the host could not answer (dropped SSH, timeout, denied path). */ @@ -164,7 +165,7 @@ export function openDetectedFilePath( clientLocal = true fileContext = { ...fileContext, - settings: CLIENT_LOCAL_FILE_SETTINGS, + target: CLIENT_LOCAL_FILE_TARGET, connectionId: undefined } statResult = { isDirectory: linkOwner.isDirectory, escapesWorktree: false } @@ -254,7 +255,7 @@ export function openDetectedFilePath( ) ) { // Why: early resolution is only needed to avoid an existing sibling-tab collision. - const runtimeOwnerId = fileContext.settings?.activeRuntimeEnvironmentId?.trim() + const runtimeOwnerId = runtimeTargetEnvironmentId(fileContext.target) const executionHostId = runtimeOwnerId ? toRuntimeExecutionHostId(runtimeOwnerId) : getConnectionExecutionHostId(fileContext.connectionId) @@ -289,7 +290,7 @@ export function openDetectedFilePath( // Why: absolute SSH paths outside the worktree otherwise look identical // to client-local external files when the editor reloads or restores. ...(relativePath === filePath && - !fileContext.settings?.activeRuntimeEnvironmentId?.trim() && + fileContext.target.kind === 'local' && fileContext.connectionId ? { externalSshTargetId: fileContext.connectionId } : {}) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts b/src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts index 56a73ce0be2..0d03dc73a0c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-path-mapping.ts @@ -2,8 +2,8 @@ import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route' import { buildWorkspaceFileContext } from '@/lib/workspace-file-host-routing' +import { getRuntimeTargetForWorktreeOwner } from '@/lib/file-owner-runtime-target' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { parseExecutionHostId } from '../../../../shared/execution-host' import { parseWslUncPath, toWindowsWslPath } from '../../../../shared/wsl-paths' import { terminalFileSourceHost } from './terminal-worktree-path-link' @@ -19,8 +19,21 @@ export function getTerminalFileContext( worktreePath: string, runtimeEnvironmentId?: string | null ): TerminalFileContext { - const context = buildWorkspaceFileContext(worktreeId, worktreePath, runtimeEnvironmentId) const state = useAppStore.getState() + if ( + runtimeEnvironmentId === undefined && + worktreeId && + !getRuntimeTargetForWorktreeOwner(state, worktreeId) + ) { + // Why: rows disagree on the owner; the local target is a placeholder every caller refuses. + return { + target: { kind: 'local' }, + worktreeId, + worktreePath, + sourceHostResolved: false + } + } + const context = buildWorkspaceFileContext(worktreeId, worktreePath, runtimeEnvironmentId) const sourceHost = terminalFileSourceHost(state, context) if (!sourceHost) { // Why: the connection lookup ignores detected rows, so its local `null` must not override an @@ -35,10 +48,7 @@ export function getTerminalFileContext( } // Why: same-id rows on several hosts leave connectionId unset, which reads downstream as local; // the resolved owner names the direct SSH host. A paired runtime keeps its own transport. - const sshHost = - getActiveRuntimeTarget(context.settings).kind === 'environment' - ? null - : parseExecutionHostId(sourceHost) + const sshHost = context.target.kind === 'environment' ? null : parseExecutionHostId(sourceHost) return sshHost?.kind === 'ssh' && !context.connectionId ? { ...context, connectionId: sshHost.targetId, sourceHostResolved: true } : { ...context, sourceHostResolved: true } diff --git a/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts index a1f2233fbc3..9306f2b56b9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts +++ b/src/renderer/src/components/terminal-pane/terminal-host-workspace-file.ts @@ -1,5 +1,9 @@ import { callHostRoute } from '@/runtime/host-route-call' -import { hostRouteForAuthority } from '@/runtime/runtime-client-target' +import { + hostRouteForAuthority, + runtimeTargetEnvironmentId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { statRuntimePath } from '@/runtime/runtime-file-client' import { isRemoteRuntimeFileOperation } from '@/runtime/runtime-file-routing' @@ -26,7 +30,7 @@ export type HostWorkspaceFile = { /** A path the host disowned that this computer has; the user's click here is the only authority to read it. */ export type ClientLocalFile = { kind: 'client'; isDirectory: boolean } -export const CLIENT_LOCAL_FILE_SETTINGS = { activeRuntimeEnvironmentId: null } +export const CLIENT_LOCAL_FILE_TARGET: RuntimeClientTarget = { kind: 'local' } /** Strips the host's relative suffix; separators are swapped 1:1, so the slice stays byte-exact. */ function workspaceRootOf(absolutePath: string, relativePath: string): string | null { @@ -56,7 +60,7 @@ async function statClientLocalFile( } try { const stat = await statRuntimePath( - { settings: CLIENT_LOCAL_FILE_SETTINGS, worktreeId: null, worktreePath: null }, + { target: CLIENT_LOCAL_FILE_TARGET, worktreeId: null, worktreePath: null }, absolutePath, userNamedFileAccess() ) @@ -77,7 +81,7 @@ export async function resolveHostWorkspaceFile( context: RuntimeFileOperationArgs, absolutePath: string ): Promise { - const environmentId = context.settings?.activeRuntimeEnvironmentId?.trim() + const environmentId = runtimeTargetEnvironmentId(context.target) if ( !environmentId || !context.worktreeId || diff --git a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts index 2df62eb3e99..524f04b119e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-remote-runtime-ssh-open.test.ts @@ -53,9 +53,13 @@ vi.mock('@/lib/file-preview', () => ({ installTerminalLinkTestEnvironment(doubles) describe('handleOscLink', () => { - it('stats remote-runtime file links through the active runtime environment', async () => { + it("stats remote-runtime file links through the workspace owner's runtime, not the focused one", async () => { setPlatform('Macintosh') - storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } + // Before: the focused server (env-2) answered for a link the workspace owner (env-1) holds. + storeState.settings = { activeRuntimeEnvironmentId: 'env-2' } + storeState.worktreesByRepo = { + 'repo-1': [{ id: 'wt-1', path: '/tmp', repoId: 'repo-1', hostId: 'runtime:env-1' }] + } runtimeEnvironmentCallMock.mockResolvedValueOnce({ id: 'rpc-1', ok: true, diff --git a/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts b/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts index c29668543b6..8a99b9ada71 100644 --- a/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts +++ b/src/renderer/src/components/terminal-pane/terminal-native-file-drop-destination.ts @@ -48,7 +48,6 @@ function captureNativeTerminalFileDropDestination( } const dropTarget = captureTerminalDropTarget(pane, transport) const state = useAppStore.getState() - const settings = state.settings const owner = captureTerminalDropTransportOwner(transport) const worktreePath = resolveTerminalDropWorktreePath( worktreeId, @@ -80,7 +79,6 @@ function captureNativeTerminalFileDropDestination( pane, dataPaths: paths, dropTarget, - settings, owner, worktreePath, localWslDrop diff --git a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts index 9bd32dbe3cc..bf739d2ebdd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts +++ b/src/renderer/src/components/terminal-pane/terminal-native-file-drop.ts @@ -2,7 +2,6 @@ import { toast } from 'sonner' import { extractIpcErrorMessage } from '@/lib/ipc-error' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' -import type { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { isWslUncPath } from '../../../../shared/wsl-paths' import type { PtyTransport } from './pty-transport' @@ -23,10 +22,10 @@ import { showTerminalDropWriteFailure } from './terminal-drop-write-failure' import { joinRuntimeTerminalDropDir } from './terminal-drop-worktree-path' import type { captureTerminalDropTransportOwner } from './terminal-drop-transport-owner' import { toLocalWslDropPath } from './terminal-drop-local-wsl' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export async function deliverNativeTerminalFileDrop( args: NativeDropFlowArgs & { - settings: ReturnType['settings'] owner: ReturnType worktreeId: string localWslDrop: boolean @@ -40,7 +39,6 @@ export async function deliverNativeTerminalFileDrop( pane, dataPaths, dropTarget, - settings, owner, worktreePath, localWslDrop @@ -52,7 +50,6 @@ export async function deliverNativeTerminalFileDrop( manager, paneTransports, pane, - settings, tabId, worktreeId, worktreePath, @@ -127,7 +124,6 @@ export type NativeDropFlowArgs = { async function uploadRuntimeDropPaths( args: NativeDropFlowArgs & { runtimeEnvironmentId: string - settings: ReturnType['settings'] worktreeId: string } ): Promise { @@ -145,7 +141,7 @@ async function uploadRuntimeDropPaths( { // Why: drops into existing worktrees must follow the worktree owner, // not the currently focused host in the sidebar. - settings: { ...args.settings, activeRuntimeEnvironmentId: args.runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(args.runtimeEnvironmentId), worktreeId: args.worktreeId, worktreePath: args.worktreePath, expectedExecutionHostId: args.expectedExecutionHostId, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx index ca79f2e1d75..dc75f2320a3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx @@ -459,7 +459,7 @@ describe('terminal element file drops', () => { await settle() expect(mocks.importPaths).toHaveBeenCalledWith( expect.objectContaining({ - settings: { activeRuntimeEnvironmentId: 'owner-runtime' }, + target: { kind: 'environment', environmentId: 'owner-runtime' }, worktreePath: '/owner/workspace', expectedExecutionHostId: 'local' }), diff --git a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts index 2945a8c2500..f1650f49c6c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.test.ts @@ -3,7 +3,11 @@ import { createTerminalPathExistenceBatch } from '@/components/terminal-pane/ter import { requirePathExistenceResults } from '../../../../shared/path-existence-batch' const remote = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/runtime-path-existence-batch', () => ({ runtimePathsExist: remote })) -const context = { settings: null, worktreeId: 'folder:/work', worktreePath: '/work' } +const context = { + target: { kind: 'local' as const }, + worktreeId: 'folder:/work', + worktreePath: '/work' +} afterEach(() => { vi.unstubAllGlobals() remote.mockReset() @@ -26,11 +30,11 @@ it('isolates identical paths by runtime, worktree, connection, and hover turn', ) const enqueue = createTerminalPathExistenceBatch() const contexts = [ - { ...context, settings: { activeRuntimeEnvironmentId: 'a' } }, - { ...context, settings: { activeRuntimeEnvironmentId: 'b' } }, + { ...context, target: { kind: 'environment' as const, environmentId: 'a' } }, + { ...context, target: { kind: 'environment' as const, environmentId: 'b' } }, { ...context, - settings: { activeRuntimeEnvironmentId: 'a' }, + target: { kind: 'environment' as const, environmentId: 'a' }, worktreeId: 'folder:/other', worktreePath: '/other' }, diff --git a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts index 010fb36bf82..019153fab12 100644 --- a/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-path-existence-batch.ts @@ -4,7 +4,6 @@ import { } from '../../../../shared/path-existence-batch' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' import { runtimePathsExist } from '@/runtime/runtime-path-existence-batch' -import { getActiveRuntimeTarget } from '@/runtime/runtime-client-target' import { captureRuntimeEnvironmentRequestRevision } from '@/runtime/runtime-environment-revision' type PendingPath = { @@ -29,13 +28,13 @@ export function createTerminalPathExistenceBatch(): ( const groups = new Map() let queued = false return (context, path, remote) => { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target const pairingRevision = target.kind === 'environment' ? captureRuntimeEnvironmentRequestRevision(target.environmentId) : undefined const key = JSON.stringify([ - context.settings?.activeRuntimeEnvironmentId, + target.kind === 'environment' ? target.environmentId : null, context.worktreeId, context.worktreePath, context.connectionId, diff --git a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts index d29cca04ecd..7408dfd6c73 100644 --- a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.test.ts @@ -120,7 +120,7 @@ describe('resolveKnownWorktreeRootPathLink', () => { }) const remoteContext = (environmentId: string, worktreeId = 'source') => ({ - settings: { activeRuntimeEnvironmentId: environmentId }, + target: { kind: 'environment' as const, environmentId: environmentId }, worktreeId, worktreePath: '/source' }) @@ -166,7 +166,7 @@ describe('terminal root link ownership', () => { ] }) const context = { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'source', worktreePath: '/source', connectionId: 'alpha' @@ -259,7 +259,7 @@ describe('terminal root link ownership', () => { activeWorkspaceExecutionHostId: activeHost }) const sshContext = (state: Parameters[0]) => ({ - settings: null, + target: { kind: 'local' as const }, worktreeId: 'repo::/home/u/proj', worktreePath: '/home/u/proj', connectionId: getConnectionIdFromState(state, 'repo::/home/u/proj') ?? undefined @@ -288,7 +288,11 @@ describe('terminal root link ownership', () => { runtimeEnvironments: [], runtimeEnvironmentCatalogHydrated: false } - const context = { settings: null, worktreeId: 'restoring::/x', worktreePath: '/x' } + const context = { + target: { kind: 'local' as const }, + worktreeId: 'restoring::/x', + worktreePath: '/x' + } expect(resolveKnownWorktreeRootPathLink('/target', state, context)).toBeNull() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts index 07a7110682a..1c21f8a0f43 100644 --- a/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts +++ b/src/renderer/src/components/terminal-pane/terminal-worktree-path-link.ts @@ -8,7 +8,6 @@ import { type WorktreeOperationRoute, type WorktreeOperationRouteState } from '@/lib/worktree-operation-route' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { toRuntimeExecutionHostId, @@ -107,8 +106,8 @@ export function terminalFileSourceHost( state: WorktreeOperationRouteState, context: RuntimeFileOperationArgs | undefined ): ExecutionHostId | null { - const target = getActiveRuntimeTarget(context?.settings) - if (target.kind === 'environment') { + const target = context?.target + if (target?.kind === 'environment') { const sourceRoute = context?.worktreeId ? resolveWorktreeOperationRouteResult(state, context.worktreeId) : null diff --git a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts index 796cd6b2b48..599f6f579ea 100644 --- a/src/renderer/src/hooks/composer-state/attachment-drop-state.ts +++ b/src/renderer/src/hooks/composer-state/attachment-drop-state.ts @@ -33,6 +33,7 @@ import { import { applyComposerNativeFileDrop } from '../composer-native-file-drop' import { useMountedRef } from '../useMountedRef' import { userNamedFileAccess } from '@/lib/local-file-access' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' // Local drops bypass the runtime importer's skip classification. function localDropFailure(detail: string | undefined): ComposerDropFailure { @@ -131,7 +132,9 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { targetWorktreeId: string | null | undefined = selectedWorktreeId, canReportFailure: () => boolean = () => true ): Promise<{ filePaths: string[]; folderPaths: string[] } | null> => { - if (!targetSettings?.activeRuntimeEnvironmentId?.trim() && !targetConnectionId) { + // Repo-owner settings: the selected repo's host, not the focused server. + const targetRuntimeEnvironmentId = targetSettings?.activeRuntimeEnvironmentId?.trim() || null + if (!targetRuntimeEnvironmentId && !targetConnectionId) { return null } if (!targetRepoPath) { @@ -150,7 +153,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { ? captureDirectSshMutationExpectation( useAppStore.getState(), targetConnectionId, - targetSettings?.activeRuntimeEnvironmentId + targetRuntimeEnvironmentId ) : { expectedExecutionHostId: 'local' as const, @@ -162,7 +165,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { const current = captureDirectSshMutationExpectation( useAppStore.getState(), targetConnectionId, - targetSettings?.activeRuntimeEnvironmentId + targetRuntimeEnvironmentId ) if ( current.expectedSshTargetId !== sshExpectation.expectedSshTargetId || @@ -175,7 +178,7 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) { : undefined const { results } = await importExternalPathsToRuntime( { - settings: targetSettings, + target: runtimeTargetForOwnerEnvironment(targetRuntimeEnvironmentId), worktreeId: targetWorktreeId ?? targetRepoPath, worktreePath: targetRepoPath, connectionId: targetConnectionId ?? undefined, diff --git a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts index 96caaf8c84a..e45444cac48 100644 --- a/src/renderer/src/hooks/editor-external-watch-disk-verification.ts +++ b/src/renderer/src/hooks/editor-external-watch-disk-verification.ts @@ -14,6 +14,7 @@ import { } from '@/components/editor/editor-self-write-registry' import { readRuntimeFileContent } from '@/runtime/runtime-file-client' import type { EditorExternalWatchTarget } from './editor-external-watch-targets' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export type EditorExternalWatchNotification = { worktreeId: string @@ -65,9 +66,7 @@ function readFileForEchoVerification(args: { let pending = inFlightEchoVerificationReads.get(key) if (!pending) { pending = readRuntimeFileContent({ - settings: args.runtimeEnvironmentId - ? { activeRuntimeEnvironmentId: args.runtimeEnvironmentId } - : null, + target: runtimeTargetForOwnerEnvironment(args.runtimeEnvironmentId ?? null), filePath: args.filePath, relativePath: args.relativePath, worktreeId: args.worktreeId ?? undefined, diff --git a/src/renderer/src/hooks/editor-runtime-file-watch.test.ts b/src/renderer/src/hooks/editor-runtime-file-watch.test.ts index 13ed3a5a6b2..ab299d7538a 100644 --- a/src/renderer/src/hooks/editor-runtime-file-watch.test.ts +++ b/src/renderer/src/hooks/editor-runtime-file-watch.test.ts @@ -71,7 +71,7 @@ describe('editor runtime file watch recovery', () => { await settle() expect(runtime.subscribe).toHaveBeenCalledWith( { - settings: { activeRuntimeEnvironmentId: 'host-a' }, + target: { kind: 'environment', environmentId: 'host-a' }, worktreeId: 'wt', worktreePath: '/repo', connectionId: undefined @@ -99,7 +99,7 @@ describe('editor runtime file watch recovery', () => { await settle() expect(runtime.subscribe).toHaveBeenLastCalledWith( { - settings: { activeRuntimeEnvironmentId: 'host-a' }, + target: { kind: 'environment', environmentId: 'host-a' }, worktreeId: folder.worktreeId, worktreePath: '/notes', connectionId: undefined diff --git a/src/renderer/src/hooks/editor-runtime-file-watch.ts b/src/renderer/src/hooks/editor-runtime-file-watch.ts index d88e425c369..29a4012427b 100644 --- a/src/renderer/src/hooks/editor-runtime-file-watch.ts +++ b/src/renderer/src/hooks/editor-runtime-file-watch.ts @@ -3,6 +3,7 @@ import { subscribeRuntimeFileChanges } from '@/runtime/runtime-file-client' import { subscribeRuntimeHostContactRegained } from '@/runtime/runtime-host-contact-regained' import { onRuntimeEnvironmentsRetired } from '@/runtime/runtime-environment-revision' import type { EditorExternalWatchTarget } from './editor-external-watch-targets' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export function subscribeEditorRuntimeFileWatch( target: EditorExternalWatchTarget & { runtimeEnvironmentId: string }, @@ -23,7 +24,7 @@ export function subscribeEditorRuntimeFileWatch( const isCurrent = (): boolean => !disposed && attempt === currentAttempt void subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: target.runtimeEnvironmentId }, + target: runtimeTargetForOwnerEnvironment(target.runtimeEnvironmentId), worktreeId: target.worktreeId, worktreePath: target.worktreePath, connectionId: target.connectionId diff --git a/src/renderer/src/lib/create-untitled-markdown.test.ts b/src/renderer/src/lib/create-untitled-markdown.test.ts index 403df38192e..c888e048389 100644 --- a/src/renderer/src/lib/create-untitled-markdown.test.ts +++ b/src/renderer/src/lib/create-untitled-markdown.test.ts @@ -9,6 +9,9 @@ import { type RuntimeEnvironmentCallRequest } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' + +const LOCAL: RuntimeClientTarget = { kind: 'local' } describe('createUntitledMarkdownFile', () => { afterEach(() => { @@ -17,7 +20,7 @@ describe('createUntitledMarkdownFile', () => { it('rejects captured ownership without a current-generation assertion', async () => { await expect( - createUntitledMarkdownFile('/repo', 'wt-1', undefined, undefined, { + createUntitledMarkdownFile('/repo', 'wt-1', undefined, LOCAL, { operationProvenance: {} as never }) ).rejects.toThrow("Couldn't verify which host owns this file") @@ -47,7 +50,7 @@ describe('createUntitledMarkdownFile', () => { } }) - await expect(createUntitledMarkdownFile('/repo', 'wt-1')).resolves.toEqual({ + await expect(createUntitledMarkdownFile('/repo', 'wt-1', undefined, LOCAL)).resolves.toEqual({ filePath: '/repo/untitled-3.md', relativePath: 'untitled-3.md', worktreeId: 'wt-1', @@ -81,7 +84,7 @@ describe('createUntitledMarkdownFile', () => { } }) - await expect(createUntitledMarkdownFile('/repo', 'wt-1')).rejects.toThrow( + await expect(createUntitledMarkdownFile('/repo', 'wt-1', undefined, LOCAL)).rejects.toThrow( 'Unable to create untitled markdown file after 100 attempts.' ) @@ -101,7 +104,9 @@ describe('createUntitledMarkdownFile', () => { } }) - await expect(createUntitledMarkdownFile('/repo', 'wt-1', 'conn-1')).resolves.toMatchObject({ + await expect( + createUntitledMarkdownFile('/repo', 'wt-1', 'conn-1', LOCAL) + ).resolves.toMatchObject({ filePath: '/repo/untitled.md' }) @@ -136,7 +141,7 @@ describe('createUntitledMarkdownFile', () => { }) await expect( - createUntitledMarkdownFile('/repo', 'wt-1', undefined, undefined, { + createUntitledMarkdownFile('/repo', 'wt-1', undefined, LOCAL, { now: new Date(2026, 4, 29, 7, 5), template: { id: '.orca/templates/daily.md', @@ -206,7 +211,7 @@ describe('createUntitledMarkdownFile', () => { try { await expect( - createUntitledMarkdownFileWithTemplateSelection('/repo', 'wt-1') + createUntitledMarkdownFileWithTemplateSelection('/repo', 'wt-1', undefined, LOCAL) ).resolves.toMatchObject({ filePath: '/repo/untitled.md', relativePath: 'untitled.md', @@ -256,7 +261,8 @@ describe('createUntitledMarkdownFile', () => { await expect( createUntitledMarkdownFile('/remote/repo', 'wt-1', undefined, { - activeRuntimeEnvironmentId: 'env-1' + kind: 'environment', + environmentId: 'env-1' }) ).resolves.toMatchObject({ filePath: '/remote/repo/untitled.md', diff --git a/src/renderer/src/lib/create-untitled-markdown.ts b/src/renderer/src/lib/create-untitled-markdown.ts index 01c4b7f9a28..571ce8d5906 100644 --- a/src/renderer/src/lib/create-untitled-markdown.ts +++ b/src/renderer/src/lib/create-untitled-markdown.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { createRuntimePath, deleteRuntimePath, @@ -48,15 +48,15 @@ type CreateUntitledMarkdownOptions = { export async function createUntitledMarkdownFile( worktreePath: string, worktreeId: string, - connectionId?: string, - settings?: Pick | null, + connectionId: string | undefined, + target: RuntimeClientTarget, options: CreateUntitledMarkdownOptions = {} ): Promise { const baseName = 'untitled' const ext = '.md' const MAX_ATTEMPTS = 100 const context = { - settings, + target, worktreeId, worktreePath, connectionId, @@ -138,8 +138,8 @@ export async function createUntitledMarkdownFile( export async function createUntitledMarkdownFileWithTemplateSelection( worktreePath: string, worktreeId: string, - connectionId?: string, - settings?: Pick | null, + connectionId: string | undefined, + target: RuntimeClientTarget, operationProvenance?: EditorFileOperationProvenance, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, @@ -150,7 +150,7 @@ export async function createUntitledMarkdownFileWithTemplateSelection( requireOperationAssertion(assertOperationCurrent)() } const context = { - settings, + target, worktreeId, worktreePath, connectionId, @@ -165,7 +165,7 @@ export async function createUntitledMarkdownFileWithTemplateSelection( return null } - return createUntitledMarkdownFile(worktreePath, worktreeId, connectionId, settings, { + return createUntitledMarkdownFile(worktreePath, worktreeId, connectionId, target, { template: selection.type === 'template' ? selection.template : undefined, operationProvenance, expectedSshTargetId, 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 43e257419db..e579bdd9b3d 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.test.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.test.ts @@ -90,7 +90,7 @@ describe('editor file operation owner', () => { '/remote/repo' ) - expect(context.settings?.activeRuntimeEnvironmentId).toBe('hub-a') + expect(context.target).toEqual({ kind: 'environment', environmentId: 'hub-a' }) }) it('rejects an open tab after the same environment id is re-paired', () => { diff --git a/src/renderer/src/lib/editor-file-operation-owner.ts b/src/renderer/src/lib/editor-file-operation-owner.ts index b05d7dd6a8f..ecf2fa6f859 100644 --- a/src/renderer/src/lib/editor-file-operation-owner.ts +++ b/src/renderer/src/lib/editor-file-operation-owner.ts @@ -2,6 +2,10 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/exec import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '@/store/types' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner' import { assertWorktreeOperationGenerationSnapshotCurrent, @@ -12,7 +16,6 @@ import { getFloatingWorkspaceOperationRoute, resolveExplicitWorktreeOperationRouteResult, resolveWorktreeOperationRoute, - settingsForWorktreeOperationRoute, type WorktreeOperationRoute } from './worktree-operation-route' @@ -168,7 +171,7 @@ export function getEditorFileOperationContext( }, worktreePath: string | null ): { - settings: AppState['settings'] + target: RuntimeClientTarget worktreeId: string worktreePath: string | null connectionId?: string @@ -213,7 +216,7 @@ export function getEditorFileOperationContext( throw new Error(OWNER_CHANGED_MESSAGE) } return { - settings: settingsForWorktreeOperationRoute(state.settings, route), + target: runtimeTargetForOwnerEnvironment(route.runtimeEnvironmentId), worktreeId: file.worktreeId, worktreePath: resolvedWorktreePath, expectedExecutionHostId: host.kind === 'ssh' ? host.id : 'local', diff --git a/src/renderer/src/lib/execute-open-editor-path-move.test.ts b/src/renderer/src/lib/execute-open-editor-path-move.test.ts index 3798a305445..7c483efe32d 100644 --- a/src/renderer/src/lib/execute-open-editor-path-move.test.ts +++ b/src/renderer/src/lib/execute-open-editor-path-move.test.ts @@ -19,7 +19,7 @@ import { getDiskBaselineSignature } from '@/components/editor/diff-content-signa import { buildEditorExternalWatchEventHandler as createExternalWatchEventHandler } from '@/hooks/editor-external-watch-event-reconciliation' const CONTEXT = { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: undefined diff --git a/src/renderer/src/lib/file-owner-runtime-target.test.ts b/src/renderer/src/lib/file-owner-runtime-target.test.ts new file mode 100644 index 00000000000..e5e8e86116b --- /dev/null +++ b/src/renderer/src/lib/file-owner-runtime-target.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { + getRuntimeTargetForFileOwner, + getRuntimeTargetForWorktreeOwner, + requireRuntimeTargetForFileOwner +} from './file-owner-runtime-target' +import type { WorktreeOperationRouteState } from './worktree-operation-route' + +const settings = { activeRuntimeEnvironmentId: 'focused-env' } + +const state: WorktreeOperationRouteState = { + settings, + repos: [ + { id: 'local-repo', connectionId: null, executionHostId: 'local' }, + { id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:owner-env' }, + { id: 'ssh-repo', connectionId: 'box', executionHostId: 'ssh:box' } + ], + worktreesByRepo: { + 'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo', hostId: 'local' }], + 'runtime-repo': [ + { + id: 'runtime-repo::wt-b', + repoId: 'runtime-repo', + hostId: 'runtime:owner-env', + runtimeOwnerEnvironmentId: 'owner-env' + } + ], + 'ssh-repo': [{ id: 'ssh-repo::wt-c', repoId: 'ssh-repo', hostId: 'ssh:box' }] + } +} + +describe('getRuntimeTargetForWorktreeOwner', () => { + it('dials the owner while another server is focused', () => { + expect(getRuntimeTargetForWorktreeOwner(state, 'runtime-repo::wt-b')).toEqual({ + kind: 'environment', + environmentId: 'owner-env' + }) + expect(getRuntimeTargetForWorktreeOwner(state, 'local-repo::wt-a')).toEqual({ kind: 'local' }) + expect(getRuntimeTargetForWorktreeOwner(state, 'ssh-repo::wt-c')).toEqual({ kind: 'local' }) + expect(getRuntimeTargetForWorktreeOwner(state, FLOATING_TERMINAL_WORKTREE_ID)).toEqual({ + kind: 'local' + }) + }) + + it('reads an unstamped legacy row as this app, not the focused server', () => { + // Before: a single saved, focused server claimed rows that predate owner stamping. + const legacy: WorktreeOperationRouteState = { + settings, + runtimeEnvironments: [{ id: 'focused-env' }], + runtimeEnvironmentCatalogHydrated: true, + repos: [{ id: 'legacy-repo' }], + worktreesByRepo: { 'legacy-repo': [{ id: 'legacy-repo::wt', repoId: 'legacy-repo' }] } + } + expect(getRuntimeTargetForWorktreeOwner(legacy, 'legacy-repo::wt')).toEqual({ kind: 'local' }) + }) + + it('stays on this app for no workspace or a workspace with no row yet', () => { + expect(getRuntimeTargetForWorktreeOwner(state, null)).toEqual({ kind: 'local' }) + expect(getRuntimeTargetForWorktreeOwner(state, 'unknown::wt')).toEqual({ kind: 'local' }) + }) + + it('has no transport when rows disagree, and a caller with an error path refuses', () => { + const ambiguous: WorktreeOperationRouteState = { + settings, + worktreesByRepo: { + a: [ + { + id: 'dup::wt', + repoId: 'a', + hostId: 'runtime:env-a', + runtimeOwnerEnvironmentId: 'env-a' + } + ], + b: [ + { + id: 'dup::wt', + repoId: 'b', + hostId: 'runtime:env-b', + runtimeOwnerEnvironmentId: 'env-b' + } + ] + } + } + expect(getRuntimeTargetForWorktreeOwner(ambiguous, 'dup::wt')).toBeNull() + expect(() => requireRuntimeTargetForFileOwner(ambiguous, 'dup::wt', undefined)).toThrow( + "Couldn't verify which host owns this file" + ) + }) +}) + +describe('getRuntimeTargetForFileOwner', () => { + it("keeps the tab's stamped owner, else the worktree's owner", () => { + expect(getRuntimeTargetForFileOwner(state, 'local-repo::wt-a', 'stamped-env')).toEqual({ + kind: 'environment', + environmentId: 'stamped-env' + }) + expect(getRuntimeTargetForFileOwner(state, 'runtime-repo::wt-b', null)).toEqual({ + kind: 'local' + }) + expect(getRuntimeTargetForFileOwner(state, 'runtime-repo::wt-b', undefined)).toEqual({ + kind: 'environment', + environmentId: 'owner-env' + }) + }) +}) diff --git a/src/renderer/src/lib/file-owner-runtime-target.ts b/src/renderer/src/lib/file-owner-runtime-target.ts new file mode 100644 index 00000000000..e39ba815517 --- /dev/null +++ b/src/renderer/src/lib/file-owner-runtime-target.ts @@ -0,0 +1,58 @@ +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' +import { runtimeTargetForWorkspaceOwner, resolveOwner } from './resolve-owner' +import type { WorktreeOperationRouteState } from './worktree-operation-route' + +const LOCAL_TARGET: RuntimeClientTarget = { kind: 'local' } + +export const FILE_OWNER_UNRESOLVED_MESSAGE = + "Couldn't verify which host owns this file. Reopen the file and try again." + +/** + * Transport to the worktree's owner, from its rows alone. `null` when rows disagree, so the caller + * refuses instead of guessing. + */ +export function getRuntimeTargetForWorktreeOwner( + state: WorktreeOperationRouteState, + worktreeId: string | null | undefined +): RuntimeClientTarget | null { + if (!worktreeId) { + return LOCAL_TARGET + } + const target = runtimeTargetForWorkspaceOwner(state, { workspaceId: worktreeId }) + if (target) { + return target + } + // Why local for a missing row: tabs and drops stamp a server owner when one exists, so a + // workspace with no row yet is this app's — the same answer the read had before focus routing. + return resolveOwner(state, { workspaceId: worktreeId }).kind === 'missing' ? LOCAL_TARGET : null +} + +/** + * Transport to an open file's owner: the owner stamped on its tab (`null` is this app), else its + * worktree's owner. Never the focused server, which can change after the tab opened. + */ +export function getRuntimeTargetForFileOwner( + state: WorktreeOperationRouteState, + worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined +): RuntimeClientTarget | null { + return runtimeEnvironmentId === undefined + ? getRuntimeTargetForWorktreeOwner(state, worktreeId) + : runtimeTargetForOwnerEnvironment(runtimeEnvironmentId) +} + +/** {@link getRuntimeTargetForFileOwner} for a caller with an error path. */ +export function requireRuntimeTargetForFileOwner( + state: WorktreeOperationRouteState, + worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined +): RuntimeClientTarget { + const target = getRuntimeTargetForFileOwner(state, worktreeId, runtimeEnvironmentId) + if (!target) { + throw new Error(FILE_OWNER_UNRESOLVED_MESSAGE) + } + return target +} diff --git a/src/renderer/src/lib/file-search-result-owner.test.ts b/src/renderer/src/lib/file-search-result-owner.test.ts index 561e14a20c8..d62dfaad402 100644 --- a/src/renderer/src/lib/file-search-result-owner.test.ts +++ b/src/renderer/src/lib/file-search-result-owner.test.ts @@ -3,9 +3,7 @@ import { createFileSearchResultOwner } from './file-search-result-owner' describe('createFileSearchResultOwner', () => { it('captures the exact runtime used by the completed search', () => { - const settings = { activeRuntimeEnvironmentId: ' runtime-owner-a ' } - const owner = createFileSearchResultOwner('worktree-a', settings) - settings.activeRuntimeEnvironmentId = 'runtime-owner-b' + const owner = createFileSearchResultOwner('worktree-a', ' runtime-owner-a ') expect(owner).toEqual({ worktreeId: 'worktree-a', @@ -14,11 +12,7 @@ describe('createFileSearchResultOwner', () => { }) it.each([null, '', ' '])('records %j as an explicit non-runtime owner', (environmentId) => { - expect( - createFileSearchResultOwner('local-or-ssh-worktree', { - activeRuntimeEnvironmentId: environmentId - }) - ).toEqual({ + expect(createFileSearchResultOwner('local-or-ssh-worktree', environmentId)).toEqual({ worktreeId: 'local-or-ssh-worktree', runtimeEnvironmentId: null }) diff --git a/src/renderer/src/lib/file-search-result-owner.ts b/src/renderer/src/lib/file-search-result-owner.ts index ed68c012796..13c96c9058c 100644 --- a/src/renderer/src/lib/file-search-result-owner.ts +++ b/src/renderer/src/lib/file-search-result-owner.ts @@ -1,5 +1,3 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' - export type FileSearchResultOwner = { worktreeId: string runtimeEnvironmentId: string | null @@ -9,13 +7,13 @@ export type FileSearchResultOwner = { export function createFileSearchResultOwner( worktreeId: string, - settings: Pick, + runtimeEnvironmentId: string | null, identity?: { rootPath: string; executionHostId: string } ): FileSearchResultOwner { return { ...identity, worktreeId, - runtimeEnvironmentId: settings.activeRuntimeEnvironmentId?.trim() || null + runtimeEnvironmentId: runtimeEnvironmentId?.trim() || null } } diff --git a/src/renderer/src/lib/floating-workspace-tab-creation.ts b/src/renderer/src/lib/floating-workspace-tab-creation.ts index 50f31ed48c1..677215ae824 100644 --- a/src/renderer/src/lib/floating-workspace-tab-creation.ts +++ b/src/renderer/src/lib/floating-workspace-tab-creation.ts @@ -68,7 +68,7 @@ export async function createFloatingWorkspaceMarkdownTab( floatingMarkdownDirectory, FLOATING_TERMINAL_WORKTREE_ID, getConnectionId(FLOATING_TERMINAL_WORKTREE_ID) ?? undefined, - { activeRuntimeEnvironmentId: null } + { kind: 'local' } ) if (!fileInfo) { return diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts index 0e1cf8e22f4..18f72470420 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts @@ -548,7 +548,7 @@ describe('createFloatingWorkspaceMarkdownTab', () => { '/tmp/orca/floating-workspace', FLOATING_TERMINAL_WORKTREE_ID, undefined, - { activeRuntimeEnvironmentId: null } + { kind: 'local' } ) expect(store.openFile).toHaveBeenCalledWith(fileInfo, { preview: false, @@ -570,7 +570,7 @@ describe('createFloatingWorkspaceMarkdownTab', () => { '/tmp/orca/floating-workspace', FLOATING_TERMINAL_WORKTREE_ID, undefined, - { activeRuntimeEnvironmentId: null } + { kind: 'local' } ) expect(store.openFile).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/local-file-access.ts b/src/renderer/src/lib/local-file-access.ts index b3480ecb26b..f6e2741a61e 100644 --- a/src/renderer/src/lib/local-file-access.ts +++ b/src/renderer/src/lib/local-file-access.ts @@ -1,9 +1,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { LocalFileAccess } from '../../../shared/local-file-access' -import { settingsForRuntimeOwner } from '@/runtime/runtime-client-target' import type { OpenFile } from '@/store/slices/editor' -import type { AppState } from '@/store/types' import { getConnectionIdForFile } from './connection-context' +import { getRuntimeTargetForFileOwner } from './file-owner-runtime-target' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner' const USER_FILE_ACCESS: LocalFileAccess = { kind: 'user-file' } const CHAT_IMAGE_ACCESS: LocalFileAccess = { kind: 'chat-image' } @@ -58,15 +58,16 @@ export function isClientLocalReadOnlyTab( * the same before and after a restart; every other tab stays inside its project root. */ export function editorTabFileAccess( - state: Pick, + state: WorktreeRuntimeOwnerState, file: EditorTabAccessFile ): LocalFileAccess | undefined { // Why: these are client-local files the user opened, whatever the worktree's host. if (isClientLocalReadOnlyTab(file)) { return USER_FILE_ACCESS } - const runtimeOwner = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) - if (file.externalSshTargetId?.trim() || runtimeOwner?.activeRuntimeEnvironmentId?.trim()) { + const owner = getRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId) + // Why `!owner`: rows that disagree may put the path on another host. + if (file.externalSshTargetId?.trim() || owner?.kind !== 'local') { return undefined } const outsideOwnerRoot = @@ -83,7 +84,7 @@ export function editorTabFileAccess( * user-named, else none, so project tabs keep their project checks. */ export function editorTabDocumentFolderAccess( - state: Pick, + state: WorktreeRuntimeOwnerState, file: EditorTabAccessFile ): LocalFileAccess | undefined { // Why: a read-only tab (an AI Vault log, possibly in an SSH workspace) is never written. diff --git a/src/renderer/src/lib/markdown-document-templates.test.ts b/src/renderer/src/lib/markdown-document-templates.test.ts index ab17687fcc2..107ffde0f30 100644 --- a/src/renderer/src/lib/markdown-document-templates.test.ts +++ b/src/renderer/src/lib/markdown-document-templates.test.ts @@ -92,7 +92,7 @@ describe('markdown document templates', () => { await expect( listMarkdownDocumentTemplates( { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'conn-1' @@ -137,7 +137,7 @@ describe('markdown document templates', () => { await expect( listMarkdownDocumentTemplates( - { settings: null, worktreeId: 'wt-1', worktreePath: '/repo' }, + { target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, '/repo' ) ).resolves.toEqual([]) @@ -150,7 +150,7 @@ describe('markdown document templates', () => { await expect( listMarkdownDocumentTemplates( - { settings: null, worktreeId: 'wt-1', worktreePath: 'C:\\repo' }, + { target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: 'C:\\repo' }, 'C:\\repo' ) ).resolves.toEqual([ diff --git a/src/renderer/src/lib/markdown-document-templates.ts b/src/renderer/src/lib/markdown-document-templates.ts index b4149389c02..166e6d3502a 100644 --- a/src/renderer/src/lib/markdown-document-templates.ts +++ b/src/renderer/src/lib/markdown-document-templates.ts @@ -1,5 +1,5 @@ import type { DirEntry } from '../../../shared/filesystem-entry-types' -import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { readRuntimeDirectory, @@ -211,14 +211,14 @@ export async function listMarkdownDocumentTemplates( export async function readMarkdownDocumentTemplateContent( context: { - settings: Pick | null | undefined + target: RuntimeClientTarget worktreeId: string connectionId?: string }, template: MarkdownDocumentTemplate ): Promise { const result = await readRuntimeFileContent({ - settings: context.settings, + target: context.target, filePath: template.filePath, relativePath: template.relativePath, worktreeId: context.worktreeId, diff --git a/src/renderer/src/lib/rename-file.ts b/src/renderer/src/lib/rename-file.ts index bfadcc41fd9..c84d361dc4e 100644 --- a/src/renderer/src/lib/rename-file.ts +++ b/src/renderer/src/lib/rename-file.ts @@ -62,7 +62,7 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise { ) const operationRoute = operationGuard.route const fileContext = { - settings: operationRoute.settings, + target: operationRoute.target, worktreeId, worktreePath, connectionId: operationRoute.connectionId, diff --git a/src/renderer/src/lib/user-opened-local-path.test.ts b/src/renderer/src/lib/user-opened-local-path.test.ts index a00a0e37a94..8d08967e84e 100644 --- a/src/renderer/src/lib/user-opened-local-path.test.ts +++ b/src/renderer/src/lib/user-opened-local-path.test.ts @@ -12,7 +12,7 @@ vi.mock('@/runtime/runtime-file-client', () => ({ import { statUserOpenedPath } from './user-opened-local-path' const project: RuntimeFileOperationArgs = { - settings: null, + target: { kind: 'local' as const }, worktreeId: 'repo::/repo', worktreePath: '/repo' } diff --git a/src/renderer/src/lib/workspace-file-host-routing.ts b/src/renderer/src/lib/workspace-file-host-routing.ts index 16350e4880c..1120e2e364d 100644 --- a/src/renderer/src/lib/workspace-file-host-routing.ts +++ b/src/renderer/src/lib/workspace-file-host-routing.ts @@ -1,7 +1,7 @@ import { getConnectionId, getConnectionIdForFile } from '@/lib/connection-context' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { hasRemoteRuntimeOwner } from '@/runtime/runtime-file-routing' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { useAppStore } from '@/store' /** Which host owns a workspace file, resolved the same way for every surface that opens one. */ @@ -10,9 +10,12 @@ export function buildWorkspaceFileContext( worktreePath: string, runtimeEnvironmentId?: string | null ): RuntimeFileOperationArgs { - const settings = useAppStore.getState().settings return { - settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId), + target: requireRuntimeTargetForFileOwner( + useAppStore.getState(), + worktreeId, + runtimeEnvironmentId + ), worktreeId: worktreeId || null, worktreePath, connectionId: getConnectionId(worktreeId || null) ?? undefined diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts index ef26415f9b1..6d168becd5e 100644 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ b/src/renderer/src/runtime/mobile-markdown-bridge.ts @@ -11,7 +11,7 @@ import { editorTabFileAccess } from '@/lib/local-file-access' import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { readRuntimeFileContent } from './runtime-file-client' -import { settingsForRuntimeOwner } from './runtime-rpc-client' +import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' import { hashMarkdownContent, isMarkdownContentByteLengthOverLimit, @@ -245,7 +245,7 @@ async function readFileContent(file: OpenFile): Promise { const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined const state = useAppStore.getState() const result: FileContent = await readRuntimeFileContent({ - settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId), + target: requireRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId), filePath: file.filePath, relativePath: file.relativePath, worktreeId: file.worktreeId, diff --git a/src/renderer/src/runtime/remote-host-file-open-repro.test.ts b/src/renderer/src/runtime/remote-host-file-open-repro.test.ts index 1c35465d5e4..1d4b8a22819 100644 --- a/src/renderer/src/runtime/remote-host-file-open-repro.test.ts +++ b/src/renderer/src/runtime/remote-host-file-open-repro.test.ts @@ -31,6 +31,7 @@ import { } from '@/components/editor/editor-panel-content-types' import { shouldRetryFileLoadError } from '@/components/editor/useEditorPanelFileLoadRetry' import { readRuntimeFileContent } from './runtime-file-client' +import type { RuntimeClientTarget } from './runtime-client-target' // Mirrors src/main/ipc/filesystem-auth.ts PATH_ACCESS_DENIED_MESSAGE. const PATH_ACCESS_DENIED_MESSAGE = @@ -97,18 +98,16 @@ afterEach(() => { async function openRemoteFileInEditor() { const resolvedConnectionId = getConnectionIdForFile(REMOTE_WORKTREE_ID, REMOTE_FILE_PATH) const connectionId = resolvedConnectionId ?? undefined - const readSettings: { activeRuntimeEnvironmentId: string | null } = { - activeRuntimeEnvironmentId: null - } + const readTarget: RuntimeClientTarget = { kind: 'local' } if ( resolvedConnectionId === undefined && - !readSettings.activeRuntimeEnvironmentId?.trim() && + readTarget.kind === 'local' && !isWorktreeConnectionResolved(REMOTE_WORKTREE_ID) ) { throw new Error(WORKTREE_OWNER_NOT_READY_ERROR) } return readRuntimeFileContent({ - settings: readSettings, + target: readTarget, filePath: REMOTE_FILE_PATH, relativePath: 'src/index.ts', worktreeId: REMOTE_WORKTREE_ID, @@ -122,18 +121,16 @@ async function openFloatingWorkspaceFileInEditor() { FLOATING_FILE_PATH ) const connectionId = resolvedConnectionId ?? undefined - const readSettings: { activeRuntimeEnvironmentId: string | null } = { - activeRuntimeEnvironmentId: null - } + const readTarget: RuntimeClientTarget = { kind: 'local' } if ( resolvedConnectionId === undefined && - !readSettings.activeRuntimeEnvironmentId?.trim() && + readTarget.kind === 'local' && !isWorktreeConnectionResolved(FLOATING_TERMINAL_WORKTREE_ID) ) { throw new Error(WORKTREE_OWNER_NOT_READY_ERROR) } return readRuntimeFileContent({ - settings: readSettings, + target: readTarget, filePath: FLOATING_FILE_PATH, relativePath: 'notes.md', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, diff --git a/src/renderer/src/runtime/runtime-client-target.ts b/src/renderer/src/runtime/runtime-client-target.ts index e17f811f10d..a7d21a05ac9 100644 --- a/src/renderer/src/runtime/runtime-client-target.ts +++ b/src/renderer/src/runtime/runtime-client-target.ts @@ -23,6 +23,11 @@ export function runtimeTargetForOwnerEnvironment( return ownerId ? { kind: 'environment', environmentId: ownerId } : { kind: 'local' } } +/** The server a transport dials, or `null` for this app. */ +export function runtimeTargetEnvironmentId(target: RuntimeClientTarget): string | null { + return target.kind === 'environment' ? target.environmentId : null +} + /** * Transport to the host that owns a row: a server's own runtime, or this app for local and direct * SSH (which ride this app's IPC with a `connectionId`). `null` when the id names no routable host. diff --git a/src/renderer/src/runtime/runtime-file-client-download.test.ts b/src/renderer/src/runtime/runtime-file-client-download.test.ts index b71b23d08be..19a80af0cfc 100644 --- a/src/renderer/src/runtime/runtime-file-client-download.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-download.test.ts @@ -47,7 +47,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -101,7 +101,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -139,7 +139,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -188,7 +188,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -226,7 +226,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -264,7 +264,7 @@ describe('runtime file client', () => { await expect( downloadRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, diff --git a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts index 52f0bb83d09..cba08809e6a 100644 --- a/src/renderer/src/runtime/runtime-file-client-external-import.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-external-import.test.ts @@ -95,7 +95,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -216,7 +216,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -282,7 +282,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -328,7 +328,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -388,7 +388,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -430,7 +430,7 @@ describe('runtime file client', () => { await importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', diff --git a/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts b/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts index 688e2e57417..4634dcc220d 100644 --- a/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-mutation-ownership.test.ts @@ -41,7 +41,7 @@ describe('runtime file client', () => { _meta: { runtimeId: 'remote-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo', expectedSshTargetId: 'ssh-1', @@ -130,7 +130,7 @@ describe('runtime file client', () => { await expect( writeRuntimeFile( { - settings: { activeRuntimeEnvironmentId: 'env-old-hub' }, + target: { kind: 'environment' as const, environmentId: 'env-old-hub' }, worktreeId: 'wt-hub-local', worktreePath: '/hub/repo', expectedExecutionHostId: 'local' @@ -169,7 +169,7 @@ describe('runtime file client', () => { await expect( renameRuntimePath( { - settings: { activeRuntimeEnvironmentId: 'env-old-hub' }, + target: { kind: 'environment' as const, environmentId: 'env-old-hub' }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', connectionId: 'hub-ssh-1', @@ -217,7 +217,7 @@ describe('runtime file client', () => { await expect( readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-old-hub' }, + target: { kind: 'environment' as const, environmentId: 'env-old-hub' }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', connectionId: 'hub-ssh-1' @@ -255,7 +255,7 @@ describe('runtime file client', () => { await expect( importExternalPathsToRuntime( { - settings: { activeRuntimeEnvironmentId: 'env-old-hub' }, + target: { kind: 'environment' as const, environmentId: 'env-old-hub' }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', expectedExecutionHostId: 'ssh:hub-ssh-1', @@ -297,7 +297,7 @@ describe('runtime file client', () => { _meta: { runtimeId: 'new-hub-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-downgraded' }, + target: { kind: 'environment' as const, environmentId: 'env-downgraded' }, worktreeId: 'wt-hub-local', worktreePath: '/hub/repo', expectedExecutionHostId: 'local' as const @@ -356,7 +356,7 @@ describe('runtime file client', () => { await expect( deleteRuntimePath( { - settings: { activeRuntimeEnvironmentId: 'env-repaired' }, + target: { kind: 'environment' as const, environmentId: 'env-repaired' }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', expectedExecutionHostId: 'ssh:hub-ssh-1', @@ -394,7 +394,7 @@ describe('runtime file client', () => { it('does not fall back to client-local mutations for remote-owned paths outside the worktree', async () => { const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' } @@ -421,7 +421,7 @@ describe('runtime file client', () => { it('does not fall back to client-local mutations when a remote Windows path escapes the worktree', async () => { const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: 'C:\\repo' } @@ -440,7 +440,7 @@ describe('runtime file client', () => { it('keeps copy operations on local filesystem IPC when no runtime is active', async () => { await copyRuntimePath( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -460,7 +460,7 @@ describe('runtime file client', () => { it('preserves the SSH connection for copy operations when no runtime is active', async () => { await copyRuntimePath( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', diff --git a/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts b/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts index d8f75a0f163..c179e180f59 100644 --- a/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-search-listing.test.ts @@ -34,7 +34,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -61,7 +61,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -76,7 +76,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -105,7 +105,7 @@ describe('runtime file client', () => { await expect( listRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -145,7 +145,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -177,7 +177,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-1' @@ -237,7 +237,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -252,7 +252,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -268,7 +268,7 @@ describe('runtime file client', () => { replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 2 }]) await searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -299,7 +299,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-legacy' }, + target: { kind: 'environment' as const, environmentId: 'env-legacy' }, worktreeId: 'wt-legacy', worktreePath: '/remote/repo' }, @@ -350,7 +350,7 @@ describe('runtime file client', () => { await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -390,7 +390,7 @@ describe('runtime file client', () => { const pending = searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -410,7 +410,7 @@ describe('runtime file client', () => { const retryController = new AbortController() const retry = searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -462,7 +462,7 @@ describe('runtime file client', () => { }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'folder-1' } await expect( @@ -486,7 +486,7 @@ describe('runtime file client', () => { await expect( listRuntimeFiles( { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-1' @@ -508,7 +508,7 @@ describe('runtime file client', () => { it('sends the Explorer name filter to local listings only', async () => { fsListFiles.mockResolvedValue([]) - const local = { settings: {}, worktreeId: 'wt-1', worktreePath: '/repo' } + const local = { target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' } await listRuntimeFiles(local, { rootPath: '/repo', nameFilter: 'AppDelegate' }) await listRuntimeFiles( @@ -523,7 +523,7 @@ describe('runtime file client', () => { it('cancelRuntimeFileList aborts the IPC listing but not environment listings (#7721)', () => { cancelRuntimeFileList( { - settings: {}, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-1' @@ -535,7 +535,7 @@ describe('runtime file client', () => { fsCancelListFiles.mockClear() cancelRuntimeFileList( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -552,7 +552,7 @@ describe('runtime file client', () => { _meta: { runtimeId: 'remote-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' } @@ -580,7 +580,7 @@ describe('runtime file client', () => { await expect( runtimePathExists( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1' @@ -600,7 +600,7 @@ describe('runtime file client', () => { await expect( statRuntimePath( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -611,7 +611,7 @@ describe('runtime file client', () => { await expect( statRuntimePath( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: 'C:\\repo' }, @@ -634,7 +634,7 @@ it.each([{ includeIgnored: false }, { followSymlinks: true }])( _meta: { runtimeId: 'remote-runtime' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'options-workspace', worktreePath: '/remote/repo' } @@ -665,7 +665,7 @@ it('forwards both listing options only to the selected current host', async () = await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'options-current', worktreePath: '/remote/repo' }, diff --git a/src/renderer/src/runtime/runtime-file-client-types.ts b/src/renderer/src/runtime/runtime-file-client-types.ts index 87f84d9aa22..e9df1c00d20 100644 --- a/src/renderer/src/runtime/runtime-file-client-types.ts +++ b/src/renderer/src/runtime/runtime-file-client-types.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { RuntimeClientTarget } from './runtime-client-target' import type { LocalFileAccess } from '../../../shared/local-file-access' export type RuntimeReadableFileContent = { @@ -11,7 +11,8 @@ export type RuntimeReadableFileContent = { } export type RuntimeFileReadArgs = { - settings: Pick | null | undefined + /** The file owner's transport; never the focused server. */ + target: RuntimeClientTarget filePath: string relativePath?: string worktreeId?: string @@ -23,7 +24,8 @@ export type RuntimeFileReadArgs = { } export type RuntimeFileOperationArgs = { - settings: Pick | null | undefined + /** The worktree owner's transport; never the focused server. */ + target: RuntimeClientTarget worktreeId: string | null | undefined worktreePath: string | null | undefined connectionId?: string diff --git a/src/renderer/src/runtime/runtime-file-client-watch.test.ts b/src/renderer/src/runtime/runtime-file-client-watch.test.ts index cd236696823..ab855b18656 100644 --- a/src/renderer/src/runtime/runtime-file-client-watch.test.ts +++ b/src/renderer/src/runtime/runtime-file-client-watch.test.ts @@ -18,7 +18,7 @@ describe('runtime file client', () => { await expect( subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo' }, @@ -41,7 +41,7 @@ describe('runtime file client', () => { const stop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -89,7 +89,7 @@ describe('runtime file client', () => { await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -116,7 +116,7 @@ describe('runtime file client', () => { await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -144,7 +144,7 @@ describe('runtime file client', () => { const retryPayload = vi.fn() let retryPromise: Promise<() => void> | undefined const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' } @@ -200,7 +200,7 @@ describe('runtime file client', () => { const retryPayload = vi.fn() let retryPromise: Promise<() => void> | undefined const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' } @@ -255,7 +255,7 @@ describe('runtime file client', () => { const firstStop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -263,7 +263,7 @@ describe('runtime file client', () => { ) const secondStop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -324,7 +324,7 @@ describe('runtime file client', () => { const stop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -362,7 +362,7 @@ describe('runtime file client', () => { const stop = await subscribeRuntimeFileChanges( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 7f2cd70ca5c..e5bf22e8292 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -27,7 +27,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, filePath: '/repo/readme.md', relativePath: 'readme.md', worktreeId: 'wt-1', @@ -45,7 +45,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, filePath: '/tmp/external.md', relativePath: '/tmp/external.md', worktreeId: 'wt-1', @@ -64,7 +64,7 @@ describe('runtime file client', () => { it('rejects an external SSH file read after the target changes', async () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, filePath: '/tmp/external.md', relativePath: '/tmp/external.md', worktreeId: 'wt-1', @@ -79,7 +79,7 @@ describe('runtime file client', () => { it('rejects an external SSH file read through a runtime environment', async () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/tmp/external.md', relativePath: '/tmp/external.md', worktreeId: 'wt-1', @@ -94,7 +94,7 @@ describe('runtime file client', () => { it('binds direct SSH mutations to the captured target and generation', async () => { const context = { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/repo', connectionId: 'ssh-1', @@ -149,7 +149,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/src/index.ts', relativePath: 'src/index.ts', worktreeId: 'wt-1' @@ -171,7 +171,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/Users/me/scratch.md', relativePath: '/Users/me/scratch.md' }) @@ -187,7 +187,7 @@ describe('runtime file client', () => { it('rejects remote-owned text reads that are not worktree-relative', async () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/tmp/scratch.md', relativePath: '/tmp/scratch.md', worktreeId: 'wt-1' @@ -196,7 +196,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/unknown.md', worktreeId: 'wt-1' }) @@ -221,7 +221,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/large.log', relativePath: 'large.log', worktreeId: 'wt-1' @@ -254,7 +254,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/doc.pdf', relativePath: 'doc.pdf', worktreeId: 'wt-1' @@ -295,7 +295,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/secret.txt', relativePath: 'secret.txt', worktreeId: 'wt-1' @@ -327,7 +327,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/huge.pdf', relativePath: 'huge.pdf', worktreeId: 'wt-1' @@ -352,7 +352,7 @@ describe('runtime file client', () => { await expect( readRuntimeFileContent({ - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, filePath: '/remote/repo/doc.pdf', relativePath: 'doc.pdf', worktreeId: 'wt-1' @@ -364,11 +364,11 @@ describe('runtime file client', () => { ) }) - it('uses the active runtime id as the dedupe scope', () => { - expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: 'env-1' }, 'ssh-1')).toBe( + it('uses the owner runtime id as the dedupe scope', () => { + expect(getRuntimeFileReadScope({ kind: 'environment', environmentId: 'env-1' }, 'ssh-1')).toBe( 'runtime:env-1' ) - expect(getRuntimeFileReadScope({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe('ssh-1') + expect(getRuntimeFileReadScope({ kind: 'local' }, 'ssh-1')).toBe('ssh-1') }) it('routes directory reads through the selected runtime environment', async () => { @@ -382,7 +382,7 @@ describe('runtime file client', () => { await expect( readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -408,7 +408,7 @@ describe('runtime file client', () => { await readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: 'C:\\Repo' }, @@ -433,7 +433,7 @@ describe('runtime file client', () => { await readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '//Server/Share/Repo' }, @@ -459,7 +459,7 @@ describe('runtime file client', () => { await expect( readRuntimeFilePreview( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -484,7 +484,7 @@ describe('runtime file client', () => { await expect( readRuntimeFilePreview( { - settings: { activeRuntimeEnvironmentId: null }, + target: { kind: 'local' as const }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-2', @@ -501,7 +501,7 @@ describe('runtime file client', () => { await expect( readRuntimeFilePreview( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -528,7 +528,7 @@ describe('runtime file client', () => { await expect( readRuntimeFilePreview( - { settings: { activeRuntimeEnvironmentId: 'env-1' }, ...owner }, + { target: { kind: 'environment' as const, environmentId: 'env-1' }, ...owner }, storedPath ) ).resolves.toMatchObject({ content: 'base64', mimeType: 'image/png' }) @@ -552,7 +552,7 @@ describe('runtime file client', () => { await readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo' }, @@ -571,7 +571,7 @@ describe('runtime file client', () => { await expect( readRuntimeDirectory( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'wt-1', worktreePath: '/remote/repo', connectionId: 'ssh-1' diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 98b28961111..9275d06b832 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -20,7 +20,6 @@ import { ensureRuntimeDirectory, uploadRuntimeFileWithoutClobber } from './runtime-file-upload-client' -import { getActiveRuntimeTarget } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import type { LocalFileAccess } from '../../../shared/local-file-access' import { localAccess } from './runtime-file-read-client' @@ -36,7 +35,7 @@ export async function importExternalPathsToRuntime( access?: LocalFileAccess } ): Promise<{ results: ImportItemResult[] }> { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { return window.api.fs.importExternalPaths( withSshMutationExpectation(context, { diff --git a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts index eb8dad53970..0c4a287b3d0 100644 --- a/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts +++ b/src/renderer/src/runtime/runtime-file-import-pairing-revision.test.ts @@ -36,7 +36,7 @@ const uploadExternalFileToRuntime = vi.fn<(args: Record) => unk const importExternalPaths = vi.fn() const nestedSshContext = { - settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + target: { kind: 'environment' as const, environmentId: ENVIRONMENT_ID }, worktreeId: 'wt-nested-ssh', worktreePath: '/ssh/repo', connectionId: 'hub-ssh-1', @@ -46,7 +46,7 @@ const nestedSshContext = { } const hubLocalContext = { - settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + target: { kind: 'environment' as const, environmentId: ENVIRONMENT_ID }, worktreeId: 'wt-hub-local', worktreePath: '/hub/repo', expectedExecutionHostId: 'local' as const diff --git a/src/renderer/src/runtime/runtime-file-metadata-client.ts b/src/renderer/src/runtime/runtime-file-metadata-client.ts index e89c2c1e299..ff026cb1173 100644 --- a/src/renderer/src/runtime/runtime-file-metadata-client.ts +++ b/src/renderer/src/runtime/runtime-file-metadata-client.ts @@ -1,7 +1,7 @@ import type { MarkdownDocument } from '../../../shared/filesystem-entry-types' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { assertLocalFilesystemFallbackAllowed, getRemoteFileArgs } from './runtime-file-routing' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import type { LocalFileAccess } from '../../../shared/local-file-access' import { localAccess } from './runtime-file-read-client' @@ -10,7 +10,7 @@ export async function listRuntimeMarkdownDocuments( context: RuntimeFileOperationArgs, rootPath: string ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { return window.api.fs.listMarkdownDocuments({ rootPath, diff --git a/src/renderer/src/runtime/runtime-file-mutation-client.ts b/src/renderer/src/runtime/runtime-file-mutation-client.ts index 0314ea7b260..e3a10644227 100644 --- a/src/renderer/src/runtime/runtime-file-mutation-client.ts +++ b/src/renderer/src/runtime/runtime-file-mutation-client.ts @@ -1,6 +1,6 @@ import type { DirEntry } from '../../../shared/filesystem-entry-types' import { normalizeRelativePath } from '@/lib/path' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { callRuntimeRpc } from './runtime-rpc-client' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' import { assertLocalFilesystemFallbackAllowed, @@ -190,7 +190,7 @@ export async function deleteRuntimeRelativePath( relativePath: string, recursive?: boolean ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if ( target.kind !== 'environment' || !context.worktreeId || diff --git a/src/renderer/src/runtime/runtime-file-range-client.test.ts b/src/renderer/src/runtime/runtime-file-range-client.test.ts index 7b8c9aa937d..de38ea733b3 100644 --- a/src/renderer/src/runtime/runtime-file-range-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-range-client.test.ts @@ -14,7 +14,7 @@ import { installRuntimeFileClientEnvironment() const localArgs = { - settings: null, + target: { kind: 'local' as const }, filePath: '/repo/large.csv', relativePath: 'large.csv', worktreeId: 'folder:workspace' @@ -65,7 +65,7 @@ describe('CSV range routing', () => { result: { contentBase64: 'YWJj', bytesRead: 3, eof: false } }) await readRuntimeFileRange( - { ...localArgs, settings: { activeRuntimeEnvironmentId: 'env-1' } }, + { ...localArgs, target: { kind: 'environment' as const, environmentId: 'env-1' } }, 10, 3 ) @@ -86,7 +86,11 @@ describe('CSV range routing', () => { it('does not substitute a local file for an unavailable or changed remote owner', async () => { await expect( readRuntimeFileRange( - { ...localArgs, settings: { activeRuntimeEnvironmentId: 'env-1' }, worktreeId: undefined }, + { + ...localArgs, + target: { kind: 'environment' as const, environmentId: 'env-1' }, + worktreeId: undefined + }, 0, 3 ) @@ -101,7 +105,7 @@ describe('CSV range routing', () => { await expect( statRuntimeReadTarget({ ...localArgs, - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, relativePath: '/other/large.csv' }) ).rejects.toThrow('outside') @@ -116,7 +120,7 @@ describe('CSV range routing', () => { }) await expect( readRuntimeFileRange( - { ...localArgs, settings: { activeRuntimeEnvironmentId: 'env-1' } }, + { ...localArgs, target: { kind: 'environment' as const, environmentId: 'env-1' } }, 0, 3 ) diff --git a/src/renderer/src/runtime/runtime-file-range-client.ts b/src/renderer/src/runtime/runtime-file-range-client.ts index bc04820d45a..08c2dd9bf9e 100644 --- a/src/renderer/src/runtime/runtime-file-range-client.ts +++ b/src/renderer/src/runtime/runtime-file-range-client.ts @@ -2,12 +2,12 @@ import type { RuntimeFileReadChunkResult } from '../../../shared/runtime-types' import { validateFileRangeRequest } from '../../../shared/file-range-read' import type { RuntimeFileReadArgs } from './runtime-file-client-types' import { assertExternalSshReadOwnership, canReadRelativeRuntimeFile } from './runtime-file-routing' -import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' function rangeReadTarget(args: RuntimeFileReadArgs) { - assertExternalSshReadOwnership(args.settings, args.connectionId, args.expectedExternalSshTargetId) - const target = getActiveRuntimeTarget(args.settings) + assertExternalSshReadOwnership(args.target, args.connectionId, args.expectedExternalSshTargetId) + const target = args.target if (target.kind !== 'environment') { return null } diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index 3cd9943fa40..ce99fd5bda2 100644 --- a/src/renderer/src/runtime/runtime-file-read-client.ts +++ b/src/renderer/src/runtime/runtime-file-read-client.ts @@ -3,7 +3,7 @@ import type { RuntimeFileReadChunkResult, RuntimeFileReadResult } from '../../../shared/runtime-types' -import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client' import type { RuntimeFileDownloadResult, RuntimeFileOperationArgs, @@ -35,7 +35,7 @@ export function localAccess( } export async function readRuntimeFileContent({ - settings, + target, filePath, relativePath, worktreeId, @@ -44,8 +44,7 @@ export async function readRuntimeFileContent({ includeLocalLogMetadata, access }: RuntimeFileReadArgs): Promise { - assertExternalSshReadOwnership(settings, connectionId, expectedExternalSshTargetId) - const target = getActiveRuntimeTarget(settings) + assertExternalSshReadOwnership(target, connectionId, expectedExternalSshTargetId) if (target.kind !== 'environment' || !worktreeId) { return window.api.fs.readFile({ filePath, @@ -95,12 +94,12 @@ export async function readRuntimeFilePreview( access?: LocalFileAccess ): Promise { assertExternalSshReadOwnership( - context.settings, + context.target, context.connectionId, context.expectedExternalSshTargetId ) const remoteArgs = getRemoteFileArgs(context, filePath) - const runtimeTarget = getActiveRuntimeTarget(context.settings) + const runtimeTarget = context.target // A chat attachment lives in the paired server's store, outside every worktree; that server // reads it back itself (an older one answers method_not_found, and the preview falls back). if ( @@ -139,7 +138,7 @@ export async function downloadRuntimeFile( suggestedName: string ): Promise { assertExternalSshReadOwnership( - context.settings, + context.target, context.connectionId, context.expectedExternalSshTargetId ) diff --git a/src/renderer/src/runtime/runtime-file-routing.ts b/src/renderer/src/runtime/runtime-file-routing.ts index e08b7f49320..57260254a9a 100644 --- a/src/renderer/src/runtime/runtime-file-routing.ts +++ b/src/renderer/src/runtime/runtime-file-routing.ts @@ -1,23 +1,19 @@ -import type { GlobalSettings } from '../../../shared/global-settings-types' import { isWindowsAbsolutePathLike, relativePathInsideRoot } from '../../../shared/cross-platform-path' import { normalizeRelativePath } from '@/lib/path' -import { getActiveRuntimeTarget } from './runtime-rpc-client' +import type { RuntimeClientTarget } from './runtime-client-target' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' export function assertExternalSshReadOwnership( - settings: Pick | null | undefined, + target: RuntimeClientTarget, connectionId: string | undefined, expectedExternalSshTargetId: string | undefined ): void { const expectedTargetId = expectedExternalSshTargetId?.trim() - if ( - expectedTargetId && - (getActiveRuntimeTarget(settings).kind === 'environment' || connectionId !== expectedTargetId) - ) { + if (expectedTargetId && (target.kind === 'environment' || connectionId !== expectedTargetId)) { throw new Error('External SSH files are not available after the workspace host changes.') } } @@ -46,10 +42,9 @@ export function withSshMutationExpectation( } export function getRuntimeFileReadScope( - settings: Pick | null | undefined, + target: RuntimeClientTarget, connectionId: string | undefined ): string | undefined { - const target = getActiveRuntimeTarget(settings) return target.kind === 'environment' ? `runtime:${target.environmentId}` : connectionId } @@ -74,12 +69,12 @@ export function getRemoteFileArgs( context: RuntimeFileOperationArgs, absolutePath: string ): { - target: ReturnType & { kind: 'environment' } + target: RuntimeClientTarget & { kind: 'environment' } worktreeId: string worktreeSelector: string relativePath: string } | null { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { return null } @@ -96,9 +91,7 @@ export function getRemoteFileArgs( } export function hasRemoteRuntimeOwner(context: RuntimeFileOperationArgs): boolean { - return ( - getActiveRuntimeTarget(context.settings).kind === 'environment' && Boolean(context.worktreeId) - ) + return context.target.kind === 'environment' && Boolean(context.worktreeId) } export function assertLocalFilesystemFallbackAllowed(context: RuntimeFileOperationArgs): void { diff --git a/src/renderer/src/runtime/runtime-file-search-client.ts b/src/renderer/src/runtime/runtime-file-search-client.ts index eff31f106df..153a9f476a9 100644 --- a/src/renderer/src/runtime/runtime-file-search-client.ts +++ b/src/renderer/src/runtime/runtime-file-search-client.ts @@ -16,7 +16,7 @@ import { searchLegacyQuickOpenInventory, validateLegacyQuickOpenRecentCandidates } from './runtime-legacy-quick-open-inventory' -import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' +import { callRuntimeRpc, RuntimeRpcCallError } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' const QUICK_OPEN_REMOTE_UPDATE_REQUIRED_MESSAGE = @@ -31,7 +31,7 @@ export async function searchRuntimeFiles( if (getRuntimeFileSearchRejectedField(options)) { return createEmptyRuntimeFileSearchResult() } - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { const requestToken = createBrowserUuid() const cancel = (): void => { @@ -80,7 +80,7 @@ export async function listRuntimeFiles( signal?: AbortSignal } ): Promise { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { return window.api.fs.listFiles({ ...(args.includeIgnored === undefined ? {} : { includeIgnored: args.includeIgnored }), @@ -170,7 +170,7 @@ export async function searchRuntimeFilePaths( signal?: AbortSignal } ): Promise<{ files: string[]; truncated: boolean }> { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment') { if (!context.connectionId || !context.worktreePath) { return { files: [], truncated: false } @@ -280,7 +280,7 @@ export function cancelRuntimeFileList( context: RuntimeFileOperationArgs, requestToken: string ): void { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId) { void window.api.fs.cancelListFiles({ requestToken }).catch(() => { /* cancellation is advisory; the request path has its own timeouts */ diff --git a/src/renderer/src/runtime/runtime-file-watch-client.ts b/src/renderer/src/runtime/runtime-file-watch-client.ts index 8809a8f0d49..9a0737b3a4e 100644 --- a/src/renderer/src/runtime/runtime-file-watch-client.ts +++ b/src/renderer/src/runtime/runtime-file-watch-client.ts @@ -1,11 +1,7 @@ import type { FsChangedPayload } from '../../../shared/filesystem-entry-types' import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import type { RuntimeFileOperationArgs } from './runtime-file-client-types' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - unwrapRuntimeRpcResult -} from './runtime-rpc-client' +import { callRuntimeRpc, unwrapRuntimeRpcResult } from './runtime-rpc-client' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' type RuntimeFileWatchEvent = @@ -46,7 +42,7 @@ export async function subscribeRuntimeFileChanges( onPayload: (payload: FsChangedPayload) => void, onError?: (error: Error) => void ): Promise<() => void> { - const target = getActiveRuntimeTarget(context.settings) + const target = context.target if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { return window.api.fs.onFsChanged(onPayload) } diff --git a/src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts b/src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts index 8ce5cc97744..d363af19259 100644 --- a/src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts +++ b/src/renderer/src/runtime/runtime-legacy-recent-freshness.test.ts @@ -26,7 +26,7 @@ for (const version of [1, 2]) { }) ) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: `freshness-${version}`, worktreePath: '/host/repo' } diff --git a/src/renderer/src/runtime/runtime-path-existence-batch.test.ts b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts index 877e5a0f986..fff0c170fad 100644 --- a/src/renderer/src/runtime/runtime-path-existence-batch.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-batch.test.ts @@ -12,7 +12,7 @@ import { } from '../../../shared/protocol-version' installRuntimeFileClientEnvironment() const context = { - settings: { activeRuntimeEnvironmentId: 'owner-a' }, + target: { kind: 'environment' as const, environmentId: 'owner-a' }, worktreeId: 'folder-1', worktreePath: '/folder' } diff --git a/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts b/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts index 9cb469c4871..17be77ef4c4 100644 --- a/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-error-parity.test.ts @@ -33,7 +33,7 @@ for (const mode of ['scalar', 'batch'] as const) { error: { code: 'not_found', message: 'Worktree not found: id:folder-1' } }) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } @@ -67,7 +67,7 @@ for (const stage of ['status', 'operation'] as const) { ) runtimeEnvironmentCall.mockImplementation(async () => failure()) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } diff --git a/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts b/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts index 6cdc453ec45..5bb570ec188 100644 --- a/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-pairing.test.ts @@ -45,7 +45,7 @@ for (const mode of ['scalar', 'batch', 'legacy', 'missing-method']) { return { id: 'result', ok: true, result: [{ exists: true }] } }) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } diff --git a/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts b/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts index f972b51eb36..ac4ab715930 100644 --- a/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts +++ b/src/renderer/src/runtime/runtime-path-existence-queue-pairing.test.ts @@ -43,7 +43,7 @@ for (const mode of ['scalar', 'queue', 'legacy', 'missing-method'] as const) { return { id: 'result', ok: true, result: [{ exists: true }] } }) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } @@ -77,7 +77,7 @@ it('keeps two queued hovers on distinct revisions of the same environment', asyn ) runtimeEnvironmentCall.mockResolvedValue({ id: 'result', ok: true, result: [{ exists: true }] }) const context = { - settings: { activeRuntimeEnvironmentId: 'owner' }, + target: { kind: 'environment' as const, environmentId: 'owner' }, worktreeId: 'folder-1', worktreePath: '/folder' } diff --git a/src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts b/src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts index 6c15b329bc4..99dbd09ea98 100644 --- a/src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts +++ b/src/renderer/src/runtime/runtime-quick-open-version-compatibility.test.ts @@ -21,7 +21,7 @@ it('accepts a future compatible search version rather than falling back on exact await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'future-host', worktreePath: '/host/repo' }, @@ -52,7 +52,7 @@ it('validates default-policy recent paths on a version-two host through complete await expect( listRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'recent-legacy', worktreePath: '/host/repo' }, @@ -81,7 +81,7 @@ it('sends bounded candidates only after a compatible host advertises their seman await expect( listRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'recent-new', worktreePath: '/host/repo' }, @@ -115,7 +115,7 @@ it('refuses to infer recent eligibility from a truncated old-host inventory', as await expect( listRuntimeFiles( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'recent-truncated', worktreePath: '/host/repo' }, @@ -129,7 +129,7 @@ it.each(['missing-search', 'old-search'] as const)( 'translates only unavailable inventory errors after %s', async (route) => { const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'fallback-errors', worktreePath: '/host/repo' } @@ -161,7 +161,7 @@ it.each(['missing-search', 'old-search'] as const)( it('preserves raw errors when joining a cached pending legacy inventory', async () => { const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'cached-errors', worktreePath: '/host/repo' } @@ -221,7 +221,7 @@ it.each(['method_not_found', 'file_inventory_capacity', 'remote_runtime_unavaila await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: `punctuation-${code}`, worktreePath: '/host/repo' }, @@ -249,7 +249,7 @@ it.each([{ includeIgnored: false }, { followSymlinks: true }])( await expect( searchRuntimeFilePaths( { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'v2-options', worktreePath: '/repo' }, @@ -277,7 +277,7 @@ it('keeps inherited ignored-file visibility from disabling an older paired host' }) ) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'legacy-ignored', worktreePath: '/repo' } @@ -317,7 +317,7 @@ it('does not reuse unfiltered recent inventory after enabling supported ignore f }) ) const context = { - settings: { activeRuntimeEnvironmentId: 'env-1' }, + target: { kind: 'environment' as const, environmentId: 'env-1' }, worktreeId: 'cached-v2-options', worktreePath: '/repo' } diff --git a/src/renderer/src/runtime/use-worktree-runtime-target.ts b/src/renderer/src/runtime/use-worktree-runtime-target.ts index 22ef4ed5c4b..503f112d77b 100644 --- a/src/renderer/src/runtime/use-worktree-runtime-target.ts +++ b/src/renderer/src/runtime/use-worktree-runtime-target.ts @@ -1,7 +1,12 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -import { runtimeTargetForExecutionHostId, type RuntimeClientTarget } from './runtime-client-target' +import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' +import { + runtimeTargetForExecutionHostId, + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from './runtime-client-target' /** * Runtime target that owns `worktreeId`, which is not always the globally @@ -14,3 +19,22 @@ export function useWorktreeRuntimeTarget( const executionHostId = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId)) return useMemo(() => runtimeTargetForExecutionHostId(executionHostId), [executionHostId]) } + +/** + * Transport to an open file's owner (see {@link getRuntimeTargetForFileOwner}), stable across + * renders; `null` while rows disagree. + */ +export function useFileOwnerRuntimeTarget( + worktreeId: string | null | undefined, + runtimeEnvironmentId: string | null | undefined +): RuntimeClientTarget | null { + // Why a string: a fresh target object per store write would re-render on every change. + const ownerKey = useAppStore((state) => { + const target = getRuntimeTargetForFileOwner(state, worktreeId, runtimeEnvironmentId) + return target ? (target.kind === 'environment' ? target.environmentId : '') : null + }) + return useMemo( + () => (ownerKey === null ? null : runtimeTargetForOwnerEnvironment(ownerKey || null)), + [ownerKey] + ) +} diff --git a/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts b/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts index fdfb2e4f2cc..dfb1e15207e 100644 --- a/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts +++ b/src/renderer/src/store/slices/editor/actions/markdown-link-action.ts @@ -8,7 +8,7 @@ import { getConnectionIdForFileFromState } from '@/lib/connection-owner-resoluti import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { getLinkSourceLocalOpenOwner } from '@/lib/link-source-local-open-owner' import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal-links' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' import { statRuntimePath } from '@/runtime/runtime-file-client' import { getOpenedEditFileIdAfterOpen } from '../file-ids/editor-file-ids' import { scheduleEditorLineReveal } from '../focus/editor-focus-reveal' @@ -44,7 +44,6 @@ export function createMarkdownLinkAction( ? ctx.runtimeEnvironmentId : inferredRuntimeEnvironmentId const runtimeOwnerId = sourceRuntimeEnvironmentId?.trim() || null - const sourceSettings = settingsForRuntimeOwner(initialState.settings, runtimeOwnerId) const resolvedConnectionId = ctx.sourceOwner || runtimeOwnerId ? undefined @@ -63,7 +62,7 @@ export function createMarkdownLinkAction( } const sourceConnectionId = sourceOwner.kind === 'ssh' ? sourceOwner.connectionId : undefined const fileContext = { - settings: sourceSettings, + target: runtimeTargetForOwnerEnvironment(runtimeOwnerId), worktreeId: ctx.worktreeId, worktreePath: ctx.worktreeRoot, connectionId: sourceConnectionId diff --git a/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts b/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts index c31880a13eb..da52fb766ca 100644 --- a/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts +++ b/src/renderer/src/store/slices/editor/actions/markdown-preview-actions.ts @@ -50,7 +50,7 @@ export function createMarkdownPreviewActions( worktree.path, worktreeId, operationContext.connectionId, - operationContext.settings, + operationContext.target, operationProvenance, operationContext.expectedSshConnectionGeneration, operationContext.expectedSshTargetId,