Skip to content
Merged
Show file tree
Hide file tree
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 Oct 6, 2026
2dcc2a9
Fix terminal and chat file drop destination ownership
brennanb2025 Oct 6, 2026
a0a5923
fix runtime terminal drop ownership and queued chat retries
brennanb2025 Oct 6, 2026
2e3f19a
fix(file-drop): preserve feedback acceptance and destination ordering
brennanb2025 Oct 6, 2026
1146abc
Merge branch 'brennanb2025/sta-6940-pr1-drop-ownership' into brennanb…
brennanb2025 Oct 6, 2026
015f710
fix(file-drop): attach chat and composer files at the drop surface
brennanb2025 Oct 6, 2026
036f0db
fix(file-drop): preserve project destinations and live chat availability
brennanb2025 Oct 6, 2026
7f5a703
fix(file-drop): keep path resolution in filesystem namespace
brennanb2025 Oct 6, 2026
3596fb7
Merge remote-tracking branch 'origin/brennanb2025/sta-6940-pr2-drop-p…
brennanb2025 Oct 6, 2026
e3b891b
test(file-drop): use filesystem path bridge in PR3 fixtures
brennanb2025 Oct 6, 2026
8391981
Merge origin/main into STA-6940 PR2
brennanb2025 Oct 6, 2026
d844eb9
fix(file-drop): bind terminal drops to pane elements
brennanb2025 Oct 6, 2026
fd67741
fix(file-drop): compare terminal pane identity across public views
brennanb2025 Oct 6, 2026
be26b1b
Merge remote-tracking branch 'origin/main' into brennanb2025/sta-6940…
brennanb2025 Oct 7, 2026
15d5b3f
Merge updated STA-6940 PR2 and main into PR3
brennanb2025 Oct 7, 2026
3dde5f1
test(file-drop): align composer lifecycle with element-owned drops
brennanb2025 Oct 7, 2026
ff58070
Merge origin/main into STA-6940 PR3
brennanb2025 Oct 7, 2026
fbb9334
Merge final STA-6940 chat and composer stack into terminal owners
brennanb2025 Oct 7, 2026
ad6d715
Merge origin/main into STA-6940 chat drop migration
brennanb2025 Oct 7, 2026
2c39f25
Fix interrupted chat composition and refuse unused quick-create drops
brennanb2025 Oct 7, 2026
c62129a
Type the quick-create drop regression transfer
brennanb2025 Oct 7, 2026
080123c
Merge remote-tracking branch 'origin/brennanb2025/sta-6940-pr3-chat-c…
brennanb2025 Oct 7, 2026
e9f07e7
Merge origin/main into STA-6940 PR4
brennanb2025 Oct 7, 2026
1e425ad
Merge origin/main into STA-6940 PR4
brennanb2025 Oct 7, 2026
c4825e8
Merge origin/main into STA-6940 PR3 chat drop migration
brennanb2025 Oct 7, 2026
0c95de3
Merge origin/main (main typecheck fix #26097) into STA-6940 PR3
brennanb2025 Oct 7, 2026
eda595d
Merge STA-6940 PR3 (0c95de36943) into PR4
brennanb2025 Oct 7, 2026
b4aa7f4
Merge origin/main (4077fb4a8de) into STA-6940 PR4
brennanb2025 Oct 7, 2026
ae8d3ab
fix(file-drop): let the explorer, project sidebar, tab strip and edit…
brennanb2025 Oct 7, 2026
e767be6
fix(file-drop): keep editor delivery tied to its destination group
brennanb2025 Oct 7, 2026
5ed9268
fix(file-drop): guard delayed opens and share floating ownership
brennanb2025 Oct 7, 2026
2ef4f37
Merge origin/main (939288e51d3) into STA-6940 PR5
brennanb2025 Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
// @vitest-environment happy-dom

import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeFileDropPayload } from '../../../shared/native-file-drop'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
statUserOpenedPath: vi.fn(),
Expand Down Expand Up @@ -31,33 +27,15 @@ vi.mock('@/store', () => ({
}
}))

import { useGlobalFileDrop } from './useGlobalFileDrop'

let dropListener: ((data: NativeFileDropPayload) => void) | null = null
import { captureEditorFileDropOpen } from './editor-dropped-file-open'

beforeEach(() => {
mocks.statUserOpenedPath.mockReset()
mocks.openFile.mockReset()
vi.stubGlobal('api', {
ui: {
onFileDrop: (listener: (data: NativeFileDropPayload) => void) => {
dropListener = listener
return () => {
dropListener = null
}
}
}
})
})

afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})

async function dropOnTabStrip(path: string): Promise<void> {
renderHook(() => useGlobalFileDrop())
dropListener?.({ target: 'editor', paths: [path] })
await captureEditorFileDropOpen({ worktreeId: 'wt-1' })([path])
await vi.waitFor(() => expect(mocks.openFile).toHaveBeenCalledTimes(1))
}

Expand Down
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 src/renderer/src/components/editor/editor-dropped-file-open.ts
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 }
)
}
Comment on lines +177 to +197

Copy link
Copy Markdown
Contributor

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:

sed -n '68,258p' src/renderer/src/components/editor/editor-dropped-file-open.ts
rg -n 'function importExternalPathsToRuntime|const importExternalPathsToRuntime|assertCurrent|function statUserOpenedPath' src/renderer/src

Repository: stablyai/orca

