Repository navigation
fix(file-drop): explorer, project sidebar, tab strip and editor own OS file drops (STA-6940 PR5) #26133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
brennanb2025
merged 32 commits into
main
from
brennanb2025/sta-6940-pr5-explorer-editor-owners
Oct 8, 2026
Merged
fix(file-drop): explorer, project sidebar, tab strip and editor own OS file drops (STA-6940 PR5) #26133
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
80f0623
feat(file-drop): add element owner preparation plumbing
brennanb2025 2dcc2a9
Fix terminal and chat file drop destination ownership
brennanb2025 a0a5923
fix runtime terminal drop ownership and queued chat retries
brennanb2025 2e3f19a
fix(file-drop): preserve feedback acceptance and destination ordering
brennanb2025 1146abc
Merge branch 'brennanb2025/sta-6940-pr1-drop-ownership' into brennanb…
brennanb2025 015f710
fix(file-drop): attach chat and composer files at the drop surface
brennanb2025 036f0db
fix(file-drop): preserve project destinations and live chat availability
brennanb2025 7f5a703
fix(file-drop): keep path resolution in filesystem namespace
brennanb2025 3596fb7
Merge remote-tracking branch 'origin/brennanb2025/sta-6940-pr2-drop-p…
brennanb2025 e3b891b
test(file-drop): use filesystem path bridge in PR3 fixtures
brennanb2025 8391981
Merge origin/main into STA-6940 PR2
brennanb2025 d844eb9
fix(file-drop): bind terminal drops to pane elements
brennanb2025 fd67741
fix(file-drop): compare terminal pane identity across public views
brennanb2025 be26b1b
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-6940…
brennanb2025 15d5b3f
Merge updated STA-6940 PR2 and main into PR3
brennanb2025 3dde5f1
test(file-drop): align composer lifecycle with element-owned drops
brennanb2025 ff58070
Merge origin/main into STA-6940 PR3
brennanb2025 fbb9334
Merge final STA-6940 chat and composer stack into terminal owners
brennanb2025 ad6d715
Merge origin/main into STA-6940 chat drop migration
brennanb2025 2c39f25
Fix interrupted chat composition and refuse unused quick-create drops
brennanb2025 c62129a
Type the quick-create drop regression transfer
brennanb2025 080123c
Merge remote-tracking branch 'origin/brennanb2025/sta-6940-pr3-chat-c…
brennanb2025 e9f07e7
Merge origin/main into STA-6940 PR4
brennanb2025 1e425ad
Merge origin/main into STA-6940 PR4
brennanb2025 c4825e8
Merge origin/main into STA-6940 PR3 chat drop migration
brennanb2025 0c95de3
Merge origin/main (main typecheck fix #26097) into STA-6940 PR3
brennanb2025 eda595d
Merge STA-6940 PR3 (0c95de36943) into PR4
brennanb2025 b4aa7f4
Merge origin/main (4077fb4a8de) into STA-6940 PR4
brennanb2025 ae8d3ab
fix(file-drop): let the explorer, project sidebar, tab strip and edit…
brennanb2025 e767be6
fix(file-drop): keep editor delivery tied to its destination group
brennanb2025 5ed9268
fix(file-drop): guard delayed opens and share floating ownership
brennanb2025 2ef4f37
Merge origin/main (939288e51d3) into STA-6940 PR5
brennanb2025 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
90 changes: 90 additions & 0 deletions
90
src/renderer/src/components/editor/editor-dropped-file-open.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { | ||
| getEditorFileDropOperationContext, | ||
| getEditorFileDropSettingsForWorktree, | ||
| shouldUploadRemoteEditorFileDrop | ||
| } from './editor-dropped-file-open' | ||
|
|
||
| describe('shouldUploadRemoteEditorFileDrop', () => { | ||
| it('does not upload editor drops for local workspaces', () => { | ||
| expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, null)).toBe(false) | ||
| }) | ||
|
|
||
| it('uploads editor drops while a runtime environment is active', () => { | ||
| expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: 'env-1' }, null)).toBe( | ||
| true | ||
| ) | ||
| }) | ||
|
|
||
| it('uploads editor drops for SSH workspaces', () => { | ||
| expect(shouldUploadRemoteEditorFileDrop({ activeRuntimeEnvironmentId: null }, 'ssh-1')).toBe( | ||
| true | ||
| ) | ||
| }) | ||
|
|
||
| it('uses the worktree owner runtime instead of the focused runtime', () => { | ||
| expect( | ||
| getEditorFileDropSettingsForWorktree( | ||
| { | ||
| settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, | ||
| repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'runtime:owner-runtime' }], | ||
| worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } | ||
| }, | ||
| 'wt-1' | ||
| ) | ||
| ).toEqual({ activeRuntimeEnvironmentId: 'owner-runtime' }) | ||
| }) | ||
|
|
||
| it('keeps explicit local worktree editor drops local while a runtime is focused', () => { | ||
| expect( | ||
| getEditorFileDropSettingsForWorktree( | ||
| { | ||
| settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, | ||
| repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], | ||
| worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } | ||
| }, | ||
| 'wt-1' | ||
| ) | ||
| ).toEqual({ activeRuntimeEnvironmentId: null }) | ||
| }) | ||
|
|
||
| it('builds file operation context from the worktree owner instead of global focus', () => { | ||
| expect( | ||
| getEditorFileDropOperationContext( | ||
| { | ||
| settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, | ||
| repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }], | ||
| worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } | ||
| }, | ||
| 'wt-1', | ||
| '/repos/repo-1', | ||
| undefined | ||
| ) | ||
| ).toEqual({ | ||
| settings: { activeRuntimeEnvironmentId: null }, | ||
| worktreeId: 'wt-1', | ||
| worktreePath: '/repos/repo-1', | ||
| connectionId: undefined | ||
| }) | ||
| }) | ||
|
|
||
| it('preserves SSH ownership in editor drop operation context', () => { | ||
| expect( | ||
| getEditorFileDropOperationContext( | ||
| { | ||
| settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, | ||
| repos: [{ id: 'repo-1', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }], | ||
| worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] } | ||
| }, | ||
| 'wt-1', | ||
| '/home/orca/repo-1', | ||
| 'ssh-1' | ||
| ) | ||
| ).toEqual({ | ||
| settings: { activeRuntimeEnvironmentId: null }, | ||
| worktreeId: 'wt-1', | ||
| worktreePath: '/home/orca/repo-1', | ||
| connectionId: 'ssh-1' | ||
| }) | ||
| }) | ||
| }) |
258 changes: 258 additions & 0 deletions
258
src/renderer/src/components/editor/editor-dropped-file-open.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| import { toast } from 'sonner' | ||
| import { detectLanguage } from '@/lib/language-detect' | ||
| import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' | ||
| 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 { | ||
| importExternalPathsToRuntime, | ||
| type RuntimeFileOperationArgs | ||
| } from '@/runtime/runtime-file-client' | ||
| import type { GlobalSettings } from '../../../../shared/global-settings-types' | ||
| 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<GlobalSettings, 'activeRuntimeEnvironmentId'> { | ||
| 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 | ||
| } | ||
| } | ||
|
|
||
| export function shouldUploadRemoteEditorFileDrop( | ||
| settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined, | ||
| connectionId: string | null | undefined | ||
| ): boolean { | ||
| return Boolean(settings?.activeRuntimeEnvironmentId?.trim() || connectionId?.trim()) | ||
| } | ||
|
|
||
| export function getEditorFileDropOperationContext( | ||
| store: WorktreeRuntimeOwnerState, | ||
| worktreeId: string, | ||
| worktreePath: string | null | undefined, | ||
| connectionId: string | undefined | ||
| ): RuntimeFileOperationArgs { | ||
| return { | ||
| settings: getEditorFileDropSettingsForWorktree(store, worktreeId), | ||
| worktreeId, | ||
| worktreePath, | ||
| connectionId | ||
| } | ||
| } | ||
|
|
||
| export type EditorFileDropDestination = { | ||
| worktreeId: string | ||
| /** Omitted only by the legacy route, which lets the store pick the worktree's active group. */ | ||
| groupId?: string | ||
| } | ||
|
|
||
| type EditorFileDropContext = { | ||
| fileContext: RuntimeFileOperationArgs | ||
| worktreePath: string | undefined | ||
| connectionId: string | undefined | ||
| } | ||
|
|
||
| function captureEditorFileDropContext(worktreeId: string): EditorFileDropContext | null { | ||
| const store = useAppStore.getState() | ||
| const worktreePath = store.getKnownWorktreeById(worktreeId)?.path | ||
| const connectionId = getConnectionId(worktreeId) ?? undefined | ||
| try { | ||
| return { | ||
| fileContext: { | ||
| ...getEditorFileDropOperationContext(store, worktreeId, worktreePath, connectionId), | ||
| ...captureWorktreeSshMutationExpectation(store, worktreeId) | ||
| }, | ||
| worktreePath, | ||
| connectionId | ||
| } | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| function sameEditorFileDropOwner(a: EditorFileDropContext, b: EditorFileDropContext): boolean { | ||
| const left = a.fileContext | ||
| const right = b.fileContext | ||
| return ( | ||
| a.worktreePath === b.worktreePath && | ||
| a.connectionId === b.connectionId && | ||
| left.settings?.activeRuntimeEnvironmentId === right.settings?.activeRuntimeEnvironmentId && | ||
| left.expectedExecutionHostId === right.expectedExecutionHostId && | ||
| left.expectedSshTargetId === right.expectedSshTargetId && | ||
| left.expectedSshConnectionGeneration === right.expectedSshConnectionGeneration | ||
| ) | ||
| } | ||
|
|
||
| function showOwnerChangedError(): void { | ||
| toast.error( | ||
| translate( | ||
| 'auto.hooks.useGlobalFileDrop.ownerChanged', | ||
| "Couldn't verify which host owns this workspace. Try again after it reconnects." | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| export function editorGroupStillExists({ | ||
| worktreeId, | ||
| groupId | ||
| }: EditorFileDropDestination): boolean { | ||
| if (!groupId) { | ||
| return true | ||
| } | ||
| const groups = useAppStore.getState().groupsByWorktree[worktreeId] ?? [] | ||
| return groups.some((group) => group.id === groupId) | ||
| } | ||
|
|
||
| /** | ||
| * Captures the destination workspace's host at drop time and returns the open | ||
| * step; a host change before preparation finishes refuses instead of re-routing. | ||
| */ | ||
| export function captureEditorFileDropOpen( | ||
| destination: EditorFileDropDestination | ||
| ): (paths: readonly string[]) => Promise<void> { | ||
| const captured = captureEditorFileDropContext(destination.worktreeId) | ||
| if (!captured) { | ||
| showOwnerChangedError() | ||
| return async () => undefined | ||
| } | ||
| return async (paths) => { | ||
| if (paths.length === 0 || !editorGroupStillExists(destination)) { | ||
| return | ||
| } | ||
| const current = captureEditorFileDropContext(destination.worktreeId) | ||
| if (!current || !sameEditorFileDropOwner(captured, current)) { | ||
| showOwnerChangedError() | ||
| return | ||
| } | ||
| await openEditorFileDropPaths(captured, destination, paths) | ||
| } | ||
| } | ||
|
|
||
| async function openEditorFileDropPaths( | ||
| { fileContext, worktreePath, connectionId }: EditorFileDropContext, | ||
| destination: EditorFileDropDestination, | ||
| paths: readonly string[] | ||
| ): Promise<void> { | ||
| 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)) { | ||
| if (!worktreePath) { | ||
| toast.error( | ||
| translate( | ||
| 'auto.hooks.useGlobalFileDrop.245faa95b9', | ||
| 'No remote workspace path is available for dropped files.' | ||
| ) | ||
| ) | ||
| return | ||
| } | ||
| try { | ||
| // Why: OS file drops provide client-local paths. Remote runtime and | ||
| // SSH editors must upload into the server worktree before opening. | ||
| const destinationDir = joinPath(worktreePath, '.orca/drops') | ||
| const { results } = await importExternalPathsToRuntime( | ||
| fileContext, | ||
| [...paths], | ||
| destinationDir, | ||
| { ensureDestinationDir: true } | ||
| ) | ||
| if (!editorGroupStillExists(destination)) { | ||
| return | ||
| } | ||
| for (const result of results) { | ||
| if (!editorGroupStillExists(destination)) { | ||
| return | ||
| } | ||
| if (result.status !== 'imported' || result.kind === 'directory') { | ||
| continue | ||
| } | ||
| const maybeRelative = toWorktreeRelativePath(result.destPath, worktreePath) | ||
| store.setActiveTabType('editor', worktreeId) | ||
| store.openFile( | ||
| { | ||
| filePath: result.destPath, | ||
| relativePath: maybeRelative ?? result.destPath, | ||
| worktreeId, | ||
| runtimeEnvironmentId: runtimeEnvironmentId ?? undefined, | ||
| language: detectLanguage(result.destPath), | ||
| mode: 'edit' | ||
| }, | ||
| { suppressActiveRuntimeFallback: runtimeEnvironmentId === null, ...groupOptions } | ||
| ) | ||
| } | ||
| if (results.some((result) => result.status !== 'imported')) { | ||
| toast.error( | ||
| translate( | ||
| 'auto.hooks.useGlobalFileDrop.d720e2f855', | ||
| 'Some dropped files could not be uploaded.' | ||
| ) | ||
| ) | ||
| } | ||
| } catch { | ||
| toast.error( | ||
| translate('auto.hooks.useGlobalFileDrop.38c9f034ff', 'Failed to upload dropped files.') | ||
| ) | ||
| } | ||
| return | ||
| } | ||
|
|
||
| // Sequential, so a multi-file drop opens its tabs in drop order. | ||
| for (const filePath of paths) { | ||
| try { | ||
| const stat = await statUserOpenedPath(fileContext, filePath) | ||
| if (!editorGroupStillExists(destination)) { | ||
| return | ||
| } | ||
| if (stat.isDirectory) { | ||
| continue | ||
| } | ||
| let relativePath = filePath | ||
| // Why: a project link out of the project keeps its absolute path, so it reads as | ||
| // user-named instead of being refused as a project file. | ||
| if (worktreePath && !stat.escapesWorktree && isPathInsideWorktree(filePath, worktreePath)) { | ||
| const maybeRelative = toWorktreeRelativePath(filePath, worktreePath) | ||
| if (maybeRelative !== null && maybeRelative.length > 0) { | ||
| relativePath = maybeRelative | ||
| } | ||
| } | ||
| store.setActiveTabType('editor', worktreeId) | ||
| if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { | ||
| openDocumentInFloatingWorkspace( | ||
| store.openFile, | ||
| { filePath, relativePath }, | ||
| groupOptions ?? {} | ||
| ) | ||
| continue | ||
| } | ||
| const file = { | ||
| filePath, | ||
| relativePath, | ||
| worktreeId, | ||
| language: detectLanguage(filePath), | ||
| mode: 'edit' as const | ||
| } | ||
| if (groupOptions) { | ||
| store.openFile(file, groupOptions) | ||
| } else { | ||
| store.openFile(file) | ||
| } | ||
| } catch { | ||
| // Ignore files that cannot be stat'd. | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: stablyai/orca
Length of output: 18681
🏁 Script executed:
Repository: stablyai/orca
Length of output: 42337
🏁 Script executed:
Repository: stablyai/orca
Length of output: 42963
🏁 Script executed:
Repository: stablyai/orca
Length of output: 41794
🏁 Script executed:
Repository: stablyai/orca
Length of output: 43022
Recheck the worktree owner after each awaited operation.
The runtime importer and SSH upload path guard their own runtime pairing and SSH generation. They do not replace a final check that the editor worktree still has the captured owner. A worktree owner can change while the import or stat is pending. The current code then checks only the group and can open a stale path or runtime tab.
Recapture the context after
importExternalPathsToRuntimeandstatUserOpenedPath. Stop opening whensameEditorFileDropOwnerreturns false. Preserve the existing upload-failure notification.Suggested owner checks