From fd7dd6fe10f2b373be4fffb81551e499c5cdc762 Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 00:32:50 +0200 Subject: [PATCH 01/20] feat(runtime): stream file uploads instead of buffering whole files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime-environment imports read the entire file into memory and base64-encoded it before sending, so peak main-process memory was ~2.3x the file size. A 25 MB per-file / 100 MB per-drop ceiling existed only to keep that from exhausting the heap, and it was checked right after lstat — so dropping a 40 MB recording failed instantly with no bytes moved. The socket already lives in main, but the chunking loop lived in the renderer, so the whole base64 payload crossed IPC only to be sliced and passed back through main. Move the byte pump into main, next to the file handle and the socket: one open handle per file, 384 KiB slices (512 KiB on the wire, matching the previous chunk size), sent through the existing files.writeBase64Chunk method. Staging now records byteLength instead of file contents. The renderer keeps deconflict, commit, rollback and cleanup, and never sees file bytes. Peak memory per upload drops to one slice. The ceilings become user safety limits rather than memory guards, raised to 2 GB / 8 GB until the drop UI can show progress and cancel a running upload. No protocol change: files.writeBase64Chunk already existed and was already used by this path for payloads over 512 KB. Small files now use it with append: false, which is exactly what files.writeBase64 does internally, so mixed client/host versions are unaffected. Symlink rejection, O_NOFOLLOW, realpath containment and the stat identity check all move to upload time and now share one handle for the whole file, so every slice comes from the same inode. Co-Authored-By: Claude Opus 5 (1M context) --- .../ipc/filesystem-import-result-types.ts | 4 +- src/main/ipc/filesystem-import.test.ts | 20 +- src/main/ipc/filesystem-mutations.ts | 33 ++- .../filesystem-runtime-upload-staging.test.ts | 91 ++++++++ .../ipc/filesystem-runtime-upload-staging.ts | 39 +++- .../ipc/runtime-upload-file-stream.test.ts | 152 +++++++++++++ src/main/ipc/runtime-upload-file-stream.ts | 148 +++++++++++++ src/preload/api/filesystem-api.ts | 12 +- src/preload/index.ts | 13 +- ...untime-file-client-external-import.test.ts | 208 +++++------------- .../runtime-file-client-test-harness.ts | 6 +- .../src/runtime/runtime-file-client.ts | 82 ++----- ...ntime-file-import-pairing-revision.test.ts | 50 ++--- src/renderer/src/web/web-preload-api.ts | 5 + 14 files changed, 595 insertions(+), 268 deletions(-) create mode 100644 src/main/ipc/filesystem-runtime-upload-staging.test.ts create mode 100644 src/main/ipc/runtime-upload-file-stream.test.ts create mode 100644 src/main/ipc/runtime-upload-file-stream.ts diff --git a/src/main/ipc/filesystem-import-result-types.ts b/src/main/ipc/filesystem-import-result-types.ts index d1d9f4468360..df04a7384620 100644 --- a/src/main/ipc/filesystem-import-result-types.ts +++ b/src/main/ipc/filesystem-import-result-types.ts @@ -48,4 +48,6 @@ export type StagedExternalImportSource = export type StagedExternalImportEntry = | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } + // Why: file bodies are streamed in slices at upload time, so staging carries + // only the size the uploader reports and validates against. + | { relativePath: string; kind: 'file'; byteLength: number } diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index bb143987c96b..cfe57f2dc0d5 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -514,11 +514,12 @@ describe('fs:importExternalPaths', () => { status: 'staged', name: 'logo.png', kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }] + entries: [{ relativePath: '', kind: 'file', byteLength: 4 }] } ]) expect(copyFileMock).not.toHaveBeenCalled() - expect(readFileHandleMock).toHaveBeenCalled() + // Why: bodies stream at upload time, so staging must never read the file. + expect(readFileHandleMock).not.toHaveBeenCalled() expect(closeMock).toHaveBeenCalled() }) @@ -597,7 +598,7 @@ describe('fs:importExternalPaths', () => { entries: [ { relativePath: '', kind: 'directory' }, { relativePath: '..assets', kind: 'directory' }, - { relativePath: '..assets/icon.txt', kind: 'file', contentBase64: 'aWNvbg==' } + { relativePath: '..assets/icon.txt', kind: 'file', byteLength: 4 } ] } ]) @@ -637,14 +638,15 @@ describe('fs:importExternalPaths', () => { expect(openMock).not.toHaveBeenCalled() }) - it('checks runtime upload directory byte budget before reading a file that exceeds the total cap', async () => { + it('checks runtime upload directory byte budget before opening a file that exceeds the total cap', async () => { const sourcePath = '/tmp/dropped/project' const resolvedPath = path.resolve(sourcePath) const filePaths = ['one.bin', 'two.bin', 'three.bin', 'four.bin', 'overflow.bin'].map((name) => path.join(resolvedPath, name) ) const mib = 1024 * 1024 - const regularSize = 25 * mib + // Four files exactly fill the 8 GB total ceiling; the fifth pushes past it. + const regularSize = 2 * 1024 * mib const overflowSize = Number(mib) const readFileMock = vi.fn().mockResolvedValue(Buffer.from('chunk')) @@ -702,11 +704,9 @@ describe('fs:importExternalPaths', () => { sourcePaths: [sourcePath] })) as { sources: { status: string; reason?: string }[] } - expect(result.sources[0]).toMatchObject({ - status: 'failed', - reason: 'Remote import is too large' - }) - expect(readFileMock).toHaveBeenCalledTimes(4) + expect(result.sources[0]).toMatchObject({ status: 'failed' }) + expect(result.sources[0]?.reason).toContain('total remote import limit') + expect(readFileMock).not.toHaveBeenCalled() expect(openMock).not.toHaveBeenCalledWith(filePaths.at(-1), expect.anything()) }) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 57ac0c5e1977..2b14c1777843 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' import { constants } from 'node:fs' import { copyFile, mkdir, writeFile } from 'node:fs/promises' import { basename, dirname } from 'node:path' @@ -19,6 +19,7 @@ import type { } from './filesystem-import-result-types' import { importOneSource } from './filesystem-import-local' import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging' +import { streamExternalFileToRuntime } from './runtime-upload-file-stream' /** * IPC handlers for file/folder creation and renaming. @@ -203,6 +204,36 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) + // Why: the file handle and the runtime socket both live in main, so the byte + // pump runs here. The renderer keeps deconflict/commit/rollback orchestration + // and never sees file contents. + ipcMain.handle( + 'fs:uploadExternalFileToRuntime', + async ( + _event, + args: { + environmentId: string + sourceRootPath: string + entryRelativePath: string + worktree: string + relativePath: string + expectedEnvironmentPairingRevision?: number + } & SshMutationExpectation + ): Promise<{ byteLength: number }> => + streamExternalFileToRuntime({ + userDataPath: app.getPath('userData'), + environmentId: args.environmentId, + sourceRootPath: args.sourceRootPath, + entryRelativePath: args.entryRelativePath, + worktree: args.worktree, + relativePath: args.relativePath, + expectedExecutionHostId: args.expectedExecutionHostId, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision + }) + ) + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees // upload each path into `${worktreePath}/.orca/drops/` and return remote diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts new file mode 100644 index 000000000000..67ace9cd592c --- /dev/null +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -0,0 +1,91 @@ +import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) + +const { stageOneSourceForRuntimeUpload } = await import('./filesystem-runtime-upload-staging') + +let workDir: string + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-staging-')) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +describe('stageOneSourceForRuntimeUpload', () => { + it('records size instead of file contents so staging never holds the body', async () => { + const filePath = join(workDir, 'note.txt') + await writeFile(filePath, 'hello world') + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ + status: 'staged', + kind: 'file', + name: 'note.txt', + entries: [{ relativePath: '', kind: 'file', byteLength: 11 }] + }) + expect(JSON.stringify(staged)).not.toContain('contentBase64') + }) + + it('stages a file far larger than the former 25 MB cap', async () => { + const filePath = join(workDir, 'big.bin') + await writeFile(filePath, '') + await truncate(filePath, 64 * 1024 * 1024) + + await expect(stageOneSourceForRuntimeUpload(filePath)).resolves.toMatchObject({ + status: 'staged', + entries: [{ kind: 'file', byteLength: 64 * 1024 * 1024 }] + }) + }) + + it('names the limit and the actual size when a file is over the ceiling', async () => { + const filePath = join(workDir, 'huge.bin') + await writeFile(filePath, '') + // Sparse: the ceiling is checked from stat(), so no real bytes are written. + await truncate(filePath, 3 * 1024 * 1024 * 1024) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ status: 'failed' }) + expect(staged.status === 'failed' && staged.reason).toContain('3 GB') + expect(staged.status === 'failed' && staged.reason).toContain('2 GB') + }) + + it('keeps rejecting symlinked sources', async () => { + const targetPath = join(workDir, 'target.txt') + await writeFile(targetPath, 'data') + const linkPath = join(workDir, 'link.txt') + await symlink(targetPath, linkPath) + + await expect(stageOneSourceForRuntimeUpload(linkPath)).resolves.toMatchObject({ + status: 'skipped', + reason: 'symlink' + }) + }) + + it('stages directory trees as metadata for every entry', async () => { + const rootPath = join(workDir, 'assets') + await mkdir(join(rootPath, 'nested'), { recursive: true }) + await writeFile(join(rootPath, 'a.txt'), 'aa') + await writeFile(join(rootPath, 'nested', 'b.txt'), 'bbb') + + const staged = await stageOneSourceForRuntimeUpload(rootPath) + + expect(staged.status).toBe('staged') + const entries = staged.status === 'staged' ? staged.entries : [] + expect(entries).toEqual( + expect.arrayContaining([ + { relativePath: '', kind: 'directory' }, + { relativePath: 'a.txt', kind: 'file', byteLength: 2 }, + { relativePath: 'nested', kind: 'directory' }, + { relativePath: 'nested/b.txt', kind: 'file', byteLength: 3 } + ]) + ) + }) +}) diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index 5af76029b988..d96b9c811759 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -8,8 +8,11 @@ import type { StagedExternalImportSource } from './filesystem-import-result-types' -const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024 -const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024 +// Why: staging streams slices at upload time and never holds a whole file, so +// these are user-safety ceilings on an unattended transfer, not memory guards. +// They stay until the drop UI can show progress and cancel a running upload. +const REMOTE_IMPORT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 +const REMOTE_IMPORT_MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024 class RuntimeUploadSymlinkError extends Error {} @@ -19,7 +22,7 @@ export async function stageOneSourceForRuntimeUpload( const resolvedSource = resolve(sourcePath) // Why: runtime uploads read client-local paths in the client main process; - // authorize before lstat/readFile just like local copy imports. + // authorize before lstat just like local copy imports. authorizeExternalPath(resolvedSource) let sourceStat: Awaited> @@ -162,16 +165,13 @@ async function stageFileEntry( ? openedStat.size : options.totalBytesBefore + openedStat.size assertRemoteUploadBudget(relativePath, openedStat.size, totalBytes) - const buffer = await fileHandle.readFile() - const afterReadStat = await fileHandle.stat() - if (afterReadStat.size !== openedStat.size) { - throw new Error(`File changed during upload staging: '${displayPath}'`) - } + // Why: bytes are read slice-by-slice at upload time, so staging only + // records what the uploader needs to find and size each entry. return { entry: { relativePath: displayPath, kind: 'file', - contentBase64: buffer.toString('base64') + byteLength: openedStat.size }, byteLength: openedStat.size } @@ -202,11 +202,28 @@ function assertRemoteUploadBudget( totalBytes: number ): void { if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { - throw new Error(`'${relativePath}' is too large for remote import`) + throw new Error( + `'${relativePath}' is ${formatByteCeiling(fileBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + ) } if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) { - throw new Error('Remote import is too large') + throw new Error( + `This import is ${formatByteCeiling(totalBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)} total remote import limit` + ) + } +} + +function formatByteCeiling(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 } + return `${value >= 10 || Number.isInteger(value) ? Math.round(value) : value.toFixed(1)} ${units[unit]}` } function normalizeRelativeUploadPath(path: string): string { diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts new file mode 100644 index 000000000000..3c6c67167bf9 --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -0,0 +1,152 @@ +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callRuntimeEnvironment = vi.fn() + +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: (...args: unknown[]) => callRuntimeEnvironment(...args) +})) +vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) + +const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } = + await import('./runtime-upload-file-stream') + +let workDir: string + +type ChunkCall = { relativePath: string; contentBase64: string; append: boolean } + +function chunkCalls(): ChunkCall[] { + return callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , params]) => params as ChunkCall) +} + +function uploadedBytes(): Buffer { + return Buffer.concat(chunkCalls().map((call) => Buffer.from(call.contentBase64, 'base64'))) +} + +function baseArgs(sourceRootPath: string) { + return { + userDataPath: '/user-data', + environmentId: 'env-1', + sourceRootPath, + entryRelativePath: '', + worktree: 'wt-1', + relativePath: '.upload.tmp' + } +} + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-upload-stream-')) + callRuntimeEnvironment.mockReset() + callRuntimeEnvironment.mockResolvedValue({ id: 'x', ok: true, result: {}, _meta: {} }) +}) + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }) +}) + +describe('streamExternalFileToRuntime', () => { + it('sends a file larger than the old 25 MB cap as ordered append-only slices', async () => { + const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + 1234 + const contents = Buffer.alloc(size) + for (let index = 0; index < size; index += 1) { + contents[index] = index % 251 + } + const filePath = join(workDir, 'big.bin') + await writeFile(filePath, contents) + + await expect(streamExternalFileToRuntime(baseArgs(filePath))).resolves.toEqual({ + byteLength: size + }) + + const calls = chunkCalls() + expect(calls).toHaveLength(3) + expect(calls.map((call) => call.append)).toEqual([false, true, true]) + expect(uploadedBytes().equals(contents)).toBe(true) + }) + + it('never buffers more than one slice per chunk', async () => { + const filePath = join(workDir, 'sliced.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 2)) + + await streamExternalFileToRuntime(baseArgs(filePath)) + + for (const call of chunkCalls()) { + expect(Buffer.from(call.contentBase64, 'base64').byteLength).toBeLessThanOrEqual( + RUNTIME_UPLOAD_SLICE_BYTES + ) + } + }) + + it('creates an empty destination for a zero-byte source', async () => { + const filePath = join(workDir, 'empty.txt') + await writeFile(filePath, '') + + await expect(streamExternalFileToRuntime(baseArgs(filePath))).resolves.toEqual({ + byteLength: 0 + }) + + expect(chunkCalls()).toEqual([expect.objectContaining({ append: false, contentBase64: '' })]) + }) + + it('carries the pairing revision on every chunk so a re-pair cannot finish the file', async () => { + const filePath = join(workDir, 'guarded.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES + 10)) + + await streamExternalFileToRuntime({ + ...baseArgs(filePath), + expectedEnvironmentPairingRevision: 41 + }) + + const revisions = callRuntimeEnvironment.mock.calls + .filter(([, , method]) => method === 'files.writeBase64Chunk') + .map(([, , , , , revision]) => revision) + expect(revisions).toHaveLength(2) + expect(revisions).toEqual([41, 41]) + }) + + it('stops at the failing chunk instead of sending the rest of the file', async () => { + const filePath = join(workDir, 'fails.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3)) + callRuntimeEnvironment.mockResolvedValueOnce({ id: 'x', ok: true, result: {}, _meta: {} }) + callRuntimeEnvironment.mockResolvedValueOnce({ + id: 'x', + ok: false, + error: { code: 'write_failed', message: 'disk full' } + }) + + await expect(streamExternalFileToRuntime(baseArgs(filePath))).rejects.toThrow('disk full') + expect(chunkCalls()).toHaveLength(2) + }) + + it('refuses a symlinked source', async () => { + const targetPath = join(workDir, 'secret.txt') + await writeFile(targetPath, 'secret') + const linkPath = join(workDir, 'link.txt') + await symlink(targetPath, linkPath) + + await expect(streamExternalFileToRuntime(baseArgs(linkPath))).rejects.toThrow( + 'Symlink not allowed' + ) + expect(chunkCalls()).toHaveLength(0) + }) + + it('refuses a directory entry whose real path escapes the dropped root', async () => { + const outsidePath = join(workDir, 'outside.txt') + await writeFile(outsidePath, 'outside') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsidePath, join(rootPath, 'escape.txt')) + + await expect( + streamExternalFileToRuntime({ + ...baseArgs(rootPath), + entryRelativePath: 'escape.txt' + }) + ).rejects.toThrow('Symlink not allowed') + expect(chunkCalls()).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts new file mode 100644 index 000000000000..60d43010214e --- /dev/null +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -0,0 +1,148 @@ +import { constants } from 'node:fs' +import { lstat, open, realpath } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import { authorizeExternalPath } from './filesystem-auth' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' + +// Why: base64 turns 3 bytes into 4 chars, so a 384 KiB slice lands on the wire +// as exactly 512 KiB — the chunk size the renderer used before streaming. +export const RUNTIME_UPLOAD_SLICE_BYTES = 384 * 1024 + +const RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS = 30_000 + +export type RuntimeUploadFileStreamArgs = { + userDataPath: string + environmentId: string + /** Client-local path of the dropped source (file, or root of a dropped directory). */ + sourceRootPath: string + /** Path of this file within the dropped directory; empty when the source is a file. */ + entryRelativePath: string + worktree: string + /** Destination path on the runtime, relative to the worktree. */ + relativePath: string + expectedExecutionHostId?: string + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number + expectedEnvironmentPairingRevision?: number +} + +/** + * Stream one client-local file to a runtime environment in slices. + * + * Replaces reading the whole file into memory and base64-encoding it before the + * first byte moves. Peak memory is one slice, so imports are no longer bounded + * by main-process heap. + */ +export async function streamExternalFileToRuntime( + args: RuntimeUploadFileStreamArgs +): Promise<{ byteLength: number }> { + const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) + + // Why: parity with staging — an OS drop authorizes the paths it hands over. + authorizeExternalPath(sourcePath) + + const displayPath = args.entryRelativePath || args.relativePath + const lstatResult = await lstat(sourcePath) + if (lstatResult.isSymbolicLink()) { + throw new Error(`Symlink not allowed in '${displayPath}'`) + } + if (!lstatResult.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if (args.entryRelativePath) { + await assertEntryInsideRoot(args.sourceRootPath, sourcePath, displayPath) + } + + const handle = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + const openedStat = await handle.stat() + if (!openedStat.isFile()) { + throw new Error(`Unsupported file type in '${displayPath}'`) + } + if ( + openedStat.size !== lstatResult.size || + (lstatResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== lstatResult.ino) || + (lstatResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== lstatResult.dev) + ) { + throw new Error(`File changed during upload: '${displayPath}'`) + } + + const totalBytes = openedStat.size + // Why: a zero-byte source produces no slices, but the destination still has + // to exist before commitUpload renames it into place. + if (totalBytes === 0) { + await sendChunk(args, '', false) + return { byteLength: 0 } + } + + const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) + let offset = 0 + while (offset < totalBytes) { + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) + if (bytesRead === 0) { + throw new Error(`File truncated during upload: '${displayPath}'`) + } + await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0) + offset += bytesRead + } + + // Why: the destination is a temp path the caller commits, so a source that + // changed mid-read is caught before anything lands at the final path. + const afterReadStat = await handle.stat() + if (afterReadStat.size !== totalBytes) { + throw new Error(`File changed during upload: '${displayPath}'`) + } + return { byteLength: totalBytes } + } finally { + await handle.close() + } +} + +async function sendChunk( + args: RuntimeUploadFileStreamArgs, + contentBase64: string, + append: boolean +): Promise { + const response = await callRuntimeEnvironment( + args.userDataPath, + args.environmentId, + 'files.writeBase64Chunk', + { + worktree: args.worktree, + relativePath: args.relativePath, + contentBase64, + append, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedExecutionHostId: args.expectedExecutionHostId + }, + RUNTIME_UPLOAD_CHUNK_TIMEOUT_MS, + // Why: re-checked per chunk, so a re-pair mid-upload aborts instead of + // appending the rest of the file on a different host. + args.expectedEnvironmentPairingRevision + ) + if (response.ok !== true) { + throw new Error(response.error.message || response.error.code) + } +} + +function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string { + return entryRelativePath ? join(sourceRootPath, entryRelativePath) : sourceRootPath +} + +async function assertEntryInsideRoot( + sourceRootPath: string, + candidatePath: string, + displayPath: string +): Promise { + const rootRealPath = await realpath(sourceRootPath) + const candidateRealPath = await realpath(candidatePath) + const relativeToRoot = relative(rootRealPath, candidateRealPath) + // Why: `..name` is a valid child path; only `..` and `../...` escape. + if ( + relativeToRoot !== '' && + (relativeToRoot === '..' || relativeToRoot.startsWith(`..${sep}`) || isAbsolute(relativeToRoot)) + ) { + throw new Error(`Path escaped upload root during upload: '${displayPath}'`) + } +} diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 0312bc87fd0c..9cc339571df7 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -159,7 +159,7 @@ export type FilesystemApi = { kind: 'file' | 'directory' entries: ( | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } + | { relativePath: string; kind: 'file'; byteLength: number } )[] } | { @@ -174,6 +174,16 @@ export type FilesystemApi = { } )[] }> + uploadExternalFileToRuntime: ( + args: { + environmentId: string + sourceRootPath: string + entryRelativePath: string + worktree: string + relativePath: string + expectedEnvironmentPairingRevision?: number + } & SshMutationExpectation + ) => Promise<{ byteLength: number }> resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/preload/index.ts b/src/preload/index.ts index b8a00c6d6473..18d30bb9f4df 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3440,7 +3440,7 @@ const api = { kind: 'file' | 'directory' entries: ( | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } + | { relativePath: string; kind: 'file'; byteLength: number } )[] } | { @@ -3455,6 +3455,17 @@ const api = { } )[] }> => ipcRenderer.invoke('fs:stageExternalPathsForRuntimeUpload', args), + uploadExternalFileToRuntime: ( + args: { + environmentId: string + sourceRootPath: string + entryRelativePath: string + worktree: string + relativePath: string + expectedEnvironmentPairingRevision?: number + } & SshMutationExpectation + ): Promise<{ byteLength: number }> => + ipcRenderer.invoke('fs:uploadExternalFileToRuntime', args), resolveDroppedPathsForAgent: ( args: { paths: string[] 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 ff8bd580d1f5..5e9d774d96c5 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 @@ -3,6 +3,7 @@ import { importExternalPathsToRuntime } from './runtime-file-client' import { fsImportExternalPaths, fsStageExternalPathsForRuntimeUpload, + fsUploadExternalFileToRuntime, runtimeEnvironmentCall, installRuntimeFileClientEnvironment } from './runtime-file-client-test-harness' @@ -20,7 +21,7 @@ describe('runtime file client', () => { kind: 'directory', entries: [ { relativePath: '', kind: 'directory' }, - { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' } + { relativePath: 'logo.png', kind: 'file', byteLength: 3 } ] } ] @@ -50,12 +51,6 @@ describe('runtime file client', () => { result: { ok: true }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'write-file', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) .mockResolvedValueOnce({ id: 'commit-upload', ok: true, @@ -132,31 +127,25 @@ describe('runtime file client', () => { }, timeoutMs: 15_000 }) - const smallWriteCall = runtimeEnvironmentCall.mock.calls[4]?.[0] as { - params: { relativePath: string } + // Why: main streams the body, so the renderer hands over a source locator + // and never issues a write RPC of its own. + const uploadArgs = fsUploadExternalFileToRuntime.mock.calls[0]?.[0] as { + relativePath: string } - expect(smallWriteCall.params.relativePath).toMatch( - /^uploads\/assets\/\.logo\.png\.orca-upload-/ - ) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { - selector: 'env-1', - method: 'files.writeBase64', - params: { - worktree: 'id:wt-1', - relativePath: smallWriteCall.params.relativePath, - contentBase64: 'cG5n', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000 + expect(uploadArgs.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) + expect(uploadArgs).toMatchObject({ + environmentId: 'env-1', + sourceRootPath: '/Users/me/assets', + entryRelativePath: 'logo.png', + worktree: 'id:wt-1', + expectedExecutionHostId: 'local' }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { selector: 'env-1', method: 'files.commitUpload', params: { worktree: 'id:wt-1', - tempRelativePath: smallWriteCall.params.relativePath, + tempRelativePath: uploadArgs.relativePath, finalRelativePath: 'uploads/assets/logo.png', expectedExecutionHostId: 'local', expectedSshTargetId: undefined, @@ -164,12 +153,12 @@ describe('runtime file client', () => { }, timeoutMs: 30_000 }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { selector: 'env-1', method: 'files.delete', params: { worktree: 'id:wt-1', - relativePath: smallWriteCall.params.relativePath, + relativePath: uploadArgs.relativePath, recursive: false, expectedExecutionHostId: 'local', expectedSshTargetId: undefined, @@ -180,9 +169,7 @@ describe('runtime file client', () => { expect(fsImportExternalPaths).not.toHaveBeenCalled() }) - it('chunks large staged runtime uploads below the WebSocket frame budget', async () => { - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + it('delegates the whole body to main instead of chunking it in the renderer', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -190,9 +177,7 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [ - { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } - ] + entries: [{ relativePath: '', kind: 'file', byteLength: 64 * 1024 * 1024 }] } ] }) @@ -215,18 +200,6 @@ describe('runtime file client', () => { error: { code: 'not_found', message: 'not found' }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) .mockResolvedValueOnce({ id: 'commit-upload', ok: true, @@ -262,72 +235,26 @@ describe('runtime file client', () => { ] }) - const chunkWriteCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as { - params: { relativePath: string } + // Why: a file far over the former 25 MB cap imports with one upload call and + // no renderer-held content; slicing is covered in runtime-upload-file-stream. + expect(fsUploadExternalFileToRuntime).toHaveBeenCalledTimes(1) + const uploadArgs = fsUploadExternalFileToRuntime.mock.calls[0]?.[0] as { + relativePath: string } - expect(chunkWriteCall.params.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - contentBase64: firstChunk, - append: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, { - selector: 'env-1', - method: 'files.writeBase64Chunk', - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - contentBase64: secondChunk, - append: true, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(6, { - selector: 'env-1', - method: 'files.commitUpload', - params: { - worktree: 'id:wt-1', - tempRelativePath: chunkWriteCall.params.relativePath, - finalRelativePath: 'uploads/large.bin', - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 30_000 - }) - expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(7, { - selector: 'env-1', - method: 'files.delete', - expectedEnvironmentPairingRevision: undefined, - params: { - worktree: 'id:wt-1', - relativePath: chunkWriteCall.params.relativePath, - recursive: false, - expectedExecutionHostId: 'local', - expectedSshTargetId: undefined, - expectedSshConnectionGeneration: undefined - }, - timeoutMs: 15_000 + expect(uploadArgs.relativePath).toMatch(/^uploads\/\.large\.bin\.orca-upload-/) + expect(uploadArgs).toMatchObject({ + sourceRootPath: '/Users/me/large.bin', + entryRelativePath: '' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.writeBase64' }) ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.writeBase64Chunk' }) + ) }) - it('stops a chunked upload when its owner generation changes between writes', async () => { - const firstChunk = 'A'.repeat(512 * 1024) + it('stops an upload when its owner generation changes during the transfer', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -335,7 +262,7 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64: `${firstChunk}BBBBBBBB` }] + entries: [{ relativePath: '', kind: 'file', byteLength: 512 * 1024 }] } ] }) @@ -352,16 +279,11 @@ describe('runtime file client', () => { error: { code: 'not_found', message: 'not found' }, _meta: { runtimeId: 'remote-runtime' } }) - .mockImplementationOnce(async () => { - ownerChanged = true - return { - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - } - }) let ownerChanged = false + fsUploadExternalFileToRuntime.mockImplementation(async () => { + ownerChanged = true + return { byteLength: 512 * 1024 } + }) const assertCurrent = vi.fn(() => { if (ownerChanged) { throw new Error('runtime owner generation changed') @@ -383,10 +305,11 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'runtime owner generation changed' }] }) + // Why: the owner is re-checked after the transfer, so a changed owner never + // reaches commit or cleanup. expect(runtimeEnvironmentCall.mock.calls.map((call) => call[0].method)).toEqual([ 'files.stat', - 'files.stat', - 'files.writeBase64Chunk' + 'files.stat' ]) expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) @@ -396,9 +319,7 @@ describe('runtime file client', () => { ) }) - it('cleans up staged runtime upload temp files when a later chunk fails', async () => { - const firstChunk = 'A'.repeat(512 * 1024) - const secondChunk = 'BBBBBBBB' + it('cleans up the staged runtime upload temp file when the transfer fails', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -406,12 +327,11 @@ describe('runtime file client', () => { status: 'staged', name: 'large.bin', kind: 'file', - entries: [ - { relativePath: '', kind: 'file', contentBase64: `${firstChunk}${secondChunk}` } - ] + entries: [{ relativePath: '', kind: 'file', byteLength: 512 * 1024 }] } ] }) + fsUploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) runtimeEnvironmentCall .mockResolvedValueOnce({ id: 'stat-destination-miss', @@ -431,18 +351,6 @@ describe('runtime file client', () => { error: { code: 'not_found', message: 'not found' }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'write-chunk-1', - ok: true, - result: { ok: true }, - _meta: { runtimeId: 'remote-runtime' } - }) - .mockResolvedValueOnce({ - id: 'write-chunk-2', - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'remote-runtime' } - }) .mockResolvedValueOnce({ id: 'delete-temp', ok: true, @@ -464,13 +372,12 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'disk full' }] }) - const chunkCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as - | { params: { relativePath: string } } + const uploadArgs = fsUploadExternalFileToRuntime.mock.calls[0]?.[0] as + | { relativePath: string } | undefined - if (!chunkCall) { - throw new Error('missing first chunk call') + if (!uploadArgs) { + throw new Error('missing upload call') } - const tempRelativePath = chunkCall.params.relativePath expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( expect.objectContaining({ method: 'files.commitUpload' }) ) @@ -479,7 +386,7 @@ describe('runtime file client', () => { method: 'files.delete', params: { worktree: 'id:wt-1', - relativePath: tempRelativePath, + relativePath: uploadArgs.relativePath, recursive: false, expectedExecutionHostId: 'local', expectedSshTargetId: undefined, @@ -499,11 +406,12 @@ describe('runtime file client', () => { kind: 'directory', entries: [ { relativePath: '', kind: 'directory' }, - { relativePath: 'logo.png', kind: 'file', contentBase64: 'cG5n' } + { relativePath: 'logo.png', kind: 'file', byteLength: 3 } ] } ] }) + fsUploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) runtimeEnvironmentCall .mockResolvedValueOnce({ id: 'stat-destination', @@ -523,12 +431,6 @@ describe('runtime file client', () => { result: { ok: true }, _meta: { runtimeId: 'remote-runtime' } }) - .mockResolvedValueOnce({ - id: 'write-file', - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'remote-runtime' } - }) .mockResolvedValueOnce({ id: 'delete-temp', ok: true, @@ -556,13 +458,13 @@ describe('runtime file client', () => { results: [{ status: 'failed', reason: 'disk full' }] }) - const writeCall = runtimeEnvironmentCall.mock.calls[3]?.[0] as - | { params: { relativePath: string } } + const uploadArgs = fsUploadExternalFileToRuntime.mock.calls[0]?.[0] as + | { relativePath: string } | undefined - if (!writeCall) { - throw new Error('missing failed file write call') + if (!uploadArgs) { + throw new Error('missing failed file upload call') } - expect(writeCall.params.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) + expect(uploadArgs.relativePath).toMatch(/^uploads\/assets\/\.logo\.png\.orca-upload-/) expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith({ selector: 'env-1', method: 'files.delete', diff --git a/src/renderer/src/runtime/runtime-file-client-test-harness.ts b/src/renderer/src/runtime/runtime-file-client-test-harness.ts index a2f75dd8751a..787727b5d1cb 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -53,6 +53,7 @@ export const fsFinishDownloadedFile: PreloadStub = vi.fn() export const fsCancelDownloadedFile: PreloadStub = vi.fn() export const fsImportExternalPaths: PreloadStub = vi.fn() export const fsStageExternalPathsForRuntimeUpload: PreloadStub = vi.fn() +export const fsUploadExternalFileToRuntime: PreloadStub = vi.fn() export const runtimeEnvironmentCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentTransportCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentSubscribe: RuntimeSubscribeStub = vi.fn() @@ -87,6 +88,8 @@ export function installRuntimeFileClientEnvironment(): void { fsCancelDownloadedFile.mockReset() fsImportExternalPaths.mockReset() fsStageExternalPathsForRuntimeUpload.mockReset() + fsUploadExternalFileToRuntime.mockReset() + fsUploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeEnvironmentSubscribe.mockReset() @@ -129,7 +132,8 @@ export function installRuntimeFileClientEnvironment(): void { finishDownloadedFile: fsFinishDownloadedFile, cancelDownloadedFile: fsCancelDownloadedFile, importExternalPaths: fsImportExternalPaths, - stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime: fsUploadExternalFileToRuntime }, runtime: { call: runtimeCall }, runtimeEnvironments: { diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index 29d1e26ad96e..c4063c2eb93f 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -136,7 +136,13 @@ type StagedRuntimeImportSource = type StagedRuntimeImportEntry = | { relativePath: string; kind: 'directory' } - | { relativePath: string; kind: 'file'; contentBase64: string } + | { relativePath: string; kind: 'file'; byteLength: number } + +/** Locates a staged file on the client so main can stream it without the renderer reading it. */ +type RuntimeUploadSource = { + sourceRootPath: string + entryRelativePath: string +} type RuntimeImportResult = | { @@ -164,7 +170,6 @@ type RuntimeFileWatchEvent = | { type: 'error'; message: string } | { type: 'end' } -const REMOTE_UPLOAD_BASE64_CHUNK_CHARS = 512 * 1024 const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024 const REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE = 'Remote file download requires a newer Orca server. Update the headless server and try again.' @@ -739,7 +744,7 @@ export async function importExternalPathsToRuntime( target, context.worktreeId, entryRelativePath, - entry.contentBase64, + { sourceRootPath: source.sourcePath, entryRelativePath: entry.relativePath }, assertImportSessionCurrent, context.expectedSshConnectionGeneration, context.expectedSshTargetId, @@ -790,7 +795,7 @@ async function uploadRuntimeFileWithoutClobber( target: { kind: 'environment'; environmentId: string }, worktreeId: string, relativePath: string, - contentBase64: string, + source: RuntimeUploadSource, assertCurrent?: () => void, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, @@ -799,17 +804,20 @@ async function uploadRuntimeFileWithoutClobber( ): Promise { const tempRelativePath = makeRuntimeUploadTempPath(relativePath) try { - await writeRuntimeBase64File( - target, - worktreeId, - tempRelativePath, - contentBase64, - assertCurrent, - expectedSshConnectionGeneration, + assertCurrent?.() + // Why: main owns the file handle and the runtime socket, so it streams the + // body in slices; the renderer never holds the whole file. + await window.api.fs.uploadExternalFileToRuntime({ + environmentId: target.environmentId, + sourceRootPath: source.sourceRootPath, + entryRelativePath: source.entryRelativePath, + worktree: toRuntimeWorktreeSelector(worktreeId), + relativePath: tempRelativePath, expectedSshTargetId, + expectedSshConnectionGeneration, expectedExecutionHostId, expectedEnvironmentPairingRevision - ) + }) assertCurrent?.() await callRuntimeFileMutation( target, @@ -844,56 +852,6 @@ async function uploadRuntimeFileWithoutClobber( } } -async function writeRuntimeBase64File( - target: { kind: 'environment'; environmentId: string }, - worktreeId: string, - relativePath: string, - contentBase64: string, - assertCurrent?: () => void, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: 'local' | `ssh:${string}`, - expectedEnvironmentPairingRevision?: number -): Promise { - if (contentBase64.length <= REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - assertCurrent?.() - await callRuntimeFileMutation( - target, - 'files.writeBase64', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000, - expectedEnvironmentPairingRevision - ) - return - } - - for (let offset = 0; offset < contentBase64.length; offset += REMOTE_UPLOAD_BASE64_CHUNK_CHARS) { - assertCurrent?.() - await callRuntimeFileMutation( - target, - 'files.writeBase64Chunk', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - relativePath, - contentBase64: contentBase64.slice(offset, offset + REMOTE_UPLOAD_BASE64_CHUNK_CHARS), - append: offset > 0, - expectedSshTargetId, - expectedSshConnectionGeneration, - expectedExecutionHostId - }, - 30_000, - expectedEnvironmentPairingRevision - ) - } -} - function makeRuntimeUploadTempPath(relativePath: string): string { const normalized = normalizeRelativePath(relativePath) const slashIndex = normalized.lastIndexOf('/') 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 d8bea3b2d4c1..745c135c08fd 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 @@ -16,6 +16,7 @@ const CAPTURED_REVISION = 41 const REPLACEMENT_REVISION = 42 const runtimeEnvironmentCall = vi.fn() const stageExternalPathsForRuntimeUpload = vi.fn() +const uploadExternalFileToRuntime = vi.fn() const importExternalPaths = vi.fn() type RuntimeCallArgs = { @@ -90,7 +91,7 @@ function repairedRuntimeResponse(method: string) { } } -function mockStagedFile(sourcePath: string, name: string, contentBase64: string): void { +function mockStagedFile(sourcePath: string, name: string, byteLength: number): void { stageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ { @@ -98,7 +99,7 @@ function mockStagedFile(sourcePath: string, name: string, contentBase64: string) status: 'staged', name, kind: 'file', - entries: [{ relativePath: '', kind: 'file', contentBase64 }] + entries: [{ relativePath: '', kind: 'file', byteLength }] } ] }) @@ -118,12 +119,15 @@ beforeEach(() => { markRuntimeEnvironmentCompatible(ENVIRONMENT_ID) runtimeEnvironmentCall.mockReset() stageExternalPathsForRuntimeUpload.mockReset() + uploadExternalFileToRuntime.mockReset() + uploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) importExternalPaths.mockReset() vi.stubGlobal('window', { api: { fs: { importExternalPaths, - stageExternalPathsForRuntimeUpload + stageExternalPathsForRuntimeUpload, + uploadExternalFileToRuntime }, runtimeEnvironments: { call: runtimeEnvironmentCall @@ -149,8 +153,14 @@ describe('runtime file import pairing revision', () => { expect(importExternalPaths).not.toHaveBeenCalled() }) - it('stops a rich-markdown upload between chunks without contacting the replacement HUB', async () => { - mockStagedFile('/client/screenshot.png', 'screenshot.png', `${'A'.repeat(512 * 1024)}BBBBBBBB`) + it('stops a streamed upload mid-file without contacting the replacement HUB', async () => { + mockStagedFile('/client/screenshot.png', 'screenshot.png', 512 * 1024 + 8) + // Why: main re-checks the pairing revision per chunk, so a re-pair surfaces + // here as a rejected upload rather than a renderer-issued chunk RPC. + uploadExternalFileToRuntime.mockImplementation(async () => { + setEnvironmentRevision(REPLACEMENT_REVISION) + throw new Error('Runtime environment was re-paired during the import.') + }) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -158,9 +168,6 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64Chunk') { - setEnvironmentRevision(REPLACEMENT_REVISION) - } return successfulRuntimeResponse(args.method) }) @@ -172,9 +179,7 @@ describe('runtime file import pairing revision', () => { expect(runtimeEnvironmentCall.mock.calls.map(([args]) => args.method)).toEqual([ 'status.get', - 'files.stat', - 'status.get', - 'files.writeBase64Chunk' + 'files.stat' ]) expectEveryRuntimeCallBoundToCapturedRevision() expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( @@ -186,23 +191,21 @@ describe('runtime file import pairing revision', () => { }) it('keeps a HUB-local composer commit on its entry revision when re-paired mid-call', async () => { - mockStagedFile('/client/note.txt', 'note.txt', 'bm90ZQ==') - let statusCalls = 0 + mockStagedFile('/client/note.txt', 'note.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.expectedEnvironmentPairingRevision !== CAPTURED_REVISION) { throw new Error('replacement HUB received an import RPC') } if (args.method === 'status.get') { - statusCalls += 1 - if (statusCalls === 3) { - setEnvironmentRevision(REPLACEMENT_REVISION) - } return runtimeStatusResponse() } if (args.method === 'files.stat') { return missingRuntimePathResponse() } if (args.method === 'files.commitUpload') { + // Why: re-paired while the commit was in flight — cleanup must not run + // against the replacement HUB. + setEnvironmentRevision(REPLACEMENT_REVISION) return repairedRuntimeResponse(args.method) } return successfulRuntimeResponse(args.method) @@ -221,7 +224,7 @@ describe('runtime file import pairing revision', () => { }) it('does not clean up against a replacement HUB after commit', async () => { - mockStagedFile('/client/drop.txt', 'drop.txt', 'ZHJvcA==') + mockStagedFile('/client/drop.txt', 'drop.txt', 4) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -260,11 +263,12 @@ describe('runtime file import pairing revision', () => { kind: 'directory', entries: [ { relativePath: '', kind: 'directory' }, - { relativePath: 'broken.txt', kind: 'file', contentBase64: 'YnJva2Vu' } + { relativePath: 'broken.txt', kind: 'file', byteLength: 6 } ] } ] }) + uploadExternalFileToRuntime.mockRejectedValue(new Error('disk full')) runtimeEnvironmentCall.mockImplementation(async (args: RuntimeCallArgs) => { if (args.method === 'status.get') { return runtimeStatusResponse() @@ -272,14 +276,6 @@ describe('runtime file import pairing revision', () => { if (args.method === 'files.stat') { return missingRuntimePathResponse() } - if (args.method === 'files.writeBase64') { - return { - id: args.method, - ok: false, - error: { code: 'write_failed', message: 'disk full' }, - _meta: { runtimeId: 'hub-runtime' } - } - } return successfulRuntimeResponse(args.method) }) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index f6b5fbd473ad..714b0a0b4b66 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2071,6 +2071,11 @@ function createFileApi(): NonNullable['fs']> { }, importExternalPaths: async () => ({ results: [] }), stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }), + // Why: the web client has no local filesystem to stream from, so staging + // never yields a source for this to upload. + uploadExternalFileToRuntime: async () => { + throw new Error('Uploading local files is not supported in the web client') + }, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), From dc62ff8e5bcb6795862285f895ec1f8856bd0f36 Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 15:21:07 +0200 Subject: [PATCH 02/20] feat(runtime): show progress while uploading dropped files to a runtime Dropping a large file onto a remote terminal gave no feedback until the transfer finished or failed, so a slow upload looked like a hung one. That became easy to hit once streaming removed the 25 MB per-file import cap. The chunk loop now reports bytes acknowledged by the runtime, coalesced to at most one update per megabyte or 100ms so a multi-gigabyte file does not flood IPC. The renderer sums staged byte counts up front and tracks committed files plus the file in flight, so the drop toast shows one figure for the whole drop rather than per file. Client-local only: main to renderer, no RPC params or stream frames change, so there is no wire-compatibility surface. Cancellation is deliberately excluded; it needs an abort path threaded into the streamer plus temp-file cleanup, and belongs with UI that can trigger it. Fixes #16077 Co-Authored-By: Claude Opus 5 (1M context) --- src/main/ipc/filesystem-mutations.ts | 28 ++- .../ipc/runtime-upload-file-stream.test.ts | 54 +++++ src/main/ipc/runtime-upload-file-stream.ts | 6 + src/main/ipc/runtime-upload-progress.test.ts | 102 +++++++++ src/main/ipc/runtime-upload-progress.ts | 49 +++++ src/preload/api/filesystem-api.ts | 4 + src/preload/index.ts | 11 + .../terminal-drop-handler.test.ts | 6 +- .../terminal-drop-upload-progress.test.ts | 30 +++ .../terminal-drop-upload-progress.ts | 26 +++ .../terminal-native-file-drop.ts | 21 +- .../runtime-file-client-test-harness.ts | 7 +- .../src/runtime/runtime-file-client.ts | 194 ++++++++++-------- .../runtime-upload-progress-tracker.test.ts | 104 ++++++++++ .../runtime-upload-progress-tracker.ts | 66 ++++++ src/renderer/src/web/web-preload-api.ts | 1 + 16 files changed, 613 insertions(+), 96 deletions(-) create mode 100644 src/main/ipc/runtime-upload-progress.test.ts create mode 100644 src/main/ipc/runtime-upload-progress.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts create mode 100644 src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts create mode 100644 src/renderer/src/runtime/runtime-upload-progress-tracker.ts diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 2b14c1777843..faf5cf71ddd0 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -20,6 +20,10 @@ import type { import { importOneSource } from './filesystem-import-local' import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging' import { streamExternalFileToRuntime } from './runtime-upload-file-stream' +import { + RUNTIME_UPLOAD_PROGRESS_CHANNEL, + throttleRuntimeUploadProgress +} from './runtime-upload-progress' /** * IPC handlers for file/folder creation and renaming. @@ -210,17 +214,28 @@ export function registerFilesystemMutationHandlers(store: Store): void { ipcMain.handle( 'fs:uploadExternalFileToRuntime', async ( - _event, + event, args: { environmentId: string sourceRootPath: string entryRelativePath: string worktree: string relativePath: string + uploadId?: string expectedEnvironmentPairingRevision?: number } & SshMutationExpectation - ): Promise<{ byteLength: number }> => - streamExternalFileToRuntime({ + ): Promise<{ byteLength: number }> => { + const uploadId = args.uploadId + // Why: replies to the frame that asked, so a second window's drop cannot + // move this one's progress bar. + const emit = uploadId + ? throttleRuntimeUploadProgress((progress) => { + if (!event.sender.isDestroyed()) { + event.sender.send(RUNTIME_UPLOAD_PROGRESS_CHANNEL, progress) + } + }) + : null + return streamExternalFileToRuntime({ userDataPath: app.getPath('userData'), environmentId: args.environmentId, sourceRootPath: args.sourceRootPath, @@ -230,8 +245,13 @@ export function registerFilesystemMutationHandlers(store: Store): void { expectedExecutionHostId: args.expectedExecutionHostId, expectedSshTargetId: args.expectedSshTargetId, expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, - expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision + expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision, + onProgress: + emit && uploadId + ? ({ sentBytes, totalBytes }) => emit({ uploadId, sentBytes, totalBytes }) + : undefined }) + } ) // Why: terminal drag-and-drop resolver. Local worktrees pass paths through diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index 3c6c67167bf9..aeefa5761bc2 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -122,6 +122,60 @@ describe('streamExternalFileToRuntime', () => { expect(chunkCalls()).toHaveLength(2) }) + it('reports monotonic progress that ends on the file size', async () => { + const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + 500 + const filePath = join(workDir, 'tracked.bin') + await writeFile(filePath, Buffer.alloc(size)) + const seen: { sentBytes: number; totalBytes: number }[] = [] + + await streamExternalFileToRuntime({ + ...baseArgs(filePath), + onProgress: (progress) => seen.push(progress) + }) + + expect(seen).toHaveLength(3) + expect(seen.every((p) => p.totalBytes === size)).toBe(true) + expect(seen.map((p) => p.sentBytes)).toEqual([ + RUNTIME_UPLOAD_SLICE_BYTES, + RUNTIME_UPLOAD_SLICE_BYTES * 2, + size + ]) + }) + + it('reports a zero-byte file as complete rather than never reporting', async () => { + const filePath = join(workDir, 'nothing.txt') + await writeFile(filePath, '') + const seen: { sentBytes: number; totalBytes: number }[] = [] + + await streamExternalFileToRuntime({ + ...baseArgs(filePath), + onProgress: (progress) => seen.push(progress) + }) + + expect(seen).toEqual([{ sentBytes: 0, totalBytes: 0 }]) + }) + + it('stops reporting progress at the chunk that failed', async () => { + const filePath = join(workDir, 'halts.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 3)) + const seen: number[] = [] + callRuntimeEnvironment.mockResolvedValueOnce({ id: 'x', ok: true, result: {}, _meta: {} }) + callRuntimeEnvironment.mockResolvedValueOnce({ + id: 'x', + ok: false, + error: { code: 'write_failed', message: 'disk full' } + }) + + await expect( + streamExternalFileToRuntime({ + ...baseArgs(filePath), + onProgress: (progress) => seen.push(progress.sentBytes) + }) + ).rejects.toThrow('disk full') + + expect(seen).toEqual([RUNTIME_UPLOAD_SLICE_BYTES]) + }) + it('refuses a symlinked source', async () => { const targetPath = join(workDir, 'secret.txt') await writeFile(targetPath, 'secret') diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index 60d43010214e..fff4e7c1813c 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -24,6 +24,8 @@ export type RuntimeUploadFileStreamArgs = { expectedSshTargetId?: string expectedSshConnectionGeneration?: number expectedEnvironmentPairingRevision?: number + /** Called after each slice lands, so the drop UI can show how far along the file is. */ + onProgress?: (progress: { sentBytes: number; totalBytes: number }) => void } /** @@ -72,6 +74,7 @@ export async function streamExternalFileToRuntime( // to exist before commitUpload renames it into place. if (totalBytes === 0) { await sendChunk(args, '', false) + args.onProgress?.({ sentBytes: 0, totalBytes: 0 }) return { byteLength: 0 } } @@ -84,6 +87,9 @@ export async function streamExternalFileToRuntime( } await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0) offset += bytesRead + // Why: reported after the chunk is acknowledged, so the bar tracks bytes the + // runtime actually has rather than bytes handed to the socket. + args.onProgress?.({ sentBytes: offset, totalBytes }) } // Why: the destination is a temp path the caller commits, so a source that diff --git a/src/main/ipc/runtime-upload-progress.test.ts b/src/main/ipc/runtime-upload-progress.test.ts new file mode 100644 index 000000000000..2c2edf84cdca --- /dev/null +++ b/src/main/ipc/runtime-upload-progress.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { throttleRuntimeUploadProgress } from './runtime-upload-progress' + +const MB = 1024 * 1024 + +function collector(now: () => number) { + const seen: number[] = [] + const emit = throttleRuntimeUploadProgress((progress) => seen.push(progress.sentBytes), now) + return { seen, emit } +} + +describe('throttleRuntimeUploadProgress', () => { + it('drops updates that are neither big enough nor old enough', () => { + let clock = 0 + const { seen, emit } = collector(() => clock) + const total = 100 * MB + + emit({ uploadId: 'u', sentBytes: 512 * 1024, totalBytes: total }) + for (let sent = 768 * 1024; sent <= 1408 * 1024; sent += 256 * 1024) { + clock += 10 + emit({ uploadId: 'u', sentBytes: sent, totalBytes: total }) + } + + // Only the first: every follow-up is under 1 MB apart and inside 100ms. + expect(seen).toEqual([512 * 1024]) + }) + + it('lets an update through once a megabyte has moved', () => { + let clock = 0 + const { seen, emit } = collector(() => clock) + const total = 100 * MB + + emit({ uploadId: 'u', sentBytes: 1, totalBytes: total }) + clock += 5 + emit({ uploadId: 'u', sentBytes: 2 * MB, totalBytes: total }) + + expect(seen).toEqual([1, 2 * MB]) + }) + + it('lets an update through once the interval has passed on a slow link', () => { + let clock = 0 + const { seen, emit } = collector(() => clock) + const total = 100 * MB + + emit({ uploadId: 'u', sentBytes: 1, totalBytes: total }) + clock += 250 + emit({ uploadId: 'u', sentBytes: 1024, totalBytes: total }) + + expect(seen).toEqual([1, 1024]) + }) + + it('always emits the final byte count so the bar lands on full', () => { + let clock = 0 + const { seen, emit } = collector(() => clock) + const total = 4 * MB + + emit({ uploadId: 'u', sentBytes: 1, totalBytes: total }) + clock += 1 + emit({ uploadId: 'u', sentBytes: total, totalBytes: total }) + + expect(seen).toEqual([1, total]) + }) + + it('emits a zero-byte file exactly once', () => { + const { seen, emit } = collector(() => 0) + + emit({ uploadId: 'u', sentBytes: 0, totalBytes: 0 }) + emit({ uploadId: 'u', sentBytes: 0, totalBytes: 0 }) + + expect(seen).toEqual([0]) + }) + + it('never moves the reported count backwards', () => { + let clock = 0 + const { seen, emit } = collector(() => clock) + const total = 100 * MB + + emit({ uploadId: 'u', sentBytes: 10 * MB, totalBytes: total }) + clock += 500 + emit({ uploadId: 'u', sentBytes: 4 * MB, totalBytes: total }) + + expect(seen).toEqual([10 * MB]) + }) + + it('keeps a separate baseline per throttle so two files do not interfere', () => { + const first = collector(() => 0) + const second = collector(() => 0) + + first.emit({ uploadId: 'a', sentBytes: 8 * MB, totalBytes: 100 * MB }) + second.emit({ uploadId: 'b', sentBytes: 1, totalBytes: 100 * MB }) + + expect(first.seen).toEqual([8 * MB]) + expect(second.seen).toEqual([1]) + }) + + it('uses a real clock by default', () => { + const spy = vi.fn() + const emit = throttleRuntimeUploadProgress(spy) + emit({ uploadId: 'u', sentBytes: 1, totalBytes: 10 }) + expect(spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/runtime-upload-progress.ts b/src/main/ipc/runtime-upload-progress.ts new file mode 100644 index 000000000000..7b0346962e69 --- /dev/null +++ b/src/main/ipc/runtime-upload-progress.ts @@ -0,0 +1,49 @@ +export const RUNTIME_UPLOAD_PROGRESS_CHANNEL = 'fs:uploadProgress' + +/** Bytes of one file that have reached the runtime, and how many there are in total. */ +export type RuntimeUploadProgress = { + uploadId: string + sentBytes: number + totalBytes: number +} + +export type RuntimeUploadProgressSink = (progress: RuntimeUploadProgress) => void + +const PROGRESS_MIN_INTERVAL_MS = 100 +const PROGRESS_MIN_BYTES = 1024 * 1024 + +/** + * Coalesce per-slice progress into something a renderer can keep up with. + * + * A 2 GB file is ~5,400 slices; forwarding each one would spend more time in IPC + * than in the transfer. First and last are always emitted so a bar starts at a + * known point and lands exactly on full. + */ +export function throttleRuntimeUploadProgress( + sink: RuntimeUploadProgressSink, + now: () => number = Date.now +): RuntimeUploadProgressSink { + let lastSentBytes = -1 + let lastEmittedAt = -Infinity + return (progress) => { + const isTerminal = progress.sentBytes >= progress.totalBytes + const isFirst = lastSentBytes < 0 + const currentTime = now() + if ( + !isTerminal && + !isFirst && + progress.sentBytes - lastSentBytes < PROGRESS_MIN_BYTES && + currentTime - lastEmittedAt < PROGRESS_MIN_INTERVAL_MS + ) { + return + } + // Why: slices land in order, so a late duplicate would only ever move the bar + // backwards. + if (progress.sentBytes <= lastSentBytes) { + return + } + lastSentBytes = progress.sentBytes + lastEmittedAt = currentTime + sink(progress) + } +} diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 9cc339571df7..a5c41ace4b2d 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -181,9 +181,13 @@ export type FilesystemApi = { entryRelativePath: string worktree: string relativePath: string + uploadId?: string expectedEnvironmentPairingRevision?: number } & SshMutationExpectation ) => Promise<{ byteLength: number }> + onUploadProgress: ( + callback: (progress: { uploadId: string; sentBytes: number; totalBytes: number }) => void + ) => () => void resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/preload/index.ts b/src/preload/index.ts index 18d30bb9f4df..bb0134925c8c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3462,10 +3462,21 @@ const api = { entryRelativePath: string worktree: string relativePath: string + uploadId?: string expectedEnvironmentPairingRevision?: number } & SshMutationExpectation ): Promise<{ byteLength: number }> => ipcRenderer.invoke('fs:uploadExternalFileToRuntime', args), + onUploadProgress: ( + callback: (progress: { uploadId: string; sentBytes: number; totalBytes: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { uploadId: string; sentBytes: number; totalBytes: number } + ) => callback(data) + ipcRenderer.on('fs:uploadProgress', listener) + return () => ipcRenderer.removeListener('fs:uploadProgress', listener) + }, resolveDroppedPathsForAgent: ( args: { paths: string[] 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 ee6a492f642d..480a58480b27 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 @@ -153,7 +153,7 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/logo.png'], '/remote/repo/.orca/drops', - { assertCurrent: expect.any(Function) } + { assertCurrent: expect.any(Function), onProgress: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith( wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png') @@ -249,7 +249,7 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/logo.png'], '\\\\server\\share\\repo\\.orca\\drops', - { assertCurrent: expect.any(Function) } + { assertCurrent: expect.any(Function), onProgress: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith( wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png') @@ -306,7 +306,7 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/spec.pdf'], '/remote/repo/.orca/drops', - { assertCurrent: expect.any(Function) } + { assertCurrent: expect.any(Function), onProgress: expect.any(Function) } ) expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ') }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts new file mode 100644 index 000000000000..45b4ee765d63 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { formatTerminalDropUploadProgress } from './terminal-drop-upload-progress' + +describe('formatTerminalDropUploadProgress', () => { + it('shows moved bytes, total and percent', () => { + const label = formatTerminalDropUploadProgress(1, 10 * 1024 * 1024, 40 * 1024 * 1024) + expect(label).toContain('10.0 MB') + expect(label).toContain('40.0 MB') + expect(label).toContain('25%') + }) + + it('pluralises the file count', () => { + expect(formatTerminalDropUploadProgress(1, 0, 100)).toContain('1 file') + expect(formatTerminalDropUploadProgress(3, 0, 100)).toContain('3 files') + }) + + it('omits the byte figures for a drop with no measurable size', () => { + const label = formatTerminalDropUploadProgress(1, 0, 0) + expect(label).not.toContain('0 B') + expect(label).not.toContain('%') + }) + + it('never rounds up to 100% before the last byte lands', () => { + expect(formatTerminalDropUploadProgress(1, 999_999, 1_000_000)).toContain('99%') + }) + + it('reaches 100% when everything has moved', () => { + expect(formatTerminalDropUploadProgress(1, 1_000, 1_000)).toContain('100%') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts new file mode 100644 index 000000000000..9f425f9052e9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts @@ -0,0 +1,26 @@ +import { formatBytes } from '../status-bar/workspace-space-format' +import { translate } from '@/i18n/i18n' + +/** + * Label for the in-place drop toast while bytes are moving. + * + * Falls back to the plain "uploading" wording when the drop has no measurable + * size — an empty file, or a folder of empty files — because "0 B of 0 B" reads + * like a failure rather than a fast success. + */ +export function formatTerminalDropUploadProgress( + fileCount: number, + sentBytes: number, + totalBytes: number +): string { + const uploading = translate( + 'auto.components.terminal.pane.terminal.drop.handler.29c031b49a', + 'Uploading {{value0}} file{{value1}} to runtime…', + { value0: fileCount, value1: fileCount === 1 ? '' : 's' } + ) + if (totalBytes <= 0) { + return uploading + } + const percent = Math.min(100, Math.floor((sentBytes / totalBytes) * 100)) + return `${uploading} ${formatBytes(sentBytes)} / ${formatBytes(totalBytes)} (${percent}%)` +} 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 197a3ffccc23..020bcddf7e58 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 @@ -12,6 +12,7 @@ import { isWslUncPath, parseWslUncPath } from '../../../../shared/wsl-paths' import type { PtyTransport } from './pty-transport' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { reportTerminalDropUploadSkipsAndFailures } from './terminal-drop-upload-report' +import { formatTerminalDropUploadProgress } from './terminal-drop-upload-progress' import { captureTerminalDropTarget, getCurrentTerminalDropTransport } from './terminal-drop-target' import { getTerminalTargetShellForWorktreePath, @@ -178,13 +179,7 @@ async function uploadRuntimeDropPaths( ): Promise { const targetShell = getTerminalTargetShellForWorktreePath(args.worktreePath) const destinationDir = joinRuntimeTerminalDropDir(args.worktreePath) - const pending = toast.loading( - translate( - 'auto.components.terminal.pane.terminal.drop.handler.29c031b49a', - 'Uploading {{value0}} file{{value1}} to runtime…', - { value0: args.dataPaths.length, value1: args.dataPaths.length === 1 ? '' : 's' } - ) - ) + const pending = toast.loading(formatTerminalDropUploadProgress(args.dataPaths.length, 0, 0)) try { const { results } = await importExternalPathsToRuntime( { @@ -199,7 +194,17 @@ async function uploadRuntimeDropPaths( }, args.dataPaths, destinationDir, - { assertCurrent: args.assertCurrent } + { + assertCurrent: args.assertCurrent, + // Why: reuses the pending toast's id so the drop keeps one row instead of + // stacking a new toast per update. + onProgress: ({ sentBytes, totalBytes }) => { + toast.loading( + formatTerminalDropUploadProgress(args.dataPaths.length, sentBytes, totalBytes), + { id: pending } + ) + } + } ) const imported = results.filter((result) => result.status === 'imported') const importedPaths = imported.map((result) => diff --git a/src/renderer/src/runtime/runtime-file-client-test-harness.ts b/src/renderer/src/runtime/runtime-file-client-test-harness.ts index 787727b5d1cb..2dad7d5a8698 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -54,6 +54,8 @@ export const fsCancelDownloadedFile: PreloadStub = vi.fn() export const fsImportExternalPaths: PreloadStub = vi.fn() export const fsStageExternalPathsForRuntimeUpload: PreloadStub = vi.fn() export const fsUploadExternalFileToRuntime: PreloadStub = vi.fn() +/** Returns the unsubscribe the import loop calls in its finally. */ +export const fsOnUploadProgress: PreloadStub = vi.fn() export const runtimeEnvironmentCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentTransportCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentSubscribe: RuntimeSubscribeStub = vi.fn() @@ -90,6 +92,8 @@ export function installRuntimeFileClientEnvironment(): void { fsStageExternalPathsForRuntimeUpload.mockReset() fsUploadExternalFileToRuntime.mockReset() fsUploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) + fsOnUploadProgress.mockReset() + fsOnUploadProgress.mockReturnValue(() => {}) runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeEnvironmentSubscribe.mockReset() @@ -133,7 +137,8 @@ export function installRuntimeFileClientEnvironment(): void { cancelDownloadedFile: fsCancelDownloadedFile, importExternalPaths: fsImportExternalPaths, stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload, - uploadExternalFileToRuntime: fsUploadExternalFileToRuntime + uploadExternalFileToRuntime: fsUploadExternalFileToRuntime, + onUploadProgress: fsOnUploadProgress }, runtime: { call: runtimeCall }, runtimeEnvironments: { diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index c4063c2eb93f..ad7319d8f95d 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -28,6 +28,12 @@ import { relativePathInsideRoot } from '../../../shared/cross-platform-path' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { + createRuntimeUploadProgressTracker, + sumStagedUploadBytes, + type RuntimeUploadProgressReport +} from './runtime-upload-progress-tracker' import { createEmptyRuntimeFileSearchResult, getRuntimeFileSearchRejectedField @@ -664,7 +670,11 @@ export async function importExternalPathsToRuntime( context: RuntimeFileOperationArgs, sourcePaths: string[], destinationDir: string, - options?: { ensureDestinationDir?: boolean; assertCurrent?: () => void } + options?: { + ensureDestinationDir?: boolean + assertCurrent?: () => void + onProgress?: (progress: RuntimeUploadProgressReport) => void + } ): Promise<{ results: RuntimeImportResult[] }> { const target = getActiveRuntimeTarget(context.settings) if (target.kind !== 'environment' || !context.worktreeId || !context.worktreePath) { @@ -695,100 +705,122 @@ export async function importExternalPathsToRuntime( assertImportSessionCurrent() const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths }) assertImportSessionCurrent() + const uploadId = options?.onProgress ? createBrowserUuid() : undefined + const progress = options?.onProgress + ? createRuntimeUploadProgressTracker( + sumStagedUploadBytes(staged.sources as StagedRuntimeImportSource[]), + options.onProgress + ) + : null + const unsubscribeProgress = + progress && uploadId + ? window.api.fs.onUploadProgress((event) => { + // Why: a concurrent drop in another pane shares this channel. + if (event.uploadId === uploadId) { + progress.reportFileProgress(event.sentBytes) + } + }) + : null const results: RuntimeImportResult[] = [] const reservedNames = new Set() - await ensureRuntimeDirectory( - context, - destinationDir, - assertImportSessionCurrent, - expectedEnvironmentPairingRevision - ) + try { + await ensureRuntimeDirectory( + context, + destinationDir, + assertImportSessionCurrent, + expectedEnvironmentPairingRevision + ) - for (const source of staged.sources as StagedRuntimeImportSource[]) { - if (source.status !== 'staged') { - results.push(source) - continue - } - let createdDirectoryImportRoot: string | null = null - try { - const finalName = await deconflictRuntimeImportName( - context, - destinationDir, - source.name, - reservedNames, - expectedEnvironmentPairingRevision - ) - const destPath = joinPath(destinationDir, finalName) - const destRelativePath = joinRuntimeRelativePath(destinationArgs.relativePath, finalName) - for (const entry of source.entries) { - const entryRelativePath = joinRuntimeRelativePath(destRelativePath, entry.relativePath) - if (entry.kind === 'directory') { + for (const source of staged.sources as StagedRuntimeImportSource[]) { + if (source.status !== 'staged') { + results.push(source) + continue + } + let createdDirectoryImportRoot: string | null = null + try { + const finalName = await deconflictRuntimeImportName( + context, + destinationDir, + source.name, + reservedNames, + expectedEnvironmentPairingRevision + ) + const destPath = joinPath(destinationDir, finalName) + const destRelativePath = joinRuntimeRelativePath(destinationArgs.relativePath, finalName) + for (const entry of source.entries) { + const entryRelativePath = joinRuntimeRelativePath(destRelativePath, entry.relativePath) + if (entry.kind === 'directory') { + assertImportSessionCurrent() + await callRuntimeFileMutation( + target, + 'files.createDirNoClobber', + withSshMutationExpectation(context, { + worktree: toRuntimeWorktreeSelector(context.worktreeId), + relativePath: entryRelativePath + }), + 15_000, + expectedEnvironmentPairingRevision + ) + if (source.kind === 'directory' && entry.relativePath === '') { + createdDirectoryImportRoot = entryRelativePath + } + continue + } + await uploadRuntimeFileWithoutClobber( + target, + context.worktreeId, + entryRelativePath, + { sourceRootPath: source.sourcePath, entryRelativePath: entry.relativePath }, + assertImportSessionCurrent, + context.expectedSshConnectionGeneration, + context.expectedSshTargetId, + context.expectedExecutionHostId ?? + (context.expectedSshTargetId + ? `ssh:${encodeURIComponent(context.expectedSshTargetId)}` + : 'local'), + expectedEnvironmentPairingRevision, + uploadId + ) + progress?.completeFile(entry.byteLength) + } + reservedNames.add(finalName) + results.push({ + sourcePath: source.sourcePath, + status: 'imported', + destPath, + kind: source.kind, + renamed: finalName !== source.name + }) + } catch (error) { + if (createdDirectoryImportRoot) { + // Why: match local directory imports by removing the no-clobber root + // Orca created when a nested runtime upload fails halfway through. assertImportSessionCurrent() await callRuntimeFileMutation( target, - 'files.createDirNoClobber', + 'files.delete', withSshMutationExpectation(context, { worktree: toRuntimeWorktreeSelector(context.worktreeId), - relativePath: entryRelativePath + relativePath: createdDirectoryImportRoot, + recursive: true }), 15_000, expectedEnvironmentPairingRevision - ) - if (source.kind === 'directory' && entry.relativePath === '') { - createdDirectoryImportRoot = entryRelativePath - } - continue + ).catch(() => {}) } - await uploadRuntimeFileWithoutClobber( - target, - context.worktreeId, - entryRelativePath, - { sourceRootPath: source.sourcePath, entryRelativePath: entry.relativePath }, - assertImportSessionCurrent, - context.expectedSshConnectionGeneration, - context.expectedSshTargetId, - context.expectedExecutionHostId ?? - (context.expectedSshTargetId - ? `ssh:${encodeURIComponent(context.expectedSshTargetId)}` - : 'local'), - expectedEnvironmentPairingRevision - ) - } - reservedNames.add(finalName) - results.push({ - sourcePath: source.sourcePath, - status: 'imported', - destPath, - kind: source.kind, - renamed: finalName !== source.name - }) - } catch (error) { - if (createdDirectoryImportRoot) { - // Why: match local directory imports by removing the no-clobber root - // Orca created when a nested runtime upload fails halfway through. - assertImportSessionCurrent() - await callRuntimeFileMutation( - target, - 'files.delete', - withSshMutationExpectation(context, { - worktree: toRuntimeWorktreeSelector(context.worktreeId), - relativePath: createdDirectoryImportRoot, - recursive: true - }), - 15_000, - expectedEnvironmentPairingRevision - ).catch(() => {}) + results.push({ + sourcePath: source.sourcePath, + status: 'failed', + reason: error instanceof Error ? error.message : String(error) + }) } - results.push({ - sourcePath: source.sourcePath, - status: 'failed', - reason: error instanceof Error ? error.message : String(error) - }) } - } - return { results } + return { results } + } finally { + unsubscribeProgress?.() + } } async function uploadRuntimeFileWithoutClobber( @@ -800,7 +832,8 @@ async function uploadRuntimeFileWithoutClobber( expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, expectedExecutionHostId?: 'local' | `ssh:${string}`, - expectedEnvironmentPairingRevision?: number + expectedEnvironmentPairingRevision?: number, + uploadId?: string ): Promise { const tempRelativePath = makeRuntimeUploadTempPath(relativePath) try { @@ -813,6 +846,7 @@ async function uploadRuntimeFileWithoutClobber( entryRelativePath: source.entryRelativePath, worktree: toRuntimeWorktreeSelector(worktreeId), relativePath: tempRelativePath, + uploadId, expectedSshTargetId, expectedSshConnectionGeneration, expectedExecutionHostId, diff --git a/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts new file mode 100644 index 000000000000..1f44c7bd4095 --- /dev/null +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { + createRuntimeUploadProgressTracker, + sumStagedUploadBytes +} from './runtime-upload-progress-tracker' + +describe('sumStagedUploadBytes', () => { + it('counts file bytes and ignores directory entries', () => { + expect( + sumStagedUploadBytes([ + { + status: 'staged', + entries: [ + { kind: 'directory' }, + { kind: 'file', byteLength: 10 }, + { kind: 'directory' }, + { kind: 'file', byteLength: 32 } + ] + } + ]) + ).toBe(42) + }) + + it('ignores sources that never staged', () => { + expect( + sumStagedUploadBytes([ + { status: 'skipped' }, + { status: 'failed', entries: [{ kind: 'file', byteLength: 999 }] }, + { status: 'staged', entries: [{ kind: 'file', byteLength: 7 }] } + ]) + ).toBe(7) + }) + + it('is zero for a drop of only empty files', () => { + expect( + sumStagedUploadBytes([{ status: 'staged', entries: [{ kind: 'file', byteLength: 0 }] }]) + ).toBe(0) + }) +}) + +describe('createRuntimeUploadProgressTracker', () => { + it('carries completed files into the running total', () => { + const seen: number[] = [] + const tracker = createRuntimeUploadProgressTracker(300, (p) => seen.push(p.sentBytes)) + + tracker.reportFileProgress(50) + tracker.reportFileProgress(100) + tracker.completeFile(100) + tracker.reportFileProgress(75) + + expect(seen).toEqual([50, 100, 175]) + }) + + it('reports the same total on every update', () => { + const totals: number[] = [] + const tracker = createRuntimeUploadProgressTracker(500, (p) => totals.push(p.totalBytes)) + + tracker.reportFileProgress(10) + // completeFile does not re-emit: the figure has not moved. + tracker.completeFile(10) + tracker.reportFileProgress(20) + + expect(totals).toEqual([500, 500]) + }) + + it('does not repeat a figure that has not moved', () => { + const seen: number[] = [] + const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + + tracker.reportFileProgress(40) + tracker.reportFileProgress(40) + tracker.completeFile(40) + + expect(seen).toEqual([40]) + }) + + it('clamps to the staged total when a source grew after staging', () => { + const seen: number[] = [] + const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + + tracker.reportFileProgress(250) + + expect(seen).toEqual([100]) + }) + + it('advances across a directory of several files', () => { + const seen: number[] = [] + const tracker = createRuntimeUploadProgressTracker(30, (p) => seen.push(p.sentBytes)) + + for (const size of [10, 10, 10]) { + tracker.reportFileProgress(size) + tracker.completeFile(size) + } + + expect(seen).toEqual([10, 20, 30]) + }) + + it('emits nothing until bytes move', () => { + const seen: number[] = [] + createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + + expect(seen).toEqual([]) + }) +}) diff --git a/src/renderer/src/runtime/runtime-upload-progress-tracker.ts b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts new file mode 100644 index 000000000000..fd1471f5d7bc --- /dev/null +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts @@ -0,0 +1,66 @@ +export type RuntimeUploadProgressReport = { sentBytes: number; totalBytes: number } + +export type RuntimeUploadProgressTracker = { + /** Bytes of the file currently in flight that have reached the runtime. */ + reportFileProgress: (sentBytes: number) => void + /** Called once a file is committed, so its bytes move from in-flight to done. */ + completeFile: (byteLength: number) => void +} + +/** + * Turn per-file byte counts into one figure for a whole drop. + * + * Files upload strictly one at a time, so "in flight" is always a single file and + * the running total is simply the committed files plus that file's progress. + * Directory entries carry no bytes and are deliberately absent from the total — + * they would otherwise stall the bar at each `createDirNoClobber`. + */ +export function createRuntimeUploadProgressTracker( + totalBytes: number, + report: (progress: RuntimeUploadProgressReport) => void +): RuntimeUploadProgressTracker { + let completedBytes = 0 + let inFlightBytes = 0 + let lastReported = -1 + + const emit = (): void => { + // Why: a source can grow between staging and upload, so the sum of what + // actually moved may exceed the total staging measured. + const sentBytes = Math.min(completedBytes + inFlightBytes, totalBytes) + if (sentBytes === lastReported) { + return + } + lastReported = sentBytes + report({ sentBytes, totalBytes }) + } + + return { + reportFileProgress: (sentBytes) => { + inFlightBytes = sentBytes + emit() + }, + completeFile: (byteLength) => { + completedBytes += byteLength + inFlightBytes = 0 + emit() + } + } +} + +/** Total bytes a staged drop will move; directory entries contribute nothing. */ +export function sumStagedUploadBytes( + sources: { status: string; entries?: { kind: string; byteLength?: number }[] }[] +): number { + let total = 0 + for (const source of sources) { + if (source.status !== 'staged') { + continue + } + for (const entry of source.entries ?? []) { + if (entry.kind === 'file') { + total += entry.byteLength ?? 0 + } + } + } + return total +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 714b0a0b4b66..b53e7d4031e0 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2076,6 +2076,7 @@ function createFileApi(): NonNullable['fs']> { uploadExternalFileToRuntime: async () => { throw new Error('Uploading local files is not supported in the web client') }, + onUploadProgress: () => noopUnsubscribe, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), From 1c0124b128281612a0df4b419a8cc18537b6036c Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 16:50:13 +0200 Subject: [PATCH 03/20] feat(runtime): per-file drop upload panel with cancel Replaces the single-line progress toast with a panel: a header carrying the aggregate percent and a collapse toggle, then one row per dropped source showing name, transferred-of-total, a bar, percent, and a cancel control. Cancel is real rather than decorative. Each dropped source streams under one id that is both its progress key and its abort handle, so cancelling a row stops that source and leaves the rest of the drop running. The streamer checks between slices, which leaves a well-formed partial temp file for the existing cleanup to remove; nothing lands at the final path. A cancel that arrives between two files of a source is remembered so the next file aborts instead of the click doing nothing, and cancelled sources are excluded from the failure summary because a cancel is not a failure. Both figures in a row scale to the total's unit: formatting them independently rendered "900 / 32.5" when a file crossed a unit boundary. Per STYLEGUIDE, cancel is a back-out rather than a destructive action, so it stays a quiet ghost control with no colour, and the bar uses the existing Progress primitive's `primary` fill rather than a new hue. Splits the drop flow into terminal-runtime-drop-upload and terminal-drop-paste; the host file had grown past the max-lines limit. Fixes #16077 Co-Authored-By: Claude Opus 5 (1M context) --- src/main/ipc/filesystem-mutations.ts | 55 +++++-- .../ipc/runtime-upload-cancellation.test.ts | 69 +++++++++ src/main/ipc/runtime-upload-cancellation.ts | 51 +++++++ .../ipc/runtime-upload-file-stream.test.ts | 28 ++++ src/main/ipc/runtime-upload-file-stream.ts | 13 ++ src/preload/api/filesystem-api.ts | 2 + src/preload/index.ts | 4 + .../terminal-pane/TerminalDropUploadToast.tsx | 134 +++++++++++++++++ .../terminal-drop-handler.test.ts | 104 +++++++++---- .../terminal-pane/terminal-drop-paste.ts | 53 +++++++ .../terminal-drop-upload-progress.test.ts | 52 ++++--- .../terminal-drop-upload-progress.ts | 44 +++--- .../terminal-native-file-drop.ts | 119 +-------------- .../terminal-runtime-drop-upload.ts | 113 ++++++++++++++ .../src/runtime/runtime-file-client.ts | 73 ++++++--- .../runtime-upload-progress-tracker.test.ts | 39 ++--- .../runtime-upload-progress-tracker.ts | 34 +++-- .../runtime-upload-session-state.test.ts | 140 ++++++++++++++++++ .../runtime/runtime-upload-session-state.ts | 111 ++++++++++++++ src/renderer/src/web/web-preload-api.ts | 2 + 20 files changed, 991 insertions(+), 249 deletions(-) create mode 100644 src/main/ipc/runtime-upload-cancellation.test.ts create mode 100644 src/main/ipc/runtime-upload-cancellation.ts create mode 100644 src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-paste.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts create mode 100644 src/renderer/src/runtime/runtime-upload-session-state.test.ts create mode 100644 src/renderer/src/runtime/runtime-upload-session-state.ts diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index faf5cf71ddd0..e8ed8b551bff 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -24,6 +24,11 @@ import { RUNTIME_UPLOAD_PROGRESS_CHANNEL, throttleRuntimeUploadProgress } from './runtime-upload-progress' +import { + cancelRuntimeUpload, + forgetRuntimeUploadCancellation, + registerCancellableUpload +} from './runtime-upload-cancellation' /** * IPC handlers for file/folder creation and renaming. @@ -235,25 +240,43 @@ export function registerFilesystemMutationHandlers(store: Store): void { } }) : null - return streamExternalFileToRuntime({ - userDataPath: app.getPath('userData'), - environmentId: args.environmentId, - sourceRootPath: args.sourceRootPath, - entryRelativePath: args.entryRelativePath, - worktree: args.worktree, - relativePath: args.relativePath, - expectedExecutionHostId: args.expectedExecutionHostId, - expectedSshTargetId: args.expectedSshTargetId, - expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, - expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision, - onProgress: - emit && uploadId - ? ({ sentBytes, totalBytes }) => emit({ uploadId, sentBytes, totalBytes }) - : undefined - }) + const cancellation = uploadId ? registerCancellableUpload(uploadId) : null + try { + return await streamExternalFileToRuntime({ + userDataPath: app.getPath('userData'), + environmentId: args.environmentId, + sourceRootPath: args.sourceRootPath, + entryRelativePath: args.entryRelativePath, + worktree: args.worktree, + relativePath: args.relativePath, + expectedExecutionHostId: args.expectedExecutionHostId, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration, + expectedEnvironmentPairingRevision: args.expectedEnvironmentPairingRevision, + signal: cancellation?.signal, + onProgress: + emit && uploadId + ? ({ sentBytes, totalBytes }) => emit({ uploadId, sentBytes, totalBytes }) + : undefined + }) + } finally { + cancellation?.release() + } } ) + // Why: the drop UI cancels a whole dropped source, so the id it sends is the + // one every file of that source streams under. + ipcMain.handle('fs:cancelRuntimeUpload', (_event, args: { uploadId: string }): void => { + cancelRuntimeUpload(args.uploadId) + }) + + // Why: ids are minted per drop, but a cancel recorded for one must not outlive + // it and abort a later upload that happens to reuse the id. + ipcMain.handle('fs:releaseRuntimeUpload', (_event, args: { uploadId: string }): void => { + forgetRuntimeUploadCancellation(args.uploadId) + }) + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees // upload each path into `${worktreePath}/.orca/drops/` and return remote diff --git a/src/main/ipc/runtime-upload-cancellation.test.ts b/src/main/ipc/runtime-upload-cancellation.test.ts new file mode 100644 index 000000000000..4972dbef6dce --- /dev/null +++ b/src/main/ipc/runtime-upload-cancellation.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + cancelRuntimeUpload, + forgetRuntimeUploadCancellation, + isRuntimeUploadCancelled, + isUploadCancelled, + registerCancellableUpload, + RuntimeUploadCancelledError +} from './runtime-upload-cancellation' + +afterEach(() => { + forgetRuntimeUploadCancellation('u') + forgetRuntimeUploadCancellation('v') +}) + +describe('runtime upload cancellation', () => { + it('aborts an upload that is already running', () => { + const { signal } = registerCancellableUpload('u') + expect(signal.aborted).toBe(false) + + cancelRuntimeUpload('u') + + expect(signal.aborted).toBe(true) + }) + + it('aborts the next file when cancel lands between two files of one drop', () => { + const first = registerCancellableUpload('u') + first.release() + cancelRuntimeUpload('u') + + const second = registerCancellableUpload('u') + + expect(second.signal.aborted).toBe(true) + }) + + it('leaves other uploads running', () => { + const target = registerCancellableUpload('u') + const bystander = registerCancellableUpload('v') + + cancelRuntimeUpload('u') + + expect(target.signal.aborted).toBe(true) + expect(bystander.signal.aborted).toBe(false) + }) + + it('stops applying a cancel once the drop is forgotten', () => { + cancelRuntimeUpload('u') + forgetRuntimeUploadCancellation('u') + + expect(isUploadCancelled('u')).toBe(false) + expect(registerCancellableUpload('u').signal.aborted).toBe(false) + }) + + it('releasing a superseded registration does not evict the live one', () => { + const stale = registerCancellableUpload('u') + const live = registerCancellableUpload('u') + stale.release() + + cancelRuntimeUpload('u') + + expect(live.signal.aborted).toBe(true) + }) + + it('recognises its own error and nothing else', () => { + expect(isRuntimeUploadCancelled(new RuntimeUploadCancelledError())).toBe(true) + expect(isRuntimeUploadCancelled(new Error('disk full'))).toBe(false) + expect(isRuntimeUploadCancelled('cancelled')).toBe(false) + }) +}) diff --git a/src/main/ipc/runtime-upload-cancellation.ts b/src/main/ipc/runtime-upload-cancellation.ts new file mode 100644 index 000000000000..b49207a583c6 --- /dev/null +++ b/src/main/ipc/runtime-upload-cancellation.ts @@ -0,0 +1,51 @@ +export class RuntimeUploadCancelledError extends Error { + constructor() { + super('Upload cancelled') + this.name = 'RuntimeUploadCancelledError' + } +} + +export function isRuntimeUploadCancelled(error: unknown): boolean { + return error instanceof Error && error.name === 'RuntimeUploadCancelledError' +} + +const controllers = new Map() +// Why: a drop's files stream one at a time, so cancel can land between two of +// them. Remembering the id means the next file aborts instead of the click +// silently doing nothing. +const cancelled = new Set() + +/** Registers an upload so it can be cancelled; returns its release callback. */ +export function registerCancellableUpload(uploadId: string): { + signal: AbortSignal + release: () => void +} { + const controller = new AbortController() + if (cancelled.has(uploadId)) { + controller.abort() + } + controllers.set(uploadId, controller) + return { + signal: controller.signal, + release: () => { + if (controllers.get(uploadId) === controller) { + controllers.delete(uploadId) + } + } + } +} + +export function cancelRuntimeUpload(uploadId: string): void { + cancelled.add(uploadId) + controllers.get(uploadId)?.abort() +} + +/** Called once a drop is finished so a reused id cannot inherit a stale cancel. */ +export function forgetRuntimeUploadCancellation(uploadId: string): void { + cancelled.delete(uploadId) + controllers.delete(uploadId) +} + +export function isUploadCancelled(uploadId: string): boolean { + return cancelled.has(uploadId) +} diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index aeefa5761bc2..b7b359a03ff1 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -176,6 +176,34 @@ describe('streamExternalFileToRuntime', () => { expect(seen).toEqual([RUNTIME_UPLOAD_SLICE_BYTES]) }) + it('stops between slices when the signal is already aborted', async () => { + const filePath = join(workDir, 'aborted.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 2)) + const controller = new AbortController() + controller.abort() + + await expect( + streamExternalFileToRuntime({ ...baseArgs(filePath), signal: controller.signal }) + ).rejects.toThrow('Upload cancelled') + expect(chunkCalls()).toHaveLength(0) + }) + + it('stops mid-file when the signal aborts after the first slice', async () => { + const filePath = join(workDir, 'midway.bin') + await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 4)) + const controller = new AbortController() + callRuntimeEnvironment.mockImplementation(async () => { + controller.abort() + return { id: 'x', ok: true, result: {}, _meta: {} } + }) + + await expect( + streamExternalFileToRuntime({ ...baseArgs(filePath), signal: controller.signal }) + ).rejects.toThrow('Upload cancelled') + // The first slice was already in flight; the loop stops before the second. + expect(chunkCalls()).toHaveLength(1) + }) + it('refuses a symlinked source', async () => { const targetPath = join(workDir, 'secret.txt') await writeFile(targetPath, 'secret') diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index fff4e7c1813c..f35580b0bd33 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -2,6 +2,7 @@ import { constants } from 'node:fs' import { lstat, open, realpath } from 'node:fs/promises' import { isAbsolute, join, relative, sep } from 'node:path' import { authorizeExternalPath } from './filesystem-auth' +import { RuntimeUploadCancelledError } from './runtime-upload-cancellation' import { callRuntimeEnvironment } from './runtime-environment-transport-routing' // Why: base64 turns 3 bytes into 4 chars, so a 384 KiB slice lands on the wire @@ -26,6 +27,8 @@ export type RuntimeUploadFileStreamArgs = { expectedEnvironmentPairingRevision?: number /** Called after each slice lands, so the drop UI can show how far along the file is. */ onProgress?: (progress: { sentBytes: number; totalBytes: number }) => void + /** Aborts between slices; the destination is a temp path the caller removes. */ + signal?: AbortSignal } /** @@ -70,6 +73,7 @@ export async function streamExternalFileToRuntime( } const totalBytes = openedStat.size + throwIfCancelled(args.signal) // Why: a zero-byte source produces no slices, but the destination still has // to exist before commitUpload renames it into place. if (totalBytes === 0) { @@ -81,6 +85,9 @@ export async function streamExternalFileToRuntime( const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) let offset = 0 while (offset < totalBytes) { + // Why: checked between slices rather than mid-flight, so a cancelled upload + // still leaves a well-formed partial temp file for the caller to delete. + throwIfCancelled(args.signal) const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) if (bytesRead === 0) { throw new Error(`File truncated during upload: '${displayPath}'`) @@ -152,3 +159,9 @@ async function assertEntryInsideRoot( throw new Error(`Path escaped upload root during upload: '${displayPath}'`) } } + +function throwIfCancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new RuntimeUploadCancelledError() + } +} diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index a5c41ace4b2d..40e64cc53799 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -188,6 +188,8 @@ export type FilesystemApi = { onUploadProgress: ( callback: (progress: { uploadId: string; sentBytes: number; totalBytes: number }) => void ) => () => void + cancelRuntimeUpload: (args: { uploadId: string }) => Promise + releaseRuntimeUpload: (args: { uploadId: string }) => Promise resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/preload/index.ts b/src/preload/index.ts index bb0134925c8c..b0e00eb8f084 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3477,6 +3477,10 @@ const api = { ipcRenderer.on('fs:uploadProgress', listener) return () => ipcRenderer.removeListener('fs:uploadProgress', listener) }, + cancelRuntimeUpload: (args: { uploadId: string }): Promise => + ipcRenderer.invoke('fs:cancelRuntimeUpload', args), + releaseRuntimeUpload: (args: { uploadId: string }): Promise => + ipcRenderer.invoke('fs:releaseRuntimeUpload', args), resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx b/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx new file mode 100644 index 000000000000..2f0fb3b93eb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx @@ -0,0 +1,134 @@ +import { useState, useSyncExternalStore } from 'react' +import { ChevronDownIcon, ChevronUpIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react' +import { Progress } from '@/components/ui/progress' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + getRuntimeUploadSession, + subscribeToRuntimeUploadSessions, + summarizeRuntimeUploadSession, + type RuntimeUploadRow +} from '@/runtime/runtime-upload-session-state' +import { formatTransferredOfTotal, toPercent } from './terminal-drop-upload-progress' + +type Props = { + sessionId: string + onCancel: (uploadId: string) => void +} + +export function TerminalDropUploadToast({ sessionId, onCancel }: Props): React.JSX.Element | null { + const session = useSyncExternalStore(subscribeToRuntimeUploadSessions, () => + getRuntimeUploadSession(sessionId) + ) + const [collapsed, setCollapsed] = useState(false) + + // createElement at the call site still yields a valid element, so rendering + // nothing here is safe once the session has ended. + if (!session) { + return null + } + const summary = summarizeRuntimeUploadSession(session) + const heading = translate( + 'auto.components.terminal.pane.terminal.drop.upload.heading', + 'Uploading {{value0}} file{{value1}}', + { value0: session.rows.length, value1: session.rows.length === 1 ? '' : 's' } + ) + + return ( +
+
+ + + + {heading} + {/* Fixed width so the row never reflows as the number gains digits. */} + + {summary.percent}% + + +
+ {!collapsed && ( +
    + {session.rows.map((row) => ( + + ))} +
+ )} +
+ ) +} + +function UploadRowItem({ + row, + onCancel +}: { + row: RuntimeUploadRow + onCancel: (uploadId: string) => void +}): React.JSX.Element { + const percent = toPercent(row.sentBytes, row.totalBytes) + const inactive = row.status !== 'uploading' + + return ( +
  • + + + + {row.name} + + + {rowSubLabel(row)} + + + + + {percent}% + + {/* Cancel is a back-out, not a destructive action: ghost, no color. */} + +
  • + ) +} + +function rowSubLabel(row: RuntimeUploadRow): string { + if (row.status === 'cancelled') { + return translate('auto.components.terminal.pane.terminal.drop.upload.cancelled', 'Cancelled') + } + if (row.status === 'failed') { + return translate('auto.components.terminal.pane.terminal.drop.upload.failed', 'Failed') + } + return formatTransferredOfTotal(row.sentBytes, row.totalBytes) +} 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 480a58480b27..829745e2abce 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 @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ toastLoading: vi.fn(() => 'toast-1'), + toastCustom: vi.fn(() => 'toast-1'), toastDismiss: vi.fn(), toastError: vi.fn(), importExternalPathsToRuntime: vi.fn(), @@ -44,6 +45,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { loading: mocks.toastLoading, + custom: mocks.toastCustom, dismiss: mocks.toastDismiss, error: mocks.toastError, message: vi.fn() @@ -84,6 +86,25 @@ function createTerminalTransport( } } +// Why: the drop panel is only created once rows exist, so a mock that never +// announces a row leaves nothing for the dismissal assertions to observe. +function startAndFinishRow(options: never): void { + const progress = ( + options as { progress?: { onStart: (r: unknown[]) => void; onFinish: () => void } } + )?.progress + progress?.onStart([ + { uploadId: 'u-1', name: 'logo.png', totalBytes: 10, sourcePath: '/Users/me/logo.png' } + ]) + progress?.onFinish() +} + +function announceRowThenResolve(value: unknown) { + return async (_context: unknown, _paths: unknown, _dest: unknown, options: never) => { + startAndFinishRow(options) + return value + } +} + describe('handleTerminalFileDrop', () => { beforeEach(() => { vi.clearAllMocks() @@ -113,17 +134,19 @@ describe('handleTerminalFileDrop', () => { }) it('uploads client-local drops into the active runtime before pasting paths', async () => { - mocks.importExternalPathsToRuntime.mockResolvedValue({ - results: [ - { - sourcePath: '/Users/me/logo.png', - status: 'imported', - destPath: '/remote/repo/.orca/drops/logo.png', - kind: 'file', - renamed: false - } - ] - }) + mocks.importExternalPathsToRuntime.mockImplementation( + announceRowThenResolve({ + results: [ + { + sourcePath: '/Users/me/logo.png', + status: 'imported', + destPath: '/remote/repo/.orca/drops/logo.png', + kind: 'file', + renamed: false + } + ] + }) + ) const sendInput = vi.fn(() => true) const focus = vi.fn() const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } } @@ -153,7 +176,15 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/logo.png'], '/remote/repo/.orca/drops', - { assertCurrent: expect.any(Function), onProgress: expect.any(Function) } + { + assertCurrent: expect.any(Function), + progress: { + onStart: expect.any(Function), + onRowProgress: expect.any(Function), + onRowSettled: expect.any(Function), + onFinish: expect.any(Function) + } + } ) expect(sendInput).toHaveBeenCalledWith( wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png') @@ -166,20 +197,23 @@ describe('handleTerminalFileDrop', () => { it('does not paste runtime-uploaded paths when the target PTY changed', async () => { let ptyId = 'pty-1' - mocks.importExternalPathsToRuntime.mockImplementation(async () => { - ptyId = 'pty-2' - return { - results: [ - { - sourcePath: '/Users/me/logo.png', - status: 'imported', - destPath: '/remote/repo/.orca/drops/logo.png', - kind: 'file', - renamed: false - } - ] + mocks.importExternalPathsToRuntime.mockImplementation( + async (_context: unknown, _paths: unknown, _dest: unknown, options: never) => { + ptyId = 'pty-2' + startAndFinishRow(options) + return { + results: [ + { + sourcePath: '/Users/me/logo.png', + status: 'imported', + destPath: '/remote/repo/.orca/drops/logo.png', + kind: 'file', + renamed: false + } + ] + } } - }) + ) const sendInput = vi.fn(() => true) const focus = vi.fn() const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } } @@ -249,7 +283,15 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/logo.png'], '\\\\server\\share\\repo\\.orca\\drops', - { assertCurrent: expect.any(Function), onProgress: expect.any(Function) } + { + assertCurrent: expect.any(Function), + progress: { + onStart: expect.any(Function), + onRowProgress: expect.any(Function), + onRowSettled: expect.any(Function), + onFinish: expect.any(Function) + } + } ) expect(sendInput).toHaveBeenCalledWith( wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png') @@ -306,7 +348,15 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/spec.pdf'], '/remote/repo/.orca/drops', - { assertCurrent: expect.any(Function), onProgress: expect.any(Function) } + { + assertCurrent: expect.any(Function), + progress: { + onStart: expect.any(Function), + onRowProgress: expect.any(Function), + onRowSettled: expect.any(Function), + onFinish: expect.any(Function) + } + } ) expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ') }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-paste.ts b/src/renderer/src/components/terminal-pane/terminal-drop-paste.ts new file mode 100644 index 000000000000..cc981000f25a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-paste.ts @@ -0,0 +1,53 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport' +import type { captureTerminalDropTarget } from './terminal-drop-target' +import { getCurrentTerminalDropTransport } from './terminal-drop-target' +import type { resolveNativeTerminalDropPane } from './terminal-drop-pane-resolution' +import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' +import { showTerminalDropWriteFailure } from './terminal-drop-write-failure' +import { recordTerminalUserInputForLeaf } from './terminal-input-activity' + +/** Everything a drop flow needs to reach the terminal it was dropped on. */ +export type NativeDropFlowArgs = { + dataPaths: string[] + dropTarget: ReturnType + manager: PaneManager + paneTransports: Map + pane: ReturnType & {} + tabId: string + worktreePath: string + expectedSshTargetId?: string + expectedSshConnectionGeneration?: number + expectedExecutionHostId?: 'local' | `ssh:${string}` + assertCurrent?: () => void +} + +export async function pasteResolvedDropPaths( + args: NativeDropFlowArgs & { paths: string[]; targetShell: 'posix' | 'windows' } +): Promise { + // Why: pane may have unmounted during upload/resolution (tab closed, + // worktree switched). Re-check before writing so we do not call sendInput + // on a torn-down PTY. + const liveTransport = getCurrentTerminalDropTransport( + args.manager, + args.paneTransports, + args.dropTarget + ) + if (!liveTransport) { + return + } + const writeResult = await writeTerminalDropPathsToCapturedTarget({ + dropTarget: args.dropTarget, + manager: args.manager, + paneTransports: args.paneTransports, + paths: args.paths, + targetShell: args.targetShell + }) + showTerminalDropWriteFailure(writeResult.failureReason) + if (writeResult.sentAnyPath) { + recordTerminalUserInputForLeaf(args.tabId, args.pane.leafId) + } + if (writeResult.targetCurrent) { + args.pane.terminal.focus() + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts index 45b4ee765d63..70304180dd89 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts @@ -1,30 +1,46 @@ import { describe, expect, it } from 'vitest' -import { formatTerminalDropUploadProgress } from './terminal-drop-upload-progress' +import { formatTransferredOfTotal, toPercent } from './terminal-drop-upload-progress' -describe('formatTerminalDropUploadProgress', () => { - it('shows moved bytes, total and percent', () => { - const label = formatTerminalDropUploadProgress(1, 10 * 1024 * 1024, 40 * 1024 * 1024) - expect(label).toContain('10.0 MB') - expect(label).toContain('40.0 MB') - expect(label).toContain('25%') +const MB = 1024 * 1024 + +describe('formatTransferredOfTotal', () => { + it('scales both figures to the total unit', () => { + expect(formatTransferredOfTotal(8.63 * MB, 32.5 * MB)).toBe('8.6 / 32.5 MB') + }) + + it('keeps the smaller figure on the total scale instead of its own', () => { + // 900 KB of 32.5 MB must not render as "900 / 32.5". + expect(formatTransferredOfTotal(900 * 1024, 32.5 * MB)).toBe('0.9 / 32.5 MB') + }) + + it('uses whole bytes without decimals', () => { + expect(formatTransferredOfTotal(120, 900)).toBe('120 / 900 B') }) - it('pluralises the file count', () => { - expect(formatTerminalDropUploadProgress(1, 0, 100)).toContain('1 file') - expect(formatTerminalDropUploadProgress(3, 0, 100)).toContain('3 files') + it('drops to no decimals once the total is large in its unit', () => { + expect(formatTransferredOfTotal(150 * MB, 300 * MB)).toBe('150 / 300 MB') }) - it('omits the byte figures for a drop with no measurable size', () => { - const label = formatTerminalDropUploadProgress(1, 0, 0) - expect(label).not.toContain('0 B') - expect(label).not.toContain('%') + it('never shows more sent than the total', () => { + expect(formatTransferredOfTotal(50 * MB, 10 * MB)).toBe('10.0 / 10.0 MB') + }) + + it('handles a zero-byte drop without dividing by zero', () => { + expect(formatTransferredOfTotal(0, 0)).toBe('0 B') + }) +}) + +describe('toPercent', () => { + it('floors so it only reads 100 when everything landed', () => { + expect(toPercent(999_999, 1_000_000)).toBe(99) + expect(toPercent(1_000_000, 1_000_000)).toBe(100) }) - it('never rounds up to 100% before the last byte lands', () => { - expect(formatTerminalDropUploadProgress(1, 999_999, 1_000_000)).toContain('99%') + it('is zero when nothing is measurable', () => { + expect(toPercent(0, 0)).toBe(0) }) - it('reaches 100% when everything has moved', () => { - expect(formatTerminalDropUploadProgress(1, 1_000, 1_000)).toContain('100%') + it('clamps a source that grew past its staged size', () => { + expect(toPercent(200, 100)).toBe(100) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts index 9f425f9052e9..a73b0d30cb65 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts @@ -1,26 +1,32 @@ -import { formatBytes } from '../status-bar/workspace-space-format' -import { translate } from '@/i18n/i18n' +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const /** - * Label for the in-place drop toast while bytes are moving. + * "8.6 / 32.5 MB" — both figures scaled to the total's unit. * - * Falls back to the plain "uploading" wording when the drop has no measurable - * size — an empty file, or a folder of empty files — because "0 B of 0 B" reads - * like a failure rather than a fast success. + * Formatting each side independently reads as a bug the moment they land in + * different units: 900 KB of a 32.5 MB file would render "900 / 32.5" with the + * two numbers on different scales. */ -export function formatTerminalDropUploadProgress( - fileCount: number, - sentBytes: number, - totalBytes: number -): string { - const uploading = translate( - 'auto.components.terminal.pane.terminal.drop.handler.29c031b49a', - 'Uploading {{value0}} file{{value1}} to runtime…', - { value0: fileCount, value1: fileCount === 1 ? '' : 's' } - ) +export function formatTransferredOfTotal(sentBytes: number, totalBytes: number): string { + if (!Number.isFinite(totalBytes) || totalBytes <= 0) { + return `0 ${BYTE_UNITS[0]}` + } + let unitIndex = 0 + let divisor = 1 + while (totalBytes / divisor >= 1024 && unitIndex < BYTE_UNITS.length - 1) { + divisor *= 1024 + unitIndex += 1 + } + const scaledTotal = totalBytes / divisor + const scaledSent = Math.min(Math.max(sentBytes, 0), totalBytes) / divisor + const precision = scaledTotal >= 100 || unitIndex === 0 ? 0 : scaledTotal >= 10 ? 1 : 2 + return `${scaledSent.toFixed(precision)} / ${scaledTotal.toFixed(precision)} ${BYTE_UNITS[unitIndex]}` +} + +export function toPercent(sentBytes: number, totalBytes: number): number { if (totalBytes <= 0) { - return uploading + return 0 } - const percent = Math.min(100, Math.floor((sentBytes / totalBytes) * 100)) - return `${uploading} ${formatBytes(sentBytes)} / ${formatBytes(totalBytes)} (${percent}%)` + // Why: floor, so a bar only reads 100% once the last byte has actually landed. + return Math.min(100, Math.max(0, Math.floor((sentBytes / totalBytes) * 100))) } 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 020bcddf7e58..07de45cee938 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 @@ -4,30 +4,21 @@ import { extractIpcErrorMessage } from '@/lib/ipc-error' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import { isWslUncPath, parseWslUncPath } from '../../../../shared/wsl-paths' import type { PtyTransport } from './pty-transport' -import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { reportTerminalDropUploadSkipsAndFailures } from './terminal-drop-upload-report' -import { formatTerminalDropUploadProgress } from './terminal-drop-upload-progress' -import { captureTerminalDropTarget, getCurrentTerminalDropTransport } from './terminal-drop-target' -import { - getTerminalTargetShellForWorktreePath, - isTerminalDropWindowsPathLike, - resolveTerminalDropTargetShell -} from './terminal-drop-shell' -import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' +import type { NativeDropFlowArgs } from './terminal-drop-paste' +import { pasteResolvedDropPaths } from './terminal-drop-paste' +import { uploadRuntimeDropPaths } from './terminal-runtime-drop-upload' +import { captureTerminalDropTarget } from './terminal-drop-target' +import { resolveTerminalDropTargetShell } from './terminal-drop-shell' import { resolveNativeTerminalDropPane } from './terminal-drop-pane-resolution' import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' -import { showTerminalDropWriteFailure } from './terminal-drop-write-failure' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' -import { - joinRuntimeTerminalDropDir, - resolveTerminalDropWorktreePath -} from './terminal-drop-worktree-path' +import { resolveTerminalDropWorktreePath } from './terminal-drop-worktree-path' import { captureRuntimeTerminalDropOwner } from './terminal-drop-runtime-owner' export type NativeTerminalFileDropArgs = { @@ -156,74 +147,6 @@ async function handleNativeTerminalFileDropWithCapturedOwner( }) } -type NativeDropFlowArgs = { - dataPaths: string[] - dropTarget: ReturnType - manager: PaneManager - paneTransports: Map - pane: ReturnType & {} - tabId: string - worktreePath: string - expectedSshTargetId?: string - expectedSshConnectionGeneration?: number - expectedExecutionHostId?: 'local' | `ssh:${string}` - assertCurrent?: () => void -} - -async function uploadRuntimeDropPaths( - args: NativeDropFlowArgs & { - runtimeEnvironmentId: string - settings: ReturnType['settings'] - worktreeId: string - } -): Promise { - const targetShell = getTerminalTargetShellForWorktreePath(args.worktreePath) - const destinationDir = joinRuntimeTerminalDropDir(args.worktreePath) - const pending = toast.loading(formatTerminalDropUploadProgress(args.dataPaths.length, 0, 0)) - try { - const { results } = await importExternalPathsToRuntime( - { - // Why: drops into existing worktrees must follow the worktree owner, - // not the currently focused host in the sidebar. - settings: { ...args.settings, activeRuntimeEnvironmentId: args.runtimeEnvironmentId }, - worktreeId: args.worktreeId, - worktreePath: args.worktreePath, - expectedExecutionHostId: args.expectedExecutionHostId, - expectedSshTargetId: args.expectedSshTargetId, - expectedSshConnectionGeneration: args.expectedSshConnectionGeneration - }, - args.dataPaths, - destinationDir, - { - assertCurrent: args.assertCurrent, - // Why: reuses the pending toast's id so the drop keeps one row instead of - // stacking a new toast per update. - onProgress: ({ sentBytes, totalBytes }) => { - toast.loading( - formatTerminalDropUploadProgress(args.dataPaths.length, sentBytes, totalBytes), - { id: pending } - ) - } - } - ) - const imported = results.filter((result) => result.status === 'imported') - const importedPaths = imported.map((result) => - isTerminalDropWindowsPathLike(args.worktreePath) - ? result.destPath.replace(/\//g, '\\') - : result.destPath - ) - await pasteResolvedDropPaths({ ...args, paths: importedPaths, targetShell }) - reportTerminalDropUploadSkipsAndFailures( - results.filter((result) => result.status === 'skipped'), - results.filter((result) => result.status === 'failed') - ) - } catch (err) { - toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) - } finally { - toast.dismiss(pending) - } -} - async function pasteLocalDropPaths( args: NativeDropFlowArgs & { localWslDrop: boolean; targetShell: 'posix' | 'windows' } ): Promise { @@ -280,36 +203,6 @@ async function uploadRemoteDropPaths( } } -async function pasteResolvedDropPaths( - args: NativeDropFlowArgs & { paths: string[]; targetShell: 'posix' | 'windows' } -): Promise { - // Why: pane may have unmounted during upload/resolution (tab closed, - // worktree switched). Re-check before writing so we do not call sendInput - // on a torn-down PTY. - const liveTransport = getCurrentTerminalDropTransport( - args.manager, - args.paneTransports, - args.dropTarget - ) - if (!liveTransport) { - return - } - const writeResult = await writeTerminalDropPathsToCapturedTarget({ - dropTarget: args.dropTarget, - manager: args.manager, - paneTransports: args.paneTransports, - paths: args.paths, - targetShell: args.targetShell - }) - showTerminalDropWriteFailure(writeResult.failureReason) - if (writeResult.sentAnyPath) { - recordTerminalUserInputForLeaf(args.tabId, args.pane.leafId) - } - if (writeResult.targetCurrent) { - args.pane.terminal.focus() - } -} - function isWorktreeUsingLocalWslRuntime( state: ReturnType, worktreeId: string diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts new file mode 100644 index 000000000000..17649718023e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts @@ -0,0 +1,113 @@ +import { createElement } from 'react' +import { toast } from 'sonner' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' +import { + endRuntimeUploadSession, + startRuntimeUploadSession, + updateRuntimeUploadRow +} from '@/runtime/runtime-upload-session-state' +import type { useAppStore } from '@/store' +import { TerminalDropUploadToast } from './TerminalDropUploadToast' +import type { NativeDropFlowArgs } from './terminal-drop-paste' +import { pasteResolvedDropPaths } from './terminal-drop-paste' +import { reportTerminalDropUploadSkipsAndFailures } from './terminal-drop-upload-report' +import { + getTerminalTargetShellForWorktreePath, + isTerminalDropWindowsPathLike +} from './terminal-drop-shell' +import { joinRuntimeTerminalDropDir } from './terminal-drop-worktree-path' + +export async function uploadRuntimeDropPaths( + args: NativeDropFlowArgs & { + runtimeEnvironmentId: string + settings: ReturnType['settings'] + worktreeId: string + } +): Promise { + const targetShell = getTerminalTargetShellForWorktreePath(args.worktreePath) + const destinationDir = joinRuntimeTerminalDropDir(args.worktreePath) + const sessionId = createBrowserUuid() + const cancelledSourcePaths = new Set() + const sourcePathsByUploadId = new Map() + let pending: string | number | null = null + const cancelRow = (uploadId: string): void => { + const sourcePath = sourcePathsByUploadId.get(uploadId) + if (sourcePath) { + cancelledSourcePaths.add(sourcePath) + } + updateRuntimeUploadRow(sessionId, uploadId, { status: 'cancelled' }) + void window.api.fs.cancelRuntimeUpload({ uploadId }) + } + try { + const { results } = await importExternalPathsToRuntime( + { + // Why: drops into existing worktrees must follow the worktree owner, + // not the currently focused host in the sidebar. + settings: { ...args.settings, activeRuntimeEnvironmentId: args.runtimeEnvironmentId }, + worktreeId: args.worktreeId, + worktreePath: args.worktreePath, + expectedExecutionHostId: args.expectedExecutionHostId, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration + }, + args.dataPaths, + destinationDir, + { + assertCurrent: args.assertCurrent, + progress: { + onStart: (rows) => { + for (const row of rows) { + sourcePathsByUploadId.set(row.uploadId, row.sourcePath) + } + startRuntimeUploadSession( + sessionId, + rows.map((row) => ({ + uploadId: row.uploadId, + name: row.name, + sentBytes: 0, + totalBytes: row.totalBytes, + status: 'uploading' as const + })) + ) + // Why: created only once rows exist, so a drop that stages nothing + // never flashes an empty panel. + pending = toast.custom( + // Why: createElement, not a direct call — the toast body must be its + // own component or its hooks run outside a component boundary. + () => createElement(TerminalDropUploadToast, { sessionId, onCancel: cancelRow }), + { duration: Infinity, dismissible: false, unstyled: true } + ) + }, + onRowProgress: (uploadId, sentBytes) => + updateRuntimeUploadRow(sessionId, uploadId, { sentBytes }), + onRowSettled: (uploadId, status) => + updateRuntimeUploadRow(sessionId, uploadId, { status }), + onFinish: () => endRuntimeUploadSession(sessionId) + } + } + ) + const imported = results.filter((result) => result.status === 'imported') + const importedPaths = imported.map((result) => + isTerminalDropWindowsPathLike(args.worktreePath) + ? result.destPath.replace(/\//g, '\\') + : result.destPath + ) + await pasteResolvedDropPaths({ ...args, paths: importedPaths, targetShell }) + reportTerminalDropUploadSkipsAndFailures( + results.filter((result) => result.status === 'skipped'), + // Why: a cancel is the user's own decision, not a failure to report back. + results + .filter((result) => result.status === 'failed') + .filter((result) => !cancelledSourcePaths.has(result.sourcePath)) + ) + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) + } finally { + endRuntimeUploadSession(sessionId) + if (pending !== null) { + toast.dismiss(pending) + } + } +} diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index ad7319d8f95d..9f9196487e82 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -31,8 +31,9 @@ import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { createBrowserUuid } from '@/lib/browser-uuid' import { createRuntimeUploadProgressTracker, - sumStagedUploadBytes, - type RuntimeUploadProgressReport + sumSourceUploadBytes, + type RuntimeImportProgressHandlers, + type RuntimeImportProgressRow } from './runtime-upload-progress-tracker' import { createEmptyRuntimeFileSearchResult, @@ -673,7 +674,7 @@ export async function importExternalPathsToRuntime( options?: { ensureDestinationDir?: boolean assertCurrent?: () => void - onProgress?: (progress: RuntimeUploadProgressReport) => void + progress?: RuntimeImportProgressHandlers } ): Promise<{ results: RuntimeImportResult[] }> { const target = getActiveRuntimeTarget(context.settings) @@ -705,22 +706,41 @@ export async function importExternalPathsToRuntime( assertImportSessionCurrent() const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths }) assertImportSessionCurrent() - const uploadId = options?.onProgress ? createBrowserUuid() : undefined - const progress = options?.onProgress - ? createRuntimeUploadProgressTracker( - sumStagedUploadBytes(staged.sources as StagedRuntimeImportSource[]), - options.onProgress + const handlers = options?.progress + // One id per dropped source: it is both the progress key and the cancel handle, + // so cancelling a row stops that source and leaves the rest of the drop running. + const uploadIdsBySourcePath = new Map() + const trackers = new Map>() + if (handlers) { + const rows: RuntimeImportProgressRow[] = [] + for (const source of staged.sources as StagedRuntimeImportSource[]) { + if (source.status !== 'staged') { + continue + } + const rowUploadId = createBrowserUuid() + uploadIdsBySourcePath.set(source.sourcePath, rowUploadId) + const totalBytes = sumSourceUploadBytes(source) + trackers.set( + rowUploadId, + createRuntimeUploadProgressTracker(totalBytes, ({ sentBytes }) => + handlers.onRowProgress(rowUploadId, sentBytes) + ) ) + rows.push({ + uploadId: rowUploadId, + name: source.name, + totalBytes, + sourcePath: source.sourcePath + }) + } + handlers.onStart(rows) + } + const unsubscribeProgress = handlers + ? window.api.fs.onUploadProgress((event) => { + // Why: a concurrent drop in another pane shares this channel. + trackers.get(event.uploadId)?.reportFileProgress(event.sentBytes) + }) : null - const unsubscribeProgress = - progress && uploadId - ? window.api.fs.onUploadProgress((event) => { - // Why: a concurrent drop in another pane shares this channel. - if (event.uploadId === uploadId) { - progress.reportFileProgress(event.sentBytes) - } - }) - : null const results: RuntimeImportResult[] = [] const reservedNames = new Set() @@ -738,6 +758,7 @@ export async function importExternalPathsToRuntime( continue } let createdDirectoryImportRoot: string | null = null + const sourceUploadId = uploadIdsBySourcePath.get(source.sourcePath) try { const finalName = await deconflictRuntimeImportName( context, @@ -780,9 +801,11 @@ export async function importExternalPathsToRuntime( ? `ssh:${encodeURIComponent(context.expectedSshTargetId)}` : 'local'), expectedEnvironmentPairingRevision, - uploadId + sourceUploadId ) - progress?.completeFile(entry.byteLength) + if (sourceUploadId) { + trackers.get(sourceUploadId)?.completeFile(entry.byteLength) + } } reservedNames.add(finalName) results.push({ @@ -792,6 +815,9 @@ export async function importExternalPathsToRuntime( kind: source.kind, renamed: finalName !== source.name }) + if (sourceUploadId) { + handlers?.onRowSettled(sourceUploadId, 'done') + } } catch (error) { if (createdDirectoryImportRoot) { // Why: match local directory imports by removing the no-clobber root @@ -814,12 +840,21 @@ export async function importExternalPathsToRuntime( status: 'failed', reason: error instanceof Error ? error.message : String(error) }) + // Why: reported as failed even for a cancel — the store keeps the row's + // already-set 'cancelled' state, so the user's own action is not relabelled. + if (sourceUploadId) { + handlers?.onRowSettled(sourceUploadId, 'failed') + } } } return { results } } finally { unsubscribeProgress?.() + for (const releasedId of uploadIdsBySourcePath.values()) { + void window.api.fs.releaseRuntimeUpload({ uploadId: releasedId }) + } + handlers?.onFinish() } } diff --git a/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts index 1f44c7bd4095..19808c3cf36f 100644 --- a/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts @@ -1,40 +1,29 @@ import { describe, expect, it } from 'vitest' import { createRuntimeUploadProgressTracker, - sumStagedUploadBytes + sumSourceUploadBytes } from './runtime-upload-progress-tracker' -describe('sumStagedUploadBytes', () => { +describe('sumSourceUploadBytes', () => { it('counts file bytes and ignores directory entries', () => { expect( - sumStagedUploadBytes([ - { - status: 'staged', - entries: [ - { kind: 'directory' }, - { kind: 'file', byteLength: 10 }, - { kind: 'directory' }, - { kind: 'file', byteLength: 32 } - ] - } - ]) + sumSourceUploadBytes({ + entries: [ + { kind: 'directory' }, + { kind: 'file', byteLength: 10 }, + { kind: 'directory' }, + { kind: 'file', byteLength: 32 } + ] + }) ).toBe(42) }) - it('ignores sources that never staged', () => { - expect( - sumStagedUploadBytes([ - { status: 'skipped' }, - { status: 'failed', entries: [{ kind: 'file', byteLength: 999 }] }, - { status: 'staged', entries: [{ kind: 'file', byteLength: 7 }] } - ]) - ).toBe(7) + it('is zero for a source of only empty files', () => { + expect(sumSourceUploadBytes({ entries: [{ kind: 'file', byteLength: 0 }] })).toBe(0) }) - it('is zero for a drop of only empty files', () => { - expect( - sumStagedUploadBytes([{ status: 'staged', entries: [{ kind: 'file', byteLength: 0 }] }]) - ).toBe(0) + it('is zero for a source with no entries at all', () => { + expect(sumSourceUploadBytes({})).toBe(0) }) }) diff --git a/src/renderer/src/runtime/runtime-upload-progress-tracker.ts b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts index fd1471f5d7bc..91245e136d8e 100644 --- a/src/renderer/src/runtime/runtime-upload-progress-tracker.ts +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts @@ -47,19 +47,29 @@ export function createRuntimeUploadProgressTracker( } } -/** Total bytes a staged drop will move; directory entries contribute nothing. */ -export function sumStagedUploadBytes( - sources: { status: string; entries?: { kind: string; byteLength?: number }[] }[] -): number { +/** One row in the drop UI: everything the user dropped becomes exactly one. */ +export type RuntimeImportProgressRow = { + uploadId: string + name: string + totalBytes: number + sourcePath: string +} + +export type RuntimeImportProgressHandlers = { + onStart: (rows: RuntimeImportProgressRow[]) => void + onRowProgress: (uploadId: string, sentBytes: number) => void + onRowSettled: (uploadId: string, status: 'done' | 'failed') => void + onFinish: () => void +} + +/** Bytes one dropped source will move; directory entries contribute nothing. */ +export function sumSourceUploadBytes(source: { + entries?: { kind: string; byteLength?: number }[] +}): number { let total = 0 - for (const source of sources) { - if (source.status !== 'staged') { - continue - } - for (const entry of source.entries ?? []) { - if (entry.kind === 'file') { - total += entry.byteLength ?? 0 - } + for (const entry of source.entries ?? []) { + if (entry.kind === 'file') { + total += entry.byteLength ?? 0 } } return total diff --git a/src/renderer/src/runtime/runtime-upload-session-state.test.ts b/src/renderer/src/runtime/runtime-upload-session-state.test.ts new file mode 100644 index 000000000000..4715789ce471 --- /dev/null +++ b/src/renderer/src/runtime/runtime-upload-session-state.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + endRuntimeUploadSession, + getRuntimeUploadSession, + startRuntimeUploadSession, + subscribeToRuntimeUploadSessions, + summarizeRuntimeUploadSession, + updateRuntimeUploadRow, + type RuntimeUploadRow +} from './runtime-upload-session-state' + +function row(overrides: Partial = {}): RuntimeUploadRow { + return { + uploadId: 'a', + name: 'a.bin', + sentBytes: 0, + totalBytes: 100, + status: 'uploading', + ...overrides + } +} + +afterEach(() => { + endRuntimeUploadSession('s') + endRuntimeUploadSession('other') +}) + +describe('runtime upload session state', () => { + it('notifies subscribers when a row advances', () => { + const listener = vi.fn() + const unsubscribe = subscribeToRuntimeUploadSessions(listener) + startRuntimeUploadSession('s', [row()]) + updateRuntimeUploadRow('s', 'a', { sentBytes: 25 }) + + expect(listener).toHaveBeenCalledTimes(2) + expect(getRuntimeUploadSession('s')?.rows[0]?.sentBytes).toBe(25) + unsubscribe() + }) + + it('replaces the row object so a memoized bar re-renders', () => { + startRuntimeUploadSession('s', [row()]) + const before = getRuntimeUploadSession('s')?.rows[0] + updateRuntimeUploadRow('s', 'a', { sentBytes: 10 }) + + expect(getRuntimeUploadSession('s')?.rows[0]).not.toBe(before) + }) + + it('refuses to drag a cancelled row back into uploading', () => { + startRuntimeUploadSession('s', [row()]) + updateRuntimeUploadRow('s', 'a', { status: 'cancelled' }) + updateRuntimeUploadRow('s', 'a', { sentBytes: 90, status: 'uploading' }) + + const settled = getRuntimeUploadSession('s')?.rows[0] + expect(settled?.status).toBe('cancelled') + expect(settled?.sentBytes).toBe(0) + }) + + it('keeps a cancelled row cancelled when the import later reports it failed', () => { + startRuntimeUploadSession('s', [row()]) + updateRuntimeUploadRow('s', 'a', { status: 'cancelled' }) + updateRuntimeUploadRow('s', 'a', { status: 'failed' }) + + expect(getRuntimeUploadSession('s')?.rows[0]?.status).toBe('cancelled') + }) + + it('leaves other sessions untouched', () => { + startRuntimeUploadSession('s', [row()]) + startRuntimeUploadSession('other', [row({ uploadId: 'b' })]) + updateRuntimeUploadRow('s', 'a', { sentBytes: 50 }) + + expect(getRuntimeUploadSession('other')?.rows[0]?.sentBytes).toBe(0) + }) + + it('ignores updates for a session that already ended', () => { + startRuntimeUploadSession('s', [row()]) + endRuntimeUploadSession('s') + + expect(() => updateRuntimeUploadRow('s', 'a', { sentBytes: 10 })).not.toThrow() + expect(getRuntimeUploadSession('s')).toBeUndefined() + }) + + it('does not notify when nothing actually changed', () => { + startRuntimeUploadSession('s', [row({ status: 'done' })]) + const listener = vi.fn() + const unsubscribe = subscribeToRuntimeUploadSessions(listener) + updateRuntimeUploadRow('s', 'a', { sentBytes: 10 }) + + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) +}) + +describe('summarizeRuntimeUploadSession', () => { + it('averages across rows by bytes, not by row count', () => { + const summary = summarizeRuntimeUploadSession({ + sessionId: 's', + rows: [ + row({ uploadId: 'a', sentBytes: 10, totalBytes: 10 }), + row({ uploadId: 'b', sentBytes: 0, totalBytes: 90 }) + ] + }) + + expect(summary.percent).toBe(10) + }) + + it('drops cancelled rows from the denominator so the bar can still finish', () => { + const summary = summarizeRuntimeUploadSession({ + sessionId: 's', + rows: [ + row({ uploadId: 'a', sentBytes: 100, totalBytes: 100, status: 'done' }), + row({ uploadId: 'b', sentBytes: 20, totalBytes: 900, status: 'cancelled' }) + ] + }) + + expect(summary.percent).toBe(100) + expect(summary.totalBytes).toBe(100) + }) + + it('counts only rows still moving as active', () => { + const summary = summarizeRuntimeUploadSession({ + sessionId: 's', + rows: [ + row({ uploadId: 'a', status: 'done' }), + row({ uploadId: 'b', status: 'uploading' }), + row({ uploadId: 'c', status: 'cancelled' }) + ] + }) + + expect(summary.activeCount).toBe(1) + }) + + it('is zero percent rather than NaN for an all-empty drop', () => { + const summary = summarizeRuntimeUploadSession({ + sessionId: 's', + rows: [row({ sentBytes: 0, totalBytes: 0 })] + }) + + expect(summary.percent).toBe(0) + }) +}) diff --git a/src/renderer/src/runtime/runtime-upload-session-state.ts b/src/renderer/src/runtime/runtime-upload-session-state.ts new file mode 100644 index 000000000000..f6c9dd81368e --- /dev/null +++ b/src/renderer/src/runtime/runtime-upload-session-state.ts @@ -0,0 +1,111 @@ +export type RuntimeUploadRowStatus = 'uploading' | 'done' | 'cancelled' | 'failed' + +export type RuntimeUploadRow = { + /** Id every file of one dropped source streams under; also the cancel handle. */ + uploadId: string + name: string + sentBytes: number + totalBytes: number + status: RuntimeUploadRowStatus +} + +export type RuntimeUploadSession = { + sessionId: string + rows: RuntimeUploadRow[] +} + +type Listener = () => void + +const sessions = new Map() +const listeners = new Set() + +function emit(): void { + for (const listener of listeners) { + listener() + } +} + +export function subscribeToRuntimeUploadSessions(listener: Listener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function getRuntimeUploadSession(sessionId: string): RuntimeUploadSession | undefined { + return sessions.get(sessionId) +} + +export function startRuntimeUploadSession(sessionId: string, rows: RuntimeUploadRow[]): void { + sessions.set(sessionId, { sessionId, rows }) + emit() +} + +export function endRuntimeUploadSession(sessionId: string): void { + sessions.delete(sessionId) + emit() +} + +/** + * Replace one row. + * + * Rows are swapped rather than mutated so `useSyncExternalStore` sees a new + * reference; mutating in place renders a stale bar that never moves. + */ +export function updateRuntimeUploadRow( + sessionId: string, + uploadId: string, + patch: Partial> +): void { + const session = sessions.get(sessionId) + if (!session) { + return + } + let changed = false + const rows = session.rows.map((row) => { + if (row.uploadId !== uploadId) { + return row + } + // Why: a cancelled or failed row must not be dragged back to 'uploading' by + // a progress event that was already in flight when the user clicked. + if (row.status !== 'uploading') { + return row + } + changed = true + return { ...row, ...patch } + }) + if (!changed) { + return + } + sessions.set(sessionId, { sessionId, rows }) + emit() +} + +export function summarizeRuntimeUploadSession(session: RuntimeUploadSession): { + sentBytes: number + totalBytes: number + percent: number + activeCount: number +} { + let sentBytes = 0 + let totalBytes = 0 + let activeCount = 0 + for (const row of session.rows) { + // Why: a cancelled row's remaining bytes are never going to move, so leaving + // them in the denominator would strand the overall bar below 100%. + if (row.status === 'cancelled' || row.status === 'failed') { + continue + } + sentBytes += Math.min(row.sentBytes, row.totalBytes) + totalBytes += row.totalBytes + if (row.status === 'uploading') { + activeCount += 1 + } + } + return { + sentBytes, + totalBytes, + percent: totalBytes > 0 ? Math.min(100, Math.floor((sentBytes / totalBytes) * 100)) : 0, + activeCount + } +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index b53e7d4031e0..11fca1f94a1f 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2077,6 +2077,8 @@ function createFileApi(): NonNullable['fs']> { throw new Error('Uploading local files is not supported in the web client') }, onUploadProgress: () => noopUnsubscribe, + cancelRuntimeUpload: async () => {}, + releaseRuntimeUpload: async () => {}, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), From 8bb9c911c5f1e1938a56f97870e2108ed921b830 Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 17:19:17 +0200 Subject: [PATCH 04/20] fix(runtime): let the drop panel state its outcome before it closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling every file removed the session immediately, so the panel vanished mid-gesture with its header still reading "Uploading 2 files" — the one moment the user most needs to see what happened. Finishing now settles the session instead of deleting it. The header switches to the outcome (cancelled, failed, uploaded X of N), the percent and collapse toggle drop away since nothing is moving, and the panel holds for 1.2s before fading out and dismissing itself. Reduced-motion skips the fade but keeps the hold, so the outcome is still readable. Dismissal moves from the drop flow to the panel, which owns the timing; only the error path still tears it down at once. Fixes #16077 Co-Authored-By: Claude Opus 5 (1M context) --- .../terminal-pane/TerminalDropUploadToast.tsx | 54 +++++++++++--- .../terminal-drop-handler.test.ts | 28 +++++++- .../terminal-drop-upload-heading.test.ts | 70 +++++++++++++++++++ .../terminal-drop-upload-heading.ts | 55 +++++++++++++++ .../terminal-runtime-drop-upload.ts | 18 +++-- .../runtime-upload-session-state.test.ts | 25 +++++++ .../runtime/runtime-upload-session-state.ts | 36 +++++++++- 7 files changed, 269 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx b/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx index 2f0fb3b93eb9..1a6e364bac94 100644 --- a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx @@ -1,4 +1,4 @@ -import { useState, useSyncExternalStore } from 'react' +import { useEffect, useState, useSyncExternalStore } from 'react' import { ChevronDownIcon, ChevronUpIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react' import { Progress } from '@/components/ui/progress' import { cn } from '@/lib/utils' @@ -10,17 +10,46 @@ import { type RuntimeUploadRow } from '@/runtime/runtime-upload-session-state' import { formatTransferredOfTotal, toPercent } from './terminal-drop-upload-progress' +import { formatTerminalDropUploadHeading } from './terminal-drop-upload-heading' + +// Long enough to read the outcome, short enough not to linger over the terminal. +const SETTLED_HOLD_MS = 1200 +const EXIT_ANIMATION_MS = 200 type Props = { sessionId: string onCancel: (uploadId: string) => void + /** Closes the toast; the panel owns the timing so the exit is not cut short. */ + onDismiss: () => void } -export function TerminalDropUploadToast({ sessionId, onCancel }: Props): React.JSX.Element | null { +export function TerminalDropUploadToast({ + sessionId, + onCancel, + onDismiss +}: Props): React.JSX.Element | null { const session = useSyncExternalStore(subscribeToRuntimeUploadSessions, () => getRuntimeUploadSession(sessionId) ) const [collapsed, setCollapsed] = useState(false) + const [leaving, setLeaving] = useState(false) + const settled = session?.settled === true + + useEffect(() => { + if (!settled) { + return + } + const reducedMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + const exitMs = reducedMotion ? 0 : EXIT_ANIMATION_MS + const startExit = window.setTimeout(() => setLeaving(true), SETTLED_HOLD_MS) + const close = window.setTimeout(onDismiss, SETTLED_HOLD_MS + exitMs) + return () => { + window.clearTimeout(startExit) + window.clearTimeout(close) + } + }, [settled, onDismiss]) // createElement at the call site still yields a valid element, so rendering // nothing here is safe once the session has ended. @@ -28,14 +57,20 @@ export function TerminalDropUploadToast({ sessionId, onCancel }: Props): React.J return null } const summary = summarizeRuntimeUploadSession(session) - const heading = translate( - 'auto.components.terminal.pane.terminal.drop.upload.heading', - 'Uploading {{value0}} file{{value1}}', - { value0: session.rows.length, value1: session.rows.length === 1 ? '' : 's' } - ) + const heading = formatTerminalDropUploadHeading({ + rowCount: session.rows.length, + settled: session.settled, + doneCount: summary.doneCount, + cancelledCount: summary.cancelledCount + }) return ( -
    +
    @@ -43,10 +78,11 @@ export function TerminalDropUploadToast({ sessionId, onCancel }: Props): React.J {heading} {/* Fixed width so the row never reflows as the number gains digits. */} - {summary.percent}% + {session.settled ? '' : `${summary.percent}%`}
    {!collapsed && ( From 1e96b70f965995397a66df7a456b5bfd87ac4081 Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 19:11:47 +0200 Subject: [PATCH 09/20] fix(runtime): name the destination in the drop upload header "Uploading 2 files" reads as a local copy. This panel only ever appears for a drop onto a remote workspace, so it now says "to runtime", matching the wording the old single-line toast used. The cancelled and failed lines stay short: once nothing landed there, the destination is no longer the point. Fixes #16077 Co-Authored-By: Claude Opus 5 (1M context) --- .../terminal-drop-upload-heading.test.ts | 8 ++++---- .../terminal-pane/terminal-drop-upload-heading.ts | 11 ++++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts index f0e3c62224b5..4c7b287d9584 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts @@ -10,7 +10,7 @@ describe('formatTerminalDropUploadHeading', () => { doneCount: 0, cancelledCount: 1 }) - ).toBe('Uploading 2 files') + ).toBe('Uploading 2 files to runtime') }) it('says cancelled once everything stopped and nothing landed', () => { @@ -32,7 +32,7 @@ describe('formatTerminalDropUploadHeading', () => { doneCount: 1, cancelledCount: 2 }) - ).toBe('Uploaded 1 of 3') + ).toBe('Uploaded 1 of 3 to runtime') }) it('reports a clean finish', () => { @@ -43,7 +43,7 @@ describe('formatTerminalDropUploadHeading', () => { doneCount: 2, cancelledCount: 0 }) - ).toBe('Uploaded 2 files') + ).toBe('Uploaded 2 files to runtime') }) it('distinguishes a failure from a cancel', () => { @@ -65,6 +65,6 @@ describe('formatTerminalDropUploadHeading', () => { doneCount: 0, cancelledCount: 0 }) - ).toBe('Uploading 1 file') + ).toBe('Uploading 1 file to runtime') }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts index f2f535c5968f..719f06281a06 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts @@ -13,6 +13,11 @@ type HeadingInput = { * While the drop is running it counts files; once everything has stopped it * states the outcome instead, so a cancelled upload leaves something to read * rather than closing on "Uploading 2 files". + * + * Names the destination, because a drop onto a remote workspace is the only + * case that shows this panel and "Uploading 2 files" alone reads local. + * The cancelled and failed lines stay short: the destination no longer matters + * once nothing landed there. */ export function formatTerminalDropUploadHeading({ rowCount, @@ -24,7 +29,7 @@ export function formatTerminalDropUploadHeading({ if (!settled) { return translate( 'auto.components.terminal.pane.terminal.drop.upload.heading', - 'Uploading {{value0}} file{{value1}}', + 'Uploading {{value0}} file{{value1}} to runtime', { value0: rowCount, value1: plural } ) } @@ -43,13 +48,13 @@ export function formatTerminalDropUploadHeading({ if (doneCount < rowCount) { return translate( 'auto.components.terminal.pane.terminal.drop.upload.heading.partial', - 'Uploaded {{value0}} of {{value1}}', + 'Uploaded {{value0}} of {{value1}} to runtime', { value0: doneCount, value1: rowCount } ) } return translate( 'auto.components.terminal.pane.terminal.drop.upload.heading.done', - 'Uploaded {{value0}} file{{value1}}', + 'Uploaded {{value0}} file{{value1}} to runtime', { value0: rowCount, value1: plural } ) } From 29288f44db77378f617b844fca5b46689d91a837 Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 19:35:09 +0200 Subject: [PATCH 10/20] fix(runtime): enforce the staged size and make over-limit errors legible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the import ceilings. Staging and upload are separate IPC calls, so a source could be replaced or grown in between and the streamer would take the new size as authoritative — transferring a file the ceilings had already rejected, and invalidating the per-drop aggregate. The staged byteLength now rides the upload request and a mismatch is refused before any chunk is sent. formatByteCeiling rounded, so 2 GiB + 1 B rendered "2.0 GB" against a "2 GB" limit and the error read like a broken check. It now rounds up, so anything over the ceiling renders strictly greater than it. Symlink tests are skipped on Windows, where symlink() needs elevation or Developer Mode. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/ipc/filesystem-mutations.ts | 2 + .../filesystem-runtime-upload-staging.test.ts | 15 +- .../ipc/filesystem-runtime-upload-staging.ts | 6 +- .../ipc/preflight-agent-detection.test.ts | 128 +++++++++++++++--- src/main/ipc/preflight-agent-refresh.test.ts | 24 +++- .../ipc/preflight-host-cli-status.test.ts | 8 +- .../ipc/preflight-wsl-agent-detection.test.ts | 8 +- .../ipc/runtime-upload-file-stream.test.ts | 55 +++++--- src/main/ipc/runtime-upload-file-stream.ts | 5 + src/preload/api/filesystem-api.ts | 1 + src/preload/index.ts | 1 + .../src/runtime/runtime-file-client.ts | 9 +- 12 files changed, 222 insertions(+), 40 deletions(-) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 2b14c1777843..937fc467d18d 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -217,6 +217,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { entryRelativePath: string worktree: string relativePath: string + expectedByteLength?: number expectedEnvironmentPairingRevision?: number } & SshMutationExpectation ): Promise<{ byteLength: number }> => @@ -225,6 +226,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { environmentId: args.environmentId, sourceRootPath: args.sourceRootPath, entryRelativePath: args.entryRelativePath, + expectedByteLength: args.expectedByteLength, worktree: args.worktree, relativePath: args.relativePath, expectedExecutionHostId: args.expectedExecutionHostId, diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts index 67ace9cd592c..15905cb7803b 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.test.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -57,7 +57,20 @@ describe('stageOneSourceForRuntimeUpload', () => { expect(staged.status === 'failed' && staged.reason).toContain('2 GB') }) - it('keeps rejecting symlinked sources', async () => { + // symlink() needs privileges or Developer Mode on Windows. + it('renders a size just over the ceiling as larger than the ceiling', async () => { + const filePath = join(workDir, 'edge.bin') + await writeFile(filePath, '') + await truncate(filePath, 2 * 1024 * 1024 * 1024 + 1) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ status: 'failed' }) + // "is 2 GB, over the 2 GB limit" reads like a broken check. + expect(staged.status === 'failed' && staged.reason).toContain('2.1 GB') + }) + + it.skipIf(process.platform === 'win32')('keeps rejecting symlinked sources', async () => { const targetPath = join(workDir, 'target.txt') await writeFile(targetPath, 'data') const linkPath = join(workDir, 'link.txt') diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index d96b9c811759..f019a38863be 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -223,7 +223,11 @@ function formatByteCeiling(bytes: number): string { value /= 1024 unit += 1 } - return `${value >= 10 || Number.isInteger(value) ? Math.round(value) : value.toFixed(1)} ${units[unit]}` + // Why: rounds up, so a file one byte over the ceiling never renders as the + // same figure as the ceiling itself — "is 2 GB, over the 2 GB limit" reads + // like a bug in the check rather than an oversized file. + const rounded = Math.ceil(value * 10) / 10 + return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${units[unit]}` } function normalizeRelativeUploadPath(path: string): string { diff --git a/src/main/ipc/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 02a47f7570fe..b1a18f4f1bdc 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -135,13 +135,31 @@ describe('preflight', () => { const target = String(args[0]) if (target === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } if (target === 'continue') { - return { environmentResolved: true, code: 0, stdout: 'continue: shell built-in command\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: 'continue: shell built-in command\n', + stderr: '', + timedOut: false + } } if (target === 'cursor-agent') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/cursor-agent\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/cursor-agent\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -155,7 +173,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -169,10 +193,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Applications/Orca.app/Contents/MacOS/orca\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -190,10 +226,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/mock/windows/npm/claude.cmd\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/mock/windows/npm/claude.cmd\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'orca') { - return { environmentResolved: true, code: 0, stdout: '/mock/windows/programs/orca.cmd\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/mock/windows/programs/orca.cmd\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -239,7 +287,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -273,10 +327,22 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'openclaude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/openclaude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/openclaude\n', + stderr: '', + timedOut: false + } } if (String(args[0]) === 'cursor-agent') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/cursor-agent\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/cursor-agent\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -303,7 +369,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) { - return { environmentResolved: true, code: 0, stdout: '/home/test/.local/bin/codex\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/home/test/.local/bin/codex\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -328,7 +400,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -349,7 +427,13 @@ describe('preflight', () => { expect(script).not.toContain("'orca-dev'") expect(script).not.toContain("'orca-ide'") if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -363,7 +447,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'vibe') { - return { environmentResolved: true, code: 0, stdout: '/home/test/.local/bin/vibe\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/home/test/.local/bin/vibe\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -392,7 +482,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) diff --git a/src/main/ipc/preflight-agent-refresh.test.ts b/src/main/ipc/preflight-agent-refresh.test.ts index fb2a8f0c99e8..cc64e2beecac 100644 --- a/src/main/ipc/preflight-agent-refresh.test.ts +++ b/src/main/ipc/preflight-agent-refresh.test.ts @@ -130,7 +130,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes("'claude'")) { - return { environmentResolved: true, code: 0, stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -183,7 +189,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'opencode') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.opencode/bin/opencode\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.opencode/bin/opencode\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) @@ -219,7 +231,13 @@ describe('preflight', () => { throw new Error(`unexpected command ${String(command)}`) } if (String(args[0]) === 'claude') { - return { environmentResolved: true, code: 0, stdout: '/Users/test/.local/bin/claude\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: '/Users/test/.local/bin/claude\n', + stderr: '', + timedOut: false + } } throw new Error('not found') }) diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index 63cb6657b92e..ba5d38afb521 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -276,7 +276,13 @@ describe('preflight', () => { }) runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => { if (script.includes('gh') && script.includes('--version')) { - return { environmentResolved: true, code: 0, stdout: 'gh version 2.0.0\n', stderr: '', timedOut: false } + return { + environmentResolved: true, + code: 0, + stdout: 'gh version 2.0.0\n', + stderr: '', + timedOut: false + } } if (script.includes('gh') && script.includes('auth status')) { return { diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 2b15efe37fcd..0210a2132c61 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -17,7 +17,13 @@ function lastSpec(): RunWslProcessSpec { describe('detectWslCommandsOnPath', () => { beforeEach(() => { runWslProcessMock.mockReset() - runWslProcessMock.mockResolvedValue({ environmentResolved: true, code: 0, stdout: '', stderr: '', timedOut: false }) + runWslProcessMock.mockResolvedValue({ + environmentResolved: true, + code: 0, + stdout: '', + stderr: '', + timedOut: false + }) }) afterEach(() => { diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index 3c6c67167bf9..e3bc9aff7b0f 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -68,6 +68,25 @@ describe('streamExternalFileToRuntime', () => { expect(uploadedBytes().equals(contents)).toBe(true) }) + it('refuses a source whose size no longer matches what staging measured', async () => { + const filePath = join(workDir, 'grown.bin') + await writeFile(filePath, Buffer.alloc(2048)) + + await expect( + streamExternalFileToRuntime({ ...baseArgs(filePath), expectedByteLength: 1024 }) + ).rejects.toThrow('File changed since it was staged') + expect(chunkCalls()).toHaveLength(0) + }) + + it('accepts a source that still matches its staged size', async () => { + const filePath = join(workDir, 'same.bin') + await writeFile(filePath, Buffer.alloc(2048)) + + await expect( + streamExternalFileToRuntime({ ...baseArgs(filePath), expectedByteLength: 2048 }) + ).resolves.toEqual({ byteLength: 2048 }) + }) + it('never buffers more than one slice per chunk', async () => { const filePath = join(workDir, 'sliced.bin') await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 2)) @@ -122,7 +141,8 @@ describe('streamExternalFileToRuntime', () => { expect(chunkCalls()).toHaveLength(2) }) - it('refuses a symlinked source', async () => { + // symlink() needs privileges or Developer Mode on Windows. + it.skipIf(process.platform === 'win32')('refuses a symlinked source', async () => { const targetPath = join(workDir, 'secret.txt') await writeFile(targetPath, 'secret') const linkPath = join(workDir, 'link.txt') @@ -134,19 +154,22 @@ describe('streamExternalFileToRuntime', () => { expect(chunkCalls()).toHaveLength(0) }) - it('refuses a directory entry whose real path escapes the dropped root', async () => { - const outsidePath = join(workDir, 'outside.txt') - await writeFile(outsidePath, 'outside') - const rootPath = join(workDir, 'root') - await mkdir(rootPath) - await symlink(outsidePath, join(rootPath, 'escape.txt')) - - await expect( - streamExternalFileToRuntime({ - ...baseArgs(rootPath), - entryRelativePath: 'escape.txt' - }) - ).rejects.toThrow('Symlink not allowed') - expect(chunkCalls()).toHaveLength(0) - }) + it.skipIf(process.platform === 'win32')( + 'refuses a directory entry whose real path escapes the dropped root', + async () => { + const outsidePath = join(workDir, 'outside.txt') + await writeFile(outsidePath, 'outside') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsidePath, join(rootPath, 'escape.txt')) + + await expect( + streamExternalFileToRuntime({ + ...baseArgs(rootPath), + entryRelativePath: 'escape.txt' + }) + ).rejects.toThrow('Symlink not allowed') + expect(chunkCalls()).toHaveLength(0) + } + ) }) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index 60d43010214e..a3fdb37ed009 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -17,6 +17,8 @@ export type RuntimeUploadFileStreamArgs = { sourceRootPath: string /** Path of this file within the dropped directory; empty when the source is a file. */ entryRelativePath: string + /** Size staging measured; a source replaced since then is refused, not streamed. */ + expectedByteLength?: number worktree: string /** Destination path on the runtime, relative to the worktree. */ relativePath: string @@ -68,6 +70,9 @@ export async function streamExternalFileToRuntime( } const totalBytes = openedStat.size + if (args.expectedByteLength !== undefined && totalBytes !== args.expectedByteLength) { + throw new Error(`File changed since it was staged: '${displayPath}'`) + } // Why: a zero-byte source produces no slices, but the destination still has // to exist before commitUpload renames it into place. if (totalBytes === 0) { diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 9cc339571df7..36dd22cb80ce 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -181,6 +181,7 @@ export type FilesystemApi = { entryRelativePath: string worktree: string relativePath: string + expectedByteLength?: number expectedEnvironmentPairingRevision?: number } & SshMutationExpectation ) => Promise<{ byteLength: number }> diff --git a/src/preload/index.ts b/src/preload/index.ts index 18d30bb9f4df..80619da4bfac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3462,6 +3462,7 @@ const api = { entryRelativePath: string worktree: string relativePath: string + expectedByteLength?: number expectedEnvironmentPairingRevision?: number } & SshMutationExpectation ): Promise<{ byteLength: number }> => diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index c4063c2eb93f..a379ee0979c9 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -142,6 +142,8 @@ type StagedRuntimeImportEntry = type RuntimeUploadSource = { sourceRootPath: string entryRelativePath: string + /** What staging measured; main refuses the upload if the file no longer matches. */ + byteLength: number } type RuntimeImportResult = @@ -744,7 +746,11 @@ export async function importExternalPathsToRuntime( target, context.worktreeId, entryRelativePath, - { sourceRootPath: source.sourcePath, entryRelativePath: entry.relativePath }, + { + sourceRootPath: source.sourcePath, + entryRelativePath: entry.relativePath, + byteLength: entry.byteLength + }, assertImportSessionCurrent, context.expectedSshConnectionGeneration, context.expectedSshTargetId, @@ -811,6 +817,7 @@ async function uploadRuntimeFileWithoutClobber( environmentId: target.environmentId, sourceRootPath: source.sourceRootPath, entryRelativePath: source.entryRelativePath, + expectedByteLength: source.byteLength, worktree: toRuntimeWorktreeSelector(worktreeId), relativePath: tempRelativePath, expectedSshTargetId, From c0c1f649d8440c181325f7a3fa0f7b95e0fc6413 Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 19:38:49 +0200 Subject: [PATCH 11/20] fix(runtime): drop late progress events and skip the panel for an empty drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the drop panel. Electron does not order webContents.send against an invoke reply, so a final progress event can arrive after completeFile and be counted a second time, jumping the bar ahead. The tracker now only accepts progress inside an explicit per-file window that completeFile closes. onStart still fires when every source was skipped, so a drop that staged nothing flashed an empty panel; it now returns before creating one. releaseRuntimeUpload was a bare `void` on a promise, which surfaces as an unhandled rejection if the IPC fails during cleanup — it now carries the same `.catch(() => {})` the sibling cleanup calls use. Comments trimmed to the one-line rule in AGENTS.md. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/ipc/filesystem-mutations.ts | 2 + .../filesystem-runtime-upload-staging.test.ts | 15 ++++- .../ipc/filesystem-runtime-upload-staging.ts | 6 +- .../ipc/runtime-upload-file-stream.test.ts | 55 +++++++++++++------ src/main/ipc/runtime-upload-file-stream.ts | 11 ++++ src/preload/api/filesystem-api.ts | 1 + src/preload/index.ts | 1 + .../terminal-pane/TerminalDropUploadToast.tsx | 9 +-- .../terminal-drop-upload-heading.ts | 13 +---- .../terminal-drop-upload-progress.ts | 8 +-- .../terminal-runtime-drop-upload.ts | 4 ++ .../src/runtime/runtime-file-client.ts | 14 ++++- .../runtime-upload-progress-tracker.test.ts | 30 ++++++++++ .../runtime-upload-progress-tracker.ts | 22 +++++--- .../runtime/runtime-upload-session-state.ts | 8 +-- 15 files changed, 137 insertions(+), 62 deletions(-) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index e8ed8b551bff..a8ca6c58e33e 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -226,6 +226,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { entryRelativePath: string worktree: string relativePath: string + expectedByteLength?: number uploadId?: string expectedEnvironmentPairingRevision?: number } & SshMutationExpectation @@ -247,6 +248,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { environmentId: args.environmentId, sourceRootPath: args.sourceRootPath, entryRelativePath: args.entryRelativePath, + expectedByteLength: args.expectedByteLength, worktree: args.worktree, relativePath: args.relativePath, expectedExecutionHostId: args.expectedExecutionHostId, diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts index 67ace9cd592c..15905cb7803b 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.test.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -57,7 +57,20 @@ describe('stageOneSourceForRuntimeUpload', () => { expect(staged.status === 'failed' && staged.reason).toContain('2 GB') }) - it('keeps rejecting symlinked sources', async () => { + // symlink() needs privileges or Developer Mode on Windows. + it('renders a size just over the ceiling as larger than the ceiling', async () => { + const filePath = join(workDir, 'edge.bin') + await writeFile(filePath, '') + await truncate(filePath, 2 * 1024 * 1024 * 1024 + 1) + + const staged = await stageOneSourceForRuntimeUpload(filePath) + + expect(staged).toMatchObject({ status: 'failed' }) + // "is 2 GB, over the 2 GB limit" reads like a broken check. + expect(staged.status === 'failed' && staged.reason).toContain('2.1 GB') + }) + + it.skipIf(process.platform === 'win32')('keeps rejecting symlinked sources', async () => { const targetPath = join(workDir, 'target.txt') await writeFile(targetPath, 'data') const linkPath = join(workDir, 'link.txt') diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index d96b9c811759..f019a38863be 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -223,7 +223,11 @@ function formatByteCeiling(bytes: number): string { value /= 1024 unit += 1 } - return `${value >= 10 || Number.isInteger(value) ? Math.round(value) : value.toFixed(1)} ${units[unit]}` + // Why: rounds up, so a file one byte over the ceiling never renders as the + // same figure as the ceiling itself — "is 2 GB, over the 2 GB limit" reads + // like a bug in the check rather than an oversized file. + const rounded = Math.ceil(value * 10) / 10 + return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${units[unit]}` } function normalizeRelativeUploadPath(path: string): string { diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index b7b359a03ff1..37b821860bec 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -122,6 +122,25 @@ describe('streamExternalFileToRuntime', () => { expect(chunkCalls()).toHaveLength(2) }) + it('refuses a source whose size no longer matches what staging measured', async () => { + const filePath = join(workDir, 'grown.bin') + await writeFile(filePath, Buffer.alloc(2048)) + + await expect( + streamExternalFileToRuntime({ ...baseArgs(filePath), expectedByteLength: 1024 }) + ).rejects.toThrow('File changed since it was staged') + expect(chunkCalls()).toHaveLength(0) + }) + + it('accepts a source that still matches its staged size', async () => { + const filePath = join(workDir, 'same.bin') + await writeFile(filePath, Buffer.alloc(2048)) + + await expect( + streamExternalFileToRuntime({ ...baseArgs(filePath), expectedByteLength: 2048 }) + ).resolves.toEqual({ byteLength: 2048 }) + }) + it('reports monotonic progress that ends on the file size', async () => { const size = RUNTIME_UPLOAD_SLICE_BYTES * 2 + 500 const filePath = join(workDir, 'tracked.bin') @@ -204,7 +223,8 @@ describe('streamExternalFileToRuntime', () => { expect(chunkCalls()).toHaveLength(1) }) - it('refuses a symlinked source', async () => { + // symlink() needs privileges or Developer Mode on Windows. + it.skipIf(process.platform === 'win32')('refuses a symlinked source', async () => { const targetPath = join(workDir, 'secret.txt') await writeFile(targetPath, 'secret') const linkPath = join(workDir, 'link.txt') @@ -216,19 +236,22 @@ describe('streamExternalFileToRuntime', () => { expect(chunkCalls()).toHaveLength(0) }) - it('refuses a directory entry whose real path escapes the dropped root', async () => { - const outsidePath = join(workDir, 'outside.txt') - await writeFile(outsidePath, 'outside') - const rootPath = join(workDir, 'root') - await mkdir(rootPath) - await symlink(outsidePath, join(rootPath, 'escape.txt')) - - await expect( - streamExternalFileToRuntime({ - ...baseArgs(rootPath), - entryRelativePath: 'escape.txt' - }) - ).rejects.toThrow('Symlink not allowed') - expect(chunkCalls()).toHaveLength(0) - }) + it.skipIf(process.platform === 'win32')( + 'refuses a directory entry whose real path escapes the dropped root', + async () => { + const outsidePath = join(workDir, 'outside.txt') + await writeFile(outsidePath, 'outside') + const rootPath = join(workDir, 'root') + await mkdir(rootPath) + await symlink(outsidePath, join(rootPath, 'escape.txt')) + + await expect( + streamExternalFileToRuntime({ + ...baseArgs(rootPath), + entryRelativePath: 'escape.txt' + }) + ).rejects.toThrow('Symlink not allowed') + expect(chunkCalls()).toHaveLength(0) + } + ) }) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index f35580b0bd33..2d1167bff402 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -18,6 +18,14 @@ export type RuntimeUploadFileStreamArgs = { sourceRootPath: string /** Path of this file within the dropped directory; empty when the source is a file. */ entryRelativePath: string + /** + * Size staging measured and validated against the import ceilings. + * + * Staging and upload are separate IPC calls, so a source can be replaced or + * grown in between. Without this the streamer would take the current size as + * authoritative and happily move a file the ceilings had already rejected. + */ + expectedByteLength?: number worktree: string /** Destination path on the runtime, relative to the worktree. */ relativePath: string @@ -73,6 +81,9 @@ export async function streamExternalFileToRuntime( } const totalBytes = openedStat.size + if (args.expectedByteLength !== undefined && totalBytes !== args.expectedByteLength) { + throw new Error(`File changed since it was staged: '${displayPath}'`) + } throwIfCancelled(args.signal) // Why: a zero-byte source produces no slices, but the destination still has // to exist before commitUpload renames it into place. diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 40e64cc53799..8e153dbc77ba 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -181,6 +181,7 @@ export type FilesystemApi = { entryRelativePath: string worktree: string relativePath: string + expectedByteLength?: number uploadId?: string expectedEnvironmentPairingRevision?: number } & SshMutationExpectation diff --git a/src/preload/index.ts b/src/preload/index.ts index b0e00eb8f084..7ddec377b20a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3462,6 +3462,7 @@ const api = { entryRelativePath: string worktree: string relativePath: string + expectedByteLength?: number uploadId?: string expectedEnvironmentPairingRevision?: number } & SshMutationExpectation diff --git a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx b/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx index f274d0a60603..67b3c9505064 100644 --- a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx @@ -22,14 +22,7 @@ type Props = { onCancel: (uploadId: string) => void /** Closes the toast; the panel owns the timing so the exit is not cut short. */ onDismiss: () => void - /** - * Re-issues the toast so its host re-measures. - * - * Sonner caches a toast's height and only recomputes it when the element - * identity changes, so a panel that shrinks in place keeps the old height and - * ends up top-aligned inside it — the collapsed header appears to hang where - * the expanded panel started instead of sitting at the bottom. - */ + /** Re-issues the toast: sonner re-measures height only on a new element identity. */ onLayoutChange: () => void } diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts index 719f06281a06..f295562e3208 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts @@ -7,18 +7,7 @@ type HeadingInput = { cancelledCount: number } -/** - * What the panel's header says. - * - * While the drop is running it counts files; once everything has stopped it - * states the outcome instead, so a cancelled upload leaves something to read - * rather than closing on "Uploading 2 files". - * - * Names the destination, because a drop onto a remote workspace is the only - * case that shows this panel and "Uploading 2 files" alone reads local. - * The cancelled and failed lines stay short: the destination no longer matters - * once nothing landed there. - */ +/** Counts files while running, states the outcome once everything has stopped. */ export function formatTerminalDropUploadHeading({ rowCount, settled, diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts index a73b0d30cb65..f8d6991f048f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts @@ -1,12 +1,6 @@ const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const -/** - * "8.6 / 32.5 MB" — both figures scaled to the total's unit. - * - * Formatting each side independently reads as a bug the moment they land in - * different units: 900 KB of a 32.5 MB file would render "900 / 32.5" with the - * two numbers on different scales. - */ +/** Both figures share the total's unit; scaling each alone renders "900 / 32.5". */ export function formatTransferredOfTotal(sentBytes: number, totalBytes: number): string { if (!Number.isFinite(totalBytes) || totalBytes <= 0) { return `0 ${BYTE_UNITS[0]}` diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts index 7d092a18cd10..30b0dcff5b29 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts @@ -59,6 +59,10 @@ export async function uploadRuntimeDropPaths( assertCurrent: args.assertCurrent, progress: { onStart: (rows) => { + // A drop where every source was skipped still reports a start. + if (rows.length === 0) { + return + } for (const row of rows) { sourcePathsByUploadId.set(row.uploadId, row.sourcePath) } diff --git a/src/renderer/src/runtime/runtime-file-client.ts b/src/renderer/src/runtime/runtime-file-client.ts index 9f9196487e82..8f2c7c5cbfe9 100644 --- a/src/renderer/src/runtime/runtime-file-client.ts +++ b/src/renderer/src/runtime/runtime-file-client.ts @@ -149,6 +149,8 @@ type StagedRuntimeImportEntry = type RuntimeUploadSource = { sourceRootPath: string entryRelativePath: string + /** What staging measured; main refuses the upload if the file no longer matches. */ + byteLength: number } type RuntimeImportResult = @@ -788,11 +790,18 @@ export async function importExternalPathsToRuntime( } continue } + if (sourceUploadId) { + trackers.get(sourceUploadId)?.beginFile() + } await uploadRuntimeFileWithoutClobber( target, context.worktreeId, entryRelativePath, - { sourceRootPath: source.sourcePath, entryRelativePath: entry.relativePath }, + { + sourceRootPath: source.sourcePath, + entryRelativePath: entry.relativePath, + byteLength: entry.byteLength + }, assertImportSessionCurrent, context.expectedSshConnectionGeneration, context.expectedSshTargetId, @@ -852,7 +861,7 @@ export async function importExternalPathsToRuntime( } finally { unsubscribeProgress?.() for (const releasedId of uploadIdsBySourcePath.values()) { - void window.api.fs.releaseRuntimeUpload({ uploadId: releasedId }) + void window.api.fs.releaseRuntimeUpload({ uploadId: releasedId }).catch(() => {}) } handlers?.onFinish() } @@ -879,6 +888,7 @@ async function uploadRuntimeFileWithoutClobber( environmentId: target.environmentId, sourceRootPath: source.sourceRootPath, entryRelativePath: source.entryRelativePath, + expectedByteLength: source.byteLength, worktree: toRuntimeWorktreeSelector(worktreeId), relativePath: tempRelativePath, uploadId, diff --git a/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts index 19808c3cf36f..1798514d8be8 100644 --- a/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts @@ -32,9 +32,11 @@ describe('createRuntimeUploadProgressTracker', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(300, (p) => seen.push(p.sentBytes)) + tracker.beginFile() tracker.reportFileProgress(50) tracker.reportFileProgress(100) tracker.completeFile(100) + tracker.beginFile() tracker.reportFileProgress(75) expect(seen).toEqual([50, 100, 175]) @@ -44,9 +46,11 @@ describe('createRuntimeUploadProgressTracker', () => { const totals: number[] = [] const tracker = createRuntimeUploadProgressTracker(500, (p) => totals.push(p.totalBytes)) + tracker.beginFile() tracker.reportFileProgress(10) // completeFile does not re-emit: the figure has not moved. tracker.completeFile(10) + tracker.beginFile() tracker.reportFileProgress(20) expect(totals).toEqual([500, 500]) @@ -56,6 +60,7 @@ describe('createRuntimeUploadProgressTracker', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + tracker.beginFile() tracker.reportFileProgress(40) tracker.reportFileProgress(40) tracker.completeFile(40) @@ -67,6 +72,7 @@ describe('createRuntimeUploadProgressTracker', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + tracker.beginFile() tracker.reportFileProgress(250) expect(seen).toEqual([100]) @@ -77,6 +83,7 @@ describe('createRuntimeUploadProgressTracker', () => { const tracker = createRuntimeUploadProgressTracker(30, (p) => seen.push(p.sentBytes)) for (const size of [10, 10, 10]) { + tracker.beginFile() tracker.reportFileProgress(size) tracker.completeFile(size) } @@ -84,6 +91,29 @@ describe('createRuntimeUploadProgressTracker', () => { expect(seen).toEqual([10, 20, 30]) }) + it('ignores a progress event that lands after the file completed', () => { + const seen: number[] = [] + const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + + tracker.beginFile() + tracker.reportFileProgress(40) + tracker.completeFile(40) + // Electron does not order webContents.send against the invoke reply, so the + // last chunk's event can arrive here; counting it would double the file. + tracker.reportFileProgress(40) + + expect(seen).toEqual([40]) + }) + + it('emits nothing before the first file begins', () => { + const seen: number[] = [] + const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + + tracker.reportFileProgress(50) + + expect(seen).toEqual([]) + }) + it('emits nothing until bytes move', () => { const seen: number[] = [] createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) diff --git a/src/renderer/src/runtime/runtime-upload-progress-tracker.ts b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts index 91245e136d8e..f474cdb3e341 100644 --- a/src/renderer/src/runtime/runtime-upload-progress-tracker.ts +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts @@ -1,20 +1,15 @@ export type RuntimeUploadProgressReport = { sentBytes: number; totalBytes: number } export type RuntimeUploadProgressTracker = { + /** Opens the window in which progress for the next file is accepted. */ + beginFile: () => void /** Bytes of the file currently in flight that have reached the runtime. */ reportFileProgress: (sentBytes: number) => void /** Called once a file is committed, so its bytes move from in-flight to done. */ completeFile: (byteLength: number) => void } -/** - * Turn per-file byte counts into one figure for a whole drop. - * - * Files upload strictly one at a time, so "in flight" is always a single file and - * the running total is simply the committed files plus that file's progress. - * Directory entries carry no bytes and are deliberately absent from the total — - * they would otherwise stall the bar at each `createDirNoClobber`. - */ +/** Committed files plus the one in flight; uploads are strictly sequential. */ export function createRuntimeUploadProgressTracker( totalBytes: number, report: (progress: RuntimeUploadProgressReport) => void @@ -22,6 +17,9 @@ export function createRuntimeUploadProgressTracker( let completedBytes = 0 let inFlightBytes = 0 let lastReported = -1 + // Electron does not order webContents.send against an invoke reply, so a final + // progress event can land after completeFile and be counted twice. + let acceptingProgress = false const emit = (): void => { // Why: a source can grow between staging and upload, so the sum of what @@ -35,11 +33,19 @@ export function createRuntimeUploadProgressTracker( } return { + beginFile: () => { + acceptingProgress = true + inFlightBytes = 0 + }, reportFileProgress: (sentBytes) => { + if (!acceptingProgress) { + return + } inFlightBytes = sentBytes emit() }, completeFile: (byteLength) => { + acceptingProgress = false completedBytes += byteLength inFlightBytes = 0 emit() diff --git a/src/renderer/src/runtime/runtime-upload-session-state.ts b/src/renderer/src/runtime/runtime-upload-session-state.ts index 52efde59d44f..7d6593a7db8f 100644 --- a/src/renderer/src/runtime/runtime-upload-session-state.ts +++ b/src/renderer/src/runtime/runtime-upload-session-state.ts @@ -45,13 +45,7 @@ export function startRuntimeUploadSession(sessionId: string, rows: RuntimeUpload emit() } -/** - * Mark the drop finished without removing it. - * - * The panel has to outlive the transfer long enough to say how it ended — - * deleting the session here is what made a cancelled upload vanish mid-gesture - * with nothing to read. - */ +/** Keeps the rows so the panel can state how the drop ended before it closes. */ export function settleRuntimeUploadSession(sessionId: string): void { const session = sessions.get(sessionId) if (!session || session.settled) { From 4a52acd979422f8577b7446b7cc1f463f5800c61 Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 19:54:50 +0200 Subject: [PATCH 12/20] fix(runtime): enforce the per-file ceiling where the bytes move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceiling lived only in staging, and the upload IPC takes the staged size as optional — so the limit held only for callers that had actually staged. The streamer now checks it itself, independent of whether a staged size was supplied. Both ceilings and their formatter move to runtime-import-limits so staging and the streamer share one definition rather than drifting. Co-Authored-By: Claude Opus 5 (1M context) --- .../ipc/filesystem-runtime-upload-staging.ts | 26 ++++--------------- src/main/ipc/runtime-import-limits.ts | 18 +++++++++++++ .../ipc/runtime-upload-file-stream.test.ts | 14 +++++++++- src/main/ipc/runtime-upload-file-stream.ts | 9 +++++++ 4 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 src/main/ipc/runtime-import-limits.ts diff --git a/src/main/ipc/filesystem-runtime-upload-staging.ts b/src/main/ipc/filesystem-runtime-upload-staging.ts index f019a38863be..7457790b93b3 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.ts @@ -1,3 +1,8 @@ +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from './runtime-import-limits' import { constants } from 'node:fs' import { lstat, open, readdir, realpath } from 'node:fs/promises' import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -8,12 +13,6 @@ import type { StagedExternalImportSource } from './filesystem-import-result-types' -// Why: staging streams slices at upload time and never holds a whole file, so -// these are user-safety ceilings on an unattended transfer, not memory guards. -// They stay until the drop UI can show progress and cancel a running upload. -const REMOTE_IMPORT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 -const REMOTE_IMPORT_MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024 - class RuntimeUploadSymlinkError extends Error {} export async function stageOneSourceForRuntimeUpload( @@ -215,21 +214,6 @@ function assertRemoteUploadBudget( } } -function formatByteCeiling(bytes: number): string { - const units = ['B', 'KB', 'MB', 'GB', 'TB'] - let value = bytes - let unit = 0 - while (value >= 1024 && unit < units.length - 1) { - value /= 1024 - unit += 1 - } - // Why: rounds up, so a file one byte over the ceiling never renders as the - // same figure as the ceiling itself — "is 2 GB, over the 2 GB limit" reads - // like a bug in the check rather than an oversized file. - const rounded = Math.ceil(value * 10) / 10 - return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${units[unit]}` -} - function normalizeRelativeUploadPath(path: string): string { return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') } diff --git a/src/main/ipc/runtime-import-limits.ts b/src/main/ipc/runtime-import-limits.ts new file mode 100644 index 000000000000..80fd680a24e8 --- /dev/null +++ b/src/main/ipc/runtime-import-limits.ts @@ -0,0 +1,18 @@ +// Why: staging streams slices at upload time and never holds a whole file, so +// these are user-safety ceilings on an unattended transfer, not memory guards. +// They stay until the drop UI can show progress and cancel a running upload. +export const REMOTE_IMPORT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 +export const REMOTE_IMPORT_MAX_TOTAL_BYTES = 8 * 1024 * 1024 * 1024 + +/** Rounds up, so a size over a ceiling never renders as the ceiling itself. */ +export function formatByteCeiling(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit += 1 + } + const rounded = Math.ceil(value * 10) / 10 + return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)} ${units[unit]}` +} diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index e3bc9aff7b0f..a403abd779e5 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -87,6 +87,18 @@ describe('streamExternalFileToRuntime', () => { ).resolves.toEqual({ byteLength: 2048 }) }) + it('refuses a file over the ceiling even when no staged size is supplied', async () => { + const filePath = join(workDir, 'huge.bin') + await writeFile(filePath, '') + // Sparse: the ceiling is read from stat(), so no real bytes are written. + await truncate(filePath, 3 * 1024 * 1024 * 1024) + + await expect(streamExternalFileToRuntime(baseArgs(filePath))).rejects.toThrow( + 'over the 2 GB per-file remote import limit' + ) + expect(chunkCalls()).toHaveLength(0) + }) + it('never buffers more than one slice per chunk', async () => { const filePath = join(workDir, 'sliced.bin') await writeFile(filePath, Buffer.alloc(RUNTIME_UPLOAD_SLICE_BYTES * 2)) diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index a3fdb37ed009..9e5e494a8f0c 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -2,6 +2,7 @@ import { constants } from 'node:fs' import { lstat, open, realpath } from 'node:fs/promises' import { isAbsolute, join, relative, sep } from 'node:path' import { authorizeExternalPath } from './filesystem-auth' +import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits' import { callRuntimeEnvironment } from './runtime-environment-transport-routing' // Why: base64 turns 3 bytes into 4 chars, so a 384 KiB slice lands on the wire @@ -73,6 +74,14 @@ export async function streamExternalFileToRuntime( if (args.expectedByteLength !== undefined && totalBytes !== args.expectedByteLength) { throw new Error(`File changed since it was staged: '${displayPath}'`) } + // Why: enforced again where the bytes actually move. Staging is a separate + // call, so the ceiling only holds here if this boundary checks it too. + if (totalBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + throw new Error( + `'${displayPath}' is ${formatByteCeiling(totalBytes)}, over the ` + + `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + ) + } // Why: a zero-byte source produces no slices, but the destination still has // to exist before commitUpload renames it into place. if (totalBytes === 0) { From df9d2539fd8421c8e99b7550bd1a8b2b96504505 Mon Sep 17 00:00:00 2001 From: Marabel Catona Date: Sun, 23 Aug 2026 20:07:13 +0200 Subject: [PATCH 13/20] test(runtime): stop allocating gigabyte fixtures to exercise the ceilings truncate() sets a logical length but does not guarantee a sparse file, so on NTFS the over-limit fixtures could allocate two to three gigabytes on Windows CI to prove a comparison. The ceiling tests now mock runtime-import-limits down to kilobytes and use small real files, and the formatter's one-byte-over case moves to a pure unit test on formatByteCeiling where no file is needed at all. Largest fixture in these suites drops from 3 GiB to 3 MiB. Co-Authored-By: Claude Opus 5 (1M context) --- .../filesystem-runtime-upload-staging.test.ts | 39 ++++++++----------- src/main/ipc/runtime-import-limits.test.ts | 33 ++++++++++++++++ .../ipc/runtime-upload-file-stream.test.ts | 17 +++++--- 3 files changed, 62 insertions(+), 27 deletions(-) create mode 100644 src/main/ipc/runtime-import-limits.test.ts diff --git a/src/main/ipc/filesystem-runtime-upload-staging.test.ts b/src/main/ipc/filesystem-runtime-upload-staging.test.ts index 15905cb7803b..ecec2af3d411 100644 --- a/src/main/ipc/filesystem-runtime-upload-staging.test.ts +++ b/src/main/ipc/filesystem-runtime-upload-staging.test.ts @@ -1,9 +1,19 @@ -import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeImportLimits from './runtime-import-limits' + +type RuntimeImportLimitsModule = typeof RuntimeImportLimits vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) +// Why: real ceilings are gigabytes, and truncate() is not sparse on NTFS, so a +// literal over-limit fixture would allocate that much on Windows CI. +vi.mock('./runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 4 * 1024, + REMOTE_IMPORT_MAX_TOTAL_BYTES: 16 * 1024 +})) const { stageOneSourceForRuntimeUpload } = await import('./filesystem-runtime-upload-staging') @@ -33,43 +43,28 @@ describe('stageOneSourceForRuntimeUpload', () => { expect(JSON.stringify(staged)).not.toContain('contentBase64') }) - it('stages a file far larger than the former 25 MB cap', async () => { + it('stages a file with no cap error, where the old buffering path refused', async () => { const filePath = join(workDir, 'big.bin') - await writeFile(filePath, '') - await truncate(filePath, 64 * 1024 * 1024) + await writeFile(filePath, Buffer.alloc(3 * 1024)) await expect(stageOneSourceForRuntimeUpload(filePath)).resolves.toMatchObject({ status: 'staged', - entries: [{ kind: 'file', byteLength: 64 * 1024 * 1024 }] + entries: [{ kind: 'file', byteLength: 3 * 1024 }] }) }) it('names the limit and the actual size when a file is over the ceiling', async () => { const filePath = join(workDir, 'huge.bin') - await writeFile(filePath, '') - // Sparse: the ceiling is checked from stat(), so no real bytes are written. - await truncate(filePath, 3 * 1024 * 1024 * 1024) + await writeFile(filePath, Buffer.alloc(6 * 1024)) const staged = await stageOneSourceForRuntimeUpload(filePath) expect(staged).toMatchObject({ status: 'failed' }) - expect(staged.status === 'failed' && staged.reason).toContain('3 GB') - expect(staged.status === 'failed' && staged.reason).toContain('2 GB') + expect(staged.status === 'failed' && staged.reason).toContain('6 KB') + expect(staged.status === 'failed' && staged.reason).toContain('4 KB') }) // symlink() needs privileges or Developer Mode on Windows. - it('renders a size just over the ceiling as larger than the ceiling', async () => { - const filePath = join(workDir, 'edge.bin') - await writeFile(filePath, '') - await truncate(filePath, 2 * 1024 * 1024 * 1024 + 1) - - const staged = await stageOneSourceForRuntimeUpload(filePath) - - expect(staged).toMatchObject({ status: 'failed' }) - // "is 2 GB, over the 2 GB limit" reads like a broken check. - expect(staged.status === 'failed' && staged.reason).toContain('2.1 GB') - }) - it.skipIf(process.platform === 'win32')('keeps rejecting symlinked sources', async () => { const targetPath = join(workDir, 'target.txt') await writeFile(targetPath, 'data') diff --git a/src/main/ipc/runtime-import-limits.test.ts b/src/main/ipc/runtime-import-limits.test.ts new file mode 100644 index 000000000000..2eaeae603b09 --- /dev/null +++ b/src/main/ipc/runtime-import-limits.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + formatByteCeiling, + REMOTE_IMPORT_MAX_FILE_BYTES, + REMOTE_IMPORT_MAX_TOTAL_BYTES +} from './runtime-import-limits' + +describe('formatByteCeiling', () => { + it('renders a size one byte over a ceiling as larger than the ceiling', () => { + // "is 2 GB, over the 2 GB limit" reads like a broken check, not a big file. + expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)).toBe('2 GB') + expect(formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES + 1)).toBe('2.1 GB') + }) + + it('leaves an exact ceiling as a whole number', () => { + expect(formatByteCeiling(REMOTE_IMPORT_MAX_TOTAL_BYTES)).toBe('8 GB') + expect(formatByteCeiling(1024)).toBe('1 KB') + }) + + it('scales through the units', () => { + expect(formatByteCeiling(512)).toBe('512 B') + expect(formatByteCeiling(1024 * 1024)).toBe('1 MB') + expect(formatByteCeiling(1024 ** 4)).toBe('1 TB') + }) + + it('rounds up rather than to nearest', () => { + expect(formatByteCeiling(1024 * 1024 + 1)).toBe('1.1 MB') + }) + + it('does not crash on zero', () => { + expect(formatByteCeiling(0)).toBe('0 B') + }) +}) diff --git a/src/main/ipc/runtime-upload-file-stream.test.ts b/src/main/ipc/runtime-upload-file-stream.test.ts index a403abd779e5..cd2adf6785f7 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -1,7 +1,10 @@ -import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeImportLimits from './runtime-import-limits' + +type RuntimeImportLimitsModule = typeof RuntimeImportLimits const callRuntimeEnvironment = vi.fn() @@ -9,6 +12,12 @@ vi.mock('./runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: (...args: unknown[]) => callRuntimeEnvironment(...args) })) vi.mock('./filesystem-auth', () => ({ authorizeExternalPath: () => {} })) +// Why: see filesystem-runtime-upload-staging.test.ts — a real over-limit fixture +// would allocate gigabytes on Windows. +vi.mock('./runtime-import-limits', async (importOriginal) => ({ + ...(await importOriginal()), + REMOTE_IMPORT_MAX_FILE_BYTES: 2 * 1024 * 1024 +})) const { RUNTIME_UPLOAD_SLICE_BYTES, streamExternalFileToRuntime } = await import('./runtime-upload-file-stream') @@ -89,12 +98,10 @@ describe('streamExternalFileToRuntime', () => { it('refuses a file over the ceiling even when no staged size is supplied', async () => { const filePath = join(workDir, 'huge.bin') - await writeFile(filePath, '') - // Sparse: the ceiling is read from stat(), so no real bytes are written. - await truncate(filePath, 3 * 1024 * 1024 * 1024) + await writeFile(filePath, Buffer.alloc(3 * 1024 * 1024)) await expect(streamExternalFileToRuntime(baseArgs(filePath))).rejects.toThrow( - 'over the 2 GB per-file remote import limit' + 'over the 2 MB per-file remote import limit' ) expect(chunkCalls()).toHaveLength(0) }) From 5c0d7ed8369b2b701fceaf3c466d28e012be1e5a Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:23:51 +0200 Subject: [PATCH 14/20] fix(terminal): pluralize drop upload headings and settle test upload rows Headings now pass count so i18next picks the plural form, with named placeholders. Drop-handler success mocks settle their row before finishing, and the runtime file client harness stubs cancel/release so progress-enabled imports resolve. --- .../terminal-drop-handler.test.ts | 32 +++++++++++-- .../terminal-drop-upload-heading.test.ts | 8 ++++ .../terminal-drop-upload-heading.ts | 13 +++-- .../src/i18n/en-runtime-required.json | 8 ++++ src/renderer/src/i18n/locales/en.json | 10 ++-- ...untime-file-client-external-import.test.ts | 48 +++++++++++++++++++ .../runtime-file-client-test-harness.ts | 11 ++++- 7 files changed, 114 insertions(+), 16 deletions(-) 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 bf2c117ae70c..f0321aec4b0a 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 @@ -67,10 +67,18 @@ vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => 'session-1' +})) + vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: mocks.recordTerminalUserInputForLeaf })) +import { + endRuntimeUploadSession, + getRuntimeUploadSession +} from '@/runtime/runtime-upload-session-state' import { handleTerminalFileDrop } from './terminal-drop-handler' import { wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' @@ -89,16 +97,25 @@ function createTerminalTransport( // Why: the drop panel is only created once rows exist, so a mock that never // announces a row leaves nothing for the dismissal assertions to observe. -type ImportOptions = { progress?: Pick } +type ImportOptions = { progress?: RuntimeImportProgressHandlers } + +const ROW = { uploadId: 'u-1', name: 'logo.png', totalBytes: 10, sourcePath: '/Users/me/logo.png' } +/** A successful import: the row moves all its bytes and settles before the drop finishes. */ function startAndFinishRow(options: ImportOptions | undefined): void { const progress = options?.progress - progress?.onStart([ - { uploadId: 'u-1', name: 'logo.png', totalBytes: 10, sourcePath: '/Users/me/logo.png' } - ]) + progress?.onStart([ROW]) + progress?.onRowProgress(ROW.uploadId, ROW.totalBytes) + progress?.onRowSettled(ROW.uploadId, 'done') progress?.onFinish() } +/** An import that throws mid-drop: rows never settle, the finally still finishes. */ +function startRowThenAbort(options: ImportOptions | undefined): void { + options?.progress?.onStart([ROW]) + options?.progress?.onFinish() +} + function announceRowThenResolve(value: unknown) { return async (_context: unknown, _paths: unknown, _dest: unknown, options?: ImportOptions) => { startAndFinishRow(options) @@ -109,6 +126,7 @@ function announceRowThenResolve(value: unknown) { describe('handleTerminalFileDrop', () => { beforeEach(() => { vi.clearAllMocks() + endRuntimeUploadSession('session-1') mocks.storeState.activeRepoId = 'repo1' mocks.storeState.activeWorktreeId = 'wt-1' vi.stubGlobal('window', { @@ -194,6 +212,9 @@ describe('handleTerminalFileDrop', () => { expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(mocks.toastError).not.toHaveBeenCalled() expect(mocks.toastCustom).toHaveBeenCalled() + const session = getRuntimeUploadSession('session-1') + expect(session?.settled).toBe(true) + expect(session?.rows).toEqual([expect.objectContaining({ status: 'done', sentBytes: 10 })]) // The panel holds briefly to show the outcome and dismisses itself; cutting // that short here is what made a cancelled drop vanish with nothing to read. expect(mocks.toastDismiss).not.toHaveBeenCalled() @@ -269,7 +290,7 @@ describe('handleTerminalFileDrop', () => { it('tears the upload panel down immediately when the import throws', async () => { mocks.importExternalPathsToRuntime.mockImplementation( async (_context: unknown, _paths: unknown, _dest: unknown, options?: ImportOptions) => { - startAndFinishRow(options) + startRowThenAbort(options) throw new Error('runtime unreachable') } ) @@ -287,6 +308,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') expect(mocks.toastError).toHaveBeenCalled() + expect(getRuntimeUploadSession('session-1')).toBeUndefined() }) it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts index 4c7b287d9584..a437567251c1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts @@ -66,5 +66,13 @@ describe('formatTerminalDropUploadHeading', () => { cancelledCount: 0 }) ).toBe('Uploading 1 file to runtime') + expect( + formatTerminalDropUploadHeading({ + rowCount: 1, + settled: true, + doneCount: 1, + cancelledCount: 0 + }) + ).toBe('Uploaded 1 file to runtime') }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts index b60dd532428d..3c3ce5194ee2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts @@ -14,12 +14,11 @@ export function formatTerminalDropUploadHeading({ doneCount, cancelledCount }: HeadingInput): string { - const plural = rowCount === 1 ? '' : 's' if (!settled) { return translate( 'auto.components.terminal.pane.terminal.drop.upload.heading.uploading', - 'Uploading {{value0}} file{{value1}} to runtime', - { value0: rowCount, value1: plural } + 'Uploading {{count}} files to runtime', + { count: rowCount } ) } if (doneCount === 0 && cancelledCount > 0) { @@ -37,13 +36,13 @@ export function formatTerminalDropUploadHeading({ if (doneCount < rowCount) { return translate( 'auto.components.terminal.pane.terminal.drop.upload.heading.partial', - 'Uploaded {{value0}} of {{value1}} to runtime', - { value0: doneCount, value1: rowCount } + 'Uploaded {{doneCount}} of {{rowCount}} to runtime', + { doneCount, rowCount } ) } return translate( 'auto.components.terminal.pane.terminal.drop.upload.heading.done', - 'Uploaded {{value0}} file{{value1}} to runtime', - { value0: rowCount, value1: plural } + 'Uploaded {{count}} files to runtime', + { count: rowCount } ) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index b5e8431c5738..ba722bc8af9d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2475,6 +2475,14 @@ "drop": { "handler": { "29c031b49a": "Uploading {{value0}} file{{value1}} to runtime…" + }, + "upload": { + "heading": { + "done_one": "Uploaded {{count}} file to runtime", + "done_other": "Uploaded {{count}} files to runtime", + "uploading_one": "Uploading {{count}} file to runtime", + "uploading_other": "Uploading {{count}} files to runtime" + } } } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9f49beb55a00..d3a2ea161417 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3346,9 +3346,13 @@ "heading": { "cancelled": "Upload cancelled", "failed": "Upload failed", - "partial": "Uploaded {{value0}} of {{value1}} to runtime", - "done": "Uploaded {{value0}} file{{value1}} to runtime", - "uploading": "Uploading {{value0}} file{{value1}} to runtime" + "partial": "Uploaded {{doneCount}} of {{rowCount}} to runtime", + "done": "Uploaded {{count}} files to runtime", + "done_one": "Uploaded {{count}} file to runtime", + "done_other": "Uploaded {{count}} files to runtime", + "uploading": "Uploading {{count}} files to runtime", + "uploading_one": "Uploading {{count}} file to runtime", + "uploading_other": "Uploading {{count}} files to runtime" } } } 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 52f0bb83d09a..3f1038a8a30b 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 @@ -3,12 +3,14 @@ import { importExternalPathsToRuntime } from './runtime-file-client' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' import { fsImportExternalPaths, + fsReleaseRuntimeUpload, fsStageExternalPathsForRuntimeUpload, fsUploadExternalFileToRuntime, runtimeEnvironmentCall, runtimeEnvironmentTransportCall, installRuntimeFileClientEnvironment } from './runtime-file-client-test-harness' +import type { RuntimeImportProgressHandlers } from './runtime-upload-progress-tracker' installRuntimeFileClientEnvironment() @@ -248,6 +250,52 @@ describe('runtime file client', () => { ) }) + it('settles and releases each row when the drop reports progress', async () => { + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/logo.png', + status: 'staged', + name: 'logo.png', + kind: 'file', + entries: [stagedFile('', 3, 55)] + } + ] + }) + runtimeEnvironmentCall + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('commit-upload')) + .mockResolvedValueOnce(okResponse('delete-temp')) + const progress = { + onStart: vi.fn(), + onRowProgress: vi.fn(), + onRowSettled: vi.fn(), + onFinish: vi.fn() + } + + await expect( + importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/logo.png'], + '/remote/repo/uploads', + { progress } + ) + ).resolves.toMatchObject({ results: [{ status: 'imported' }] }) + + const uploadId = progress.onStart.mock.calls[0]?.[0][0]?.uploadId + expect(uploadId).toEqual(expect.any(String)) + expect(progress.onRowProgress).toHaveBeenLastCalledWith(uploadId, 3) + expect(progress.onRowSettled).toHaveBeenCalledWith(uploadId, 'done') + expect(progress.onFinish).toHaveBeenCalledTimes(1) + expect(fsReleaseRuntimeUpload).toHaveBeenCalledWith({ uploadId }) + }) + it('does not commit an upload when the owner generation changes while it streams', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ diff --git a/src/renderer/src/runtime/runtime-file-client-test-harness.ts b/src/renderer/src/runtime/runtime-file-client-test-harness.ts index 2787ccbe463d..8ae8d4941e7e 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -57,6 +57,9 @@ export const fsStageExternalPathsForRuntimeUpload: PreloadStub = vi.fn() export const fsUploadExternalFileToRuntime: PreloadStub = vi.fn() /** Returns the unsubscribe the import loop calls in its finally. */ export const fsOnUploadProgress: PreloadStub = vi.fn() +export const fsCancelRuntimeUpload: PreloadStub = vi.fn() +/** Called in the import loop's finally for every row it started. */ +export const fsReleaseRuntimeUpload: PreloadStub = vi.fn() export const runtimeEnvironmentCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentTransportCall: RuntimeRpcStub = vi.fn() export const runtimeEnvironmentSubscribe: RuntimeSubscribeStub = vi.fn() @@ -95,6 +98,10 @@ export function installRuntimeFileClientEnvironment(): void { fsUploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) fsOnUploadProgress.mockReset() fsOnUploadProgress.mockReturnValue(() => {}) + fsCancelRuntimeUpload.mockReset() + fsCancelRuntimeUpload.mockResolvedValue(undefined) + fsReleaseRuntimeUpload.mockReset() + fsReleaseRuntimeUpload.mockResolvedValue(undefined) runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeEnvironmentSubscribe.mockReset() @@ -140,7 +147,9 @@ export function installRuntimeFileClientEnvironment(): void { importExternalPaths: fsImportExternalPaths, stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload, uploadExternalFileToRuntime: fsUploadExternalFileToRuntime, - onUploadProgress: fsOnUploadProgress + onUploadProgress: fsOnUploadProgress, + cancelRuntimeUpload: fsCancelRuntimeUpload, + releaseRuntimeUpload: fsReleaseRuntimeUpload }, runtime: { call: runtimeCall }, runtimeEnvironments: { From 7d12794c3b2d4ef0bdf141947b6e650b11987e4b Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:53:50 +0200 Subject: [PATCH 15/20] feat(runtime): show drop upload progress only in the workspace it targets The upload panel (and the SSH upload spinner) was an app-wide toast, so it followed the user into every workspace and stacked with uploads bound for other workspaces. It now shows only while the destination workspace is active, comes back with current progress on return, and a drop that finishes while the user is elsewhere ends without replaying its outcome. Failures stay visible everywhere and name the workspace when it is not the active one. The drop-handler tests share one SSH repo stub so the file stays under the max-lines limit after the main merge. Refs #24151 --- .../terminal-drop-handler.test.ts | 79 +++------- .../terminal-drop-upload-report.test.ts | 11 +- .../terminal-drop-upload-report.ts | 7 +- .../terminal-drop-workspace-label.test.ts | 40 +++++ .../terminal-drop-workspace-label.ts | 30 ++++ .../terminal-native-file-drop.ts | 35 +++-- .../terminal-runtime-drop-upload.test.ts | 146 ++++++++++++++++++ .../terminal-runtime-drop-upload.ts | 59 ++++--- src/renderer/src/i18n/locales/en.json | 3 +- .../src/lib/worktree-scoped-toast.test.ts | 112 ++++++++++++++ src/renderer/src/lib/worktree-scoped-toast.ts | 72 +++++++++ 11 files changed, 501 insertions(+), 93 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-workspace-label.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-workspace-label.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts create mode 100644 src/renderer/src/lib/worktree-scoped-toast.test.ts create mode 100644 src/renderer/src/lib/worktree-scoped-toast.ts 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 65ce95d16187..05d3c2b05f5a 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 @@ -55,7 +55,8 @@ vi.mock('sonner', () => ({ vi.mock('@/store', () => ({ useAppStore: { - getState: () => mocks.storeState + getState: () => mocks.storeState, + subscribe: () => () => {} } })) @@ -123,6 +124,20 @@ function announceRowThenResolve(value: unknown) { } } +/** Points the store at one SSH-backed repo whose only worktree is `wt-1`. */ +function stubSshRepo( + connectionId: string, + path: string, + sshState: { remotePlatform?: NodeJS.Platform; connectionGeneration?: number } +): void { + mocks.storeState.settings = { activeRuntimeEnvironmentId: null } + mocks.storeState.repos = [ + { id: 'repo1', connectionId, path, executionHostId: `ssh:${connectionId}` } + ] + mocks.storeState.worktreesByRepo = { repo1: [{ id: 'wt-1', repoId: 'repo1', path }] } + mocks.storeState.sshConnectionStates = new Map([[connectionId, sshState]]) +} + describe('handleTerminalFileDrop', () => { beforeEach(() => { vi.clearAllMocks() @@ -721,21 +736,7 @@ describe('handleTerminalFileDrop', () => { }) it('uses SSH remote platform metadata for Windows remote path drops', async () => { - mocks.storeState.settings = { activeRuntimeEnvironmentId: null } - mocks.storeState.repos = [ - { - id: 'repo1', - connectionId: 'ssh-win', - path: 'C:\\Remote Repo', - executionHostId: 'ssh:ssh-win' - } - ] - mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Remote Repo' }] - } - mocks.storeState.sshConnectionStates = new Map([ - ['ssh-win', { remotePlatform: 'win32', connectionGeneration: 4 }] - ]) + stubSshRepo('ssh-win', 'C:\\Remote Repo', { remotePlatform: 'win32', connectionGeneration: 4 }) mocks.resolveDroppedPathsForAgent.mockResolvedValue({ failed: [], resolvedPaths: ['C:\\Remote Repo\\A&B.txt'], @@ -772,21 +773,7 @@ describe('handleTerminalFileDrop', () => { }) it('pastes a spaced image dropped on a Windows SSH host with Windows quoting', async () => { - mocks.storeState.settings = { activeRuntimeEnvironmentId: null } - mocks.storeState.repos = [ - { - id: 'repo1', - connectionId: 'ssh-win', - path: 'C:\\Remote Repo', - executionHostId: 'ssh:ssh-win' - } - ] - mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: 'C:\\Remote Repo' }] - } - mocks.storeState.sshConnectionStates = new Map([ - ['ssh-win', { remotePlatform: 'win32', connectionGeneration: 4 }] - ]) + stubSshRepo('ssh-win', 'C:\\Remote Repo', { remotePlatform: 'win32', connectionGeneration: 4 }) mocks.resolveDroppedPathsForAgent.mockResolvedValue({ failed: [], resolvedPaths: ['C:\\Remote Repo\\.orca\\drops\\Screenshot 1.png'], @@ -812,19 +799,7 @@ describe('handleTerminalFileDrop', () => { }) it('surfaces stale SSH owner capture failures without rejecting the native drop', async () => { - mocks.storeState.settings = { activeRuntimeEnvironmentId: null } - mocks.storeState.repos = [ - { - id: 'repo1', - connectionId: 'ssh-stale', - path: '/remote/repo', - executionHostId: 'ssh:ssh-stale' - } - ] - mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }] - } - mocks.storeState.sshConnectionStates = new Map([['ssh-stale', { remotePlatform: 'linux' }]]) + stubSshRepo('ssh-stale', '/remote/repo', { remotePlatform: 'linux' }) const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } } await expect( @@ -845,21 +820,7 @@ describe('handleTerminalFileDrop', () => { }) it('keeps SSH Linux path drops on POSIX shell escaping', async () => { - mocks.storeState.settings = { activeRuntimeEnvironmentId: null } - mocks.storeState.repos = [ - { - id: 'repo1', - connectionId: 'ssh-linux', - path: '/remote/repo', - executionHostId: 'ssh:ssh-linux' - } - ] - mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo' }] - } - mocks.storeState.sshConnectionStates = new Map([ - ['ssh-linux', { remotePlatform: 'linux', connectionGeneration: 5 }] - ]) + stubSshRepo('ssh-linux', '/remote/repo', { remotePlatform: 'linux', connectionGeneration: 5 }) mocks.resolveDroppedPathsForAgent.mockResolvedValue({ failed: [], resolvedPaths: ["/remote/repo/it's here.txt"], diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts index 8448a117b83b..ede86bf0dcc6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-report.test.ts @@ -47,7 +47,8 @@ describe('reportTerminalDropUploadSkipsAndFailures', () => { { value0: 1, value1: 'file' } ) expect(toast.error).toHaveBeenCalledWith( - expect.not.stringContaining('/secret/project/file.txt') + expect.not.stringContaining('/secret/project/file.txt'), + { description: undefined } ) }) @@ -75,6 +76,14 @@ describe('reportTerminalDropUploadSkipsAndFailures', () => { ]) }) + it('names the workspace on a failure raised while the user is elsewhere', () => { + reportTerminalDropUploadSkipsAndFailures([], [{ reason: 'a' }], 'Dropped into ux-polish') + + expect(toast.error).toHaveBeenCalledWith(expect.any(String), { + description: 'Dropped into ux-polish' + }) + }) + it('keeps upload wording for failures', () => { reportTerminalDropUploadSkipsAndFailures([], [{ reason: 'a' }, { reason: 'b' }]) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-report.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-report.ts index b74e677aadf1..b660c0dae90d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-report.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-report.ts @@ -4,7 +4,9 @@ import { describeDropSkipReason } from '@/lib/drop-skip-reason-copy' export function reportTerminalDropUploadSkipsAndFailures( skipped: { reason: string }[], - failed: { reason: string }[] + failed: { reason: string }[], + /** Names the destination workspace when the user has switched away from it. */ + workspaceDescription?: string ): void { if (skipped.length > 0) { // Why: symlink rejection is policy, not an error; mixed reasons stay generic. @@ -38,7 +40,8 @@ export function reportTerminalDropUploadSkipsAndFailures( 'auto.components.terminal.pane.terminal.drop.handler.1e072f611e', 'Failed to upload {{value0}} {{value1}}.', { value0: failed.length, value1: noun } - ) + ), + { description: workspaceDescription } ) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-workspace-label.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-workspace-label.test.ts new file mode 100644 index 000000000000..cc3c85d67df3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-workspace-label.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const holder: { state: Record } = { state: {} } + return holder +}) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.state } })) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values: { workspace: string }) => + fallback.replace('{{workspace}}', values.workspace) +})) + +import { describeDropWorkspaceIfInactive } from './terminal-drop-workspace-label' + +describe('describeDropWorkspaceIfInactive', () => { + beforeEach(() => { + mocks.state = { + activeWorktreeId: 'wt-a', + worktreesByRepo: { + 'repo-a': [ + { id: 'wt-a', displayName: '', branch: 'refs/heads/main', path: '/srv/repo/main' }, + { id: 'wt-b', displayName: 'ux-polish', branch: 'refs/heads/x', path: '/srv/repo/x' } + ] + } + } + }) + + it('stays silent when the drop went to the active workspace', () => { + expect(describeDropWorkspaceIfInactive('wt-a', '/srv/repo/main')).toBeUndefined() + }) + + it('names the workspace the user switched away from', () => { + expect(describeDropWorkspaceIfInactive('wt-b', '/srv/repo/x')).toBe('Dropped into ux-polish') + }) + + it('falls back to the folder name for a workspace outside worktreesByRepo', () => { + expect(describeDropWorkspaceIfInactive('folder:notes', '/srv/notes')).toBe('Dropped into notes') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-workspace-label.ts b/src/renderer/src/components/terminal-pane/terminal-drop-workspace-label.ts new file mode 100644 index 000000000000..fa5ec1e0a712 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-workspace-label.ts @@ -0,0 +1,30 @@ +import { translate } from '@/i18n/i18n' +import { basename } from '@/lib/path' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' +import { useAppStore } from '@/store' +import { findWorktreeById } from '@/store/slices/worktree-helpers' + +/** + * Names the workspace a drop went to, for a notice that surfaces while the + * user is looking at a different one. Undefined when that workspace is active. + */ +export function describeDropWorkspaceIfInactive( + worktreeId: string, + worktreePath: string +): string | undefined { + const state = useAppStore.getState() + if (state.activeWorktreeId === worktreeId) { + return undefined + } + const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) + // Why: folder workspaces are not in worktreesByRepo; their folder name is the label. + const name = (worktree ? resolveWorktreeDisplayName(worktree) : '') || basename(worktreePath) + if (!name) { + return undefined + } + return translate( + 'auto.components.terminal.pane.terminal.drop.upload.droppedInto', + 'Dropped into {{workspace}}', + { workspace: name } + ) +} 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 07de45cee938..75b9d7df639d 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 @@ -4,12 +4,14 @@ import { extractIpcErrorMessage } from '@/lib/ipc-error' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import { openWorktreeScopedToast } from '@/lib/worktree-scoped-toast' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import { isWslUncPath, parseWslUncPath } from '../../../../shared/wsl-paths' import type { PtyTransport } from './pty-transport' import { reportTerminalDropUploadSkipsAndFailures } from './terminal-drop-upload-report' +import { describeDropWorkspaceIfInactive } from './terminal-drop-workspace-label' import type { NativeDropFlowArgs } from './terminal-drop-paste' import { pasteResolvedDropPaths } from './terminal-drop-paste' import { uploadRuntimeDropPaths } from './terminal-runtime-drop-upload' @@ -143,6 +145,7 @@ async function handleNativeTerminalFileDropWithCapturedOwner( pane, tabId, targetShell, + worktreeId, worktreePath }) } @@ -176,15 +179,21 @@ async function pasteLocalDropPaths( } async function uploadRemoteDropPaths( - args: NativeDropFlowArgs & { connectionId: string; targetShell: 'posix' | 'windows' } + args: NativeDropFlowArgs & { + connectionId: string + targetShell: 'posix' | 'windows' + worktreeId: string + } ): Promise { - const pending = toast.loading( - translate( - 'auto.components.terminal.pane.terminal.drop.handler.29c031b49a', - 'Uploading {{value0}} file{{value1}} to remote…', - { value0: args.dataPaths.length, value1: args.dataPaths.length === 1 ? '' : 's' } - ) + const message = translate( + 'auto.components.terminal.pane.terminal.drop.handler.29c031b49a', + 'Uploading {{value0}} file{{value1}} to remote…', + { value0: args.dataPaths.length, value1: args.dataPaths.length === 1 ? '' : 's' } ) + const pending = openWorktreeScopedToast({ + worktreeId: args.worktreeId, + show: (id) => (id === undefined ? toast.loading(message) : toast.loading(message, { id })) + }) try { const { resolvedPaths, skipped, failed } = await window.api.fs.resolveDroppedPathsForAgent({ paths: args.dataPaths, @@ -195,11 +204,17 @@ async function uploadRemoteDropPaths( expectedSshConnectionGeneration: args.expectedSshConnectionGeneration }) await pasteResolvedDropPaths({ ...args, paths: resolvedPaths, targetShell: args.targetShell }) - reportTerminalDropUploadSkipsAndFailures(skipped, failed) + reportTerminalDropUploadSkipsAndFailures( + skipped, + failed, + describeDropWorkspaceIfInactive(args.worktreeId, args.worktreePath) + ) } catch (err) { - toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) + toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'), { + description: describeDropWorkspaceIfInactive(args.worktreeId, args.worktreePath) + }) } finally { - toast.dismiss(pending) + pending.close() } } diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts new file mode 100644 index 000000000000..11f8fa6c1b6c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { RuntimeImportProgressHandlers } from '@/runtime/runtime-upload-progress-tracker' +import type { ImportItemResult } from '../../../../shared/filesystem-import-result-types' + +const mocks = vi.hoisted(() => { + const listeners = new Set<(state: { activeWorktreeId: string | null }) => void>() + const state: { + activeWorktreeId: string | null + worktreesByRepo: Record< + string, + { id: string; displayName: string; branch: string; path: string }[] + > + } = { + activeWorktreeId: 'wt-a', + worktreesByRepo: { + 'repo-a': [{ id: 'wt-a', displayName: 'ux-polish', branch: '', path: '/srv/a' }] + } + } + return { + store: { state, listeners }, + runImport: vi.fn() + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.store.state, + subscribe: (listener: (state: { activeWorktreeId: string | null }) => void) => { + mocks.store.listeners.add(listener) + return () => mocks.store.listeners.delete(listener) + } + } +})) +vi.mock('sonner', () => ({ + toast: { + custom: vi.fn((_render: unknown, options?: { id?: string | number }) => options?.id ?? 'panel'), + dismiss: vi.fn(), + error: vi.fn(), + message: vi.fn() + } +})) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'session-1' })) +vi.mock('@/runtime/runtime-file-client', () => ({ + importExternalPathsToRuntime: ( + _context: unknown, + _paths: string[], + _destination: string, + options: { progress: RuntimeImportProgressHandlers } + ) => mocks.runImport(options.progress) +})) +vi.mock('./terminal-drop-paste', () => ({ pasteResolvedDropPaths: vi.fn() })) +vi.mock('./TerminalDropUploadToast', () => ({ TerminalDropUploadToast: () => null })) + +import { getRuntimeUploadSession } from '@/runtime/runtime-upload-session-state' +import { uploadRuntimeDropPaths } from './terminal-runtime-drop-upload' + +function setActiveWorktree(activeWorktreeId: string | null): void { + mocks.store.state = { ...mocks.store.state, activeWorktreeId } + for (const listener of mocks.store.listeners) { + listener(mocks.store.state) + } +} + +const row = { uploadId: 'up-1', name: 'clip.mp4', totalBytes: 10, sourcePath: '/local/clip.mp4' } +const imported: ImportItemResult = { + sourcePath: '/local/clip.mp4', + status: 'imported', + destPath: '/srv/a/.orca/drops/clip.mp4', + kind: 'file', + renamed: false +} + +type PaneHandles = Pick< + Parameters[0], + 'dropTarget' | 'manager' | 'paneTransports' | 'pane' +> +const fakePaneHandles = { dropTarget: {}, manager: {}, paneTransports: new Map(), pane: {} } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pasteResolvedDropPaths is mocked, so these pane handles are passed through and never read. +const paneHandles = fakePaneHandles as unknown as PaneHandles + +/** Runs one single-file drop; `during` fires after the panel opened, before the upload ends. */ +function dropOneFile(during: () => void = () => {}): Promise { + mocks.runImport.mockImplementation(async (progress: RuntimeImportProgressHandlers) => { + progress.onStart([row]) + during() + progress.onRowSettled(row.uploadId, 'done') + progress.onFinish() + return { results: [imported] } + }) + return uploadRuntimeDropPaths({ + ...paneHandles, + dataPaths: ['/local/clip.mp4'], + tabId: 'tab-1', + worktreePath: '/srv/a', + runtimeEnvironmentId: 'env-1', + settings: null, + worktreeId: 'wt-a' + }) +} + +describe('uploadRuntimeDropPaths panel scope', () => { + beforeEach(() => { + mocks.store.state = { ...mocks.store.state, activeWorktreeId: 'wt-a' } + mocks.store.listeners.clear() + vi.mocked(toast.custom).mockClear() + vi.mocked(toast.dismiss).mockClear() + }) + + it('keeps the panel off a workspace the user is not looking at', async () => { + setActiveWorktree('wt-b') + + await dropOneFile() + + expect(toast.custom).not.toHaveBeenCalled() + // Nothing is left to replay a "done" notice on return. + expect(getRuntimeUploadSession('session-1')).toBeUndefined() + expect(mocks.store.listeners.size).toBe(0) + }) + + it('hides the panel on switching away and brings it back on return', async () => { + await dropOneFile(() => { + expect(toast.custom).toHaveBeenCalledTimes(1) + setActiveWorktree('wt-b') + expect(toast.dismiss).toHaveBeenCalledWith('panel') + setActiveWorktree('wt-a') + }) + + expect(toast.custom).toHaveBeenCalledTimes(2) + // A fresh toast, not an update of the one sonner is animating out. + expect(vi.mocked(toast.custom).mock.calls[1]?.[1]).not.toHaveProperty('id') + // Still shown, so the panel keeps its outcome hold before closing itself. + expect(getRuntimeUploadSession('session-1')?.settled).toBe(true) + }) + + it('ends a finished drop when the user leaves during the outcome hold', async () => { + await dropOneFile() + expect(getRuntimeUploadSession('session-1')?.settled).toBe(true) + + setActiveWorktree('wt-b') + + expect(getRuntimeUploadSession('session-1')).toBeUndefined() + expect(mocks.store.listeners.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts index 30b0dcff5b29..cec7a32cd915 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts @@ -2,9 +2,11 @@ import { createElement } from 'react' import { toast } from 'sonner' import { createBrowserUuid } from '@/lib/browser-uuid' import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { openWorktreeScopedToast, type WorktreeScopedToast } from '@/lib/worktree-scoped-toast' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { endRuntimeUploadSession, + getRuntimeUploadSession, settleRuntimeUploadSession, startRuntimeUploadSession, updateRuntimeUploadRow @@ -13,6 +15,7 @@ import type { useAppStore } from '@/store' import { TerminalDropUploadToast } from './TerminalDropUploadToast' import type { NativeDropFlowArgs } from './terminal-drop-paste' import { pasteResolvedDropPaths } from './terminal-drop-paste' +import { describeDropWorkspaceIfInactive } from './terminal-drop-workspace-label' import { reportTerminalDropUploadSkipsAndFailures } from './terminal-drop-upload-report' import { getTerminalTargetShellForWorktreePath, @@ -32,7 +35,11 @@ export async function uploadRuntimeDropPaths( const sessionId = createBrowserUuid() const cancelledSourcePaths = new Set() const sourcePathsByUploadId = new Map() - let pending: string | number | null = null + let panel: WorktreeScopedToast | null = null + const closePanel = (): void => { + panel?.close() + endRuntimeUploadSession(sessionId) + } const cancelRow = (uploadId: string): void => { const sourcePath = sourcePathsByUploadId.get(uploadId) if (sourcePath) { @@ -80,34 +87,46 @@ export async function uploadRuntimeDropPaths( // never flashes an empty panel. // Why: createElement, not a direct call — the toast body must be its // own component or its hooks run outside a component boundary. - const renderPanel = (toastId: string | number) => + const renderPanel = () => createElement(TerminalDropUploadToast, { sessionId, onCancel: cancelRow, - onDismiss: () => { - toast.dismiss(toastId) - endRuntimeUploadSession(sessionId) - }, - onLayoutChange: () => showPanel() + onDismiss: closePanel, + onLayoutChange: () => panel?.refresh() }) const panelOptions = { duration: Infinity, dismissible: false, unstyled: true } - const showPanel = (): void => { + // Why: the upload belongs to the workspace it was dropped into, so its + // panel shows only there instead of stacking over every workspace. + panel = openWorktreeScopedToast({ + worktreeId: args.worktreeId, // Why: the id key is omitted, not set to undefined. sonner spreads these // options over the id it just minted, so an explicit `id: undefined` // makes it register the toast under a different id than it returns — // and the next re-issue then adds a second panel instead of updating. - pending = - pending === null + show: (id) => + id === undefined ? toast.custom(renderPanel, panelOptions) - : toast.custom(renderPanel, { ...panelOptions, id: pending }) - } - showPanel() + : toast.custom(renderPanel, { ...panelOptions, id }), + // Why: hiding unmounts the panel's outcome timer, so a settled drop + // would otherwise never end. + onHidden: () => { + if (getRuntimeUploadSession(sessionId)?.settled) { + closePanel() + } + } + }) }, onRowProgress: (uploadId, sentBytes) => updateRuntimeUploadRow(sessionId, uploadId, { sentBytes }), onRowSettled: (uploadId, status) => updateRuntimeUploadRow(sessionId, uploadId, { status }), - onFinish: () => settleRuntimeUploadSession(sessionId) + onFinish: () => { + settleRuntimeUploadSession(sessionId) + // Why: no "done" replay on return; failures still get their own toast. + if (panel && !panel.isShown()) { + closePanel() + } + } } } ) @@ -123,15 +142,15 @@ export async function uploadRuntimeDropPaths( // Why: a cancel is the user's own decision, not a failure to report back. results .filter((result) => result.status === 'failed') - .filter((result) => !cancelledSourcePaths.has(result.sourcePath)) + .filter((result) => !cancelledSourcePaths.has(result.sourcePath)), + describeDropWorkspaceIfInactive(args.worktreeId, args.worktreePath) ) } catch (err) { // Why: only the error path tears the panel down immediately. On success it // owns its own exit, holding long enough to show how the drop ended. - endRuntimeUploadSession(sessionId) - if (pending !== null) { - toast.dismiss(pending) - } - toast.error(extractIpcErrorMessage(err, 'Failed to upload files.')) + closePanel() + toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'), { + description: describeDropWorkspaceIfInactive(args.worktreeId, args.worktreePath) + }) } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 609d0b778644..d3451b70ff4c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3369,7 +3369,8 @@ "uploading": "Uploading {{count}} files to runtime", "uploading_one": "Uploading {{count}} file to runtime", "uploading_other": "Uploading {{count}} files to runtime" - } + }, + "droppedInto": "Dropped into {{workspace}}" } } }, diff --git a/src/renderer/src/lib/worktree-scoped-toast.test.ts b/src/renderer/src/lib/worktree-scoped-toast.test.ts new file mode 100644 index 000000000000..2fb1a6ddb80b --- /dev/null +++ b/src/renderer/src/lib/worktree-scoped-toast.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' + +const mocks = vi.hoisted(() => { + const listeners = new Set<(state: { activeWorktreeId: string | null }) => void>() + const state: { activeWorktreeId: string | null; unrelated: number } = { + activeWorktreeId: null, + unrelated: 0 + } + const store = { state, listeners } + return { store } +}) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mocks.store.state, + subscribe: (listener: (state: { activeWorktreeId: string | null }) => void) => { + mocks.store.listeners.add(listener) + return () => mocks.store.listeners.delete(listener) + } + } +})) + +vi.mock('sonner', () => ({ toast: { dismiss: vi.fn() } })) + +import { openWorktreeScopedToast } from './worktree-scoped-toast' + +function setStoreState(patch: Partial): void { + mocks.store.state = { ...mocks.store.state, ...patch } + for (const listener of mocks.store.listeners) { + listener(mocks.store.state) + } +} + +function openScopedToast(worktreeId = 'wt-a') { + let nextId = 0 + const show = vi.fn((id: string | number | undefined) => id ?? `toast-${++nextId}`) + const onHidden = vi.fn() + const scoped = openWorktreeScopedToast({ worktreeId, show, onHidden }) + return { scoped, show, onHidden } +} + +describe('openWorktreeScopedToast', () => { + beforeEach(() => { + mocks.store.state = { activeWorktreeId: 'wt-a', unrelated: 0 } + mocks.store.listeners.clear() + vi.mocked(toast.dismiss).mockClear() + }) + + it('shows right away when its workspace is active', () => { + const { scoped, show } = openScopedToast() + + expect(show).toHaveBeenCalledWith(undefined) + expect(scoped.isShown()).toBe(true) + }) + + it('stays hidden when the upload targets another workspace', () => { + const { scoped, show } = openScopedToast('wt-b') + + expect(show).not.toHaveBeenCalled() + expect(scoped.isShown()).toBe(false) + }) + + it('hides on switching away and comes back under a fresh id', () => { + const { scoped, show, onHidden } = openScopedToast() + + setStoreState({ activeWorktreeId: 'wt-b' }) + expect(toast.dismiss).toHaveBeenCalledWith('toast-1') + expect(onHidden).toHaveBeenCalledTimes(1) + expect(scoped.isShown()).toBe(false) + + setStoreState({ activeWorktreeId: 'wt-a' }) + expect(show).toHaveBeenLastCalledWith(undefined) + expect(show).toHaveBeenCalledTimes(2) + expect(scoped.isShown()).toBe(true) + }) + + it('ignores store changes that keep the same workspace active', () => { + const { show, onHidden } = openScopedToast() + + setStoreState({ unrelated: 1 }) + + expect(show).toHaveBeenCalledTimes(1) + expect(onHidden).not.toHaveBeenCalled() + }) + + it('refreshes in place only while shown', () => { + const { scoped, show } = openScopedToast() + + scoped.refresh() + expect(show).toHaveBeenLastCalledWith('toast-1') + + setStoreState({ activeWorktreeId: 'wt-b' }) + scoped.refresh() + expect(show).toHaveBeenCalledTimes(2) + }) + + it('stops following workspace switches once closed', () => { + const { scoped, show } = openScopedToast() + + scoped.close() + expect(toast.dismiss).toHaveBeenCalledWith('toast-1') + + setStoreState({ activeWorktreeId: 'wt-b' }) + setStoreState({ activeWorktreeId: 'wt-a' }) + scoped.refresh() + + expect(show).toHaveBeenCalledTimes(1) + expect(scoped.isShown()).toBe(false) + expect(mocks.store.listeners.size).toBe(0) + }) +}) diff --git a/src/renderer/src/lib/worktree-scoped-toast.ts b/src/renderer/src/lib/worktree-scoped-toast.ts new file mode 100644 index 000000000000..91fef6ac5d5d --- /dev/null +++ b/src/renderer/src/lib/worktree-scoped-toast.ts @@ -0,0 +1,72 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' + +export type ToastId = string | number + +export type WorktreeScopedToast = { + /** Re-issues the toast while it is on screen, e.g. so sonner re-measures a resized body. */ + refresh: () => void + isShown: () => boolean + /** Removes the toast for good and stops following workspace switches. */ + close: () => void +} + +/** + * Shows a long-running toast only while `worktreeId` is the active workspace. + * Switching away dismisses it; switching back issues it again, so its body + * re-reads whatever progress it tracks. + */ +export function openWorktreeScopedToast(args: { + worktreeId: string + /** Issues the toast; `id` is set only when updating one already on screen. */ + show: (id: ToastId | undefined) => ToastId + /** Runs after the user switched away and the toast was dismissed. */ + onHidden?: () => void +}): WorktreeScopedToast { + let id: ToastId | undefined + let closed = false + let active = useAppStore.getState().activeWorktreeId === args.worktreeId + const show = (): void => { + id = args.show(id) + } + const dismiss = (): void => { + if (id !== undefined) { + toast.dismiss(id) + id = undefined + } + } + if (active) { + show() + } + const unsubscribe = useAppStore.subscribe((state) => { + const nextActive = state.activeWorktreeId === args.worktreeId + if (closed || nextActive === active) { + return + } + active = nextActive + if (nextActive) { + // Why: a fresh id, not the dismissed one — sonner may still be animating + // that one out, and reusing it would update the leaving toast instead. + show() + return + } + dismiss() + args.onHidden?.() + }) + return { + refresh: () => { + if (!closed && active) { + show() + } + }, + isShown: () => !closed && active, + close: () => { + if (closed) { + return + } + closed = true + unsubscribe() + dismiss() + } + } +} From 33753f67d2ab1144fb28e03002d2ab597f74fffa Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:20:26 +0200 Subject: [PATCH 16/20] fix(runtime): close drop upload gaps found in review - A late progress event from one file no longer counts toward the next file of the same source: main echoes a per-file sequence and the tracker drops events that do not carry the current one. - The same path dropped twice gets two upload ids, so each copy has its own bar and cancel instead of the second overwriting the first. - Cancelling a source made only of directories stops it before the next directory is created and removes the root it already made. - A finished empty file or folder reads 100% instead of 0%. - A drop where nothing landed reads "Upload failed" unless every row was cancelled, so a cancel no longer hides a failure. --- src/main/ipc/filesystem-mutations.ts | 3 +- src/main/ipc/runtime-upload-progress.ts | 1 + src/preload/api/filesystem-api.ts | 7 +- src/preload/api/fs-bridge.ts | 9 +- .../terminal-pane/TerminalDropUploadToast.tsx | 4 +- .../terminal-drop-handler.test.ts | 9 +- .../terminal-drop-upload-heading.test.ts | 11 ++ .../terminal-drop-upload-heading.ts | 3 +- .../terminal-drop-upload-progress.test.ts | 16 ++- .../terminal-drop-upload-progress.ts | 9 ++ .../terminal-runtime-drop-upload.ts | 3 + ...untime-file-client-external-import.test.ts | 110 ++++++++++++++++++ .../src/runtime/runtime-file-import-client.ts | 30 +++-- .../src/runtime/runtime-file-upload-client.ts | 4 +- .../runtime-upload-progress-tracker.test.ts | 67 +++++++---- .../runtime-upload-progress-tracker.ts | 19 ++- src/shared/runtime-upload-staging-contract.ts | 2 + 17 files changed, 259 insertions(+), 48 deletions(-) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index f5fe481e441e..9dee42f8566a 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -261,7 +261,8 @@ export function registerFilesystemMutationHandlers(store: Store): void { cancelSignal: cancellation?.signal, onProgress: emit && uploadId - ? ({ sentBytes, totalBytes }) => emit({ uploadId, sentBytes, totalBytes }) + ? ({ sentBytes, totalBytes }) => + emit({ uploadId, sentBytes, totalBytes, fileSequence: args.fileSequence }) : undefined }) } catch (error) { diff --git a/src/main/ipc/runtime-upload-progress.ts b/src/main/ipc/runtime-upload-progress.ts index 7b0346962e69..1a1112c0d16e 100644 --- a/src/main/ipc/runtime-upload-progress.ts +++ b/src/main/ipc/runtime-upload-progress.ts @@ -5,6 +5,7 @@ export type RuntimeUploadProgress = { uploadId: string sentBytes: number totalBytes: number + fileSequence?: number } export type RuntimeUploadProgressSink = (progress: RuntimeUploadProgress) => void diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index a486b2f92c0c..17898eca73fc 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -156,7 +156,12 @@ export type FilesystemApi = { args: RuntimeUploadFileStreamRequest ) => Promise<{ byteLength: number }> onUploadProgress: ( - callback: (progress: { uploadId: string; sentBytes: number; totalBytes: number }) => void + callback: (progress: { + uploadId: string + sentBytes: number + totalBytes: number + fileSequence?: number + }) => void ) => () => void cancelRuntimeUpload: (args: { uploadId: string }) => Promise releaseRuntimeUpload: (args: { uploadId: string }) => Promise diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 8b79819cf8f3..daced4c9eaba 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -167,11 +167,16 @@ export const fsApi = { args: RuntimeUploadFileStreamRequest ): Promise<{ byteLength: number }> => ipcRenderer.invoke('fs:uploadExternalFileToRuntime', args), onUploadProgress: ( - callback: (progress: { uploadId: string; sentBytes: number; totalBytes: number }) => void + callback: (progress: { + uploadId: string + sentBytes: number + totalBytes: number + fileSequence?: number + }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { uploadId: string; sentBytes: number; totalBytes: number } + data: { uploadId: string; sentBytes: number; totalBytes: number; fileSequence?: number } ) => callback(data) ipcRenderer.on('fs:uploadProgress', listener) return () => ipcRenderer.removeListener('fs:uploadProgress', listener) diff --git a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx b/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx index c99a21777a84..9a8f957d3342 100644 --- a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx @@ -10,7 +10,7 @@ import { toggleRuntimeUploadCollapsed, type RuntimeUploadRow } from '@/runtime/runtime-upload-session-state' -import { formatTransferredOfTotal, toPercent } from './terminal-drop-upload-progress' +import { formatTransferredOfTotal, toRowPercent } from './terminal-drop-upload-progress' import { formatTerminalDropUploadHeading } from './terminal-drop-upload-heading' // Long enough to read the outcome, short enough not to linger over the terminal. @@ -135,7 +135,7 @@ function UploadRowItem({ row: RuntimeUploadRow onCancel: (uploadId: string) => void }): React.JSX.Element { - const percent = toPercent(row.sentBytes, row.totalBytes) + const percent = toRowPercent(row) const inactive = row.status !== 'uploading' return ( 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 05d3c2b05f5a..a0972a0bad31 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 @@ -216,7 +216,8 @@ describe('handleTerminalFileDrop', () => { onStart: expect.any(Function), onRowProgress: expect.any(Function), onRowSettled: expect.any(Function), - onFinish: expect.any(Function) + onFinish: expect.any(Function), + isCancelled: expect.any(Function) } } ) @@ -377,7 +378,8 @@ describe('handleTerminalFileDrop', () => { onStart: expect.any(Function), onRowProgress: expect.any(Function), onRowSettled: expect.any(Function), - onFinish: expect.any(Function) + onFinish: expect.any(Function), + isCancelled: expect.any(Function) } } ) @@ -443,7 +445,8 @@ describe('handleTerminalFileDrop', () => { onStart: expect.any(Function), onRowProgress: expect.any(Function), onRowSettled: expect.any(Function), - onFinish: expect.any(Function) + onFinish: expect.any(Function), + isCancelled: expect.any(Function) } } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts index a437567251c1..09e779578de5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.test.ts @@ -24,6 +24,17 @@ describe('formatTerminalDropUploadHeading', () => { ).toBe('Upload cancelled') }) + it('does not let a cancel hide a failure when nothing landed', () => { + expect( + formatTerminalDropUploadHeading({ + rowCount: 2, + settled: true, + doneCount: 0, + cancelledCount: 1 + }) + ).toBe('Upload failed') + }) + it('reports a partial drop by count', () => { expect( formatTerminalDropUploadHeading({ diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts index 3c3ce5194ee2..671104b35e1e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts @@ -21,7 +21,8 @@ export function formatTerminalDropUploadHeading({ { count: rowCount } ) } - if (doneCount === 0 && cancelledCount > 0) { + // Why: only an all-cancelled drop reads as cancelled; a failure among the cancels must show. + if (doneCount === 0 && cancelledCount > 0 && cancelledCount === rowCount) { return translate( 'auto.components.terminal.pane.terminal.drop.upload.heading.cancelled', 'Upload cancelled' diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts index 70304180dd89..030081fe4b1e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { formatTransferredOfTotal, toPercent } from './terminal-drop-upload-progress' +import { formatTransferredOfTotal, toPercent, toRowPercent } from './terminal-drop-upload-progress' const MB = 1024 * 1024 @@ -30,6 +30,20 @@ describe('formatTransferredOfTotal', () => { }) }) +describe('toRowPercent', () => { + it('reads 100 for a finished row with nothing to move', () => { + expect(toRowPercent({ status: 'done', sentBytes: 0, totalBytes: 0 })).toBe(100) + }) + + it('stays at 0 for an empty row still in flight', () => { + expect(toRowPercent({ status: 'uploading', sentBytes: 0, totalBytes: 0 })).toBe(0) + }) + + it('follows the bytes for a row with content', () => { + expect(toRowPercent({ status: 'uploading', sentBytes: 25, totalBytes: 100 })).toBe(25) + }) +}) + describe('toPercent', () => { it('floors so it only reads 100 when everything landed', () => { expect(toPercent(999_999, 1_000_000)).toBe(99) diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts index f8d6991f048f..e3959aefb8e8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-upload-progress.ts @@ -1,3 +1,5 @@ +import type { RuntimeUploadRow } from '@/runtime/runtime-upload-session-state' + const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const /** Both figures share the total's unit; scaling each alone renders "900 / 32.5". */ @@ -17,6 +19,13 @@ export function formatTransferredOfTotal(sentBytes: number, totalBytes: number): return `${scaledSent.toFixed(precision)} / ${scaledTotal.toFixed(precision)} ${BYTE_UNITS[unitIndex]}` } +/** A finished row reads 100% even with no bytes to move (an empty file or folder). */ +export function toRowPercent( + row: Pick +): number { + return row.status === 'done' ? 100 : toPercent(row.sentBytes, row.totalBytes) +} + export function toPercent(sentBytes: number, totalBytes: number): number { if (totalBytes <= 0) { return 0 diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts index cec7a32cd915..2c1fa327aa54 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts @@ -34,6 +34,7 @@ export async function uploadRuntimeDropPaths( const destinationDir = joinRuntimeTerminalDropDir(args.worktreePath) const sessionId = createBrowserUuid() const cancelledSourcePaths = new Set() + const cancelledUploadIds = new Set() const sourcePathsByUploadId = new Map() let panel: WorktreeScopedToast | null = null const closePanel = (): void => { @@ -45,6 +46,7 @@ export async function uploadRuntimeDropPaths( if (sourcePath) { cancelledSourcePaths.add(sourcePath) } + cancelledUploadIds.add(uploadId) updateRuntimeUploadRow(sessionId, uploadId, { status: 'cancelled' }) void window.api.fs.cancelRuntimeUpload({ uploadId }) } @@ -120,6 +122,7 @@ export async function uploadRuntimeDropPaths( updateRuntimeUploadRow(sessionId, uploadId, { sentBytes }), onRowSettled: (uploadId, status) => updateRuntimeUploadRow(sessionId, uploadId, { status }), + isCancelled: (uploadId) => cancelledUploadIds.has(uploadId), onFinish: () => { settleRuntimeUploadSession(sessionId) // Why: no "done" replay on return; failures still get their own toast. 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 3f1038a8a30b..43927048ddfb 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 @@ -50,6 +50,8 @@ type UploadRequest = { expected: Record worktree: string relativePath: string + uploadId?: string + fileSequence?: number expectedExecutionHostId?: string expectedSshTargetId?: string expectedSshConnectionGeneration?: number @@ -296,6 +298,114 @@ describe('runtime file client', () => { expect(fsReleaseRuntimeUpload).toHaveBeenCalledWith({ uploadId }) }) + it('gives each copy of a path dropped twice its own row and file sequence', async () => { + const logo = { + sourcePath: '/Users/me/logo.png', + status: 'staged', + name: 'logo.png', + kind: 'file', + entries: [stagedFile('', 3, 55)] + } + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [logo, logo] }) + runtimeEnvironmentCall + .mockResolvedValueOnce(notFoundResponse('stat-destination-miss')) + .mockResolvedValueOnce(okResponse('create-destination-dir')) + .mockResolvedValueOnce(notFoundResponse('stat-miss')) + .mockResolvedValueOnce(okResponse('commit-upload')) + .mockResolvedValueOnce(okResponse('delete-temp')) + // The copy first finds `logo.png` reserved by the first, then takes `logo copy.png`. + .mockResolvedValueOnce(notFoundResponse('stat-name-miss')) + .mockResolvedValueOnce(notFoundResponse('stat-copy-miss')) + .mockResolvedValueOnce(okResponse('commit-copy')) + .mockResolvedValueOnce(okResponse('delete-copy-temp')) + const progress = { + onStart: vi.fn(), + onRowProgress: vi.fn(), + onRowSettled: vi.fn(), + onFinish: vi.fn() + } + + await importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/logo.png', '/Users/me/logo.png'], + '/remote/repo/uploads', + { progress } + ) + + const rowIds = progress.onStart.mock.calls[0]?.[0].map((row) => row.uploadId) + expect(new Set(rowIds).size).toBe(2) + expect(uploadRequests().map((request) => request.uploadId)).toEqual(rowIds) + expect(uploadRequests().map((request) => request.fileSequence)).toEqual([0, 1]) + expect(progress.onRowSettled.mock.calls).toEqual([ + [rowIds?.[0], 'done'], + [rowIds?.[1], 'done'] + ]) + }) + + it('stops a cancelled source of empty directories and removes its root', async () => { + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/empty', + status: 'staged', + name: 'empty', + kind: 'directory', + entries: [ + { relativePath: '', kind: 'directory' }, + { relativePath: 'nested', kind: 'directory' } + ] + } + ] + }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'stat-destination', + ok: true, + result: { size: 0, isDirectory: true, mtime: 1 }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce(notFoundResponse('stat-import-root-miss')) + .mockResolvedValueOnce(okResponse('create-import-root')) + .mockResolvedValueOnce(okResponse('delete-import-root')) + // The user cancels after the root exists, before the nested directory. + const isCancelled = vi.fn<(uploadId: string) => boolean>() + isCancelled.mockReturnValueOnce(false).mockReturnValue(true) + + await expect( + importExternalPathsToRuntime( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/empty'], + '/remote/repo/uploads', + { + progress: { + onStart: vi.fn(), + onRowProgress: vi.fn(), + onRowSettled: vi.fn(), + onFinish: vi.fn(), + isCancelled + } + } + ) + ).resolves.toMatchObject({ results: [{ status: 'failed', reason: 'Upload cancelled' }] }) + + const methods = runtimeEnvironmentCall.mock.calls.map(([call]) => call.method) + expect(methods.filter((method) => method === 'files.createDirNoClobber')).toHaveLength(1) + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: 'files.delete', + params: expect.objectContaining({ relativePath: 'uploads/empty', recursive: true }) + }) + ) + }) + it('does not commit an upload when the owner generation changes while it streams', async () => { fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ sources: [ diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 9765ea08d203..6535b5fcdfa7 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -86,16 +86,18 @@ export async function importExternalPathsToRuntime( const handlers = options?.progress // One id per dropped source: it is both the progress key and the cancel handle, // so cancelling a row stops that source and leaves the rest of the drop running. - const uploadIdsBySourcePath = new Map() + // Why: keyed by position, not path — the same path dropped twice stages twice + // and each copy needs its own bar and cancel. + const uploadIdsBySourceIndex = new Map() const trackers = new Map() if (handlers) { const rows: RuntimeImportProgressRow[] = [] - for (const source of staged.sources) { + for (const [sourceIndex, source] of staged.sources.entries()) { if (source.status !== 'staged') { continue } const rowUploadId = createBrowserUuid() - uploadIdsBySourcePath.set(source.sourcePath, rowUploadId) + uploadIdsBySourceIndex.set(sourceIndex, rowUploadId) const totalBytes = sumSourceUploadBytes(source) trackers.set( rowUploadId, @@ -115,22 +117,23 @@ export async function importExternalPathsToRuntime( const unsubscribeProgress = handlers ? window.api.fs.onUploadProgress((event) => { // Why: a concurrent drop in another pane shares this channel. - trackers.get(event.uploadId)?.reportFileProgress(event.sentBytes) + trackers.get(event.uploadId)?.reportFileProgress(event.sentBytes, event.fileSequence) }) : null const results: ImportItemResult[] = [] const reservedNames = new Set() + let nextFileSequence = 0 try { await ensureRuntimeDirectory(context, destinationDir, importSession) - for (const source of staged.sources) { + for (const [sourceIndex, source] of staged.sources.entries()) { if (source.status !== 'staged') { results.push(source) continue } let createdDirectoryImportRoot: string | null = null - const sourceUploadId = uploadIdsBySourcePath.get(source.sourcePath) + const sourceUploadId = uploadIdsBySourceIndex.get(sourceIndex) try { const finalName = await deconflictRuntimeImportName( context, @@ -144,6 +147,11 @@ export async function importExternalPathsToRuntime( for (const entry of source.entries) { const entryRelativePath = joinRuntimeRelativePath(destRelativePath, entry.relativePath) if (entry.kind === 'directory') { + // Why: only file streams reach main's cancel registry, so a source made + // of empty directories would otherwise finish despite the cancel. + if (sourceUploadId && handlers?.isCancelled?.(sourceUploadId)) { + throw new Error('Upload cancelled') + } await callRuntimeFileImportMutation( importSession, 'files.createDirNoClobber', @@ -158,8 +166,9 @@ export async function importExternalPathsToRuntime( } continue } - if (sourceUploadId) { - trackers.get(sourceUploadId)?.beginFile() + const fileSequence = sourceUploadId ? nextFileSequence++ : undefined + if (sourceUploadId && fileSequence !== undefined) { + trackers.get(sourceUploadId)?.beginFile(fileSequence) } await uploadRuntimeFileWithoutClobber( importSession, @@ -181,7 +190,8 @@ export async function importExternalPathsToRuntime( (context.expectedSshTargetId ? `ssh:${encodeURIComponent(context.expectedSshTargetId)}` : 'local'), - sourceUploadId + sourceUploadId, + fileSequence ) if (sourceUploadId) { trackers.get(sourceUploadId)?.completeFile(entry.byteLength) @@ -229,7 +239,7 @@ export async function importExternalPathsToRuntime( return { results } } finally { unsubscribeProgress?.() - for (const releasedId of uploadIdsBySourcePath.values()) { + for (const releasedId of uploadIdsBySourceIndex.values()) { void window.api.fs.releaseRuntimeUpload({ uploadId: releasedId }).catch(() => {}) } handlers?.onFinish() diff --git a/src/renderer/src/runtime/runtime-file-upload-client.ts b/src/renderer/src/runtime/runtime-file-upload-client.ts index 94634a2c0b55..74e45916a9e8 100644 --- a/src/renderer/src/runtime/runtime-file-upload-client.ts +++ b/src/renderer/src/runtime/runtime-file-upload-client.ts @@ -31,7 +31,8 @@ export async function uploadRuntimeFileWithoutClobber( expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, expectedExecutionHostId?: 'local' | `ssh:${string}`, - uploadId?: string + uploadId?: string, + fileSequence?: number ): Promise { const tempRelativePath = makeRuntimeUploadTempPath(relativePath) try { @@ -47,6 +48,7 @@ export async function uploadRuntimeFileWithoutClobber( worktree: toRuntimeWorktreeSelector(worktreeId), relativePath: tempRelativePath, uploadId, + fileSequence, expectedSshTargetId, expectedSshConnectionGeneration, expectedExecutionHostId, diff --git a/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts index 1798514d8be8..51acc56bd5b4 100644 --- a/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts @@ -32,12 +32,12 @@ describe('createRuntimeUploadProgressTracker', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(300, (p) => seen.push(p.sentBytes)) - tracker.beginFile() - tracker.reportFileProgress(50) - tracker.reportFileProgress(100) + tracker.beginFile(0) + tracker.reportFileProgress(50, 0) + tracker.reportFileProgress(100, 0) tracker.completeFile(100) - tracker.beginFile() - tracker.reportFileProgress(75) + tracker.beginFile(1) + tracker.reportFileProgress(75, 1) expect(seen).toEqual([50, 100, 175]) }) @@ -46,12 +46,12 @@ describe('createRuntimeUploadProgressTracker', () => { const totals: number[] = [] const tracker = createRuntimeUploadProgressTracker(500, (p) => totals.push(p.totalBytes)) - tracker.beginFile() - tracker.reportFileProgress(10) + tracker.beginFile(0) + tracker.reportFileProgress(10, 0) // completeFile does not re-emit: the figure has not moved. tracker.completeFile(10) - tracker.beginFile() - tracker.reportFileProgress(20) + tracker.beginFile(1) + tracker.reportFileProgress(20, 1) expect(totals).toEqual([500, 500]) }) @@ -60,9 +60,9 @@ describe('createRuntimeUploadProgressTracker', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) - tracker.beginFile() - tracker.reportFileProgress(40) - tracker.reportFileProgress(40) + tracker.beginFile(0) + tracker.reportFileProgress(40, 0) + tracker.reportFileProgress(40, 0) tracker.completeFile(40) expect(seen).toEqual([40]) @@ -72,8 +72,8 @@ describe('createRuntimeUploadProgressTracker', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) - tracker.beginFile() - tracker.reportFileProgress(250) + tracker.beginFile(0) + tracker.reportFileProgress(250, 0) expect(seen).toEqual([100]) }) @@ -82,9 +82,9 @@ describe('createRuntimeUploadProgressTracker', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(30, (p) => seen.push(p.sentBytes)) - for (const size of [10, 10, 10]) { - tracker.beginFile() - tracker.reportFileProgress(size) + for (const [sequence, size] of [10, 10, 10].entries()) { + tracker.beginFile(sequence) + tracker.reportFileProgress(size, sequence) tracker.completeFile(size) } @@ -95,21 +95,46 @@ describe('createRuntimeUploadProgressTracker', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) - tracker.beginFile() - tracker.reportFileProgress(40) + tracker.beginFile(0) + tracker.reportFileProgress(40, 0) tracker.completeFile(40) // Electron does not order webContents.send against the invoke reply, so the // last chunk's event can arrive here; counting it would double the file. - tracker.reportFileProgress(40) + tracker.reportFileProgress(40, 0) expect(seen).toEqual([40]) }) + it('ignores a late event from the previous file once the next file began', () => { + const seen: number[] = [] + const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + + tracker.beginFile(0) + tracker.reportFileProgress(40, 0) + tracker.completeFile(40) + tracker.beginFile(1) + // File 0's last event, delivered after file 1 opened the gate. + tracker.reportFileProgress(40, 0) + tracker.reportFileProgress(5, 1) + + expect(seen).toEqual([40, 45]) + }) + + it('ignores an event that carries no file sequence', () => { + const seen: number[] = [] + const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) + + tracker.beginFile(0) + tracker.reportFileProgress(40, undefined) + + expect(seen).toEqual([]) + }) + it('emits nothing before the first file begins', () => { const seen: number[] = [] const tracker = createRuntimeUploadProgressTracker(100, (p) => seen.push(p.sentBytes)) - tracker.reportFileProgress(50) + tracker.reportFileProgress(50, 0) expect(seen).toEqual([]) }) diff --git a/src/renderer/src/runtime/runtime-upload-progress-tracker.ts b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts index f474cdb3e341..06afb65e8f20 100644 --- a/src/renderer/src/runtime/runtime-upload-progress-tracker.ts +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts @@ -2,9 +2,9 @@ export type RuntimeUploadProgressReport = { sentBytes: number; totalBytes: numbe export type RuntimeUploadProgressTracker = { /** Opens the window in which progress for the next file is accepted. */ - beginFile: () => void - /** Bytes of the file currently in flight that have reached the runtime. */ - reportFileProgress: (sentBytes: number) => void + beginFile: (fileSequence: number) => void + /** Bytes of the file in flight; an event without this file's sequence is dropped. */ + reportFileProgress: (sentBytes: number, fileSequence: number | undefined) => void /** Called once a file is committed, so its bytes move from in-flight to done. */ completeFile: (byteLength: number) => void } @@ -20,6 +20,9 @@ export function createRuntimeUploadProgressTracker( // Electron does not order webContents.send against an invoke reply, so a final // progress event can land after completeFile and be counted twice. let acceptingProgress = false + // Why: a late event from the previous file can also land after the next file's + // beginFile; the sequence main echoes back tells the two apart. + let currentFileSequence: number | undefined const emit = (): void => { // Why: a source can grow between staging and upload, so the sum of what @@ -33,14 +36,18 @@ export function createRuntimeUploadProgressTracker( } return { - beginFile: () => { + beginFile: (fileSequence) => { acceptingProgress = true + currentFileSequence = fileSequence inFlightBytes = 0 }, - reportFileProgress: (sentBytes) => { + reportFileProgress: (sentBytes, fileSequence) => { if (!acceptingProgress) { return } + if (fileSequence === undefined || fileSequence !== currentFileSequence) { + return + } inFlightBytes = sentBytes emit() }, @@ -66,6 +73,8 @@ export type RuntimeImportProgressHandlers = { onRowProgress: (uploadId: string, sentBytes: number) => void onRowSettled: (uploadId: string, status: 'done' | 'failed') => void onFinish: () => void + /** Lets the import stop a source the user cancelled before it streams any file. */ + isCancelled?: (uploadId: string) => boolean } /** Bytes one dropped source will move; directory entries contribute nothing. */ diff --git a/src/shared/runtime-upload-staging-contract.ts b/src/shared/runtime-upload-staging-contract.ts index 2df119c65e9d..b965cf46ec73 100644 --- a/src/shared/runtime-upload-staging-contract.ts +++ b/src/shared/runtime-upload-staging-contract.ts @@ -47,6 +47,8 @@ export type RuntimeUploadFileStreamRequest = { relativePath: string /** Progress key and cancel handle; every file of one dropped source shares it. */ uploadId?: string + /** Echoed on this file's progress events so a late one cannot count toward the next file. */ + fileSequence?: number expectedEnvironmentPairingRevision?: number expectedEnvironmentRuntimeId?: string } & SshMutationExpectation From b10aea3964ecb7255244124575ed3e975373ba11 Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:46:14 +0200 Subject: [PATCH 17/20] fix(runtime): report a failed copy even when another copy was cancelled Cancelled sources were filtered out of the failure notice by path, so when the same path was dropped twice and one copy was cancelled, the other copy's failure was hidden too. That notice is the only signal once the panel is hidden in another workspace. The import now marks the cancelled source's own result, and the drop flow filters on that instead of the shared path. --- .../terminal-runtime-drop-upload.test.ts | 73 ++++++++++++++++--- .../terminal-runtime-drop-upload.ts | 11 +-- ...untime-file-client-external-import.test.ts | 4 +- .../src/runtime/runtime-file-import-client.ts | 4 +- src/shared/filesystem-import-result-types.ts | 2 + 5 files changed, 71 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts index 11f8fa6c1b6c..b6a8732f2b91 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts @@ -1,3 +1,4 @@ +import { isValidElement } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' import type { RuntimeImportProgressHandlers } from '@/runtime/runtime-upload-progress-tracker' @@ -17,8 +18,10 @@ const mocks = vi.hoisted(() => { 'repo-a': [{ id: 'wt-a', displayName: 'ux-polish', branch: '', path: '/srv/a' }] } } + const panel: { element: unknown } = { element: null } return { store: { state, listeners }, + panel, runImport: vi.fn() } }) @@ -34,13 +37,19 @@ vi.mock('@/store', () => ({ })) vi.mock('sonner', () => ({ toast: { - custom: vi.fn((_render: unknown, options?: { id?: string | number }) => options?.id ?? 'panel'), + custom: vi.fn((render: () => unknown, options?: { id?: string | number }) => { + mocks.panel.element = render() + return options?.id ?? 'panel' + }), dismiss: vi.fn(), error: vi.fn(), message: vi.fn() } })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values?.[name] ?? '')) +})) vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'session-1' })) vi.mock('@/runtime/runtime-file-client', () => ({ importExternalPathsToRuntime: ( @@ -80,6 +89,27 @@ const fakePaneHandles = { dropTarget: {}, manager: {}, paneTransports: new Map() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: pasteResolvedDropPaths is mocked, so these pane handles are passed through and never read. const paneHandles = fakePaneHandles as unknown as PaneHandles +function dropArgs(dataPaths: string[]): Parameters[0] { + return { + ...paneHandles, + dataPaths, + tabId: 'tab-1', + worktreePath: '/srv/a', + runtimeEnvironmentId: 'env-1', + settings: null, + worktreeId: 'wt-a' + } +} + +/** Presses a row's cancel button on the panel that is currently shown. */ +function cancelPanelRow(uploadId: string): void { + const panel = mocks.panel.element + if (!isValidElement<{ onCancel: (uploadId: string) => void }>(panel)) { + throw new Error('no upload panel is shown') + } + panel.props.onCancel(uploadId) +} + /** Runs one single-file drop; `during` fires after the panel opened, before the upload ends. */ function dropOneFile(during: () => void = () => {}): Promise { mocks.runImport.mockImplementation(async (progress: RuntimeImportProgressHandlers) => { @@ -89,15 +119,7 @@ function dropOneFile(during: () => void = () => {}): Promise { progress.onFinish() return { results: [imported] } }) - return uploadRuntimeDropPaths({ - ...paneHandles, - dataPaths: ['/local/clip.mp4'], - tabId: 'tab-1', - worktreePath: '/srv/a', - runtimeEnvironmentId: 'env-1', - settings: null, - worktreeId: 'wt-a' - }) + return uploadRuntimeDropPaths(dropArgs(['/local/clip.mp4'])) } describe('uploadRuntimeDropPaths panel scope', () => { @@ -106,6 +128,7 @@ describe('uploadRuntimeDropPaths panel scope', () => { mocks.store.listeners.clear() vi.mocked(toast.custom).mockClear() vi.mocked(toast.dismiss).mockClear() + vi.mocked(toast.error).mockClear() }) it('keeps the panel off a workspace the user is not looking at', async () => { @@ -143,4 +166,32 @@ describe('uploadRuntimeDropPaths panel scope', () => { expect(getRuntimeUploadSession('session-1')).toBeUndefined() expect(mocks.store.listeners.size).toBe(0) }) + + it('still reports a failed copy when another copy of the same path was cancelled', async () => { + vi.stubGlobal('window', { api: { fs: { cancelRuntimeUpload: vi.fn(async () => {}) } } }) + const copies = [ + { ...row, uploadId: 'up-1' }, + { ...row, uploadId: 'up-2' } + ] + mocks.runImport.mockImplementation(async (progress: RuntimeImportProgressHandlers) => { + progress.onStart(copies) + cancelPanelRow('up-1') + progress.onFinish() + // Mirrors the import client, which marks only the cancelled copy. + const results: ImportItemResult[] = copies.map(({ uploadId }) => ({ + sourcePath: row.sourcePath, + status: 'failed', + reason: 'disk full', + ...(progress.isCancelled?.(uploadId) ? { cancelled: true } : {}) + })) + return { results } + }) + + await uploadRuntimeDropPaths(dropArgs([row.sourcePath, row.sourcePath])) + + expect(toast.error).toHaveBeenCalledWith('Failed to upload 1 file.', { + description: undefined + }) + vi.unstubAllGlobals() + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts index 2c1fa327aa54..6469df4d78f3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts @@ -33,19 +33,13 @@ export async function uploadRuntimeDropPaths( const targetShell = getTerminalTargetShellForWorktreePath(args.worktreePath) const destinationDir = joinRuntimeTerminalDropDir(args.worktreePath) const sessionId = createBrowserUuid() - const cancelledSourcePaths = new Set() const cancelledUploadIds = new Set() - const sourcePathsByUploadId = new Map() let panel: WorktreeScopedToast | null = null const closePanel = (): void => { panel?.close() endRuntimeUploadSession(sessionId) } const cancelRow = (uploadId: string): void => { - const sourcePath = sourcePathsByUploadId.get(uploadId) - if (sourcePath) { - cancelledSourcePaths.add(sourcePath) - } cancelledUploadIds.add(uploadId) updateRuntimeUploadRow(sessionId, uploadId, { status: 'cancelled' }) void window.api.fs.cancelRuntimeUpload({ uploadId }) @@ -72,9 +66,6 @@ export async function uploadRuntimeDropPaths( if (rows.length === 0) { return } - for (const row of rows) { - sourcePathsByUploadId.set(row.uploadId, row.sourcePath) - } startRuntimeUploadSession( sessionId, rows.map((row) => ({ @@ -145,7 +136,7 @@ export async function uploadRuntimeDropPaths( // Why: a cancel is the user's own decision, not a failure to report back. results .filter((result) => result.status === 'failed') - .filter((result) => !cancelledSourcePaths.has(result.sourcePath)), + .filter((result) => result.cancelled !== true), describeDropWorkspaceIfInactive(args.worktreeId, args.worktreePath) ) } catch (err) { 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 43927048ddfb..538c2544b656 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 @@ -394,7 +394,9 @@ describe('runtime file client', () => { } } ) - ).resolves.toMatchObject({ results: [{ status: 'failed', reason: 'Upload cancelled' }] }) + ).resolves.toMatchObject({ + results: [{ status: 'failed', reason: 'Upload cancelled', cancelled: true }] + }) const methods = runtimeEnvironmentCall.mock.calls.map(([call]) => call.method) expect(methods.filter((method) => method === 'files.createDirNoClobber')).toHaveLength(1) diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 6535b5fcdfa7..88d534ad5edc 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -226,7 +226,9 @@ export async function importExternalPathsToRuntime( results.push({ sourcePath: source.sourcePath, status: 'failed', - reason: error instanceof Error ? error.message : String(error) + reason: error instanceof Error ? error.message : String(error), + // Why: marked per source, since a path dropped twice can be cancelled once. + ...(sourceUploadId && handlers?.isCancelled?.(sourceUploadId) ? { cancelled: true } : {}) }) // Why: reported as failed even for a cancel — the store keeps the row's // already-set 'cancelled' state, so the user's own action is not relabelled. diff --git a/src/shared/filesystem-import-result-types.ts b/src/shared/filesystem-import-result-types.ts index 206570e9a33e..f8e32073d83b 100644 --- a/src/shared/filesystem-import-result-types.ts +++ b/src/shared/filesystem-import-result-types.ts @@ -28,6 +28,8 @@ export type ImportItemResult = sourcePath: string status: 'failed' reason: string + /** The user cancelled this source, so it is not a failure to report back. */ + cancelled?: boolean } // Why: staging crosses IPC to the renderer and back into the streamer, so the From 363dea098bbf5ddb34265b5b51a9ab712a78c643 Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:33:53 +0200 Subject: [PATCH 18/20] fix(runtime): harden upload cancellation ownership --- ...ilesystem-mutations-runtime-upload.test.ts | 37 ++++++++- src/main/ipc/filesystem-mutations.ts | 23 ++--- .../ipc/runtime-upload-cancel-ipc.test.ts | 83 +++++++++++++++++++ src/main/ipc/runtime-upload-cancel-ipc.ts | 58 +++++++++++++ .../ipc/runtime-upload-cancellation.test.ts | 43 ++++++++-- src/main/ipc/runtime-upload-cancellation.ts | 21 ++++- src/main/ipc/runtime-upload-progress.test.ts | 12 +++ src/main/ipc/runtime-upload-progress.ts | 3 +- 8 files changed, 253 insertions(+), 27 deletions(-) create mode 100644 src/main/ipc/runtime-upload-cancel-ipc.test.ts create mode 100644 src/main/ipc/runtime-upload-cancel-ipc.ts diff --git a/src/main/ipc/filesystem-mutations-runtime-upload.test.ts b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts index fa7edc1d5b7f..082e27b05a73 100644 --- a/src/main/ipc/filesystem-mutations-runtime-upload.test.ts +++ b/src/main/ipc/filesystem-mutations-runtime-upload.test.ts @@ -38,8 +38,18 @@ const request = { expectedEnvironmentRuntimeId: 'rt-1' } -function fakeSender(): EventEmitter { - return new EventEmitter() +let nextSenderId = 0 + +function fakeSender(): EventEmitter & { + id: number + isDestroyed: () => boolean + send: ReturnType +} { + return Object.assign(new EventEmitter(), { + id: ++nextSenderId, + isDestroyed: () => false, + send: vi.fn() + }) } function listenerCount(sender: EventEmitter): number { @@ -147,6 +157,29 @@ describe('fs:uploadExternalFileToRuntime', () => { expect(sweepMock).toHaveBeenCalledTimes(1) }) + it("does not let another renderer cancel this renderer's upload id", async () => { + const owner = fakeSender() + const other = fakeSender() + streamMock.mockImplementation(async ({ cancelSignal }: { cancelSignal: AbortSignal }) => { + await handlers.get('fs:cancelRuntimeUpload')!({ sender: other }, { uploadId: 'same-id' }) + expect(cancelSignal.aborted).toBe(false) + + await handlers.get('fs:cancelRuntimeUpload')!({ sender: owner }, { uploadId: 'same-id' }) + expect(cancelSignal.aborted).toBe(true) + await handlers.get('fs:releaseRuntimeUpload')!({ sender: owner }, { uploadId: 'same-id' }) + return { byteLength: 0 } + }) + + await expect( + handlers.get('fs:uploadExternalFileToRuntime')!( + { sender: owner }, + { ...request, uploadId: 'same-id' } + ) + ).resolves.toEqual({ byteLength: 0 }) + owner.emit('destroyed') + other.emit('destroyed') + }) + it('does not sweep when the stream fails while the renderer is still alive', async () => { const sender = fakeSender() streamMock.mockRejectedValue(new Error("File changed since it was staged: 'file.bin'")) diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts index 9dee42f8566a..2bddec769280 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -29,11 +29,8 @@ import { RUNTIME_UPLOAD_PROGRESS_CHANNEL, throttleRuntimeUploadProgress } from './runtime-upload-progress' -import { - cancelRuntimeUpload, - forgetRuntimeUploadCancellation, - registerCancellableUpload -} from './runtime-upload-cancellation' +import { registerRuntimeUploadCancelHandlers } from './runtime-upload-cancel-ipc' +import { registerCancellableUpload, scopeRuntimeUploadId } from './runtime-upload-cancellation' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' import { resolveEnvironment } from '../../shared/runtime-environment-store' @@ -252,7 +249,9 @@ export function registerFilesystemMutationHandlers(store: Store): void { } }) : null - const cancellation = uploadId ? registerCancellableUpload(uploadId) : null + const cancellation = uploadId + ? registerCancellableUpload(scopeRuntimeUploadId(event.sender.id, uploadId)) + : null try { return await streamExternalFileToRuntime({ ...request, @@ -279,17 +278,7 @@ export function registerFilesystemMutationHandlers(store: Store): void { } ) - // Why: the drop UI cancels a whole dropped source, so the id it sends is the - // one every file of that source streams under. - ipcMain.handle('fs:cancelRuntimeUpload', (_event, args: { uploadId: string }): void => { - cancelRuntimeUpload(args.uploadId) - }) - - // Why: ids are minted per drop, but a cancel recorded for one must not outlive - // it and abort a later upload that happens to reuse the id. - ipcMain.handle('fs:releaseRuntimeUpload', (_event, args: { uploadId: string }): void => { - forgetRuntimeUploadCancellation(args.uploadId) - }) + registerRuntimeUploadCancelHandlers() // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees diff --git a/src/main/ipc/runtime-upload-cancel-ipc.test.ts b/src/main/ipc/runtime-upload-cancel-ipc.test.ts new file mode 100644 index 000000000000..f7ffd7899736 --- /dev/null +++ b/src/main/ipc/runtime-upload-cancel-ipc.test.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const handlers = new Map< + string, + (event: { sender: EventEmitter & { id: number } }, args: unknown) => unknown +>() + +vi.mock('electron', () => ({ + ipcMain: { + handle: ( + channel: string, + handler: (event: { sender: EventEmitter & { id: number } }, args: unknown) => unknown + ) => handlers.set(channel, handler) + } +})) + +import { registerRuntimeUploadCancelHandlers } from './runtime-upload-cancel-ipc' +import { + forgetRuntimeUploadCancellation, + isUploadCancelled, + scopeRuntimeUploadId +} from './runtime-upload-cancellation' + +registerRuntimeUploadCancelHandlers() + +function renderer(id: number): EventEmitter & { id: number } { + return Object.assign(new EventEmitter(), { id }) +} + +function invoke(channel: string, sender: EventEmitter & { id: number }, args: unknown): unknown { + return handlers.get(channel)!({ sender }, args) +} + +afterEach(() => { + for (const senderId of [1, 2, 3]) { + for (const uploadId of ['u', 'a', 'b']) { + forgetRuntimeUploadCancellation(scopeRuntimeUploadId(senderId, uploadId)) + } + } +}) + +describe('runtime upload cancel IPC', () => { + it('records and releases a cancel only for the renderer that sent it', () => { + const first = renderer(1) + invoke('fs:cancelRuntimeUpload', first, { uploadId: 'u' }) + + expect(isUploadCancelled(scopeRuntimeUploadId(1, 'u'))).toBe(true) + expect(isUploadCancelled(scopeRuntimeUploadId(2, 'u'))).toBe(false) + + invoke('fs:releaseRuntimeUpload', first, { uploadId: 'u' }) + expect(isUploadCancelled(scopeRuntimeUploadId(1, 'u'))).toBe(false) + }) + + it('rejects malformed upload ids without changing cancellation state', () => { + const sender = renderer(1) + for (const args of [ + null, + undefined, + {}, + { uploadId: '' }, + { uploadId: ' ' }, + { uploadId: 3 } + ]) { + expect(() => invoke('fs:cancelRuntimeUpload', sender, args)).not.toThrow() + expect(() => invoke('fs:releaseRuntimeUpload', sender, args)).not.toThrow() + } + + expect(isUploadCancelled(scopeRuntimeUploadId(1, ''))).toBe(false) + expect(isUploadCancelled(scopeRuntimeUploadId(1, ' '))).toBe(false) + }) + + it('drops cancels when the renderer goes away and re-arms after reload', () => { + const sender = renderer(3) + invoke('fs:cancelRuntimeUpload', sender, { uploadId: 'a' }) + sender.emit('did-navigate') + expect(isUploadCancelled(scopeRuntimeUploadId(3, 'a'))).toBe(false) + + invoke('fs:cancelRuntimeUpload', sender, { uploadId: 'b' }) + sender.emit('render-process-gone') + expect(isUploadCancelled(scopeRuntimeUploadId(3, 'b'))).toBe(false) + }) +}) diff --git a/src/main/ipc/runtime-upload-cancel-ipc.ts b/src/main/ipc/runtime-upload-cancel-ipc.ts new file mode 100644 index 000000000000..1d8d442473d8 --- /dev/null +++ b/src/main/ipc/runtime-upload-cancel-ipc.ts @@ -0,0 +1,58 @@ +import { ipcMain, type WebContents } from 'electron' +import { abortWhenRendererGone } from './renderer-lifetime-abort' +import { + cancelRuntimeUpload, + forgetRuntimeUploadCancellation, + forgetRuntimeUploadCancellationsForSender, + scopeRuntimeUploadId +} from './runtime-upload-cancellation' + +type CancelSender = Pick + +const sendersWithCleanup = new WeakSet() + +function readUploadId(args: unknown): string | undefined { + if (!args || typeof args !== 'object' || !('uploadId' in args)) { + return undefined + } + return typeof args.uploadId === 'string' && args.uploadId.trim() !== '' + ? args.uploadId + : undefined +} + +/** A reloaded or gone renderer can no longer release its remembered cancels. */ +function forgetCancelsWhenRendererGoes(sender: CancelSender): void { + if (sendersWithCleanup.has(sender)) { + return + } + sendersWithCleanup.add(sender) + const senderId = sender.id + const lifetime = abortWhenRendererGone(sender) + lifetime.signal.addEventListener( + 'abort', + () => { + lifetime.dispose() + forgetRuntimeUploadCancellationsForSender(senderId) + // A reload reuses WebContents, so its next cancel must arm cleanup again. + sendersWithCleanup.delete(sender) + }, + { once: true } + ) +} + +export function registerRuntimeUploadCancelHandlers(): void { + ipcMain.handle('fs:cancelRuntimeUpload', (event, args: unknown): void => { + const uploadId = readUploadId(args) + if (uploadId) { + forgetCancelsWhenRendererGoes(event.sender) + cancelRuntimeUpload(scopeRuntimeUploadId(event.sender.id, uploadId)) + } + }) + + ipcMain.handle('fs:releaseRuntimeUpload', (event, args: unknown): void => { + const uploadId = readUploadId(args) + if (uploadId) { + forgetRuntimeUploadCancellation(scopeRuntimeUploadId(event.sender.id, uploadId)) + } + }) +} diff --git a/src/main/ipc/runtime-upload-cancellation.test.ts b/src/main/ipc/runtime-upload-cancellation.test.ts index 4972dbef6dce..79316ae2547f 100644 --- a/src/main/ipc/runtime-upload-cancellation.test.ts +++ b/src/main/ipc/runtime-upload-cancellation.test.ts @@ -2,10 +2,12 @@ import { afterEach, describe, expect, it } from 'vitest' import { cancelRuntimeUpload, forgetRuntimeUploadCancellation, + forgetRuntimeUploadCancellationsForSender, isRuntimeUploadCancelled, isUploadCancelled, registerCancellableUpload, - RuntimeUploadCancelledError + RuntimeUploadCancelledError, + scopeRuntimeUploadId } from './runtime-upload-cancellation' afterEach(() => { @@ -15,12 +17,13 @@ afterEach(() => { describe('runtime upload cancellation', () => { it('aborts an upload that is already running', () => { - const { signal } = registerCancellableUpload('u') - expect(signal.aborted).toBe(false) + const upload = registerCancellableUpload('u') + expect(upload.signal.aborted).toBe(false) cancelRuntimeUpload('u') - expect(signal.aborted).toBe(true) + expect(upload.signal.aborted).toBe(true) + upload.release() }) it('aborts the next file when cancel lands between two files of one drop', () => { @@ -31,6 +34,7 @@ describe('runtime upload cancellation', () => { const second = registerCancellableUpload('u') expect(second.signal.aborted).toBe(true) + second.release() }) it('leaves other uploads running', () => { @@ -41,14 +45,18 @@ describe('runtime upload cancellation', () => { expect(target.signal.aborted).toBe(true) expect(bystander.signal.aborted).toBe(false) + target.release() + bystander.release() }) it('stops applying a cancel once the drop is forgotten', () => { cancelRuntimeUpload('u') forgetRuntimeUploadCancellation('u') + const fresh = registerCancellableUpload('u') expect(isUploadCancelled('u')).toBe(false) - expect(registerCancellableUpload('u').signal.aborted).toBe(false) + expect(fresh.signal.aborted).toBe(false) + fresh.release() }) it('releasing a superseded registration does not evict the live one', () => { @@ -59,6 +67,31 @@ describe('runtime upload cancellation', () => { cancelRuntimeUpload('u') expect(live.signal.aborted).toBe(true) + live.release() + }) + + it('keeps windows apart even when they mint the same id', () => { + expect(scopeRuntimeUploadId(1, 'u')).not.toBe(scopeRuntimeUploadId(2, 'u')) + }) + + it("forgets a gone renderer's remembered cancels and only that renderer's", () => { + cancelRuntimeUpload(scopeRuntimeUploadId(1, 'u')) + cancelRuntimeUpload(scopeRuntimeUploadId(2, 'u')) + + forgetRuntimeUploadCancellationsForSender(1) + + expect(isUploadCancelled(scopeRuntimeUploadId(1, 'u'))).toBe(false) + expect(isUploadCancelled(scopeRuntimeUploadId(2, 'u'))).toBe(true) + forgetRuntimeUploadCancellation(scopeRuntimeUploadId(2, 'u')) + }) + + it('keeps a live upload aborted if its drop is released mid-flight', () => { + const live = registerCancellableUpload('u') + cancelRuntimeUpload('u') + forgetRuntimeUploadCancellation('u') + + expect(live.signal.aborted).toBe(true) + live.release() }) it('recognises its own error and nothing else', () => { diff --git a/src/main/ipc/runtime-upload-cancellation.ts b/src/main/ipc/runtime-upload-cancellation.ts index b49207a583c6..b9c91cc85e47 100644 --- a/src/main/ipc/runtime-upload-cancellation.ts +++ b/src/main/ipc/runtime-upload-cancellation.ts @@ -15,6 +15,11 @@ const controllers = new Map() // silently doing nothing. const cancelled = new Set() +/** Ids are renderer-minted, so one window must not reach another window's upload. */ +export function scopeRuntimeUploadId(senderId: number, uploadId: string): string { + return `${senderId}:${uploadId}` +} + /** Registers an upload so it can be cancelled; returns its release callback. */ export function registerCancellableUpload(uploadId: string): { signal: AbortSignal @@ -40,10 +45,22 @@ export function cancelRuntimeUpload(uploadId: string): void { controllers.get(uploadId)?.abort() } -/** Called once a drop is finished so a reused id cannot inherit a stale cancel. */ +/** + * Called once a drop is finished so a reused id cannot inherit a stale cancel. + * A live registration keeps its abort: only its own release may end it. + */ export function forgetRuntimeUploadCancellation(uploadId: string): void { cancelled.delete(uploadId) - controllers.delete(uploadId) +} + +/** A gone renderer can never release its drops, so its remembered cancels go with it. */ +export function forgetRuntimeUploadCancellationsForSender(senderId: number): void { + const prefix = scopeRuntimeUploadId(senderId, '') + for (const key of cancelled) { + if (key.startsWith(prefix)) { + cancelled.delete(key) + } + } } export function isUploadCancelled(uploadId: string): boolean { diff --git a/src/main/ipc/runtime-upload-progress.test.ts b/src/main/ipc/runtime-upload-progress.test.ts index 2c2edf84cdca..445e3fa07d44 100644 --- a/src/main/ipc/runtime-upload-progress.test.ts +++ b/src/main/ipc/runtime-upload-progress.test.ts @@ -99,4 +99,16 @@ describe('throttleRuntimeUploadProgress', () => { emit({ uploadId: 'u', sentBytes: 1, totalBytes: 10 }) expect(spy).toHaveBeenCalledTimes(1) }) + + it('keeps throttling while the total is unknown instead of treating every slice as final', () => { + let clock = 0 + const { seen, emit } = collector(() => clock) + emit({ uploadId: 'u', sentBytes: 0, totalBytes: 0 }) + emit({ uploadId: 'u', sentBytes: 64 * 1024, totalBytes: 0 }) + emit({ uploadId: 'u', sentBytes: 128 * 1024, totalBytes: 0 }) + clock = 100 + emit({ uploadId: 'u', sentBytes: 192 * 1024, totalBytes: 0 }) + + expect(seen).toEqual([0, 192 * 1024]) + }) }) diff --git a/src/main/ipc/runtime-upload-progress.ts b/src/main/ipc/runtime-upload-progress.ts index 1a1112c0d16e..30a683d8e29f 100644 --- a/src/main/ipc/runtime-upload-progress.ts +++ b/src/main/ipc/runtime-upload-progress.ts @@ -27,7 +27,8 @@ export function throttleRuntimeUploadProgress( let lastSentBytes = -1 let lastEmittedAt = -Infinity return (progress) => { - const isTerminal = progress.sentBytes >= progress.totalBytes + // An unknown total (0) is not done; treating it so would forward every slice. + const isTerminal = progress.totalBytes > 0 && progress.sentBytes >= progress.totalBytes const isFirst = lastSentBytes < 0 const currentTime = now() if ( From de80e940107c4256d77643c5983df7e39c000257 Mon Sep 17 00:00:00 2001 From: mmarabel <166927047+mmarabel@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:38:24 +0200 Subject: [PATCH 19/20] test(runtime): cover cancel release race --- src/main/ipc/runtime-upload-cancellation.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/ipc/runtime-upload-cancellation.test.ts b/src/main/ipc/runtime-upload-cancellation.test.ts index 79316ae2547f..0d2b21252eeb 100644 --- a/src/main/ipc/runtime-upload-cancellation.test.ts +++ b/src/main/ipc/runtime-upload-cancellation.test.ts @@ -94,6 +94,16 @@ describe('runtime upload cancellation', () => { live.release() }) + it('keeps a live registration cancellable after its remembered cancel is released', () => { + const live = registerCancellableUpload('u') + forgetRuntimeUploadCancellation('u') + + cancelRuntimeUpload('u') + + expect(live.signal.aborted).toBe(true) + live.release() + }) + it('recognises its own error and nothing else', () => { expect(isRuntimeUploadCancelled(new RuntimeUploadCancelledError())).toBe(true) expect(isRuntimeUploadCancelled(new Error('disk full'))).toBe(false) From 43c77436bbce5c9948c76c35b8e009a6b32c2ace Mon Sep 17 00:00:00 2001 From: microdaery Date: Sun, 11 Oct 2026 17:11:08 +0800 Subject: [PATCH 20/20] feat(transfers): one progress panel with cancel for remote uploads and downloads SSH workspaces now run on the host's managed runtime, so their transfers use the same runtime path as remote Orca servers. This builds on #16107's runtime upload progress instead of the removed desktop-side SFTP import: - #16107's terminal-drop panel moves to components/transfer-progress and takes a direction; file explorer and editor drops use it too. - Runtime downloads (explorer Download, terminal "download & open") count the chunks the renderer already pulls, show the panel, and cancel between chunks through an AbortSignal; the temp file is discarded and nothing is committed. - SSH workspace files download through the host runtime instead of the SSH filesystem provider, which nothing registers anymore. Folder downloads keep their existing SSH-only path. - Runtime import cancel fixes: directories check the cancel before and after each create, and a source is marked cancelled only when the cancel is what stopped it. Co-Authored-By: Claude Opus 5.5 --- .../editor/editor-dropped-file-open.ts | 12 +- .../FileExplorerRow.actions.test.tsx | 43 ++- .../file-explorer-row-context-menu.tsx | 8 +- .../file-explorer-row-file-transfer.ts | 18 +- .../right-sidebar/useFileExplorerImport.ts | 8 +- .../terminal-drop-handler.test.ts | 126 +++------ .../terminal-drop-upload-heading.ts | 49 ---- .../terminal-remote-file-download-open.ts | 9 +- .../terminal-runtime-drop-upload.test.ts | 14 +- .../terminal-runtime-drop-upload.ts | 90 +------ .../TransferProgressPanel.test.tsx | 49 ++++ .../TransferProgressPanel.tsx} | 117 ++++++--- .../download-progress-panel.test.ts | 104 ++++++++ .../download-progress-panel.ts | 66 +++++ .../download-progress-tracker.test.ts | 56 ++++ .../download-progress-tracker.ts | 49 ++++ .../open-transfer-progress-panel.test.ts | 46 ++++ .../open-transfer-progress-panel.ts | 120 +++++++++ .../transfer-progress-format.test.ts} | 7 +- .../transfer-progress-format.ts} | 25 +- .../transfer-progress-heading.test.ts} | 59 +++-- .../transfer-progress-heading.ts | 64 +++++ .../transfer-session-state.test.ts | 244 ++++++++++++++++++ .../transfer-session-state.ts | 182 +++++++++++++ .../upload-progress-panel.test.ts | 104 ++++++++ .../upload-progress-panel.ts | 56 ++++ .../src/i18n/en-runtime-required.json | 24 +- src/renderer/src/i18n/locales/en.json | 50 ++-- ...time-file-client-download-progress.test.ts | 224 ++++++++++++++++ ...untime-file-client-external-import.test.ts | 141 +++++++++- .../src/runtime/runtime-file-client-types.ts | 7 + .../src/runtime/runtime-file-client.ts | 1 + .../src/runtime/runtime-file-import-client.ts | 28 +- .../src/runtime/runtime-file-read-client.ts | 34 ++- .../runtime-upload-progress-tracker.ts | 27 ++ .../runtime-upload-session-state.test.ts | 185 ------------- .../runtime/runtime-upload-session-state.ts | 146 ----------- 37 files changed, 1904 insertions(+), 688 deletions(-) delete mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts create mode 100644 src/renderer/src/components/transfer-progress/TransferProgressPanel.test.tsx rename src/renderer/src/components/{terminal-pane/TerminalDropUploadToast.tsx => transfer-progress/TransferProgressPanel.tsx} (61%) create mode 100644 src/renderer/src/components/transfer-progress/download-progress-panel.test.ts create mode 100644 src/renderer/src/components/transfer-progress/download-progress-panel.ts create mode 100644 src/renderer/src/components/transfer-progress/download-progress-tracker.test.ts create mode 100644 src/renderer/src/components/transfer-progress/download-progress-tracker.ts create mode 100644 src/renderer/src/components/transfer-progress/open-transfer-progress-panel.test.ts create mode 100644 src/renderer/src/components/transfer-progress/open-transfer-progress-panel.ts rename src/renderer/src/components/{terminal-pane/terminal-drop-upload-progress.test.ts => transfer-progress/transfer-progress-format.test.ts} (86%) rename src/renderer/src/components/{terminal-pane/terminal-drop-upload-progress.ts => transfer-progress/transfer-progress-format.ts} (57%) rename src/renderer/src/components/{terminal-pane/terminal-drop-upload-heading.test.ts => transfer-progress/transfer-progress-heading.test.ts} (53%) create mode 100644 src/renderer/src/components/transfer-progress/transfer-progress-heading.ts create mode 100644 src/renderer/src/components/transfer-progress/transfer-session-state.test.ts create mode 100644 src/renderer/src/components/transfer-progress/transfer-session-state.ts create mode 100644 src/renderer/src/components/transfer-progress/upload-progress-panel.test.ts create mode 100644 src/renderer/src/components/transfer-progress/upload-progress-panel.ts create mode 100644 src/renderer/src/runtime/runtime-file-client-download-progress.test.ts delete mode 100644 src/renderer/src/runtime/runtime-upload-session-state.test.ts delete mode 100644 src/renderer/src/runtime/runtime-upload-session-state.ts 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 1d253dbf0eee..4ff67fb9ca47 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.ts @@ -1,4 +1,5 @@ import { toast } from 'sonner' +import { createUploadProgressPanel } from '@/components/transfer-progress/upload-progress-panel' import { detectLanguage } from '@/lib/language-detect' import { isPathInsideWorktree, toWorktreeRelativePath } from '@/lib/terminal-links' import { useAppStore } from '@/store' @@ -149,6 +150,7 @@ async function openEditorFileDropPaths( ) return } + const panel = createUploadProgressPanel() try { // Why: OS file drops provide client-local paths. Remote runtime and // SSH editors must upload into the server worktree before opening. @@ -157,7 +159,7 @@ async function openEditorFileDropPaths( fileContext, [...paths], destinationDir, - { ensureDestinationDir: true } + { ensureDestinationDir: true, progress: panel.progress } ) if (!editorGroupStillExists(destination)) { return @@ -183,7 +185,12 @@ async function openEditorFileDropPaths( { suppressActiveRuntimeFallback: runtimeEnvironmentId === null, ...groupOptions } ) } - if (results.some((result) => result.status !== 'imported')) { + if ( + results.some( + (result) => + result.status === 'skipped' || (result.status === 'failed' && result.cancelled !== true) + ) + ) { toast.error( translate( 'auto.hooks.useGlobalFileDrop.d720e2f855', @@ -192,6 +199,7 @@ async function openEditorFileDropPaths( ) } } catch { + panel.close() toast.error( translate('auto.hooks.useGlobalFileDrop.38c9f034ff', 'Failed to upload dropped files.') ) 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 08eab39b9e6f..d471208bf9d0 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.actions.test.tsx @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { copyFileToOsClipboard, downloadRemoteFile } from './file-explorer-row-file-transfer' +import { + copyFileToOsClipboard, + downloadRemoteFile, + pickRemoteDownloadTarget +} from './file-explorer-row-file-transfer' import { shouldShowCollapseFolderAction, shouldShowFindInFolderAction, @@ -11,11 +15,17 @@ import { import { directoryNode, fileNode } from './file-explorer-tree-node-test-fixtures' import type * as RuntimeFileClient from '@/runtime/runtime-file-client' -const { downloadRuntimeFileMock, toastErrorMock, toastSuccessMock } = vi.hoisted(() => ({ - downloadRuntimeFileMock: vi.fn(), - toastErrorMock: vi.fn(), - toastSuccessMock: vi.fn() -})) +const { downloadRuntimeFileMock, toastErrorMock, toastSuccessMock, testTransfer } = vi.hoisted( + () => ({ + downloadRuntimeFileMock: vi.fn(), + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn(), + testTransfer: { + signal: new AbortController().signal, + trackProgress: () => ({ addBytes: () => {}, flush: () => {} }) + } + }) +) vi.mock('sonner', () => ({ toast: { @@ -24,6 +34,11 @@ vi.mock('sonner', () => ({ } })) +vi.mock('@/components/transfer-progress/download-progress-panel', () => ({ + runRemoteDownloadWithProgress: (_name: string, run: (transfer: unknown) => Promise) => + run(testTransfer) +})) + vi.mock('@/runtime/runtime-file-client', async (importOriginal) => { const actual = await importOriginal() return { @@ -315,7 +330,8 @@ describe('FileExplorerRow collapse folder action', () => { expect(downloadRuntimeFileMock).toHaveBeenCalledWith( runtimeContext, '/repo/src/index.ts', - 'index.ts' + 'index.ts', + testTransfer ) expect(toastSuccessMock).toHaveBeenCalledWith("Downloaded 'index.ts'", { action: { @@ -326,6 +342,19 @@ describe('FileExplorerRow collapse folder action', () => { expect(toastErrorMock).not.toHaveBeenCalled() }) + it('downloads SSH files through the host runtime and keeps folders on the connection', () => { + const runtimeContext = { + target: { kind: 'environment' as const, environmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + } + expect(pickRemoteDownloadTarget(fileNode, 'ssh-1', runtimeContext)).toBe(runtimeContext) + expect(pickRemoteDownloadTarget(directoryNode, 'ssh-1', runtimeContext)).toBe('ssh-1') + expect(pickRemoteDownloadTarget(fileNode, 'ssh-1', null)).toBe('ssh-1') + expect(pickRemoteDownloadTarget(fileNode, null, null)).toBeNull() + }) + it('shows a failure toast when remote download fails', async () => { const downloadFile = vi.fn().mockRejectedValue(new Error('Remote connection dropped')) ;( diff --git a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx index 942e83bda53c..af74927343b8 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx @@ -44,7 +44,11 @@ import { shouldShowRemoteDownloadAction, shouldShowViewFileAction } from './file-explorer-row-action-visibility' -import { copyFileToOsClipboard, downloadRemoteFile } from './file-explorer-row-file-transfer' +import { + copyFileToOsClipboard, + downloadRemoteFile, + pickRemoteDownloadTarget +} from './file-explorer-row-file-transfer' const isMac = navigator.userAgent.includes('Mac') @@ -137,7 +141,7 @@ export function FileExplorerRowContextMenu({ } }, [activeWorktreeId, node.path]) const handleDownload = useCallback(() => { - const downloadTarget = connectionId || runtimeDownloadContext + const downloadTarget = pickRemoteDownloadTarget(node, connectionId, runtimeDownloadContext) if (!downloadTarget) { return } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-row-file-transfer.ts b/src/renderer/src/components/right-sidebar/file-explorer-row-file-transfer.ts index 1da2c3515c51..07c4793e39fd 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-row-file-transfer.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-row-file-transfer.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { downloadRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { runRemoteDownloadWithProgress } from '@/components/transfer-progress/download-progress-panel' import type { TreeNode } from './file-explorer-types' import { shouldShowCopyFileAction } from './file-explorer-row-action-visibility' @@ -14,6 +15,19 @@ function getLocalDownloadName(destinationPath: string, platform: NodeJS.Platform return destinationPath.slice(lastSeparatorIndex + 1) } +/** + * SSH workspaces are served by the host's runtime, so a file downloads through it; a folder, + * which the runtime cannot download, still needs the SSH connection. + */ +export function pickRemoteDownloadTarget( + node: Pick, + connectionId: string | null | undefined, + runtimeContext: RuntimeFileOperationArgs | null | undefined +): string | RuntimeFileOperationArgs | null { + const preferred = node.isDirectory ? connectionId : runtimeContext + return preferred || connectionId || runtimeContext || null +} + export async function downloadRemoteFile( node: TreeNode, connectionIdOrRuntimeContext: string | RuntimeFileOperationArgs @@ -30,7 +44,9 @@ export async function downloadRemoteFile( filePath: node.path, connectionId: connectionIdOrRuntimeContext }) - : await downloadRuntimeFile(connectionIdOrRuntimeContext, node.path, node.name) + : await runRemoteDownloadWithProgress(node.name, (transfer) => + downloadRuntimeFile(connectionIdOrRuntimeContext, node.path, node.name, transfer) + ) // Why: Suppress toasts when the user cancels the native save dialog per design. if (result.canceled) { return diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts index 165a2ecdfedf..72fc411119d4 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerImport.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' import { translate } from '@/i18n/i18n' +import { createUploadProgressPanel } from '@/components/transfer-progress/upload-progress-panel' import { createOsFileDropSequence, useOsFileDropOwner } from '@/hooks/use-os-file-drop-owner' import { getNativeFileDropRejectionMessage } from '@/lib/native-file-drop-rejection-message' import type { FileExplorerOperationOwner } from './file-explorer-types' @@ -94,6 +95,7 @@ export function useFileExplorerImport({ clearNativeDragState() return } + const panel = createUploadProgressPanel() try { if ('error' in destination) { throw destination.error @@ -112,7 +114,7 @@ export function useFileExplorerImport({ }, prepared.paths, destinationDir, - { assertCurrent: guard.assertCurrent } + { assertCurrent: guard.assertCurrent, progress: panel.progress } ) // Refresh the destination directory once per gesture @@ -123,7 +125,8 @@ export function useFileExplorerImport({ // snap the tree viewport away from the user's drop target. const imported = results.filter((r) => r.status === 'imported') const skipped = results.filter((r) => r.status === 'skipped') - const failed = results.filter((r) => r.status === 'failed') + // Why: a cancel is the user's own decision, not a failure to report back. + const failed = results.filter((r) => r.status === 'failed' && r.cancelled !== true) const shown = shownRef.current if ( @@ -154,6 +157,7 @@ export function useFileExplorerImport({ ) } } catch (err) { + panel.close() toast.error(extractIpcErrorMessage(err, 'Failed to import files.')) } finally { clearNativeDragState() 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 be61f3a2de2e..6d2374589c34 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 @@ -84,9 +84,9 @@ vi.mock('./terminal-input-activity', () => ({ })) import { - endRuntimeUploadSession, - getRuntimeUploadSession -} from '@/runtime/runtime-upload-session-state' + endTransferSession, + getTransferSession +} from '@/components/transfer-progress/transfer-session-state' import { handleTerminalFileDrop } from './terminal-drop-handler' import { wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' @@ -134,6 +134,17 @@ function announceRowThenResolve(value: unknown) { } } +const EXPECTED_IMPORT_OPTIONS = { + assertCurrent: expect.any(Function), + progress: { + onStart: expect.any(Function), + onRowProgress: expect.any(Function), + onRowSettled: expect.any(Function), + onFinish: expect.any(Function), + isCancelled: expect.any(Function) + } +} + /** Points the store at one SSH-backed repo whose only worktree is `wt-1`. */ function stubSshRepo( connectionId: string, @@ -150,10 +161,19 @@ function stubSshRepo( mocks.storeState.sshConnectionStates = new Map([[connectionId, sshState]]) } +function stubLocalRepo(repoPath: string, worktreePath = repoPath): void { + mocks.storeState.repos = [ + { id: 'repo1', connectionId: null, path: repoPath, executionHostId: 'local' } + ] + mocks.storeState.worktreesByRepo = { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: worktreePath, hostId: 'local' }] + } +} + describe('handleTerminalFileDrop', () => { beforeEach(() => { vi.clearAllMocks() - endRuntimeUploadSession('session-1') + endTransferSession('session-1') mocks.storeState.activeRepoId = 'repo1' mocks.storeState.activeWorktreeId = 'wt-1' vi.stubGlobal('window', { @@ -227,16 +247,7 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/logo.png'], '/remote/repo/.orca/drops', - { - assertCurrent: expect.any(Function), - progress: { - onStart: expect.any(Function), - onRowProgress: expect.any(Function), - onRowSettled: expect.any(Function), - onFinish: expect.any(Function), - isCancelled: expect.any(Function) - } - } + EXPECTED_IMPORT_OPTIONS ) expect(sendInput).toHaveBeenCalledWith( wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png'), @@ -246,7 +257,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(mocks.toastError).not.toHaveBeenCalled() expect(mocks.toastCustom).toHaveBeenCalled() - const session = getRuntimeUploadSession('session-1') + const session = getTransferSession('session-1') expect(session?.settled).toBe(true) expect(session?.rows).toEqual([expect.objectContaining({ status: 'done', sentBytes: 10 })]) // The panel holds briefly to show the outcome and dismisses itself; cutting @@ -342,7 +353,7 @@ describe('handleTerminalFileDrop', () => { expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') expect(mocks.toastError).toHaveBeenCalled() - expect(getRuntimeUploadSession('session-1')).toBeUndefined() + expect(getTransferSession('session-1')).toBeUndefined() }) it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => { @@ -397,16 +408,7 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/logo.png'], '\\\\server\\share\\repo\\.orca\\drops', - { - assertCurrent: expect.any(Function), - progress: { - onStart: expect.any(Function), - onRowProgress: expect.any(Function), - onRowSettled: expect.any(Function), - onFinish: expect.any(Function), - isCancelled: expect.any(Function) - } - } + EXPECTED_IMPORT_OPTIONS ) expect(sendInput).toHaveBeenCalledWith( wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png'), @@ -470,28 +472,14 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/spec.pdf'], '/remote/repo/.orca/drops', - { - assertCurrent: expect.any(Function), - progress: { - onStart: expect.any(Function), - onRowProgress: expect.any(Function), - onRowSettled: expect.any(Function), - onFinish: expect.any(Function), - isCancelled: expect.any(Function) - } - } + EXPECTED_IMPORT_OPTIONS ) expect(sendInput).toHaveBeenCalledWith('/remote/repo/.orca/drops/spec.pdf ', 'driving') }) it('keeps explicit local worktree drops local while a runtime is focused', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - mocks.storeState.repos = [ - { id: 'repo1', connectionId: null, path: '/remote/repo', executionHostId: 'local' } - ] - mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo', hostId: 'local' }] - } + stubLocalRepo('/remote/repo') const sendInput = vi.fn(() => true) const focus = vi.fn() const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } } @@ -524,24 +512,7 @@ describe('handleTerminalFileDrop', () => { localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } ] - mocks.storeState.repos = [ - { - id: 'repo1', - connectionId: null, - path: 'C:\\Users\\alice\\repo', - executionHostId: 'local' - } - ] - mocks.storeState.worktreesByRepo = { - repo1: [ - { - id: 'wt-1', - repoId: 'repo1', - path: 'C:\\Users\\alice\\repo\\feature', - hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local' - } - ] - } + stubLocalRepo('C:\\Users\\alice\\repo', 'C:\\Users\\alice\\repo\\feature') const sendInput = vi.fn(() => true) const focus = vi.fn() const pane = { id: 1, leafId: 'leaf-1', terminal: { focus } } @@ -579,19 +550,7 @@ describe('handleTerminalFileDrop', () => { mocks.storeState.projects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } ] - mocks.storeState.repos = [ - { id: 'repo1', connectionId: null, path: 'C:\\Users\\alice\\repo', executionHostId: 'local' } - ] - mocks.storeState.worktreesByRepo = { - repo1: [ - { - id: 'wt-1', - repoId: 'repo1', - path: 'C:\\Users\\alice\\repo', - hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local' - } - ] - } + stubLocalRepo('C:\\Users\\alice\\repo') const sendInput = vi.fn(() => true) const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } } @@ -614,12 +573,7 @@ describe('handleTerminalFileDrop', () => { it('uses acknowledged PTY writes for native local drops when available', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - mocks.storeState.repos = [ - { id: 'repo1', connectionId: null, path: '/repo', executionHostId: 'local' } - ] - mocks.storeState.worktreesByRepo = { - repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/repo', hostId: 'local' }] - } + stubLocalRepo('/repo') const sendInput = vi.fn(() => true) const sendInputAccepted = vi.fn(async () => true) const focus = vi.fn() @@ -652,19 +606,7 @@ describe('handleTerminalFileDrop', () => { it('pastes native file drops into the pane captured by its element', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - mocks.storeState.repos = [ - { id: 'repo1', connectionId: null, path: '/repo', executionHostId: 'local' } - ] - mocks.storeState.worktreesByRepo = { - repo1: [ - { - id: 'wt-1', - repoId: 'repo1', - path: '/repo', - hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local' - } - ] - } + stubLocalRepo('/repo') const activeSendInput = vi.fn(() => true) const targetSendInput = vi.fn(() => true) const activeFocus = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts b/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts deleted file mode 100644 index 671104b35e1e..000000000000 --- a/src/renderer/src/components/terminal-pane/terminal-drop-upload-heading.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { translate } from '@/i18n/i18n' - -type HeadingInput = { - rowCount: number - settled: boolean - doneCount: number - cancelledCount: number -} - -/** Counts files while running, states the outcome once everything has stopped. */ -export function formatTerminalDropUploadHeading({ - rowCount, - settled, - doneCount, - cancelledCount -}: HeadingInput): string { - if (!settled) { - return translate( - 'auto.components.terminal.pane.terminal.drop.upload.heading.uploading', - 'Uploading {{count}} files to runtime', - { count: rowCount } - ) - } - // Why: only an all-cancelled drop reads as cancelled; a failure among the cancels must show. - if (doneCount === 0 && cancelledCount > 0 && cancelledCount === rowCount) { - return translate( - 'auto.components.terminal.pane.terminal.drop.upload.heading.cancelled', - 'Upload cancelled' - ) - } - if (doneCount === 0) { - return translate( - 'auto.components.terminal.pane.terminal.drop.upload.heading.failed', - 'Upload failed' - ) - } - if (doneCount < rowCount) { - return translate( - 'auto.components.terminal.pane.terminal.drop.upload.heading.partial', - 'Uploaded {{doneCount}} of {{rowCount}} to runtime', - { doneCount, rowCount } - ) - } - return translate( - 'auto.components.terminal.pane.terminal.drop.upload.heading.done', - 'Uploaded {{count}} files to runtime', - { count: rowCount } - ) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts b/src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts index b6ee810115e5..e71116d62249 100644 --- a/src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts +++ b/src/renderer/src/components/terminal-pane/terminal-remote-file-download-open.ts @@ -4,6 +4,7 @@ import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { basename } from '@/lib/path' import { downloadRuntimeFile, type RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { runRemoteDownloadWithProgress } from '@/components/transfer-progress/download-progress-panel' /** * Remote counterpart of "Open with default app": the OS can only launch a local @@ -15,9 +16,11 @@ export async function downloadAndOpenRemoteTerminalFile( ): Promise { const name = basename(filePath) || filePath try { - const result = fileContext.connectionId - ? await window.api.fs.downloadFile({ filePath, connectionId: fileContext.connectionId }) - : await downloadRuntimeFile(fileContext, filePath, name) + // Why: SSH workspaces are served by the host's runtime; downloadRuntimeFile still falls + // back to the SSH connection for a context with no runtime owner. + const result = await runRemoteDownloadWithProgress(name, (transfer) => + downloadRuntimeFile(fileContext, filePath, name, transfer) + ) // Why: cancelling the native save dialog is a deliberate no-op, not a failure. if (result.canceled) { return diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts index efbfc0c891c1..b693731473bc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts @@ -60,9 +60,11 @@ vi.mock('@/runtime/runtime-file-client', () => ({ ) => mocks.runImport(options.progress) })) vi.mock('./terminal-drop-paste', () => ({ pasteResolvedDropPaths: vi.fn() })) -vi.mock('./TerminalDropUploadToast', () => ({ TerminalDropUploadToast: () => null })) +vi.mock('@/components/transfer-progress/TransferProgressPanel', () => ({ + TransferProgressPanel: () => null +})) -import { getRuntimeUploadSession } from '@/runtime/runtime-upload-session-state' +import { getTransferSession } from '@/components/transfer-progress/transfer-session-state' import { uploadRuntimeDropPaths } from './terminal-runtime-drop-upload' function setActiveWorktree(activeWorktreeId: string | null): void { @@ -137,7 +139,7 @@ describe('uploadRuntimeDropPaths panel scope', () => { expect(toast.custom).not.toHaveBeenCalled() // Nothing is left to replay a "done" notice on return. - expect(getRuntimeUploadSession('session-1')).toBeUndefined() + expect(getTransferSession('session-1')).toBeUndefined() expect(mocks.store.listeners.size).toBe(0) }) @@ -153,16 +155,16 @@ describe('uploadRuntimeDropPaths panel scope', () => { // A fresh toast, not an update of the one sonner is animating out. expect(vi.mocked(toast.custom).mock.calls[1]?.[1]).not.toHaveProperty('id') // Still shown, so the panel keeps its outcome hold before closing itself. - expect(getRuntimeUploadSession('session-1')?.settled).toBe(true) + expect(getTransferSession('session-1')?.settled).toBe(true) }) it('ends a finished drop when the user leaves during the outcome hold', async () => { await dropOneFile() - expect(getRuntimeUploadSession('session-1')?.settled).toBe(true) + expect(getTransferSession('session-1')?.settled).toBe(true) setActiveWorktree('wt-b') - expect(getRuntimeUploadSession('session-1')).toBeUndefined() + expect(getTransferSession('session-1')).toBeUndefined() expect(mocks.store.listeners.size).toBe(0) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts index b4b16303fd38..ead409473952 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts @@ -1,17 +1,7 @@ -import { createElement } from 'react' import { toast } from 'sonner' -import { createBrowserUuid } from '@/lib/browser-uuid' import { extractIpcErrorMessage } from '@/lib/ipc-error' -import { openWorktreeScopedToast, type WorktreeScopedToast } from '@/lib/worktree-scoped-toast' import { importExternalPathsToRuntime } from '@/runtime/runtime-file-client' -import { - endRuntimeUploadSession, - getRuntimeUploadSession, - settleRuntimeUploadSession, - startRuntimeUploadSession, - updateRuntimeUploadRow -} from '@/runtime/runtime-upload-session-state' -import { TerminalDropUploadToast } from './TerminalDropUploadToast' +import { createUploadProgressPanel } from '@/components/transfer-progress/upload-progress-panel' import type { NativeDropFlowArgs } from './terminal-drop-paste' import { pasteResolvedDropPaths } from './terminal-drop-paste' import { describeDropWorkspaceIfInactive } from './terminal-drop-workspace-label' @@ -31,18 +21,7 @@ export async function uploadRuntimeDropPaths( ): Promise { const targetShell = getTerminalTargetShellForWorktreePath(args.worktreePath) const destinationDir = joinRuntimeTerminalDropDir(args.worktreePath) - const sessionId = createBrowserUuid() - const cancelledUploadIds = new Set() - let panel: WorktreeScopedToast | null = null - const closePanel = (): void => { - panel?.close() - endRuntimeUploadSession(sessionId) - } - const cancelRow = (uploadId: string): void => { - cancelledUploadIds.add(uploadId) - updateRuntimeUploadRow(sessionId, uploadId, { status: 'cancelled' }) - void window.api.fs.cancelRuntimeUpload({ uploadId }) - } + const panel = createUploadProgressPanel({ worktreeId: args.worktreeId }) try { const { results } = await importExternalPathsToRuntime( { @@ -59,68 +38,7 @@ export async function uploadRuntimeDropPaths( destinationDir, { assertCurrent: args.assertCurrent, - progress: { - onStart: (rows) => { - // A drop where every source was skipped still reports a start. - if (rows.length === 0) { - return - } - startRuntimeUploadSession( - sessionId, - rows.map((row) => ({ - uploadId: row.uploadId, - name: row.name, - sentBytes: 0, - totalBytes: row.totalBytes, - status: 'uploading' as const - })) - ) - // Why: created only once rows exist, so a drop that stages nothing - // never flashes an empty panel. - // Why: createElement, not a direct call — the toast body must be its - // own component or its hooks run outside a component boundary. - const renderPanel = () => - createElement(TerminalDropUploadToast, { - sessionId, - onCancel: cancelRow, - onDismiss: closePanel, - onLayoutChange: () => panel?.refresh() - }) - const panelOptions = { duration: Infinity, dismissible: false, unstyled: true } - // Why: the upload belongs to the workspace it was dropped into, so its - // panel shows only there instead of stacking over every workspace. - panel = openWorktreeScopedToast({ - worktreeId: args.worktreeId, - // Why: the id key is omitted, not set to undefined. sonner spreads these - // options over the id it just minted, so an explicit `id: undefined` - // makes it register the toast under a different id than it returns — - // and the next re-issue then adds a second panel instead of updating. - show: (id) => - id === undefined - ? toast.custom(renderPanel, panelOptions) - : toast.custom(renderPanel, { ...panelOptions, id }), - // Why: hiding unmounts the panel's outcome timer, so a settled drop - // would otherwise never end. - onHidden: () => { - if (getRuntimeUploadSession(sessionId)?.settled) { - closePanel() - } - } - }) - }, - onRowProgress: (uploadId, sentBytes) => - updateRuntimeUploadRow(sessionId, uploadId, { sentBytes }), - onRowSettled: (uploadId, status) => - updateRuntimeUploadRow(sessionId, uploadId, { status }), - isCancelled: (uploadId) => cancelledUploadIds.has(uploadId), - onFinish: () => { - settleRuntimeUploadSession(sessionId) - // Why: no "done" replay on return; failures still get their own toast. - if (panel && !panel.isShown()) { - closePanel() - } - } - } + progress: panel.progress } ) const imported = results.filter((result) => result.status === 'imported') @@ -141,7 +59,7 @@ export async function uploadRuntimeDropPaths( } catch (err) { // Why: only the error path tears the panel down immediately. On success it // owns its own exit, holding long enough to show how the drop ended. - closePanel() + panel.close() toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'), { description: describeDropWorkspaceIfInactive(args.worktreeId, args.worktreePath) }) diff --git a/src/renderer/src/components/transfer-progress/TransferProgressPanel.test.tsx b/src/renderer/src/components/transfer-progress/TransferProgressPanel.test.tsx new file mode 100644 index 000000000000..da3c04f30599 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/TransferProgressPanel.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { TransferProgressPanel } from './TransferProgressPanel' +import { + endTransferSession, + startTransferSession, + type TransferRow +} from './transfer-session-state' + +function renderRow(row: Omit): HTMLElement { + startTransferSession('s', 'download', [{ transferId: 'd', name: 'build', ...row }]) + render( + {}} + onDismiss={() => {}} + onLayoutChange={() => {}} + /> + ) + return screen.getByRole('progressbar', { name: 'build' }) +} + +describe('TransferProgressPanel row bar', () => { + afterEach(() => { + cleanup() + endTransferSession('s') + }) + + it('animates instead of sitting at 0 while bytes move toward an unknown total', () => { + const bar = renderRow({ sentBytes: 4096, totalBytes: 0, status: 'active' }) + + expect(bar.getAttribute('aria-valuenow')).toBeNull() + expect(bar.innerHTML).toContain('animate-[skill-update-slide') + }) + + it('shows a determinate bar once the total is known', () => { + const bar = renderRow({ sentBytes: 25, totalBytes: 100, status: 'active' }) + + expect(bar.getAttribute('aria-valuenow')).toBe('25') + expect(bar.innerHTML).not.toContain('skill-update-slide') + }) + + it('stops animating once a row with no total has ended', () => { + const bar = renderRow({ sentBytes: 4096, totalBytes: 0, status: 'cancelled' }) + + expect(bar.innerHTML).not.toContain('skill-update-slide') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx b/src/renderer/src/components/transfer-progress/TransferProgressPanel.tsx similarity index 61% rename from src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx rename to src/renderer/src/components/transfer-progress/TransferProgressPanel.tsx index 9a8f957d3342..d13a40288d03 100644 --- a/src/renderer/src/components/terminal-pane/TerminalDropUploadToast.tsx +++ b/src/renderer/src/components/transfer-progress/TransferProgressPanel.tsx @@ -1,39 +1,47 @@ import { useEffect, useState, useSyncExternalStore } from 'react' -import { ChevronDownIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react' +import { + ChevronDownIcon, + DownloadIcon, + FileIcon, + FolderIcon, + UploadIcon, + XIcon +} from 'lucide-react' import { Progress } from '@/components/ui/progress' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { - getRuntimeUploadSession, - subscribeToRuntimeUploadSessions, - summarizeRuntimeUploadSession, - toggleRuntimeUploadCollapsed, - type RuntimeUploadRow -} from '@/runtime/runtime-upload-session-state' -import { formatTransferredOfTotal, toRowPercent } from './terminal-drop-upload-progress' -import { formatTerminalDropUploadHeading } from './terminal-drop-upload-heading' + getTransferSession, + subscribeToTransferSessions, + summarizeTransferSession, + toggleTransferCollapsed, + type TransferDirection, + type TransferRow +} from './transfer-session-state' +import { formatTransferredOfTotal, toRowPercent } from './transfer-progress-format' +import { formatTransferProgressHeading } from './transfer-progress-heading' -// Long enough to read the outcome, short enough not to linger over the terminal. +// Long enough to read the outcome, short enough not to linger over the workspace. const SETTLED_HOLD_MS = 1200 const EXIT_ANIMATION_MS = 200 type Props = { sessionId: string - onCancel: (uploadId: string) => void + onCancel: (transferId: string) => void /** Closes the toast; the panel owns the timing so the exit is not cut short. */ onDismiss: () => void /** Re-issues the toast: sonner re-measures height only on a new element identity. */ onLayoutChange: () => void } -export function TerminalDropUploadToast({ +export function TransferProgressPanel({ sessionId, onCancel, onDismiss, onLayoutChange }: Props): React.JSX.Element | null { - const session = useSyncExternalStore(subscribeToRuntimeUploadSessions, () => - getRuntimeUploadSession(sessionId) + const session = useSyncExternalStore(subscribeToTransferSessions, () => + getTransferSession(sessionId) ) const [leaving, setLeaving] = useState(false) const settled = session?.settled === true @@ -60,8 +68,10 @@ export function TerminalDropUploadToast({ return null } const collapsed = session.collapsed - const summary = summarizeRuntimeUploadSession(session) - const heading = formatTerminalDropUploadHeading({ + const summary = summarizeTransferSession(session) + const DirectionIcon = session.direction === 'upload' ? UploadIcon : DownloadIcon + const heading = formatTransferProgressHeading({ + direction: session.direction, rowCount: session.rows.length, settled: session.settled, doneCount: summary.doneCount, @@ -82,28 +92,25 @@ export function TerminalDropUploadToast({ >
    - + {heading} {/* Fixed width so the row never reflows as the number gains digits. */} - {session.settled ? '' : `${summary.percent}%`} + {session.settled || summary.percent === null ? '' : `${summary.percent}%`}