mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(composer): clarify failed attachment drops (#20704)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape * fix(composer): name the attachments a drop could not add, in one toast * fix(composer, source-control): use one stable failure toast slot - Replace per-worktree toast IDs with single slot that replaces on each failure - Remove destructive retry actions; discard must confirm in dialog - Consolidate filesystem import types to shared location - Add compactIpcErrorMessage for string error handling * refactor: centralize filesystem import types and clarify failure naming Move import result types from main/ipc to shared layer so they're available across preload and renderer. Rename uniformFailure → commonFailure and skippedOrFailed → failureCount for clarity. Simplify preload/API type definitions by reusing shared types directly instead of duplicating inlined union shapes. * Reuse single toast slot for composer drop failures Multiple drop failures now replace the previous toast instead of stacking, preventing notification clutter. Uses a dedicated toast ID separate from Source Control's stage/discard notifications.
This commit is contained in:
@@ -2,7 +2,7 @@ import { lstat, rm } from 'node:fs/promises'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { authorizeExternalPath } from './filesystem-auth'
|
||||
import { isENOENT } from './filesystem-path-containment'
|
||||
import type { ImportItemResult } from './filesystem-import-result-types'
|
||||
import type { ImportItemResult } from '../../shared/filesystem-import-result-types'
|
||||
import {
|
||||
copyLocalFileNoFollow,
|
||||
preScanForSymlinks,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isENOENT } from './filesystem-path-containment'
|
||||
import { getSshConnectionManager } from './ssh'
|
||||
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
|
||||
import type { FileUploadSession, IFilesystemProvider } from '../providers/types'
|
||||
import type { ImportItemResult } from './filesystem-import-result-types'
|
||||
import type { ImportItemResult } from '../../shared/filesystem-import-result-types'
|
||||
import { assertSafeRemotePathSegment, type RemotePathFlavor } from '../ssh/ssh-remote-platform'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
ImportSkipReason,
|
||||
ResolveDroppedPathsResult,
|
||||
StagedExternalImportSource
|
||||
} from './filesystem-import-result-types'
|
||||
} from '../../shared/filesystem-import-result-types'
|
||||
import { importOneSource } from './filesystem-import-local'
|
||||
import {
|
||||
stagedRuntimeUploadByteLength,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isENOENT } from './filesystem-path-containment'
|
||||
import type {
|
||||
StagedExternalImportEntry,
|
||||
StagedExternalImportSource
|
||||
} from './filesystem-import-result-types'
|
||||
} from '../../shared/filesystem-import-result-types'
|
||||
|
||||
class RuntimeUploadSymlinkError extends Error {}
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"capturedAt": "2026-09-14T11:25:01.730Z",
|
||||
"platform": "darwin",
|
||||
"command": [
|
||||
"bun",
|
||||
"tests/tools/omp-native-title-capture.mjs",
|
||||
"<read-only-omp-checkout>"
|
||||
],
|
||||
"command": ["bun", "tests/tools/omp-native-title-capture.mjs", "<read-only-omp-checkout>"],
|
||||
"cols": 100,
|
||||
"rows": 30,
|
||||
"note": "OMP source ne7546987ca526eac8f605fac19ef9805b8f01898 buildTerminalTitleWithState; explicit win32 argument on macOS PTY, synthetic state transitions, no model/account. Not a Windows runtime capture.",
|
||||
|
||||
@@ -5,6 +5,11 @@ import type {
|
||||
FsChangedPayload,
|
||||
MarkdownDocument
|
||||
} from '../../shared/filesystem-entry-types'
|
||||
import type {
|
||||
ImportItemResult,
|
||||
ResolveDroppedPathsResult,
|
||||
StagedExternalImportSource
|
||||
} from '../../shared/filesystem-import-result-types'
|
||||
import type {
|
||||
LocalLogTailChangedPayload,
|
||||
LocalLogTailReadArgs,
|
||||
@@ -12,10 +17,7 @@ import type {
|
||||
LocalLogTailWatchArgs
|
||||
} from '../../shared/local-log-tail-types'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import type {
|
||||
RuntimeUploadFileStreamRequest,
|
||||
StageRuntimeUploadResult
|
||||
} from '../../shared/runtime-upload-staging-contract'
|
||||
import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract'
|
||||
|
||||
export type ExportApi = {
|
||||
htmlToPdf: (args: {
|
||||
@@ -138,30 +140,10 @@ export type FilesystemApi = {
|
||||
connectionId?: string
|
||||
ensureDir?: boolean
|
||||
} & SshMutationExpectation
|
||||
) => Promise<{
|
||||
results: (
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'imported'
|
||||
destPath: string
|
||||
kind: 'file' | 'directory'
|
||||
renamed: boolean
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'skipped'
|
||||
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'failed'
|
||||
reason: string
|
||||
}
|
||||
)[]
|
||||
}>
|
||||
) => Promise<{ results: ImportItemResult[] }>
|
||||
stageExternalPathsForRuntimeUpload: (args: {
|
||||
sourcePaths: string[]
|
||||
}) => Promise<StageRuntimeUploadResult>
|
||||
}) => Promise<{ sources: StagedExternalImportSource[] }>
|
||||
uploadExternalFileToRuntime: (
|
||||
args: RuntimeUploadFileStreamRequest
|
||||
) => Promise<{ byteLength: number }>
|
||||
@@ -171,14 +153,7 @@ export type FilesystemApi = {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
) => Promise<{
|
||||
resolvedPaths: string[]
|
||||
skipped: {
|
||||
sourcePath: string
|
||||
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
|
||||
}[]
|
||||
failed: { sourcePath: string; reason: string }[]
|
||||
}>
|
||||
) => Promise<ResolveDroppedPathsResult>
|
||||
watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
|
||||
unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise<void>
|
||||
onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { PathExistenceResult } from '../../shared/path-existence-batch'
|
||||
import { ipcRenderer } from 'electron'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import type {
|
||||
RuntimeUploadFileStreamRequest,
|
||||
StageRuntimeUploadResult
|
||||
} from '../../shared/runtime-upload-staging-contract'
|
||||
import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract'
|
||||
import type { SearchResult } from '../../shared/code-search-types'
|
||||
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
|
||||
import type {
|
||||
ImportItemResult,
|
||||
ResolveDroppedPathsResult,
|
||||
StagedExternalImportSource
|
||||
} from '../../shared/filesystem-import-result-types'
|
||||
import type {
|
||||
LocalLogTailChangedPayload,
|
||||
LocalLogTailReadArgs,
|
||||
@@ -155,30 +157,10 @@ export const fsApi = {
|
||||
connectionId?: string
|
||||
ensureDir?: boolean
|
||||
} & SshMutationExpectation
|
||||
): Promise<{
|
||||
results: (
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'imported'
|
||||
destPath: string
|
||||
kind: 'file' | 'directory'
|
||||
renamed: boolean
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'skipped'
|
||||
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'failed'
|
||||
reason: string
|
||||
}
|
||||
)[]
|
||||
}> => ipcRenderer.invoke('fs:importExternalPaths', args),
|
||||
): Promise<{ results: ImportItemResult[] }> => ipcRenderer.invoke('fs:importExternalPaths', args),
|
||||
stageExternalPathsForRuntimeUpload: (args: {
|
||||
sourcePaths: string[]
|
||||
}): Promise<StageRuntimeUploadResult> =>
|
||||
}): Promise<{ sources: StagedExternalImportSource[] }> =>
|
||||
ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args),
|
||||
uploadExternalFileToRuntime: (
|
||||
args: RuntimeUploadFileStreamRequest
|
||||
@@ -189,14 +171,8 @@ export const fsApi = {
|
||||
worktreePath: string
|
||||
connectionId?: string
|
||||
} & SshMutationExpectation
|
||||
): Promise<{
|
||||
resolvedPaths: string[]
|
||||
skipped: {
|
||||
sourcePath: string
|
||||
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
|
||||
}[]
|
||||
failed: { sourcePath: string; reason: string }[]
|
||||
}> => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args),
|
||||
): Promise<ResolveDroppedPathsResult> =>
|
||||
ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args),
|
||||
watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('fs:watchWorktree', args),
|
||||
unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise<void> =>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { toastError } = vi.hoisted(() => ({
|
||||
toastError: vi.fn<(title: string, options?: { id?: string; description?: string }) => void>()
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: toastError } }))
|
||||
|
||||
import { showComposerDropFailureToast } from './composer-drop-failure-toast'
|
||||
import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types'
|
||||
|
||||
const SKIP_REASON_COPY = [
|
||||
['missing', 'No longer at its original path.'],
|
||||
['symlink', 'Symbolic links cannot be attached.'],
|
||||
['permission-denied', 'Permission denied.'],
|
||||
['unsupported', 'Unsupported file type.']
|
||||
] as const satisfies readonly (readonly [ImportSkipReason, string])[]
|
||||
|
||||
function lastToast(): { title: string; id?: string; description?: string } {
|
||||
const call = toastError.mock.calls.at(-1)
|
||||
return {
|
||||
title: String(call?.[0]),
|
||||
id: call?.[1]?.id,
|
||||
description: call?.[1]?.description
|
||||
}
|
||||
}
|
||||
|
||||
describe('showComposerDropFailureToast', () => {
|
||||
beforeEach(() => {
|
||||
toastError.mockClear()
|
||||
})
|
||||
|
||||
it('stays neutral about the gesture, and pluralises like its namespace siblings', () => {
|
||||
showComposerDropFailureToast({ failureCount: 1, total: 1 })
|
||||
expect(lastToast().title).toBe('1 of 1 item could not be attached.')
|
||||
|
||||
showComposerDropFailureToast({ failureCount: 2, total: 5 })
|
||||
expect(lastToast().title).toBe('2 of 5 items could not be attached.')
|
||||
})
|
||||
|
||||
it("turns the import client's skip enum into copy instead of leaking the token", () => {
|
||||
for (const [reason, expected] of SKIP_REASON_COPY) {
|
||||
showComposerDropFailureToast({
|
||||
failureCount: 1,
|
||||
total: 3,
|
||||
commonFailure: { status: 'skipped', reason }
|
||||
})
|
||||
expect(lastToast().description).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('passes a free-form failure reason straight through', () => {
|
||||
showComposerDropFailureToast({
|
||||
failureCount: 2,
|
||||
total: 4,
|
||||
commonFailure: { status: 'failed', reason: 'EACCES: permission denied' }
|
||||
})
|
||||
expect(lastToast().description).toBe('EACCES: permission denied')
|
||||
})
|
||||
|
||||
it('shows no description when nothing explained the failure', () => {
|
||||
showComposerDropFailureToast({ failureCount: 1, total: 2 })
|
||||
expect(lastToast().description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('unwraps and clamps a host-minted failure reason before it reaches the row', () => {
|
||||
showComposerDropFailureToast({
|
||||
failureCount: 1,
|
||||
total: 2,
|
||||
commonFailure: {
|
||||
status: 'failed',
|
||||
reason:
|
||||
"Error invoking remote method 'runtime:call': Error: EACCES: permission denied\nat Object.upload"
|
||||
}
|
||||
})
|
||||
expect(lastToast().description).toBe('EACCES: permission denied')
|
||||
})
|
||||
|
||||
it('reuses one slot so a second failed drop replaces the first instead of stacking', () => {
|
||||
showComposerDropFailureToast({ failureCount: 1, total: 2 })
|
||||
const first = lastToast().id
|
||||
showComposerDropFailureToast({ failureCount: 2, total: 3 })
|
||||
expect(first).toBeDefined()
|
||||
expect(lastToast().id).toBe(first)
|
||||
})
|
||||
|
||||
it('gives no reason at all when the batch failed for differing reasons', () => {
|
||||
showComposerDropFailureToast({ failureCount: 3, total: 6 })
|
||||
expect(lastToast().title).toBe('3 of 6 items could not be attached.')
|
||||
expect(lastToast().description).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { compactIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import type { ComposerDropFailure } from './composer-drop-result'
|
||||
import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types'
|
||||
|
||||
// Own slot, not Source Control's: a drop failure must not erase an unread stage/discard failure.
|
||||
const DROP_FAILURE_TOAST_ID = 'composer-drop-failure'
|
||||
|
||||
const SKIP_REASON_COPY: Record<ImportSkipReason, { key: string; fallback: string }> = {
|
||||
missing: {
|
||||
key: 'auto.hooks.useComposerState.attachSkipMissing',
|
||||
fallback: 'No longer at its original path.'
|
||||
},
|
||||
symlink: {
|
||||
key: 'auto.hooks.useComposerState.attachSkipSymlink',
|
||||
fallback: 'Symbolic links cannot be attached.'
|
||||
},
|
||||
'permission-denied': {
|
||||
key: 'auto.hooks.useComposerState.attachSkipPermissionDenied',
|
||||
fallback: 'Permission denied.'
|
||||
},
|
||||
unsupported: {
|
||||
key: 'auto.hooks.useComposerState.attachSkipUnsupported',
|
||||
fallback: 'Unsupported file type.'
|
||||
}
|
||||
}
|
||||
|
||||
function failureDescription(failure: ComposerDropFailure): string | undefined {
|
||||
if (failure.status === 'failed') {
|
||||
return failure.reason ? compactIpcErrorMessage(failure.reason) : undefined
|
||||
}
|
||||
const copy = SKIP_REASON_COPY[failure.reason]
|
||||
return copy ? translate(copy.key, copy.fallback) : undefined
|
||||
}
|
||||
|
||||
export function showComposerDropFailureToast({
|
||||
failureCount,
|
||||
total,
|
||||
commonFailure
|
||||
}: {
|
||||
failureCount: number
|
||||
total: number
|
||||
commonFailure?: ComposerDropFailure
|
||||
}): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.hooks.useComposerState.dropPartiallyAttached',
|
||||
'{{failureCount}} of {{count}} items could not be attached.',
|
||||
{ failureCount, count: total }
|
||||
),
|
||||
{
|
||||
id: DROP_FAILURE_TOAST_ID,
|
||||
description: commonFailure ? failureDescription(commonFailure) : undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectComposerDropResult, type ComposerDropItemResult } from './composer-drop-result'
|
||||
|
||||
describe('composer drop result', () => {
|
||||
it('separates imported files and folders while summarizing failures', () => {
|
||||
const results: ComposerDropItemResult[] = [
|
||||
{ status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' },
|
||||
{ status: 'imported', kind: 'directory', destPath: '/repo/.orca/drops/folder' },
|
||||
{ status: 'skipped', reason: 'permission-denied' },
|
||||
{ status: 'failed', reason: 'disk full' }
|
||||
]
|
||||
|
||||
expect(collectComposerDropResult(results)).toEqual({
|
||||
filePaths: ['/repo/.orca/drops/file.txt'],
|
||||
folderPaths: ['/repo/.orca/drops/folder'],
|
||||
failureCount: 2,
|
||||
commonFailure: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a failure only when it explains the whole failed subset', () => {
|
||||
expect(
|
||||
collectComposerDropResult([
|
||||
{ status: 'skipped', reason: 'missing' },
|
||||
{ status: 'skipped', reason: 'missing' }
|
||||
]).commonFailure
|
||||
).toEqual({ status: 'skipped', reason: 'missing' })
|
||||
|
||||
expect(
|
||||
collectComposerDropResult([
|
||||
{ status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' }
|
||||
]).commonFailure
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ImportSkipReason } from '../../../shared/filesystem-import-result-types'
|
||||
|
||||
export type ComposerDropItemResult =
|
||||
| {
|
||||
status: 'imported'
|
||||
destPath: string
|
||||
kind: 'file' | 'directory'
|
||||
}
|
||||
| {
|
||||
status: 'skipped'
|
||||
reason: ImportSkipReason
|
||||
}
|
||||
| {
|
||||
status: 'failed'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type ComposerDropFailure = Exclude<ComposerDropItemResult, { status: 'imported' }>
|
||||
|
||||
export type ComposerDropResult = {
|
||||
filePaths: string[]
|
||||
folderPaths: string[]
|
||||
failureCount: number
|
||||
commonFailure?: ComposerDropFailure
|
||||
}
|
||||
|
||||
function sameFailure(left: ComposerDropFailure, right: ComposerDropFailure): boolean {
|
||||
return left.status === right.status && left.reason === right.reason
|
||||
}
|
||||
|
||||
export function collectComposerDropResult(
|
||||
results: readonly ComposerDropItemResult[]
|
||||
): ComposerDropResult {
|
||||
const filePaths: string[] = []
|
||||
const folderPaths: string[] = []
|
||||
const failures: ComposerDropFailure[] = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status !== 'imported') {
|
||||
failures.push(result)
|
||||
} else if (result.kind === 'directory') {
|
||||
folderPaths.push(result.destPath)
|
||||
} else {
|
||||
filePaths.push(result.destPath)
|
||||
}
|
||||
}
|
||||
|
||||
const firstFailure = failures[0]
|
||||
return {
|
||||
filePaths,
|
||||
folderPaths,
|
||||
failureCount: failures.length,
|
||||
commonFailure:
|
||||
firstFailure && failures.every((failure) => sameFailure(firstFailure, failure))
|
||||
? firstFailure
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectComposerDropUploadResult,
|
||||
shouldReportComposerDropUploadFailure,
|
||||
type ComposerDropUploadImportResult
|
||||
} from './composer-drop-upload-result'
|
||||
|
||||
describe('composer drop upload result', () => {
|
||||
it('separates imported files and folders while counting skipped or failed paths', () => {
|
||||
const results: ComposerDropUploadImportResult[] = [
|
||||
{ status: 'imported', kind: 'file', destPath: '/repo/.orca/drops/file.txt' },
|
||||
{ status: 'imported', kind: 'directory', destPath: '/repo/.orca/drops/folder' },
|
||||
{ status: 'skipped' },
|
||||
{ status: 'failed' }
|
||||
]
|
||||
|
||||
expect(collectComposerDropUploadResult(results)).toEqual({
|
||||
filePaths: ['/repo/.orca/drops/file.txt'],
|
||||
folderPaths: ['/repo/.orca/drops/folder'],
|
||||
skippedOrFailed: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses failed-upload reporting after a composer loses drop ownership', () => {
|
||||
const uploadResult = { skippedOrFailed: 1 }
|
||||
|
||||
expect(shouldReportComposerDropUploadFailure(uploadResult, () => true)).toBe(true)
|
||||
expect(shouldReportComposerDropUploadFailure(uploadResult, () => false)).toBe(false)
|
||||
expect(shouldReportComposerDropUploadFailure({ skippedOrFailed: 0 }, () => true)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
export type ComposerDropUploadImportResult =
|
||||
| {
|
||||
status: 'imported'
|
||||
destPath: string
|
||||
kind: 'file' | 'directory'
|
||||
}
|
||||
| {
|
||||
status: 'skipped' | 'failed'
|
||||
}
|
||||
|
||||
export type ComposerDropUploadResult = {
|
||||
filePaths: string[]
|
||||
folderPaths: string[]
|
||||
skippedOrFailed: number
|
||||
}
|
||||
|
||||
export function collectComposerDropUploadResult(
|
||||
results: readonly ComposerDropUploadImportResult[]
|
||||
): ComposerDropUploadResult {
|
||||
const filePaths: string[] = []
|
||||
const folderPaths: string[] = []
|
||||
let skippedOrFailed = 0
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status !== 'imported') {
|
||||
skippedOrFailed += 1
|
||||
continue
|
||||
}
|
||||
if (result.kind === 'directory') {
|
||||
folderPaths.push(result.destPath)
|
||||
} else {
|
||||
filePaths.push(result.destPath)
|
||||
}
|
||||
}
|
||||
|
||||
return { filePaths, folderPaths, skippedOrFailed }
|
||||
}
|
||||
|
||||
export function shouldReportComposerDropUploadFailure(
|
||||
uploadResult: Pick<ComposerDropUploadResult, 'skippedOrFailed'>,
|
||||
canReport: () => boolean
|
||||
): boolean {
|
||||
return uploadResult.skippedOrFailed > 0 && canReport()
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ toastError: vi.fn(), importExternalPaths: vi.fn() }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(() => undefined, { getState: () => ({}) })
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-file-client', () => ({
|
||||
importExternalPathsToRuntime: (...args: unknown[]) => mocks.importExternalPaths(...args)
|
||||
}))
|
||||
vi.mock('./composer-drop-listener', () => ({ useComposerDropListener: vi.fn() }))
|
||||
|
||||
import { useAttachmentDropState } from './attachment-drop-state'
|
||||
|
||||
const FAILING_PATHS = new Set(['/drop/bad-1.png', '/drop/bad-2.png', '/drop/bad-3.png'])
|
||||
|
||||
function dropPaths(count: number): string[] {
|
||||
return [
|
||||
...FAILING_PATHS,
|
||||
...Array.from({ length: count - FAILING_PATHS.size }, (_, index) => `/drop/ok-${index}.png`)
|
||||
]
|
||||
}
|
||||
|
||||
function installFsApi(): void {
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
fs: {
|
||||
authorizeExternalPath: vi.fn(async () => {}),
|
||||
stat: vi.fn(async ({ filePath }: { filePath: string }) => {
|
||||
if (FAILING_PATHS.has(filePath)) {
|
||||
throw new Error(
|
||||
"Error invoking remote method 'fs:stat': Error: ENOENT: no such file or directory"
|
||||
)
|
||||
}
|
||||
return { isDirectory: false }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderDropState(setAttachmentPaths: Dispatch<SetStateAction<string[]>>) {
|
||||
return renderHook(() =>
|
||||
useAttachmentDropState({
|
||||
agentPromptRef: { current: '' },
|
||||
cancelPromptCaretFrame: () => {},
|
||||
connectionId: null,
|
||||
promptCaretFrameRef: { current: null },
|
||||
promptTextareaRef: createRef<HTMLTextAreaElement>(),
|
||||
selectedRepoPath: '/repo',
|
||||
selectedRepoSettings: null,
|
||||
setAgentPrompt: () => {},
|
||||
setAttachmentPaths
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
installFsApi()
|
||||
})
|
||||
|
||||
describe('local composer drop failures', () => {
|
||||
it('reports partially skipped paths in one aggregated toast and still attaches the rest', async () => {
|
||||
const attached: string[] = []
|
||||
const { result } = renderDropState((next) => {
|
||||
attached.push(...(typeof next === 'function' ? next([]) : next))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.applyLocalComposerDrop(dropPaths(12))
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
const [title, options] = mocks.toastError.mock.calls[0] ?? []
|
||||
expect(title).toBe('3 of 12 items could not be attached.')
|
||||
expect(options.description).toBe('No longer at its original path.')
|
||||
expect(attached).toHaveLength(9)
|
||||
expect(attached).not.toContain('/drop/bad-1.png')
|
||||
})
|
||||
|
||||
it('stays silent when every dropped path attaches', async () => {
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.applyLocalComposerDrop(['/drop/ok-0.png', '/drop/ok-1.png'])
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('says nothing once the composer that owned the drop is gone', async () => {
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.applyLocalComposerDrop(dropPaths(12), () => false)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Why: the upload branch returns early unless a runtime environment or connection is resolved.
|
||||
const RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: 'env-1' }
|
||||
|
||||
describe('composer upload failures', () => {
|
||||
it('aggregates a mixed runtime import into one toast, and withholds a reason that is not shared', async () => {
|
||||
mocks.importExternalPaths.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
sourcePath: '/a.png',
|
||||
status: 'imported',
|
||||
destPath: '/repo/.orca/drops/a.png',
|
||||
kind: 'file',
|
||||
renamed: false
|
||||
},
|
||||
{ sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' },
|
||||
{ sourcePath: '/c.png', status: 'failed', reason: 'disk full' }
|
||||
]
|
||||
})
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadComposerPaths(
|
||||
['/a.png', '/b.png', '/c.png'],
|
||||
RUNTIME_SETTINGS,
|
||||
null,
|
||||
'/repo'
|
||||
)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledTimes(1)
|
||||
const [title, options] = mocks.toastError.mock.calls[0] ?? []
|
||||
expect(title).toBe('2 of 3 items could not be attached.')
|
||||
expect(options.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stays silent when every uploaded path imports', async () => {
|
||||
mocks.importExternalPaths.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
sourcePath: '/a.png',
|
||||
status: 'imported',
|
||||
destPath: '/repo/.orca/drops/a.png',
|
||||
kind: 'file',
|
||||
renamed: false
|
||||
}
|
||||
]
|
||||
})
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadComposerPaths(['/a.png'], RUNTIME_SETTINGS, null, '/repo')
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report after the composer that owned the upload is gone', async () => {
|
||||
mocks.importExternalPaths.mockResolvedValue({
|
||||
results: [{ sourcePath: '/b.png', status: 'skipped', reason: 'missing' }]
|
||||
})
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadComposerPaths(
|
||||
['/b.png'],
|
||||
RUNTIME_SETTINGS,
|
||||
null,
|
||||
'/repo',
|
||||
() => false
|
||||
)
|
||||
})
|
||||
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does give the shared reason when every uploaded path failed the same way', async () => {
|
||||
mocks.importExternalPaths.mockResolvedValue({
|
||||
results: [
|
||||
{ sourcePath: '/b.png', status: 'skipped', reason: 'permission-denied' },
|
||||
{ sourcePath: '/c.png', status: 'skipped', reason: 'permission-denied' }
|
||||
]
|
||||
})
|
||||
const { result } = renderDropState(() => {})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.uploadComposerPaths(
|
||||
['/b.png', '/c.png'],
|
||||
RUNTIME_SETTINGS,
|
||||
null,
|
||||
'/repo'
|
||||
)
|
||||
})
|
||||
|
||||
const [, options] = mocks.toastError.mock.calls[0] ?? []
|
||||
expect(options.description).toBe('Permission denied.')
|
||||
})
|
||||
})
|
||||
@@ -20,13 +20,27 @@ import { joinPath } from '@/lib/path'
|
||||
import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation'
|
||||
import { useAppStore } from '@/store'
|
||||
import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client'
|
||||
import { readIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { showComposerDropFailureToast } from '../composer-drop-failure-toast'
|
||||
import {
|
||||
collectComposerDropUploadResult,
|
||||
shouldReportComposerDropUploadFailure
|
||||
} from '../composer-drop-upload-result'
|
||||
collectComposerDropResult,
|
||||
type ComposerDropFailure,
|
||||
type ComposerDropItemResult
|
||||
} from '../composer-drop-result'
|
||||
import { applyComposerNativeFileDrop } from '../composer-native-file-drop'
|
||||
import { useComposerDropListener } from './composer-drop-listener'
|
||||
|
||||
// Local drops bypass the runtime importer's skip classification.
|
||||
function localDropFailure(detail: string | undefined): ComposerDropFailure {
|
||||
if (detail?.startsWith('ENOENT')) {
|
||||
return { status: 'skipped', reason: 'missing' }
|
||||
}
|
||||
if (/^(EACCES|EPERM)/.test(detail ?? '')) {
|
||||
return { status: 'skipped', reason: 'permission-denied' }
|
||||
}
|
||||
return { status: 'failed', reason: detail }
|
||||
}
|
||||
|
||||
export function useAttachmentDropState(input: AttachmentDropStateInput) {
|
||||
const {
|
||||
agentPromptRef,
|
||||
@@ -164,14 +178,13 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) {
|
||||
destinationDir,
|
||||
{ ensureDestinationDir: true, assertCurrent }
|
||||
)
|
||||
const uploadResult = collectComposerDropUploadResult(results)
|
||||
if (shouldReportComposerDropUploadFailure(uploadResult, canReportFailure)) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.hooks.useComposerState.a9ff236145',
|
||||
'Some attachments could not be uploaded.'
|
||||
)
|
||||
)
|
||||
const uploadResult = collectComposerDropResult(results)
|
||||
if (uploadResult.failureCount > 0 && canReportFailure()) {
|
||||
showComposerDropFailureToast({
|
||||
failureCount: uploadResult.failureCount,
|
||||
total: sourcePaths.length,
|
||||
commonFailure: uploadResult.commonFailure
|
||||
})
|
||||
}
|
||||
return { filePaths: uploadResult.filePaths, folderPaths: uploadResult.folderPaths }
|
||||
},
|
||||
@@ -199,27 +212,34 @@ export function useAttachmentDropState(input: AttachmentDropStateInput) {
|
||||
|
||||
const applyLocalComposerDrop = useCallback(
|
||||
async (paths: string[], canApply: () => boolean = () => true): Promise<void> => {
|
||||
const fileAttachments: string[] = []
|
||||
const folderPaths: string[] = []
|
||||
const results: ComposerDropItemResult[] = []
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
await window.api.fs.authorizeExternalPath({ targetPath: filePath })
|
||||
const stat = await window.api.fs.stat({ filePath })
|
||||
if (stat.isDirectory) {
|
||||
folderPaths.push(filePath)
|
||||
} else {
|
||||
fileAttachments.push(filePath)
|
||||
}
|
||||
} catch {
|
||||
// Skip paths we cannot authorize or stat.
|
||||
results.push({
|
||||
status: 'imported',
|
||||
destPath: filePath,
|
||||
kind: stat.isDirectory ? 'directory' : 'file'
|
||||
})
|
||||
} catch (error) {
|
||||
results.push(localDropFailure(readIpcErrorMessage(error)))
|
||||
}
|
||||
}
|
||||
|
||||
if (!canApply()) {
|
||||
return
|
||||
}
|
||||
addComposerAttachments(fileAttachments)
|
||||
insertComposerFolderPaths(folderPaths)
|
||||
const dropResult = collectComposerDropResult(results)
|
||||
addComposerAttachments(dropResult.filePaths)
|
||||
insertComposerFolderPaths(dropResult.folderPaths)
|
||||
if (dropResult.failureCount > 0) {
|
||||
showComposerDropFailureToast({
|
||||
failureCount: dropResult.failureCount,
|
||||
total: paths.length,
|
||||
commonFailure: dropResult.commonFailure
|
||||
})
|
||||
}
|
||||
},
|
||||
[addComposerAttachments, insertComposerFolderPaths]
|
||||
)
|
||||
|
||||
+8
@@ -2535,6 +2535,14 @@
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"useComposerState": {
|
||||
"attachSkipMissing": "No longer at its original path.",
|
||||
"attachSkipPermissionDenied": "Permission denied.",
|
||||
"attachSkipSymlink": "Symbolic links cannot be attached.",
|
||||
"attachSkipUnsupported": "Unsupported file type.",
|
||||
"dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.",
|
||||
"dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached."
|
||||
},
|
||||
"useIpcEvents": {
|
||||
"60428567b4": "Local terminal reveal is unavailable while a remote runtime is active",
|
||||
"f6300deb8b": "New Browser Tab"
|
||||
|
||||
@@ -990,14 +990,20 @@
|
||||
"useComposerState": {
|
||||
"7eb3f44ff7": "Selected agent is disabled. Choose an enabled agent before creating.",
|
||||
"b2ead86962": "Failed to resolve PR base.",
|
||||
"a9ff236145": "Some attachments could not be uploaded.",
|
||||
"3db83fc58a": "No project path is available on this host for attachments.",
|
||||
"ba6cb77082": "Failed to connect to project.",
|
||||
"chooseOrAddProjectBeforeWorkspace": "Choose or add a project before creating a workspace.",
|
||||
"folderWorkspaceCreateFailedTitle": "Folder workspace creation failed",
|
||||
"folderWorkspaceCreateFailedMessage": "The folder workspace could not be created. Check the error details above, then try again.",
|
||||
"setupAgentStartupPolicySaveFailed": "Failed to save setup startup behavior.",
|
||||
"5f3d2c8a1b": "Failed to resolve MR base."
|
||||
"5f3d2c8a1b": "Failed to resolve MR base.",
|
||||
"dropPartiallyAttached": "{{failureCount}} of {{count}} items could not be attached.",
|
||||
"dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.",
|
||||
"dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached.",
|
||||
"attachSkipMissing": "No longer at its original path.",
|
||||
"attachSkipSymlink": "Symbolic links cannot be attached.",
|
||||
"attachSkipPermissionDenied": "Permission denied.",
|
||||
"attachSkipUnsupported": "Unsupported file type."
|
||||
},
|
||||
"useGlobalFileDrop": {
|
||||
"38c9f034ff": "Failed to upload dropped files.",
|
||||
|
||||
@@ -713,7 +713,6 @@
|
||||
"useComposerState": {
|
||||
"7eb3f44ff7": "El agente seleccionado está deshabilitado. Elige un agente habilitado antes de crear.",
|
||||
"b2ead86962": "No se pudo resolver la base del PR.",
|
||||
"a9ff236145": "Algunos archivos adjuntos no se pudieron cargar.",
|
||||
"3db83fc58a": "No hay ninguna ruta de proyecto remoto disponible para los archivos adjuntos.",
|
||||
"ba6cb77082": "No se pudo conectar al proyecto.",
|
||||
"chooseOrAddProjectBeforeWorkspace": "Elige o agrega un proyecto antes de crear un espacio de trabajo.",
|
||||
|
||||
@@ -835,7 +835,6 @@
|
||||
"useComposerState": {
|
||||
"7eb3f44ff7": "L'agent sélectionné est désactivé. Choisissez un agent activé avant de créer.",
|
||||
"b2ead86962": "Échec de la résolution de la base de la PR.",
|
||||
"a9ff236145": "Certaines pièces jointes n'ont pas pu être envoyées.",
|
||||
"3db83fc58a": "Aucun chemin de projet n'est disponible sur cet hôte pour les pièces jointes.",
|
||||
"ba6cb77082": "Échec de la connexion au projet.",
|
||||
"chooseOrAddProjectBeforeWorkspace": "Choisissez ou ajoutez un projet avant de créer un espace de travail.",
|
||||
|
||||
@@ -713,7 +713,6 @@
|
||||
"useComposerState": {
|
||||
"7eb3f44ff7": "選択した Agent は無効です。作成する前に、有効な Agent を選択してください。",
|
||||
"b2ead86962": "PR ベースを解決できませんでした。",
|
||||
"a9ff236145": "一部の添付ファイルをアップロードできませんでした。",
|
||||
"3db83fc58a": "このホスト上に、添付に使用できるプロジェクトパスがありません。",
|
||||
"ba6cb77082": "プロジェクトへの接続に失敗しました。",
|
||||
"chooseOrAddProjectBeforeWorkspace": "ワークスペースを作成する前に、プロジェクトを選択または追加してください。",
|
||||
|
||||
@@ -716,7 +716,6 @@
|
||||
"useComposerState": {
|
||||
"7eb3f44ff7": "선택한 agent가 비활성화되었습니다. 생성하기 전에 활성화된 agent를 선택하세요.",
|
||||
"b2ead86962": "PR 기반을 해결하지 못했습니다.",
|
||||
"a9ff236145": "일부 첨부파일을 업로드할 수 없습니다.",
|
||||
"3db83fc58a": "이 호스트에 첨부 파일에 사용할 수 있는 프로젝트 경로가 없습니다.",
|
||||
"ba6cb77082": "프로젝트에 연결하지 못했습니다.",
|
||||
"chooseOrAddProjectBeforeWorkspace": "워크스페이스를 만들기 전에 프로젝트를 선택하거나 추가하세요.",
|
||||
|
||||
@@ -716,7 +716,6 @@
|
||||
"useComposerState": {
|
||||
"7eb3f44ff7": "所选智能体已禁用。创建之前选择启用的智能体。",
|
||||
"b2ead86962": "无法解析 PR 基础引用。",
|
||||
"a9ff236145": "部分附件无法上传。",
|
||||
"3db83fc58a": "没有可用于附件的远程项目路径。",
|
||||
"ba6cb77082": "无法连接到项目。",
|
||||
"chooseOrAddProjectBeforeWorkspace": "创建工作区前,请选择或添加项目。",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractIpcErrorMessage, readIpcErrorDetail, readIpcErrorMessage } from './ipc-error'
|
||||
import {
|
||||
compactIpcErrorMessage,
|
||||
extractIpcErrorMessage,
|
||||
readIpcErrorDetail,
|
||||
readIpcErrorMessage
|
||||
} from './ipc-error'
|
||||
|
||||
describe('readIpcErrorMessage', () => {
|
||||
it('strips the Electron invoke wrapper Electron adds to a rejected handler', () => {
|
||||
@@ -45,6 +50,16 @@ describe('readIpcErrorMessage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('compactIpcErrorMessage', () => {
|
||||
it('normalizes string error fields without manufacturing an Error', () => {
|
||||
expect(
|
||||
compactIpcErrorMessage(
|
||||
"Error invoking remote method 'files:import': Error: permission denied\nstack"
|
||||
)
|
||||
).toBe('permission denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractIpcErrorMessage', () => {
|
||||
it('unwraps the same way readIpcErrorMessage does', () => {
|
||||
expect(
|
||||
|
||||
@@ -2,19 +2,20 @@
|
||||
const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/
|
||||
const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/
|
||||
|
||||
function unwrapIpcErrorMessage(message: string): string | undefined {
|
||||
const detail = message.replace(IPC_INVOKE_PREFIX, '').replace(IPC_HANDLER_PREFIX, '').trim()
|
||||
return detail || undefined
|
||||
}
|
||||
|
||||
export function compactIpcErrorMessage(message: string): string | undefined {
|
||||
return unwrapIpcErrorMessage(message)?.split('\n')[0]?.trim() || undefined
|
||||
}
|
||||
export function readIpcErrorDetail(error: unknown): string | undefined {
|
||||
if (!(error instanceof Error)) {
|
||||
return undefined
|
||||
}
|
||||
const message = error.message
|
||||
.replace(IPC_INVOKE_PREFIX, '')
|
||||
.replace(IPC_HANDLER_PREFIX, '')
|
||||
.trim()
|
||||
return message || undefined
|
||||
return error instanceof Error ? unwrapIpcErrorMessage(error.message) : undefined
|
||||
}
|
||||
|
||||
export function readIpcErrorMessage(error: unknown): string | undefined {
|
||||
return readIpcErrorDetail(error)?.split('\n')[0]?.trim() || undefined
|
||||
return error instanceof Error ? compactIpcErrorMessage(error.message) : undefined
|
||||
}
|
||||
|
||||
// Preserve the legacy contract: wrapped errors are compact, while plain errors retain detail.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename, joinPath } from '@/lib/path'
|
||||
import type { ImportItemResult } from '../../../shared/filesystem-import-result-types'
|
||||
import { getRuntimeEnvironmentConnectionGeneration } from '@/store/slices/runtime-status'
|
||||
import type { RuntimeFileOperationArgs } from './runtime-file-client-types'
|
||||
import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision'
|
||||
@@ -21,31 +22,12 @@ import {
|
||||
import { getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
|
||||
type RuntimeImportResult =
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'imported'
|
||||
destPath: string
|
||||
kind: 'file' | 'directory'
|
||||
renamed: boolean
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'skipped'
|
||||
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'failed'
|
||||
reason: string
|
||||
}
|
||||
|
||||
export async function importExternalPathsToRuntime(
|
||||
context: RuntimeFileOperationArgs,
|
||||
sourcePaths: string[],
|
||||
destinationDir: string,
|
||||
options?: { ensureDestinationDir?: boolean; assertCurrent?: () => void }
|
||||
): Promise<{ results: RuntimeImportResult[] }> {
|
||||
): Promise<{ results: ImportItemResult[] }> {
|
||||
const target = getActiveRuntimeTarget(context.settings)
|
||||
if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) {
|
||||
return window.api.fs.importExternalPaths(
|
||||
@@ -89,7 +71,7 @@ export async function importExternalPathsToRuntime(
|
||||
importSession.assertCurrent()
|
||||
const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths })
|
||||
importSession.assertCurrent()
|
||||
const results: RuntimeImportResult[] = []
|
||||
const results: ImportItemResult[] = []
|
||||
const reservedNames = new Set<string>()
|
||||
|
||||
await ensureRuntimeDirectory(context, destinationDir, importSession)
|
||||
|
||||
+1
-3
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
StagedRuntimeUploadEntry,
|
||||
StagedRuntimeUploadSource
|
||||
} from '../../shared/runtime-upload-staging-contract'
|
||||
} from './runtime-upload-staging-contract'
|
||||
|
||||
export type ImportSkipReason = 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
|
||||
|
||||
@@ -11,8 +11,6 @@ export type ResolveDroppedPathsResult = {
|
||||
failed: { sourcePath: string; reason: string }[]
|
||||
}
|
||||
|
||||
// ─── External Import Types ──────────────────────────────────────────
|
||||
|
||||
export type ImportItemResult =
|
||||
| {
|
||||
sourcePath: string
|
||||
Reference in New Issue
Block a user