Length of output: 18681


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- target imports and declarations ---'
sed -n '1,120p' src/renderer/src/components/editor/editor-dropped-file-open.ts
sed -n '1,250p' src/renderer/src/runtime/runtime-file-import-client.ts
sed -n '1,120p' src/renderer/src/lib/user-opened-local-path.ts
printf '%s\n' '--- callers and owner/context references ---'
rg -n -F -- 'captureEditorFileDropOpen' src/renderer/src || true
rg -n -F -- 'captureWorktreeSshMutationExpectation' src/renderer/src || true
rg -n -F -- 'openEditorFileDropPaths' src/renderer/src || true
printf '%s\n' '--- focused tests ---'
rg -n -F -- 'editor-dropped-file-open' src/renderer/src --glob '*test*' || true
rg -n -F -- 'ownerChanged' src/renderer/src/components src/renderer/src/runtime --glob '*test*' || true
rg -n -F -- 'importExternalPathsToRuntime' src/renderer/src/components src/renderer/src/runtime --glob '*test*' || true
printf '%s\n' '--- openFile definitions and runtime resolution ---'
rg -n -F -- 'openFile:' src/renderer/src/store src/renderer/src --glob '*.ts' --glob '*.tsx' || true
rg -n -F -- 'function openFile' src/renderer/src --glob '*.ts' --glob '*.tsx' || true

Repository: stablyai/orca

Length of output: 42337


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- SSH expectation and runtime stat routing ---'
sed -n '1,240p' src/renderer/src/lib/ssh-mutation-expectation.ts
rg -n -F -- 'function statRuntimePath' src/renderer/src/runtime src/renderer/src
sed -n '1,180p' src/renderer/src/runtime/runtime-file-metadata-client.ts
sed -n '1,180p' src/renderer/src/runtime/runtime-file-routing.ts
printf '%s\n' '--- editor drop tests ---'
sed -n '1,280p' src/renderer/src/components/editor/editor-dropped-file-open.test.ts
sed -n '1,220p' src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx
sed -n '1,240p' src/renderer/src/components/editor/use-editor-group-file-drop-owner.test.tsx
sed -n '1,180p' src/renderer/src/components/editor/editor-dropped-file-open.escaping-link.test.ts
printf '%s\n' '--- importer ownership tests ---'
sed -n '240,470p' src/renderer/src/runtime/runtime-file-client-external-import.test.ts
printf '%s\n' '--- store open behavior ---'
sed -n '1,180p' src/renderer/src/store/slices/editor/actions/open-file-action.ts
sed -n '1,100p' src/renderer/src/store/slices/editor/types/editor-files-slice.ts

Repository: stablyai/orca

Length of output: 42963


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- filesystem IPC consumers and owner checks ---'
rg -n -F -- 'importExternalPaths' src --glob '*.ts' --glob '*.tsx' || true
rg -n -F -- 'expectedSshConnectionGeneration' src --glob '*.ts' --glob '*.tsx' || true
rg -n -F -- 'expectedExecutionHostId' src --glob '*.ts' --glob '*.tsx' || true
rg -n -F -- 'fs.stat' src --glob '*.ts' --glob '*.tsx' || true
printf '%s\n' '--- open-file state application ---'
sed -n '1,280p' src/renderer/src/store/slices/editor/actions/open-file-apply.ts
printf '%s\n' '--- exact remote owner-change test range ---'
rg -n -F -- 'refuses when the workspace runtime owner changes' src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx || true
sed -n '180,360p' src/renderer/src/components/editor/use-editor-group-file-drop-owner.remote.test.tsx
printf '%s\n' '--- exact post-stat and post-import test ranges ---'
rg -n -F -- 'during upload' src/renderer/src/components/editor src/renderer/src/runtime --glob '*.test.ts' --glob '*.test.tsx' || true
rg -n -F -- 'during stat' src/renderer/src/components/editor --glob '*.test.ts' --glob '*.test.tsx' || true

Repository: stablyai/orca

Length of output: 41794


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- filesystem mutation validation ---'
rg -n --glob '*.ts' --glob '*.tsx' 'expectedSshConnectionGeneration|expectedExecutionHostId|importExternalPathsSsh' src/main src/shared src/preload
sed -n '130,220p' src/main/ipc/filesystem-mutations.ts
sed -n '1,130p' src/main/ipc/filesystem-import-ssh.ts
sed -n '1,180p' src/main/ipc/filesystem-import-ssh-ops.test.ts
printf '%s\n' '--- editor operation provenance ---'
rg -n --glob '*.ts' --glob '*.tsx' 'captureEditorFileOperationProvenance' src/renderer/src
sed -n '1,260p' src/renderer/src/lib/editor-file-operation-owner.ts
printf '%s\n' '--- runtime import guard ---'
sed -n '1,180p' src/renderer/src/runtime/runtime-file-mutation-rpc.ts

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 importExternalPathsToRuntime and statUserOpenedPath. Stop opening when sameEditorFileDropOwner returns false. Preserve the existing upload-failure notification.

Suggested owner checks
 async function openEditorFileDropPaths(
-  { fileContext, worktreePath, connectionId }: EditorFileDropContext,
+  captured: EditorFileDropContext,
   destination: EditorFileDropDestination,
   paths: readonly string[]
 ): Promise<void> {
+  const { fileContext, worktreePath, connectionId } = captured
   const store = useAppStore.getState()
...
       if (!editorGroupStillExists(destination)) {
         return
       }
-      for (const result of results) {
+      const current = captureEditorFileDropContext(worktreeId)
+      const ownerStillCurrent =
+        current !== null && sameEditorFileDropOwner(captured, current)
+      if (!ownerStillCurrent) {
+        showOwnerChangedError()
+      }
+      for (const result of ownerStillCurrent ? results : []) {
...
       const stat = await statUserOpenedPath(fileContext, filePath)
       if (!editorGroupStillExists(destination)) {
         return
       }
+      const current = captureEditorFileDropContext(worktreeId)
+      if (!current || !sameEditorFileDropOwner(captured, current)) {
+        showOwnerChangedError()
+        return
+      }

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.
}
}
}
Loading
Loading