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 112dff10bada..3f10c6fa863a 100644 --- a/src/main/ipc/filesystem-mutations.ts +++ b/src/main/ipc/filesystem-mutations.ts @@ -28,6 +28,12 @@ import { import { streamExternalFileToRuntime } from './runtime-upload-file-stream' import { abortWhenRendererGone } from './renderer-lifetime-abort' import { sweepAbandonedRuntimeUploadTempPath } from './runtime-upload-temp-sweep' +import { + RUNTIME_UPLOAD_PROGRESS_CHANNEL, + throttleRuntimeUploadProgress +} from './runtime-upload-progress' +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' @@ -238,11 +244,30 @@ export function registerFilesystemMutationHandlers(store: Store): void { // move in main, a reload or close has to stop the transfer explicitly, // or a multi-GB upload outlives the window that asked for it. const lifetime = abortWhenRendererGone(event.sender) + 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 + const cancellation = uploadId + ? registerCancellableUpload(scopeRuntimeUploadId(event.sender.id, uploadId)) + : null try { return await streamExternalFileToRuntime({ ...request, userDataPath, - signal: lifetime.signal + signal: lifetime.signal, + cancelSignal: cancellation?.signal, + onProgress: + emit && uploadId + ? ({ sentBytes, totalBytes }) => + emit({ uploadId, sentBytes, totalBytes, fileSequence: args.fileSequence }) + : undefined }) } catch (error) { if (lifetime.signal.aborted) { @@ -252,11 +277,14 @@ export function registerFilesystemMutationHandlers(store: Store): void { } throw error } finally { + cancellation?.release() lifetime.dispose() } } ) + registerRuntimeUploadCancelHandlers() + // Why: terminal drag-and-drop resolver. Local worktrees pass paths through // unchanged (reference-in-place; preserves zero-latency drop). Kept as a // separate IPC from fs:importExternalPaths because terminal semantics differ 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 new file mode 100644 index 000000000000..0d2b21252eeb --- /dev/null +++ b/src/main/ipc/runtime-upload-cancellation.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + cancelRuntimeUpload, + forgetRuntimeUploadCancellation, + forgetRuntimeUploadCancellationsForSender, + isRuntimeUploadCancelled, + isUploadCancelled, + registerCancellableUpload, + RuntimeUploadCancelledError, + scopeRuntimeUploadId +} from './runtime-upload-cancellation' + +afterEach(() => { + forgetRuntimeUploadCancellation('u') + forgetRuntimeUploadCancellation('v') +}) + +describe('runtime upload cancellation', () => { + it('aborts an upload that is already running', () => { + const upload = registerCancellableUpload('u') + expect(upload.signal.aborted).toBe(false) + + cancelRuntimeUpload('u') + + expect(upload.signal.aborted).toBe(true) + upload.release() + }) + + 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) + second.release() + }) + + 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) + 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(fresh.signal.aborted).toBe(false) + fresh.release() + }) + + 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) + 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('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) + 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..b9c91cc85e47 --- /dev/null +++ b/src/main/ipc/runtime-upload-cancellation.ts @@ -0,0 +1,68 @@ +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() + +/** 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 + 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. + * A live registration keeps its abort: only its own release may end it. + */ +export function forgetRuntimeUploadCancellation(uploadId: string): void { + cancelled.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 { + 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 9d1b10d6193d..187e4631f4b4 100644 --- a/src/main/ipc/runtime-upload-file-stream.test.ts +++ b/src/main/ipc/runtime-upload-file-stream.test.ts @@ -330,6 +330,97 @@ 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({ + ...(await 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({ + ...(await 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({ + ...(await baseArgs(filePath)), + onProgress: (progress) => seen.push(progress.sentBytes) + }) + ).rejects.toThrow('disk full') + + expect(seen).toEqual([RUNTIME_UPLOAD_SLICE_BYTES]) + }) + + it('stops between slices when the cancel 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({ + ...(await baseArgs(filePath)), + cancelSignal: controller.signal + }) + ).rejects.toThrow('Upload cancelled') + expect(chunkCalls()).toHaveLength(0) + }) + + it('stops mid-file when the cancel 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({ + ...(await baseArgs(filePath)), + cancelSignal: controller.signal + }) + ).rejects.toThrow('Upload cancelled') + // The first slice was already in flight; the loop stops before the second. + expect(chunkCalls()).toHaveLength(1) + // A cancel must let the in-flight append land before the renderer deletes the temp path. + const [, , , , , , , options] = callRuntimeEnvironment.mock.calls[0]! + expect(options?.signal).not.toBe(controller.signal) + }) + // symlink() needs privileges or Developer Mode on Windows. it.skipIf(process.platform === 'win32')('refuses a symlinked source', async () => { const targetPath = join(workDir, 'secret.txt') diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index a05b6ec2acd4..7f9d71d60783 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -5,6 +5,7 @@ import type { RuntimeUploadFileStreamRequest, StagedRuntimeUploadFileIdentity } from '../../shared/runtime-upload-staging-contract' +import { RuntimeUploadCancelledError } from './runtime-upload-cancellation' import { formatByteCeiling, REMOTE_IMPORT_MAX_FILE_BYTES } from './runtime-import-limits' import { isRuntimeEnvironmentManuallyDisconnected, @@ -24,6 +25,13 @@ export type RuntimeUploadFileStreamArgs = RuntimeUploadFileStreamRequest & { userDataPath: string /** Aborts the transfer; the caller's lifetime is what raises it today. */ signal?: AbortSignal + /** + * The user's cancel. Unlike `signal` it never aborts the in-flight chunk: the + * renderer deletes the temp path, so no straggling append may land after it. + */ + cancelSignal?: AbortSignal + /** Called after each slice lands, so the drop UI can show how far along the file is. */ + onProgress?: (progress: { sentBytes: number; totalBytes: number }) => void } /** @@ -41,6 +49,8 @@ export async function streamExternalFileToRuntime( entryRelativePath: args.entryRelativePath, expected: args.expected, signal: args.signal, + cancelSignal: args.cancelSignal, + onProgress: args.onProgress, maxFileBytes: REMOTE_IMPORT_MAX_FILE_BYTES, limitLabel: 'per-file remote import limit', writeSlice: (contentBase64, offset) => sendChunk(args, contentBase64, offset > 0) @@ -54,6 +64,9 @@ export type ExternalFileSliceStreamArgs = { entryRelativePath: string expected: StagedRuntimeUploadFileIdentity signal?: AbortSignal + /** See `RuntimeUploadFileStreamArgs.cancelSignal`. */ + cancelSignal?: AbortSignal + onProgress?: (progress: { sentBytes: number; totalBytes: number }) => void maxFileBytes: number /** Names the ceiling in the refusal, e.g. "per-file remote import limit". */ limitLabel: string @@ -86,6 +99,7 @@ export async function streamExternalFileSlices( assertMatchesStagedIdentity(lstatResult, args.expected, displayPath) args.signal?.throwIfAborted() + throwIfCancelled(args.cancelSignal) const handle = await open(sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) try { @@ -114,6 +128,7 @@ export async function streamExternalFileSlices( // Why: a zero-byte source produces no slices, but the destination still // has to exist before commitUpload renames it into place. await args.writeSlice('', 0) + args.onProgress?.({ sentBytes: 0, totalBytes: 0 }) } else { const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) let offset = 0 @@ -121,12 +136,16 @@ export async function streamExternalFileSlices( // Why: checked per slice, so an abort stops the transfer at the next // boundary instead of after the whole file has moved. args.signal?.throwIfAborted() + throwIfCancelled(args.cancelSignal) const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) if (bytesRead === 0) { throw new Error(`File truncated during upload: '${displayPath}'`) } await args.writeSlice(buffer.subarray(0, bytesRead).toString('base64'), offset) 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 }) } } @@ -216,6 +235,12 @@ async function sendChunk( } } +function throwIfCancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new RuntimeUploadCancelledError() + } +} + function resolveEntrySourcePath(sourceRootPath: string, entryRelativePath: string): string { // Why: staging resolves the source first, so the streamer has to agree on // the same absolute path or the two checks can disagree. 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..445e3fa07d44 --- /dev/null +++ b/src/main/ipc/runtime-upload-progress.test.ts @@ -0,0 +1,114 @@ +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) + }) + + 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 new file mode 100644 index 000000000000..30a683d8e29f --- /dev/null +++ b/src/main/ipc/runtime-upload-progress.ts @@ -0,0 +1,51 @@ +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 + fileSequence?: 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) => { + // 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 ( + !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 5e822328195a..82b0b514e914 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -198,6 +198,16 @@ export type FilesystemApi = { uploadExternalFileToRuntime: ( args: RuntimeUploadFileStreamRequest ) => Promise<{ byteLength: number }> + onUploadProgress: ( + callback: (progress: { + uploadId: string + sentBytes: number + totalBytes: number + fileSequence?: number + }) => void + ) => () => void + cancelRuntimeUpload: (args: { uploadId: string }) => Promise + releaseRuntimeUpload: (args: { uploadId: string }) => Promise resolveDroppedPathsForAgent: ( args: { paths: string[] diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 6fc8e9b69d8c..87d6e935219e 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -207,6 +207,25 @@ export const fsApi = { uploadExternalFileToRuntime: ( args: RuntimeUploadFileStreamRequest ): Promise<{ byteLength: number }> => ipcRenderer.invoke('fs:uploadExternalFileToRuntime', args), + onUploadProgress: ( + callback: (progress: { + uploadId: string + sentBytes: number + totalBytes: number + fileSequence?: number + }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { uploadId: string; sentBytes: number; totalBytes: number; fileSequence?: number } + ) => callback(data) + 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/editor/editor-dropped-file-open.ts b/src/renderer/src/components/editor/editor-dropped-file-open.ts index 4c915939706e..52b014ad2dc1 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' @@ -145,6 +146,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. @@ -153,7 +155,7 @@ async function openEditorFileDropPaths( fileContext, [...paths], destinationDir, - { ensureDestinationDir: true } + { ensureDestinationDir: true, progress: panel.progress } ) if (!editorGroupStillExists(destination)) { return @@ -179,7 +181,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', @@ -188,6 +195,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 d249cc8d1ae7..81730aa1fe83 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 RuntimeFileReadClient from '@/runtime/runtime-file-read-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-read-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 893e6947afb8..cedcd618f373 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 22fb316e36a7..d032a0d64d7f 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 @@ -4,6 +4,7 @@ import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { downloadRuntimeFile } from '@/runtime/runtime-file-read-client' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' +import { runRemoteDownloadWithProgress } from '@/components/transfer-progress/download-progress-panel' import type { TreeNode } from './file-explorer-types' import { shouldShowCopyFileAction } from './file-explorer-row-action-visibility' @@ -15,6 +16,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 @@ -31,7 +45,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 c90626806b28..e74c28f695d4 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-import-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 c72ea9af1022..daf99f8c3707 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 @@ -1,5 +1,6 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeImportProgressHandlers } from '@/runtime/runtime-upload-progress-tracker' const mocks = vi.hoisted(() => { const worktreesByRepo: Record< @@ -10,6 +11,7 @@ const mocks = vi.hoisted(() => { } return { toastLoading: vi.fn(() => 'toast-1'), + toastCustom: vi.fn((_render: unknown, _options?: Record) => 'toast-1'), toastDismiss: vi.fn(), toastError: vi.fn(), importExternalPathsToRuntime: vi.fn(), @@ -51,6 +53,7 @@ const mocks = vi.hoisted(() => { vi.mock('sonner', () => ({ toast: { loading: mocks.toastLoading, + custom: mocks.toastCustom, dismiss: mocks.toastDismiss, error: mocks.toastError, message: vi.fn() @@ -59,7 +62,8 @@ vi.mock('sonner', () => ({ vi.mock('@/store', () => ({ useAppStore: { - getState: () => mocks.storeState + getState: () => mocks.storeState, + subscribe: () => () => {} } })) @@ -71,10 +75,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 { + endTransferSession, + getTransferSession +} from '@/components/transfer-progress/transfer-session-state' import { handleTerminalFileDrop } from './terminal-drop-handler' import { wrapTerminalBracketedPasteText } from './terminal-bracketed-paste' @@ -94,9 +106,74 @@ 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?: 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([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) + return value + } +} + +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, + 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, hostId: `ssh:${connectionId}` }] + } + 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() + endTransferSession('session-1') mocks.storeState.activeRepoId = 'repo1' mocks.storeState.activeWorktreeId = 'wt-1' vi.stubGlobal('window', { @@ -130,17 +207,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 } } @@ -168,7 +247,7 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/logo.png'], '/remote/repo/.orca/drops', - { assertCurrent: expect.any(Function) } + EXPECTED_IMPORT_OPTIONS ) expect(sendInput).toHaveBeenCalledWith( wrapTerminalBracketedPasteText('/remote/repo/.orca/drops/logo.png'), @@ -177,25 +256,34 @@ describe('handleTerminalFileDrop', () => { expect(focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).toHaveBeenCalledWith('tab-1', 'leaf-1') expect(mocks.toastError).not.toHaveBeenCalled() - expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') + expect(mocks.toastCustom).toHaveBeenCalled() + 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 + // that short here is what made a cancelled drop vanish with nothing to read. + expect(mocks.toastDismiss).not.toHaveBeenCalled() }) 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?: ImportOptions) => { + 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 } } @@ -216,7 +304,56 @@ describe('handleTerminalFileDrop', () => { expect(sendInput).not.toHaveBeenCalled() expect(focus).not.toHaveBeenCalled() expect(mocks.recordTerminalUserInputForLeaf).not.toHaveBeenCalled() + expect(mocks.toastDismiss).not.toHaveBeenCalled() + }) + + it('publishes the upload panel without an explicit undefined id', async () => { + mocks.importExternalPathsToRuntime.mockImplementation(announceRowThenResolve({ results: [] })) + const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } } + const manager = { getActivePane: () => pane, getPanes: () => [pane] } + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: new Map([[1, createTerminalTransport(vi.fn(() => true))]]) as never, + worktreeId: 'wt-1', + tabId: 'tab-1', + cwd: undefined, + pane, + paths: ['/Users/me/logo.png'] + }) + + // Why: sonner spreads these options over the id it just minted, so passing + // `id: undefined` registers the toast under an id it never returns and every + // re-issue stacks another panel instead of updating the first. + expect(mocks.toastCustom).toHaveBeenCalledTimes(1) + const options = mocks.toastCustom.mock.calls[0]?.[1] + expect(options).toBeDefined() + expect(Object.keys(options ?? {})).not.toContain('id') + }) + + it('tears the upload panel down immediately when the import throws', async () => { + mocks.importExternalPathsToRuntime.mockImplementation( + async (_context: unknown, _paths: unknown, _dest: unknown, options?: ImportOptions) => { + startRowThenAbort(options) + throw new Error('runtime unreachable') + } + ) + const pane = { id: 1, leafId: 'leaf-1', terminal: { focus: vi.fn() } } + const manager = { getActivePane: () => pane, getPanes: () => [pane] } + + await handleTerminalFileDrop({ + manager: manager as never, + paneTransports: new Map([[1, createTerminalTransport(vi.fn(() => true))]]) as never, + worktreeId: 'wt-1', + tabId: 'tab-1', + cwd: undefined, + pane, + paths: ['/Users/me/logo.png'] + }) + expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') + expect(mocks.toastError).toHaveBeenCalled() + expect(getTransferSession('session-1')).toBeUndefined() }) it('uses Windows shell paths for forward-slash UNC runtime worktrees', async () => { @@ -271,7 +408,7 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/logo.png'], '\\\\server\\share\\repo\\.orca\\drops', - { assertCurrent: expect.any(Function) } + EXPECTED_IMPORT_OPTIONS ) expect(sendInput).toHaveBeenCalledWith( wrapTerminalBracketedPasteText('\\\\server\\share\\repo\\.orca\\drops\\logo.png'), @@ -335,19 +472,14 @@ describe('handleTerminalFileDrop', () => { }, ['/Users/me/spec.pdf'], '/remote/repo/.orca/drops', - { assertCurrent: 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 } } @@ -380,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 } } @@ -435,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() } } @@ -470,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() @@ -508,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() @@ -657,26 +743,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', - hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local' - } - ] - } - 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( @@ -698,28 +765,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', - hostId: parseExecutionHostId(mocks.storeState.repos[0]?.executionHostId)?.id ?? 'local' - } - ] - } - 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-paste.ts b/src/renderer/src/components/terminal-pane/terminal-drop-paste.ts new file mode 100644 index 000000000000..4ff307e86de2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-drop-paste.ts @@ -0,0 +1,50 @@ +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport-types' +import type { captureTerminalDropTarget } from './terminal-drop-target' +import { getCurrentTerminalDropTransport } from './terminal-drop-target' +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: { id: number; leafId: string } + 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 { + args.assertCurrent?.() + // 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) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts index cf46c0682e96..394c2a664da4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-runtime-catalog-owner.test.ts @@ -176,7 +176,7 @@ describe.each(['native', 'internal'])('%s runtime terminal catalog ownership', ( }, ['/client/file.txt'], '/owner/workspace/.orca/drops', - { assertCurrent: expect.any(Function) } + { assertCurrent: expect.any(Function), progress: expect.any(Object) } ) expect(sendInput).toHaveBeenCalledExactlyOnceWith( '/owner/workspace/.orca/drops/file.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 cb9432bca016..7871166d4add 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 @@ -1,27 +1,15 @@ import { toast } from 'sonner' import { extractIpcErrorMessage } from '@/lib/ipc-error' -import type { PaneManager } from '@/lib/pane-manager/pane-manager' -import { importExternalPathsToRuntime } from '@/runtime/runtime-file-import-client' import { translate } from '@/i18n/i18n' import { isWslUncPath } from '../../../../shared/wsl-paths' -import type { PtyTransport } from './pty-transport-types' -import { recordTerminalUserInputForLeaf } from './terminal-input-activity' +import { openWorktreeScopedToast } from '@/lib/worktree-scoped-toast' import { reportTerminalDropUploadSkipsAndFailures } from './terminal-drop-upload-report' -import { - type captureTerminalDropTarget, - getCurrentTerminalDropTransport -} from './terminal-drop-target' -import { - getTerminalTargetShellForWorktreePath, - isTerminalDropWindowsPathLike, - resolveTerminalDropTargetShell -} from './terminal-drop-shell' -import { writeTerminalDropPathsToCapturedTarget } from './terminal-drop-path-writer' -import { showTerminalDropWriteFailure } from './terminal-drop-write-failure' -import { joinRuntimeTerminalDropDir } from './terminal-drop-worktree-path' +import { describeDropWorkspaceIfInactive } from './terminal-drop-workspace-label' +import { pasteResolvedDropPaths, type NativeDropFlowArgs } from './terminal-drop-paste' +import { uploadRuntimeDropPaths } from './terminal-runtime-drop-upload' +import { resolveTerminalDropTargetShell } from './terminal-drop-shell' import type { captureTerminalDropTransportOwner } from './terminal-drop-transport-owner' import { toLocalWslDropPath } from './terminal-drop-local-wsl' -import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' export async function deliverNativeTerminalFileDrop( args: NativeDropFlowArgs & { @@ -101,73 +89,11 @@ export async function deliverNativeTerminalFileDrop( pane, tabId, targetShell, + worktreeId, worktreePath }) } -export type NativeDropFlowArgs = { - dataPaths: string[] - dropTarget: ReturnType - manager: PaneManager - paneTransports: Map - pane: { id: number; leafId: string } - tabId: string - worktreePath: string - expectedSshTargetId?: string - expectedSshConnectionGeneration?: number - expectedExecutionHostId?: 'local' | `ssh:${string}` - assertCurrent?: () => void -} - -async function uploadRuntimeDropPaths( - args: NativeDropFlowArgs & { - runtimeEnvironmentId: string - worktreeId: string - } -): 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' } - ) - ) - try { - const { results } = await importExternalPathsToRuntime( - { - // Why: drops into existing worktrees must follow the worktree owner, - // not the currently focused host in the sidebar. - target: runtimeTargetForOwnerEnvironment(args.runtimeEnvironmentId), - worktreeId: args.worktreeId, - worktreePath: args.worktreePath, - expectedExecutionHostId: args.expectedExecutionHostId, - expectedSshTargetId: args.expectedSshTargetId, - expectedSshConnectionGeneration: args.expectedSshConnectionGeneration - }, - args.dataPaths, - destinationDir, - { assertCurrent: args.assertCurrent } - ) - 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 { @@ -197,15 +123,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, @@ -216,38 +148,16 @@ 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) - } -} - -async function pasteResolvedDropPaths( - args: NativeDropFlowArgs & { paths: string[]; targetShell: 'posix' | 'windows' } -): Promise { - args.assertCurrent?.() - // 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) + pending.close() } } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx index 5c7a28e1b6f3..3d08b56b768f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-pane-element-file-drop.test.tsx @@ -49,7 +49,7 @@ vi.mock('electron', () => ({ vi.mock('@/store', () => ({ useAppStore: Object.assign( (selector: (state: typeof mocks.state) => T) => selector(mocks.state), - { getState: () => mocks.state } + { getState: () => mocks.state, subscribe: () => () => {} } ) })) vi.mock('@/runtime/runtime-file-import-client', () => ({ @@ -57,7 +57,13 @@ vi.mock('@/runtime/runtime-file-import-client', () => ({ })) vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' })) vi.mock('sonner', () => ({ - toast: { error: mocks.toastError, loading: vi.fn(), dismiss: vi.fn(), message: vi.fn() } + toast: { + error: mocks.toastError, + loading: vi.fn(), + custom: vi.fn(), + dismiss: vi.fn(), + message: vi.fn() + } })) vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: vi.fn() })) vi.mock('@/components/ui/tooltip', () => ({ @@ -267,7 +273,9 @@ beforeEach(() => { fs: { getPathForFile: (file: File) => `/client/${file.name}`, prepareDroppedPaths: mocks.prepare, - resolveDroppedPathsForAgent: mocks.resolvePaths + resolveDroppedPathsForAgent: mocks.resolvePaths, + onUploadProgress: () => () => {}, + releaseRuntimeUpload: vi.fn().mockResolvedValue(undefined) } }) }) @@ -465,7 +473,7 @@ describe('terminal element file drops', () => { }), ['/client/file.txt'], '/owner/workspace/.orca/drops', - { assertCurrent: expect.any(Function) } + { assertCurrent: expect.any(Function), progress: expect.any(Object) } ) expect(fixture.sends[0]).toHaveBeenCalledExactlyOnceWith( '/owner/workspace/.orca/drops/file.txt ', 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 431eb3ee0d8e..fc712782723e 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 @@ -5,6 +5,7 @@ import { extractIpcErrorMessage } from '@/lib/ipc-error' import { basename } from '@/lib/path' import { downloadRuntimeFile } from '@/runtime/runtime-file-read-client' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' +import { runRemoteDownloadWithProgress } from '@/components/transfer-progress/download-progress-panel' /** * Remote counterpart of "Open with default app": the OS can only launch a local @@ -16,9 +17,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 new file mode 100644 index 000000000000..e8ee3aabb5df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.test.ts @@ -0,0 +1,198 @@ +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' +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' }] + } + } + const panel: { element: unknown } = { element: null } + return { + store: { state, listeners }, + panel, + 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 }) => { + 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, 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-import-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('@/components/transfer-progress/TransferProgressPanel', () => ({ + TransferProgressPanel: () => null +})) + +import { getTransferSession } from '@/components/transfer-progress/transfer-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 + +function dropArgs(dataPaths: string[]): Parameters[0] { + return { + ...paneHandles, + dataPaths, + tabId: 'tab-1', + worktreePath: '/srv/a', + runtimeEnvironmentId: 'env-1', + 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) => { + progress.onStart([row]) + during() + progress.onRowSettled(row.uploadId, 'done') + progress.onFinish() + return { results: [imported] } + }) + return uploadRuntimeDropPaths(dropArgs(['/local/clip.mp4'])) +} + +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() + vi.mocked(toast.error).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(getTransferSession('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(getTransferSession('session-1')?.settled).toBe(true) + }) + + it('ends a finished drop when the user leaves during the outcome hold', async () => { + await dropOneFile() + expect(getTransferSession('session-1')?.settled).toBe(true) + + setActiveWorktree('wt-b') + + expect(getTransferSession('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 new file mode 100644 index 000000000000..812f81966f87 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-drop-upload.ts @@ -0,0 +1,67 @@ +import { toast } from 'sonner' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { importExternalPathsToRuntime } from '@/runtime/runtime-file-import-client' +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' +import { reportTerminalDropUploadSkipsAndFailures } from './terminal-drop-upload-report' +import { + getTerminalTargetShellForWorktreePath, + isTerminalDropWindowsPathLike +} from './terminal-drop-shell' +import { joinRuntimeTerminalDropDir } from './terminal-drop-worktree-path' +import { runtimeTargetForOwnerEnvironment } from '@/runtime/runtime-client-target' + +export async function uploadRuntimeDropPaths( + args: NativeDropFlowArgs & { + runtimeEnvironmentId: string + worktreeId: string + } +): Promise { + const targetShell = getTerminalTargetShellForWorktreePath(args.worktreePath) + const destinationDir = joinRuntimeTerminalDropDir(args.worktreePath) + const panel = createUploadProgressPanel({ worktreeId: args.worktreeId }) + try { + const { results } = await importExternalPathsToRuntime( + { + // Why: drops into existing worktrees must follow the worktree owner, + // not the currently focused host in the sidebar. + target: runtimeTargetForOwnerEnvironment(args.runtimeEnvironmentId), + worktreeId: args.worktreeId, + worktreePath: args.worktreePath, + expectedExecutionHostId: args.expectedExecutionHostId, + expectedSshTargetId: args.expectedSshTargetId, + expectedSshConnectionGeneration: args.expectedSshConnectionGeneration + }, + args.dataPaths, + destinationDir, + { + assertCurrent: args.assertCurrent, + progress: panel.progress + } + ) + 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) => result.cancelled !== true), + 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. + 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/transfer-progress/TransferProgressPanel.tsx b/src/renderer/src/components/transfer-progress/TransferProgressPanel.tsx new file mode 100644 index 000000000000..d13a40288d03 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/TransferProgressPanel.tsx @@ -0,0 +1,219 @@ +import { useEffect, useState, useSyncExternalStore } from '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 { + 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 workspace. +const SETTLED_HOLD_MS = 1200 +const EXIT_ANIMATION_MS = 200 + +type Props = { + sessionId: string + 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 TransferProgressPanel({ + sessionId, + onCancel, + onDismiss, + onLayoutChange +}: Props): React.JSX.Element | null { + const session = useSyncExternalStore(subscribeToTransferSessions, () => + getTransferSession(sessionId) + ) + 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. + if (!session) { + return null + } + const collapsed = session.collapsed + 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, + cancelledCount: summary.cancelledCount + }) + + return ( +
+
+ + + + {heading} + {/* Fixed width so the row never reflows as the number gains digits. */} + + {session.settled || summary.percent === null ? '' : `${summary.percent}%`} + + +
+ {!collapsed && ( +
    + {session.rows.map((row) => ( + + ))} +
+ )} +
+ ) +} + +function TransferRowItem({ + row, + direction, + onCancel +}: { + row: TransferRow + direction: TransferDirection + onCancel: (transferId: string) => void +}): React.JSX.Element { + // Why: without a known total a percentage would read as 0% forever; show bytes alone. + // A finished row still reads 100%, even an empty file or folder. + const percent = row.status === 'done' || row.totalBytes > 0 ? toRowPercent(row) : null + const inactive = row.status !== 'active' + const RowIcon = row.kind === 'directory' ? FolderIcon : FileIcon + + return ( +
  • + + + + {row.name} + + + {rowSubLabel(row)} + + + {/* Dimmed through a wrapper: owns its own effects. */} +
    + {percent === null && !inactive ? ( + // Why: bytes are moving toward a total not known yet; a bar parked at 0 reads as stuck. +
    +
    +
    + ) : ( + + )} +
    + + {percent === null ? '' : `${percent}%`} + + {/* Cancel is a back-out, not a destructive action: ghost, no color. */} + +
  • + ) +} + +function rowSubLabel(row: TransferRow): string { + if (row.status === 'cancelling') { + return translate('transferProgress.cancelling', 'Cancelling…') + } + if (row.status === 'unconfirmed') { + return translate('transferProgress.cancelUnconfirmed', 'Cancel sent; remote state unconfirmed') + } + if (row.status === 'cancelled') { + return translate('transferProgress.cancelled', 'Cancelled') + } + if (row.status === 'failed') { + return translate('transferProgress.failed', 'Failed') + } + return formatTransferredOfTotal(row.sentBytes, row.totalBytes) +} diff --git a/src/renderer/src/components/transfer-progress/download-progress-panel.test.ts b/src/renderer/src/components/transfer-progress/download-progress-panel.test.ts new file mode 100644 index 000000000000..f4c28f457819 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/download-progress-panel.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runRemoteDownloadWithProgress } from './download-progress-panel' + +const { panel, openPanel } = vi.hoisted(() => { + const panel = { + sessionId: 's', + updateRow: vi.fn(), + markCancelling: vi.fn(), + settle: vi.fn(), + close: vi.fn() + } + return { + panel, + openPanel: vi.fn( + (_direction: string, _rows: unknown[], _onCancel: (transferId: string) => void) => panel + ) + } +}) + +vi.mock('./open-transfer-progress-panel', () => ({ openTransferProgressPanel: openPanel })) +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'd1' })) + +describe('runRemoteDownloadWithProgress', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens no panel when the save dialog was dismissed', async () => { + await expect( + runRemoteDownloadWithProgress('a.bin', async () => ({ canceled: true })) + ).resolves.toEqual({ canceled: true }) + expect(openPanel).not.toHaveBeenCalled() + }) + + it('opens the panel on the first progress report and closes on success', async () => { + const result = await runRemoteDownloadWithProgress('a.bin', async (transfer) => { + const tracker = transfer.trackProgress(10) + tracker.addBytes(4) + return { canceled: false, destinationPath: '/downloads/a.bin' } + }) + + expect(result).toEqual({ canceled: false, destinationPath: '/downloads/a.bin' }) + expect(openPanel).toHaveBeenCalledTimes(1) + expect(openPanel).toHaveBeenCalledWith( + 'download', + [expect.objectContaining({ transferId: 'd1', name: 'a.bin', status: 'active' })], + expect.any(Function) + ) + expect(panel.updateRow).toHaveBeenCalledWith('d1', { sentBytes: 4, totalBytes: 10 }) + expect(panel.close).toHaveBeenCalledTimes(1) + }) + + it('aborts the transfer on cancel and resolves as a canceled download', async () => { + let signal: AbortSignal | undefined + const download = runRemoteDownloadWithProgress('a.bin', (transfer) => { + signal = transfer.signal + transfer.trackProgress(10).flush() + return new Promise((_resolve, reject) => { + transfer.signal.addEventListener('abort', () => reject(transfer.signal.reason)) + }) + }) + const cancel = openPanel.mock.calls[0][2] + cancel('d1') + + await expect(download).resolves.toEqual({ canceled: true }) + expect(signal?.aborted).toBe(true) + expect(panel.markCancelling).toHaveBeenCalledWith('d1') + expect(panel.updateRow).toHaveBeenLastCalledWith('d1', { status: 'cancelled' }) + expect(panel.settle).toHaveBeenCalledTimes(1) + expect(panel.close).not.toHaveBeenCalled() + }) + + it('closes the panel and rethrows a real failure', async () => { + await expect( + runRemoteDownloadWithProgress('a.bin', async (transfer) => { + transfer.trackProgress(10).addBytes(1) + throw new Error('Remote connection dropped') + }) + ).rejects.toThrow('Remote connection dropped') + expect(panel.close).toHaveBeenCalledTimes(1) + expect(panel.settle).not.toHaveBeenCalled() + }) + + it('shows bytes without a percentage when the total is unknown', async () => { + await runRemoteDownloadWithProgress('a.bin', async (transfer) => { + transfer.trackProgress(null).addBytes(3) + return { canceled: false, destinationPath: '/downloads/a.bin' } + }) + expect(panel.updateRow).toHaveBeenCalledWith('d1', { sentBytes: 3, totalBytes: 0 }) + }) + + it('never claims Cancelled when the download finished before the cancel landed', async () => { + const result = await runRemoteDownloadWithProgress('a.bin', async (transfer) => { + transfer.trackProgress(10).addBytes(10) + // Why: the click arrives after promote; the result, not the click, decides the row. + openPanel.mock.calls[0][2]('d1') + return { canceled: false, destinationPath: '/downloads/a.bin' } + }) + + expect(result).toEqual({ canceled: false, destinationPath: '/downloads/a.bin' }) + expect(panel.updateRow).not.toHaveBeenCalledWith('d1', { status: 'cancelled' }) + expect(panel.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/transfer-progress/download-progress-panel.ts b/src/renderer/src/components/transfer-progress/download-progress-panel.ts new file mode 100644 index 000000000000..dbbacb588d05 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/download-progress-panel.ts @@ -0,0 +1,66 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { + RuntimeFileDownloadResult, + RuntimeFileDownloadTransfer +} from '@/runtime/runtime-file-client-types' +import { createDownloadProgressTracker, type DownloadProgress } from './download-progress-tracker' +import { + openTransferProgressPanel, + type TransferProgressPanelHandle +} from './open-transfer-progress-panel' + +/** + * Shows the transfer panel with a Cancel control while `run` downloads a remote + * file. A user cancel resolves as `{ canceled: true }`, the same as a dismissed + * save dialog, so callers keep a single success/error path. + */ +export async function runRemoteDownloadWithProgress( + name: string, + run: (transfer: RuntimeFileDownloadTransfer) => Promise +): Promise { + const rowId = createBrowserUuid() + const controller = new AbortController() + // Why: assigned inside the progress callback, which control-flow narrowing cannot see. + const view: { panel: TransferProgressPanelHandle | null } = { panel: null } + + const cancel = (): void => { + if (controller.signal.aborted) { + return + } + controller.abort(new Error('Download canceled')) + view.panel?.markCancelling(rowId) + } + + const onProgress = (progress: DownloadProgress): void => { + // Why: the first event arrives once the save dialog has closed, so a + // dismissed dialog never flashes a panel. + view.panel ??= openTransferProgressPanel( + 'download', + [{ transferId: rowId, name, sentBytes: 0, totalBytes: 0, status: 'active' }], + cancel + ) + view.panel.updateRow(rowId, { + sentBytes: progress.transferredBytes, + totalBytes: progress.totalBytes ?? 0 + }) + } + + try { + const result = await run({ + signal: controller.signal, + trackProgress: (totalBytes) => createDownloadProgressTracker({ totalBytes, emit: onProgress }) + }) + // Why: a cancel that lost the race to completion still ends as a download; the caller's + // "Downloaded … / Open" toast states that, so the panel never claims "Cancelled". + view.panel?.close() + return result + } catch (error) { + if (controller.signal.aborted) { + view.panel?.updateRow(rowId, { status: 'cancelled' }) + view.panel?.settle() + return { canceled: true } + } + view.panel?.close() + throw error + } +} diff --git a/src/renderer/src/components/transfer-progress/download-progress-tracker.test.ts b/src/renderer/src/components/transfer-progress/download-progress-tracker.test.ts new file mode 100644 index 000000000000..e3603be42500 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/download-progress-tracker.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createDownloadProgressTracker, + DOWNLOAD_PROGRESS_INTERVAL_MS +} from './download-progress-tracker' + +describe('createDownloadProgressTracker', () => { + function setup(totalBytes: number | null = 100) { + let now = 0 + const emit = vi.fn() + const tracker = createDownloadProgressTracker({ totalBytes, emit, now: () => now }) + return { + tracker, + emit, + advance: (ms: number) => { + now += ms + } + } + } + + it('emits the first update immediately and coalesces updates inside the interval', () => { + const { tracker, emit, advance } = setup() + tracker.addBytes(10) + tracker.addBytes(10) + tracker.addBytes(10) + expect(emit).toHaveBeenCalledTimes(1) + expect(emit).toHaveBeenLastCalledWith({ transferredBytes: 10, totalBytes: 100 }) + + advance(DOWNLOAD_PROGRESS_INTERVAL_MS) + tracker.addBytes(5) + expect(emit).toHaveBeenCalledTimes(2) + expect(emit).toHaveBeenLastCalledWith({ transferredBytes: 35, totalBytes: 100 }) + }) + + it('flushes the latest total even inside the interval, but only when it changed', () => { + const { tracker, emit } = setup(null) + tracker.addBytes(4) + tracker.addBytes(6) + tracker.flush() + expect(emit).toHaveBeenLastCalledWith({ transferredBytes: 10, totalBytes: null }) + const calls = emit.mock.calls.length + tracker.flush() + expect(emit).toHaveBeenCalledTimes(calls) + }) + + it('ignores empty and invalid byte counts', () => { + const { tracker, emit } = setup() + tracker.flush() + tracker.addBytes(0) + tracker.addBytes(-3) + tracker.addBytes(Number.NaN) + tracker.flush() + expect(emit).toHaveBeenCalledTimes(1) + expect(emit).toHaveBeenLastCalledWith({ transferredBytes: 0, totalBytes: 100 }) + }) +}) diff --git a/src/renderer/src/components/transfer-progress/download-progress-tracker.ts b/src/renderer/src/components/transfer-progress/download-progress-tracker.ts new file mode 100644 index 000000000000..40087b522a11 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/download-progress-tracker.ts @@ -0,0 +1,49 @@ +export type DownloadProgress = { + transferredBytes: number + /** Null when the size is not known, e.g. a stat an older server could not answer. */ + totalBytes: number | null +} + +export const DOWNLOAD_PROGRESS_INTERVAL_MS = 100 + +export type DownloadProgressTracker = { + addBytes: (bytes: number) => void + flush: () => void +} + +export function createDownloadProgressTracker(args: { + totalBytes: number | null + emit: (progress: DownloadProgress) => void + now?: () => number +}): DownloadProgressTracker { + const now = args.now ?? Date.now + const { totalBytes } = args + let transferredBytes = 0 + let lastEmittedAt = -Infinity + let dirty = true + + const flush = (): void => { + if (!dirty) { + return + } + dirty = false + lastEmittedAt = now() + args.emit({ transferredBytes, totalBytes }) + } + + return { + addBytes: (bytes) => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return + } + transferredBytes += bytes + dirty = true + // Why: fast links deliver many chunks per second; re-rendering the toast for each costs + // more than the transfer itself. + if (now() - lastEmittedAt >= DOWNLOAD_PROGRESS_INTERVAL_MS) { + flush() + } + }, + flush + } +} diff --git a/src/renderer/src/components/transfer-progress/open-transfer-progress-panel.test.ts b/src/renderer/src/components/transfer-progress/open-transfer-progress-panel.test.ts new file mode 100644 index 000000000000..39aaf1d13571 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/open-transfer-progress-panel.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CANCEL_UNCONFIRMED_AFTER_MS, + openTransferProgressPanel +} from './open-transfer-progress-panel' +import { endTransferSession, getTransferSession } from './transfer-session-state' + +vi.mock('sonner', () => ({ toast: { custom: vi.fn(() => 'toast-1'), dismiss: vi.fn() } })) +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'panel-1' })) + +const rows = [ + { transferId: 'a', name: 'a.bin', sentBytes: 0, totalBytes: 10, status: 'active' as const } +] + +describe('openTransferProgressPanel cancel states', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + endTransferSession('panel-1') + }) + + const status = () => getTransferSession('panel-1')?.rows[0]?.status + + it('says the remote state is unconfirmed when no result follows the cancel', () => { + const panel = openTransferProgressPanel('upload', rows, vi.fn()) + panel.markCancelling('a') + expect(status()).toBe('cancelling') + + vi.advanceTimersByTime(CANCEL_UNCONFIRMED_AFTER_MS) + + expect(status()).toBe('unconfirmed') + }) + + it('keeps the real result when it arrives before the deadline', () => { + const panel = openTransferProgressPanel('upload', rows, vi.fn()) + panel.markCancelling('a') + panel.updateRow('a', { status: 'cancelled' }) + + vi.advanceTimersByTime(CANCEL_UNCONFIRMED_AFTER_MS) + + expect(status()).toBe('cancelled') + }) +}) diff --git a/src/renderer/src/components/transfer-progress/open-transfer-progress-panel.ts b/src/renderer/src/components/transfer-progress/open-transfer-progress-panel.ts new file mode 100644 index 000000000000..1a827f39aa7f --- /dev/null +++ b/src/renderer/src/components/transfer-progress/open-transfer-progress-panel.ts @@ -0,0 +1,120 @@ +import { createElement } from 'react' +import { toast } from 'sonner' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { + openWorktreeScopedToast, + type ToastId, + type WorktreeScopedToast +} from '@/lib/worktree-scoped-toast' +import { TransferProgressPanel } from './TransferProgressPanel' +import { + endTransferSession, + getTransferSession, + settleTransferSession, + startTransferSession, + updateTransferRow, + type TransferDirection, + type TransferRow +} from './transfer-session-state' + +// Why: a result that never returns (stalled link, hung remote open) must not leave the row +// promising "Cancelling…" forever; past this it says the remote state is unconfirmed. +export const CANCEL_UNCONFIRMED_AFTER_MS = 15_000 + +export type TransferProgressPanelHandle = { + sessionId: string + updateRow: (transferId: string, patch: Partial>) => void + /** The click only asks; the transfer's own result later decides how the row ends. */ + markCancelling: (transferId: string) => void + /** Holds the panel long enough to show how the transfer ended, then lets it leave. */ + settle: () => void + /** Removes the panel at once, for paths that report their outcome elsewhere. */ + close: () => void +} + +/** Ties the panel to the workspace a transfer targets; without one it shows everywhere. */ +export type TransferPanelScope = { worktreeId: string } + +/** A toast shown everywhere, with the same surface as a workspace-scoped one. */ +function openGlobalToast(show: (id: ToastId | undefined) => ToastId): WorktreeScopedToast { + let id = show(undefined) + let closed = false + return { + refresh: () => { + if (!closed) { + id = show(id) + } + }, + isShown: () => !closed, + close: () => { + closed = true + toast.dismiss(id) + } + } +} + +export function openTransferProgressPanel( + direction: TransferDirection, + rows: TransferRow[], + onCancel: (transferId: string) => void, + scope?: TransferPanelScope +): TransferProgressPanelHandle { + const sessionId = createBrowserUuid() + startTransferSession(sessionId, direction, rows) + const close = (): void => { + endTransferSession(sessionId) + presenter.close() + } + // 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(TransferProgressPanel, { + sessionId, + onCancel, + onDismiss: close, + onLayoutChange: () => presenter.refresh() + }) + const panelOptions = { duration: Infinity, dismissible: false, unstyled: true } + // 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. + const show = (id: ToastId | undefined): ToastId => + id === undefined + ? toast.custom(renderPanel, panelOptions) + : toast.custom(renderPanel, { ...panelOptions, id }) + const presenter: WorktreeScopedToast = scope + ? openWorktreeScopedToast({ + worktreeId: scope.worktreeId, + show, + // Why: hiding unmounts the panel's outcome timer, so a settled transfer + // would otherwise never end. + onHidden: () => { + if (getTransferSession(sessionId)?.settled) { + close() + } + } + }) + : openGlobalToast(show) + return { + sessionId, + updateRow: (transferId, patch) => updateTransferRow(sessionId, transferId, patch), + markCancelling: (transferId) => { + updateTransferRow(sessionId, transferId, { status: 'cancelling' }) + setTimeout(() => { + const row = getTransferSession(sessionId)?.rows.find((r) => r.transferId === transferId) + if (row?.status === 'cancelling') { + updateTransferRow(sessionId, transferId, { status: 'unconfirmed' }) + } + }, CANCEL_UNCONFIRMED_AFTER_MS) + }, + settle: () => { + settleTransferSession(sessionId) + // Why: no outcome replay for a panel settled while its workspace was hidden. + if (!presenter.isShown()) { + close() + } + }, + close + } +} diff --git a/src/renderer/src/components/transfer-progress/transfer-progress-format.test.ts b/src/renderer/src/components/transfer-progress/transfer-progress-format.test.ts new file mode 100644 index 000000000000..3d434ddba1db --- /dev/null +++ b/src/renderer/src/components/transfer-progress/transfer-progress-format.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { formatTransferredOfTotal, toPercent, toRowPercent } from './transfer-progress-format' + +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('drops to no decimals once the total is large in its unit', () => { + expect(formatTransferredOfTotal(150 * MB, 300 * MB)).toBe('150 / 300 MB') + }) + + 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') + expect(formatTransferredOfTotal(3 * 1024 * 1024, 0)).toBe('3.00 MB') + }) +}) + +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: 'active', sentBytes: 0, totalBytes: 0 })).toBe(0) + }) + + it('follows the bytes for a row with content', () => { + expect(toRowPercent({ status: 'active', 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) + expect(toPercent(1_000_000, 1_000_000)).toBe(100) + }) + + it('is zero when nothing is measurable', () => { + expect(toPercent(0, 0)).toBe(0) + }) + + it('clamps a source that grew past its staged size', () => { + expect(toPercent(200, 100)).toBe(100) + }) +}) diff --git a/src/renderer/src/components/transfer-progress/transfer-progress-format.ts b/src/renderer/src/components/transfer-progress/transfer-progress-format.ts new file mode 100644 index 000000000000..b31950e21ec5 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/transfer-progress-format.ts @@ -0,0 +1,46 @@ +import type { TransferRow } from './transfer-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". */ +export function formatTransferredOfTotal(sentBytes: number, totalBytes: number): string { + if (!Number.isFinite(totalBytes) || totalBytes <= 0) { + // Why: a download whose size lookup failed still moves bytes worth showing. + if (Number.isFinite(sentBytes) && sentBytes > 0) { + const { divisor, unit, precision } = scaleFor(sentBytes) + return `${(sentBytes / divisor).toFixed(precision)} ${unit}` + } + return `0 ${BYTE_UNITS[0]}` + } + const { divisor, unit, precision } = scaleFor(totalBytes) + const scaledTotal = totalBytes / divisor + const scaledSent = Math.min(Math.max(sentBytes, 0), totalBytes) / divisor + return `${scaledSent.toFixed(precision)} / ${scaledTotal.toFixed(precision)} ${unit}` +} + +function scaleFor(bytes: number): { divisor: number; unit: string; precision: number } { + let unitIndex = 0 + let divisor = 1 + while (bytes / divisor >= 1024 && unitIndex < BYTE_UNITS.length - 1) { + divisor *= 1024 + unitIndex += 1 + } + const scaled = bytes / divisor + const precision = scaled >= 100 || unitIndex === 0 ? 0 : scaled >= 10 ? 1 : 2 + return { divisor, unit: BYTE_UNITS[unitIndex], precision } +} + +/** 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 + } + // 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/transfer-progress/transfer-progress-heading.test.ts b/src/renderer/src/components/transfer-progress/transfer-progress-heading.test.ts new file mode 100644 index 000000000000..63193f3ab780 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/transfer-progress-heading.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { formatTransferProgressHeading } from './transfer-progress-heading' + +describe('formatTransferProgressHeading', () => { + it('counts files while the drop is running', () => { + expect( + formatTransferProgressHeading({ + direction: 'upload', + rowCount: 2, + settled: false, + doneCount: 0, + cancelledCount: 1 + }) + ).toBe('Uploading 2 items') + }) + + it('says cancelled once everything stopped and nothing landed', () => { + expect( + formatTransferProgressHeading({ + direction: 'upload', + rowCount: 2, + settled: true, + doneCount: 0, + cancelledCount: 2 + }) + ).toBe('Upload cancelled') + }) + + it('does not let a cancel hide a failure when nothing landed', () => { + expect( + formatTransferProgressHeading({ + direction: 'upload', + rowCount: 2, + settled: true, + doneCount: 0, + cancelledCount: 1 + }) + ).toBe('Upload failed') + }) + + it('reports a partial drop by count', () => { + expect( + formatTransferProgressHeading({ + direction: 'upload', + rowCount: 3, + settled: true, + doneCount: 1, + cancelledCount: 2 + }) + ).toBe('Uploaded 1 of 3') + }) + + it('reports a clean finish', () => { + expect( + formatTransferProgressHeading({ + direction: 'upload', + rowCount: 2, + settled: true, + doneCount: 2, + cancelledCount: 0 + }) + ).toBe('Uploaded 2 items') + }) + + it('distinguishes a failure from a cancel', () => { + expect( + formatTransferProgressHeading({ + direction: 'upload', + rowCount: 1, + settled: true, + doneCount: 0, + cancelledCount: 0 + }) + ).toBe('Upload failed') + }) + + it('uses the singular for one file', () => { + expect( + formatTransferProgressHeading({ + direction: 'upload', + rowCount: 1, + settled: false, + doneCount: 0, + cancelledCount: 0 + }) + ).toBe('Uploading 1 item') + expect( + formatTransferProgressHeading({ + direction: 'upload', + rowCount: 1, + settled: true, + doneCount: 1, + cancelledCount: 0 + }) + ).toBe('Uploaded 1 item') + }) + + it('words a download the same way', () => { + expect( + formatTransferProgressHeading({ + direction: 'download', + rowCount: 1, + settled: false, + doneCount: 0, + cancelledCount: 0 + }) + ).toBe('Downloading 1 item') + expect( + formatTransferProgressHeading({ + direction: 'download', + rowCount: 2, + settled: true, + doneCount: 0, + cancelledCount: 2 + }) + ).toBe('Download cancelled') + }) +}) diff --git a/src/renderer/src/components/transfer-progress/transfer-progress-heading.ts b/src/renderer/src/components/transfer-progress/transfer-progress-heading.ts new file mode 100644 index 000000000000..1065c67a4b0f --- /dev/null +++ b/src/renderer/src/components/transfer-progress/transfer-progress-heading.ts @@ -0,0 +1,64 @@ +import { translate } from '@/i18n/i18n' +import type { TransferDirection } from './transfer-session-state' + +type HeadingInput = { + direction: TransferDirection + rowCount: number + settled: boolean + doneCount: number + cancelledCount: number +} + +/** Counts files while running, states the outcome once everything has stopped. */ +export function formatTransferProgressHeading({ + direction, + rowCount, + settled, + doneCount, + cancelledCount +}: HeadingInput): string { + const upload = direction === 'upload' + if (!settled) { + return upload + ? translate('transferProgress.heading.upload.active', 'Uploading {{count}} items', { + count: rowCount + }) + : translate('transferProgress.heading.download.active', 'Downloading {{count}} items', { + count: rowCount + }) + } + // Why: only an all-cancelled transfer reads as cancelled; a failure among the cancels must show. + if (doneCount === 0 && cancelledCount > 0 && cancelledCount === rowCount) { + return upload + ? translate('transferProgress.heading.upload.cancelled', 'Upload cancelled') + : translate('transferProgress.heading.download.cancelled', 'Download cancelled') + } + if (doneCount === 0) { + return upload + ? translate('transferProgress.heading.upload.failed', 'Upload failed') + : translate('transferProgress.heading.download.failed', 'Download failed') + } + if (doneCount < rowCount) { + return upload + ? translate( + 'transferProgress.heading.upload.partial', + 'Uploaded {{doneCount}} of {{rowCount}}', + { + doneCount, + rowCount + } + ) + : translate( + 'transferProgress.heading.download.partial', + 'Downloaded {{doneCount}} of {{rowCount}}', + { doneCount, rowCount } + ) + } + return upload + ? translate('transferProgress.heading.upload.done', 'Uploaded {{count}} items', { + count: rowCount + }) + : translate('transferProgress.heading.download.done', 'Downloaded {{count}} items', { + count: rowCount + }) +} diff --git a/src/renderer/src/components/transfer-progress/transfer-session-state.test.ts b/src/renderer/src/components/transfer-progress/transfer-session-state.test.ts new file mode 100644 index 000000000000..d23c1913a7f2 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/transfer-session-state.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + endTransferSession, + getTransferSession, + settleTransferSession, + startTransferSession, + subscribeToTransferSessions, + summarizeTransferSession, + toggleTransferCollapsed, + updateTransferRow, + type TransferRow +} from './transfer-session-state' + +function row(overrides: Partial = {}): TransferRow { + return { + transferId: 'a', + name: 'a.bin', + sentBytes: 0, + totalBytes: 100, + status: 'active', + ...overrides + } +} + +afterEach(() => { + endTransferSession('s') + endTransferSession('other') +}) + +describe('runtime upload session state', () => { + it('notifies subscribers when a row advances', () => { + const listener = vi.fn() + const unsubscribe = subscribeToTransferSessions(listener) + startTransferSession('s', 'upload', [row()]) + updateTransferRow('s', 'a', { sentBytes: 25 }) + + expect(listener).toHaveBeenCalledTimes(2) + expect(getTransferSession('s')?.rows[0]?.sentBytes).toBe(25) + unsubscribe() + }) + + it('replaces the row object so a memoized bar re-renders', () => { + startTransferSession('s', 'upload', [row()]) + const before = getTransferSession('s')?.rows[0] + updateTransferRow('s', 'a', { sentBytes: 10 }) + + expect(getTransferSession('s')?.rows[0]).not.toBe(before) + }) + + it('refuses to drag a cancelled row back into uploading', () => { + startTransferSession('s', 'upload', [row()]) + updateTransferRow('s', 'a', { status: 'cancelled' }) + updateTransferRow('s', 'a', { sentBytes: 90, status: 'active' }) + + const settled = getTransferSession('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', () => { + startTransferSession('s', 'upload', [row()]) + updateTransferRow('s', 'a', { status: 'cancelled' }) + updateTransferRow('s', 'a', { status: 'failed' }) + + expect(getTransferSession('s')?.rows[0]?.status).toBe('cancelled') + }) + + it('leaves other sessions untouched', () => { + startTransferSession('s', 'upload', [row()]) + startTransferSession('other', 'upload', [row({ transferId: 'b' })]) + updateTransferRow('s', 'a', { sentBytes: 50 }) + + expect(getTransferSession('other')?.rows[0]?.sentBytes).toBe(0) + }) + + it('ignores updates for a session that already ended', () => { + startTransferSession('s', 'upload', [row()]) + endTransferSession('s') + + expect(() => updateTransferRow('s', 'a', { sentBytes: 10 })).not.toThrow() + expect(getTransferSession('s')).toBeUndefined() + }) + + it('keeps rows when settled so the panel can state the outcome', () => { + startTransferSession('s', 'upload', [row({ status: 'cancelled' })]) + settleTransferSession('s') + + const session = getTransferSession('s') + expect(session?.settled).toBe(true) + expect(session?.rows).toHaveLength(1) + }) + + it('toggles collapse in the store so the panel can be remounted to re-measure', () => { + startTransferSession('s', 'upload', [row()]) + expect(getTransferSession('s')?.collapsed).toBe(false) + + toggleTransferCollapsed('s') + expect(getTransferSession('s')?.collapsed).toBe(true) + + toggleTransferCollapsed('s') + expect(getTransferSession('s')?.collapsed).toBe(false) + }) + + it('survives a collapse toggle on a session that already ended', () => { + expect(() => toggleTransferCollapsed('gone')).not.toThrow() + }) + + it('settles only once', () => { + startTransferSession('s', 'upload', [row()]) + settleTransferSession('s') + const listener = vi.fn() + const unsubscribe = subscribeToTransferSessions(listener) + settleTransferSession('s') + + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) + + it('does not notify when nothing actually changed', () => { + startTransferSession('s', 'upload', [row({ status: 'done' })]) + const listener = vi.fn() + const unsubscribe = subscribeToTransferSessions(listener) + updateTransferRow('s', 'a', { sentBytes: 10 }) + + expect(listener).not.toHaveBeenCalled() + unsubscribe() + }) +}) + +describe('summarizeTransferSession', () => { + it('averages across rows by bytes, not by row count', () => { + const summary = summarizeTransferSession({ + sessionId: 's', + direction: 'upload', + settled: true, + collapsed: false, + rows: [ + row({ transferId: 'a', sentBytes: 10, totalBytes: 10 }), + row({ transferId: '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 = summarizeTransferSession({ + sessionId: 's', + direction: 'upload', + settled: true, + collapsed: false, + rows: [ + row({ transferId: 'a', sentBytes: 100, totalBytes: 100, status: 'done' }), + row({ transferId: '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 = summarizeTransferSession({ + sessionId: 's', + direction: 'upload', + settled: true, + collapsed: false, + rows: [ + row({ transferId: 'a', status: 'done' }), + row({ transferId: 'b', status: 'active' }), + row({ transferId: 'c', status: 'cancelled' }) + ] + }) + + expect(summary.activeCount).toBe(1) + }) + + it('claims no percentage rather than NaN when no total is known', () => { + const summary = summarizeTransferSession({ + sessionId: 's', + direction: 'upload', + settled: true, + collapsed: false, + // Why: a finished row does not mark the total unknown, so only the zero-total guard is left. + rows: [row({ sentBytes: 0, totalBytes: 0, status: 'done' })] + }) + + expect(summary.percent).toBeNull() + }) + + it('counts bytes of a row with no total and marks the header indeterminate', () => { + const summary = summarizeTransferSession({ + sessionId: 's', + direction: 'download', + settled: false, + collapsed: false, + rows: [ + row({ transferId: 'a', sentBytes: 10, totalBytes: 10, status: 'done' }), + row({ transferId: 'b', sentBytes: 7, totalBytes: 0 }) + ] + }) + + expect(summary.sentBytes).toBe(17) + expect(summary.percent).toBeNull() + }) + + it('does not let a finished unknown-size row push the header to 100% early', () => { + const summary = summarizeTransferSession({ + sessionId: 's', + direction: 'upload', + settled: false, + collapsed: false, + rows: [ + row({ transferId: 'a', sentBytes: 100, totalBytes: 0, status: 'done' }), + row({ transferId: 'b', sentBytes: 0, totalBytes: 100 }) + ] + }) + + expect(summary.percent).toBe(0) + }) + + it('lets a cancelling row still move and still take the outcome its result reports', () => { + startTransferSession('s', 'download', [row({ status: 'cancelling' })]) + + updateTransferRow('s', 'a', { sentBytes: 40 }) + updateTransferRow('s', 'a', { status: 'active' }) + updateTransferRow('s', 'a', { status: 'done' }) + + expect(getTransferSession('s')?.rows[0]).toMatchObject({ sentBytes: 40, status: 'done' }) + }) +}) + +describe('unconfirmed cancels', () => { + it('still take the late result, but never slide back to cancelling or active', () => { + startTransferSession('s', 'upload', [row({ status: 'unconfirmed' })]) + + updateTransferRow('s', 'a', { status: 'cancelling' }) + updateTransferRow('s', 'a', { status: 'active' }) + expect(getTransferSession('s')?.rows[0]?.status).toBe('unconfirmed') + + updateTransferRow('s', 'a', { status: 'cancelled' }) + expect(getTransferSession('s')?.rows[0]?.status).toBe('cancelled') + }) +}) diff --git a/src/renderer/src/components/transfer-progress/transfer-session-state.ts b/src/renderer/src/components/transfer-progress/transfer-session-state.ts new file mode 100644 index 000000000000..5509ce1b5110 --- /dev/null +++ b/src/renderer/src/components/transfer-progress/transfer-session-state.ts @@ -0,0 +1,182 @@ +export type TransferDirection = 'upload' | 'download' + +/** + * `cancelling` holds from the click until the transfer's own result says how it ended; + * `unconfirmed` is a cancel whose result never came back, so the remote state is unknown. + */ +export type TransferRowStatus = + | 'active' + | 'cancelling' + | 'unconfirmed' + | 'done' + | 'cancelled' + | 'failed' + +export type TransferRow = { + /** Id every file of one source moves under; also the cancel handle. */ + transferId: string + name: string + sentBytes: number + /** 0 while unknown (not yet measured, or the size lookup failed). */ + totalBytes: number + status: TransferRowStatus + kind?: 'file' | 'directory' +} + +export type TransferSession = { + sessionId: string + direction: TransferDirection + rows: TransferRow[] + /** Every row has stopped moving; the panel shows its outcome, then leaves. */ + settled: boolean + /** Kept here, not in the panel: toggling it remounts the panel to re-measure. */ + collapsed: boolean +} + +type Listener = () => void + +const sessions = new Map() +const listeners = new Set() + +function emit(): void { + for (const listener of listeners) { + listener() + } +} + +export function subscribeToTransferSessions(listener: Listener): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function getTransferSession(sessionId: string): TransferSession | undefined { + return sessions.get(sessionId) +} + +export function startTransferSession( + sessionId: string, + direction: TransferDirection, + rows: TransferRow[] +): void { + sessions.set(sessionId, { sessionId, direction, rows, settled: false, collapsed: false }) + emit() +} + +/** Keeps the rows so the panel can state how the drop ended before it closes. */ +export function settleTransferSession(sessionId: string): void { + const session = sessions.get(sessionId) + if (!session || session.settled) { + return + } + sessions.set(sessionId, { ...session, settled: true }) + emit() +} + +export function toggleTransferCollapsed(sessionId: string): void { + const session = sessions.get(sessionId) + if (!session) { + return + } + sessions.set(sessionId, { ...session, collapsed: !session.collapsed }) + emit() +} + +export function endTransferSession(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 updateTransferRow( + sessionId: string, + transferId: string, + patch: Partial> +): void { + const session = sessions.get(sessionId) + if (!session) { + return + } + let changed = false + const rows = session.rows.map((row) => { + if (row.transferId !== transferId) { + return row + } + // Why: a settled row must not be dragged back by a progress event that was + // already in flight; a cancelling row still moves and still takes its outcome. + if (row.status !== 'active' && row.status !== 'cancelling' && row.status !== 'unconfirmed') { + return row + } + if (row.status !== 'active' && patch.status === 'active') { + return row + } + if (row.status === 'unconfirmed' && patch.status === 'cancelling') { + return row + } + changed = true + return { ...row, ...patch } + }) + if (!changed) { + return + } + sessions.set(sessionId, { ...session, rows }) + emit() +} + +export function summarizeTransferSession(session: TransferSession): { + sentBytes: number + totalBytes: number + /** Null while any moving row has no known total, so the header cannot claim a percentage. */ + percent: number | null + activeCount: number + doneCount: number + cancelledCount: number +} { + let sentBytes = 0 + let totalBytes = 0 + let activeCount = 0 + let doneCount = 0 + let cancelledCount = 0 + let unknownTotal = false + // Why: bytes of a row with no total have no denominator; counting them in the + // percentage would let a finished unknown-size row push the header to 100% early. + let knownSentBytes = 0 + for (const row of session.rows) { + if (row.status === 'done') { + doneCount += 1 + } + if (row.status === 'cancelled') { + cancelledCount += 1 + } + // 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 + } + // Why: a row without a total still moved bytes; count them rather than zeroing the row. + sentBytes += row.totalBytes > 0 ? Math.min(row.sentBytes, row.totalBytes) : row.sentBytes + knownSentBytes += row.totalBytes > 0 ? Math.min(row.sentBytes, row.totalBytes) : 0 + totalBytes += row.totalBytes + if (row.status === 'active' || row.status === 'cancelling' || row.status === 'unconfirmed') { + activeCount += 1 + unknownTotal ||= row.totalBytes <= 0 + } + } + return { + sentBytes, + totalBytes, + percent: + unknownTotal || totalBytes <= 0 + ? null + : Math.min(100, Math.floor((knownSentBytes / totalBytes) * 100)), + activeCount, + doneCount, + cancelledCount + } +} diff --git a/src/renderer/src/components/transfer-progress/upload-progress-panel.test.ts b/src/renderer/src/components/transfer-progress/upload-progress-panel.test.ts new file mode 100644 index 000000000000..7d3617e3ffde --- /dev/null +++ b/src/renderer/src/components/transfer-progress/upload-progress-panel.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createUploadProgressPanel } from './upload-progress-panel' + +const { panel, openPanel } = vi.hoisted(() => { + const panel = { + sessionId: 's', + updateRow: vi.fn(), + markCancelling: vi.fn(), + settle: vi.fn(), + close: vi.fn() + } + return { + panel, + openPanel: vi.fn( + (_direction: string, _rows: unknown[], _onCancel: (transferId: string) => void) => panel + ) + } +}) + +vi.mock('./open-transfer-progress-panel', () => ({ openTransferProgressPanel: openPanel })) + +const row = { uploadId: 'u1', name: 'videos', totalBytes: 0, sourcePath: '/local/videos' } + +describe('createUploadProgressPanel', () => { + const cancelRuntimeUpload = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + cancelRuntimeUpload.mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { fs: { cancelRuntimeUpload } } }) + }) + + it('never opens a panel for a drop that staged nothing', () => { + const upload = createUploadProgressPanel() + upload.progress.onStart([]) + upload.progress.onFinish() + upload.close() + expect(openPanel).not.toHaveBeenCalled() + }) + + it('forwards row progress and the row outcome', () => { + const upload = createUploadProgressPanel() + upload.progress.onStart([row]) + upload.progress.onRowProgress('u1', 5) + upload.progress.onRowProgress('u1', 6) + upload.progress.onRowSettled('u1', 'done') + upload.progress.onFinish() + + expect(openPanel).toHaveBeenCalledWith( + 'upload', + [{ transferId: 'u1', name: 'videos', sentBytes: 0, totalBytes: 0, status: 'active' }], + expect.any(Function), + undefined + ) + expect(panel.updateRow.mock.calls).toEqual([ + ['u1', { sentBytes: 5 }], + ['u1', { sentBytes: 6 }], + ['u1', { status: 'done' }] + ]) + expect(panel.settle).toHaveBeenCalledTimes(1) + }) + + it('cancels a row in main and tells the import which row was cancelled', () => { + const upload = createUploadProgressPanel() + upload.progress.onStart([row]) + const cancel = openPanel.mock.calls[0][2] + cancel('u1') + + expect(cancelRuntimeUpload).toHaveBeenCalledWith({ uploadId: 'u1' }) + // Why: the click only asks; the result decides the final state. + expect(panel.markCancelling).toHaveBeenCalledWith('u1') + expect(upload.progress.isCancelled?.('u1')).toBe(true) + expect(upload.progress.isCancelled?.('u2')).toBe(false) + }) + + it('handles a failed cancel request and leaves the row cancelling', () => { + const catchRejection = vi.fn() + cancelRuntimeUpload.mockReturnValue({ catch: catchRejection }) + const upload = createUploadProgressPanel() + upload.progress.onStart([row]) + openPanel.mock.calls[0][2]('u1') + + // Why: an unhandled rejection only fails the run, not this case, so run the handler here. + const onRejected = catchRejection.mock.calls[0]?.[0] + expect(() => onRejected(new Error('IPC unavailable'))).not.toThrow() + expect(panel.markCancelling).toHaveBeenCalledTimes(1) + expect(panel.updateRow).not.toHaveBeenCalled() + expect(panel.settle).not.toHaveBeenCalled() + }) + + it('ends a cancelled source as cancelled, but one that finished anyway as done', () => { + const upload = createUploadProgressPanel() + upload.progress.onStart([row, { ...row, uploadId: 'u2', sourcePath: '/local/b.txt' }]) + const cancel = openPanel.mock.calls[0][2] + cancel('u1') + cancel('u2') + + upload.progress.onRowSettled('u1', 'failed') + upload.progress.onRowSettled('u2', 'done') + + expect(panel.updateRow).toHaveBeenCalledWith('u1', { status: 'cancelled' }) + expect(panel.updateRow).toHaveBeenCalledWith('u2', { status: 'done' }) + }) +}) diff --git a/src/renderer/src/components/transfer-progress/upload-progress-panel.ts b/src/renderer/src/components/transfer-progress/upload-progress-panel.ts new file mode 100644 index 000000000000..9cd35164be9a --- /dev/null +++ b/src/renderer/src/components/transfer-progress/upload-progress-panel.ts @@ -0,0 +1,56 @@ +import type { RuntimeImportProgressHandlers } from '@/runtime/runtime-upload-progress-tracker' +import { + openTransferProgressPanel, + type TransferPanelScope, + type TransferProgressPanelHandle +} from './open-transfer-progress-panel' + +export type UploadProgressPanel = { + progress: RuntimeImportProgressHandlers + /** Tears the panel down for a flow that failed before it could settle. */ + close: () => void +} + +/** Import progress handlers that drive one upload panel; it opens once rows exist. */ +export function createUploadProgressPanel(scope?: TransferPanelScope): UploadProgressPanel { + let panel: TransferProgressPanelHandle | null = null + // Why: per row, since a path dropped twice is two rows and can be cancelled once. + const cancelledUploadIds = new Set() + const cancelRow = (uploadId: string): void => { + cancelledUploadIds.add(uploadId) + panel?.markCancelling(uploadId) + void window.api.fs.cancelRuntimeUpload({ uploadId }).catch(() => {}) + } + return { + progress: { + onStart: (rows) => { + // Why: a drop that stages nothing never flashes an empty panel. + if (rows.length === 0) { + return + } + panel = openTransferProgressPanel( + 'upload', + rows.map((row) => ({ + transferId: row.uploadId, + name: row.name, + sentBytes: 0, + totalBytes: row.totalBytes, + status: 'active' as const, + ...(row.kind ? { kind: row.kind } : {}) + })), + cancelRow, + scope + ) + }, + onRowProgress: (uploadId, sentBytes) => panel?.updateRow(uploadId, { sentBytes }), + // Why: a cancelled source reports failed; one that finished anyway reports done. + onRowSettled: (uploadId, status) => + panel?.updateRow(uploadId, { + status: status === 'failed' && cancelledUploadIds.has(uploadId) ? 'cancelled' : status + }), + isCancelled: (uploadId) => cancelledUploadIds.has(uploadId), + onFinish: () => panel?.settle() + }, + close: () => panel?.close() + } +} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 2ef0bb0848c8..0d3c09e125f6 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3267,6 +3267,22 @@ "resumed_other": "{{count}} agent sessions resumed." } }, + "transferProgress": { + "heading": { + "download": { + "active_one": "Downloading {{count}} item", + "active_other": "Downloading {{count}} items", + "done_one": "Downloaded {{count}} item", + "done_other": "Downloaded {{count}} items" + }, + "upload": { + "active_one": "Uploading {{count}} item", + "active_other": "Uploading {{count}} items", + "done_one": "Uploaded {{count}} item", + "done_other": "Uploaded {{count}} items" + } + } + }, "workspaceSpace": { "chart": { "workspaceSizes": "Workspace sizes" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4009acd80dde..8daf484ae5ef 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3602,6 +3602,9 @@ "b4cf68e889": "Skipped {{value0}} {{value1}}.", "writeTimeout": "File drop cancelled: terminal did not accept the path before the safety timeout.", "writeRejected": "File drop cancelled: terminal could not accept the path." + }, + "upload": { + "droppedInto": "Dropped into {{workspace}}" } } }, @@ -19822,6 +19825,40 @@ "retry": "Retry", "cancel": "Cancel" }, + "transferProgress": { + "cancelDownload": "Cancel download", + "cancelUpload": "Cancel upload", + "cancelling": "Cancelling…", + "cancelUnconfirmed": "Cancel sent; remote state unconfirmed", + "cancelled": "Cancelled", + "failed": "Failed", + "showItems": "Show items", + "hideItems": "Hide items", + "heading": { + "upload": { + "active": "Uploading {{count}} items", + "active_one": "Uploading {{count}} item", + "active_other": "Uploading {{count}} items", + "done": "Uploaded {{count}} items", + "done_one": "Uploaded {{count}} item", + "done_other": "Uploaded {{count}} items", + "partial": "Uploaded {{doneCount}} of {{rowCount}}", + "cancelled": "Upload cancelled", + "failed": "Upload failed" + }, + "download": { + "active": "Downloading {{count}} items", + "active_one": "Downloading {{count}} item", + "active_other": "Downloading {{count}} items", + "done": "Downloaded {{count}} items", + "done_one": "Downloaded {{count}} item", + "done_other": "Downloaded {{count}} items", + "partial": "Downloaded {{doneCount}} of {{rowCount}}", + "cancelled": "Download cancelled", + "failed": "Download failed" + } + } + }, "terminal": { "sessionSaveFailure": { "title": "Could not save terminal session", 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() + } + } +} diff --git a/src/renderer/src/runtime/runtime-file-client-download-progress.test.ts b/src/renderer/src/runtime/runtime-file-client-download-progress.test.ts new file mode 100644 index 000000000000..b401177f2a95 --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-client-download-progress.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from 'vitest' +import { downloadRuntimeFile } from './runtime-file-read-client' +import { + fsSaveDownloadedFile, + fsStartDownloadedFile, + fsAppendDownloadedFileChunk, + fsFinishDownloadedFile, + fsCancelDownloadedFile, + fsDownloadFile, + runtimeEnvironmentCall, + installRuntimeFileClientEnvironment +} from './runtime-file-client-test-harness' + +installRuntimeFileClientEnvironment() + +const context = { + target: { kind: 'environment' as const, environmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' +} + +function rpcResult(result: unknown) { + return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'remote-runtime' } } +} + +function createTransfer(controller = new AbortController()) { + const tracker = { addBytes: vi.fn(), flush: vi.fn() } + return { + tracker, + controller, + transfer: { + signal: controller.signal, + trackProgress: vi.fn(() => tracker) + } + } +} + +describe('runtime file download progress', () => { + it('reports each appended chunk against the remote file size', async () => { + fsStartDownloadedFile.mockResolvedValue({ + canceled: false, + transferId: 'download-1', + destinationPath: '/downloads/archive.zip' + }) + fsAppendDownloadedFileChunk.mockResolvedValue({ ok: true }) + fsFinishDownloadedFile.mockResolvedValue({ + canceled: false, + destinationPath: '/downloads/archive.zip' + }) + runtimeEnvironmentCall + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YQ==', bytesRead: 1, eof: false })) + .mockResolvedValueOnce(rpcResult({ size: 4, isDirectory: false, mtime: 1 })) + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YWJj', bytesRead: 3, eof: false })) + .mockResolvedValueOnce(rpcResult({ contentBase64: 'ZA==', bytesRead: 1, eof: true })) + const { transfer, tracker } = createTransfer() + + await expect( + downloadRuntimeFile(context, '/remote/repo/archive.zip', 'archive.zip', transfer) + ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/archive.zip' }) + + expect(transfer.trackProgress).toHaveBeenCalledWith(4) + expect(tracker.addBytes.mock.calls).toEqual([[3], [1]]) + expect(tracker.flush).toHaveBeenCalled() + }) + + it('downloads an SSH workspace file through its host runtime, not the SSH connection', async () => { + fsStartDownloadedFile.mockResolvedValue({ + canceled: false, + transferId: 'download-1', + destinationPath: '/downloads/a.txt' + }) + fsAppendDownloadedFileChunk.mockResolvedValue({ ok: true }) + fsFinishDownloadedFile.mockResolvedValue({ + canceled: false, + destinationPath: '/downloads/a.txt' + }) + runtimeEnvironmentCall + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YQ==', bytesRead: 1, eof: false })) + .mockResolvedValueOnce(rpcResult({ size: 1, isDirectory: false, mtime: 1 })) + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YQ==', bytesRead: 1, eof: true })) + const { transfer, tracker } = createTransfer() + + await expect( + downloadRuntimeFile( + { ...context, connectionId: 'ssh-1' }, + '/remote/repo/a.txt', + 'a.txt', + transfer + ) + ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/a.txt' }) + + expect(fsDownloadFile).not.toHaveBeenCalled() + expect(tracker.addBytes.mock.calls).toEqual([[1]]) + }) + + it('still downloads with an unknown total when the size lookup fails', async () => { + fsStartDownloadedFile.mockResolvedValue({ + canceled: false, + transferId: 'download-1', + destinationPath: '/downloads/archive.zip' + }) + fsAppendDownloadedFileChunk.mockResolvedValue({ ok: true }) + fsFinishDownloadedFile.mockResolvedValue({ + canceled: false, + destinationPath: '/downloads/archive.zip' + }) + runtimeEnvironmentCall + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YQ==', bytesRead: 1, eof: false })) + .mockRejectedValueOnce(new Error('stat failed')) + .mockResolvedValueOnce(rpcResult({ contentBase64: 'ZA==', bytesRead: 1, eof: true })) + const { transfer } = createTransfer() + + await expect( + downloadRuntimeFile(context, '/remote/repo/archive.zip', 'archive.zip', transfer) + ).resolves.toEqual({ canceled: false, destinationPath: '/downloads/archive.zip' }) + expect(transfer.trackProgress).toHaveBeenCalledWith(null) + }) + + it('stops between chunks and discards the temp file when canceled', async () => { + fsStartDownloadedFile.mockResolvedValue({ + canceled: false, + transferId: 'download-1', + destinationPath: '/downloads/archive.zip' + }) + fsCancelDownloadedFile.mockResolvedValue({ ok: true }) + const { transfer, controller } = createTransfer() + fsAppendDownloadedFileChunk.mockImplementation(async () => { + controller.abort(new Error('Download canceled')) + return { ok: true } + }) + runtimeEnvironmentCall + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YQ==', bytesRead: 1, eof: false })) + .mockResolvedValueOnce(rpcResult({ size: 8, isDirectory: false, mtime: 1 })) + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YWJj', bytesRead: 3, eof: false })) + + await expect( + downloadRuntimeFile(context, '/remote/repo/archive.zip', 'archive.zip', transfer) + ).rejects.toThrow('Download canceled') + + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(3) + expect(fsCancelDownloadedFile).toHaveBeenCalledWith({ transferId: 'download-1' }) + expect(fsFinishDownloadedFile).not.toHaveBeenCalled() + }) + + it('does not commit the file when cancel lands during the final append', async () => { + fsStartDownloadedFile.mockResolvedValue({ + canceled: false, + transferId: 'download-1', + destinationPath: '/downloads/archive.zip' + }) + fsCancelDownloadedFile.mockResolvedValue({ ok: true }) + const { transfer, controller } = createTransfer() + fsAppendDownloadedFileChunk.mockImplementation(async () => { + controller.abort(new Error('Download canceled')) + return { ok: true } + }) + runtimeEnvironmentCall + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YQ==', bytesRead: 1, eof: false })) + .mockResolvedValueOnce(rpcResult({ size: 1, isDirectory: false, mtime: 1 })) + .mockResolvedValueOnce(rpcResult({ contentBase64: 'YQ==', bytesRead: 1, eof: true })) + + await expect( + downloadRuntimeFile(context, '/remote/repo/archive.zip', 'archive.zip', transfer) + ).rejects.toThrow('Download canceled') + expect(fsFinishDownloadedFile).not.toHaveBeenCalled() + expect(fsCancelDownloadedFile).toHaveBeenCalledWith({ transferId: 'download-1' }) + }) + + function olderServerReplies() { + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'chunk-1', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: files.readChunk' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockResolvedValueOnce(rpcResult({ content: 'hello\n', isBinary: false })) + } + + it('shows the panel for an older server one-reply download so it can be cancelled', async () => { + olderServerReplies() + fsSaveDownloadedFile.mockResolvedValue({ canceled: false, destinationPath: '/d/report.txt' }) + const { transfer, tracker } = createTransfer() + + await downloadRuntimeFile(context, '/remote/repo/report.txt', 'report.txt', transfer) + + expect(transfer.trackProgress).toHaveBeenCalledWith(null) + expect(tracker.flush).toHaveBeenCalled() + }) + + it('saves nothing when an older server download is cancelled before the save', async () => { + const { transfer, controller } = createTransfer() + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'chunk-1', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: files.readChunk' }, + _meta: { runtimeId: 'remote-runtime' } + }) + .mockImplementationOnce(async () => { + controller.abort(new Error('Download canceled')) + return rpcResult({ content: 'hello\n', isBinary: false }) + }) + + await expect( + downloadRuntimeFile(context, '/remote/repo/report.txt', 'report.txt', transfer) + ).rejects.toThrow('Download canceled') + expect(fsSaveDownloadedFile).not.toHaveBeenCalled() + }) + + it('reports the saved file, not a cancel, when cancel lands after the preview during the save', async () => { + olderServerReplies() + const { transfer, controller } = createTransfer() + fsSaveDownloadedFile.mockImplementation(async () => { + // Why: the checkpoint is behind us; the save completes, so the result must say so. + controller.abort(new Error('Download canceled')) + return { canceled: false, destinationPath: '/d/report.txt' } + }) + + await expect( + downloadRuntimeFile(context, '/remote/repo/report.txt', 'report.txt', transfer) + ).resolves.toEqual({ canceled: false, destinationPath: '/d/report.txt' }) + }) +}) 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 abaa4cce34a0..e81b33a70092 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,15 @@ import { importExternalPathsToRuntime } from './runtime-file-import-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' +import type { ImportItemResult } from '../../../shared/filesystem-import-result-types' installRuntimeFileClientEnvironment() @@ -48,6 +51,8 @@ type UploadRequest = { expected: Record worktree: string relativePath: string + uploadId?: string + fileSequence?: number expectedExecutionHostId?: string expectedSshTargetId?: string expectedSshConnectionGeneration?: number @@ -59,6 +64,40 @@ function uploadRequests(): UploadRequest[] { return fsUploadExternalFileToRuntime.mock.calls.flat() } +function quietHandlers(): RuntimeImportProgressHandlers { + return { onStart: vi.fn(), onRowProgress: vi.fn(), onRowSettled: vi.fn(), onFinish: vi.fn() } +} + +/** One staged file whose upload rejects with `error` after the user clicked Cancel on its row. */ +async function importFileThatThrowsAfterCancel(error: string): Promise { + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/clip.mp4', + status: 'staged', + name: 'clip.mp4', + kind: 'file', + entries: [stagedFile('', 10, 7)] + } + ] + }) + runtimeEnvironmentCall.mockImplementation(async (call: { method: string }) => + call.method === 'files.stat' ? notFoundResponse('stat-miss') : okResponse('ok') + ) + fsUploadExternalFileToRuntime.mockRejectedValueOnce(new Error(error)) + const { results } = await importExternalPathsToRuntime( + { + target: { kind: 'environment' as const, environmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/clip.mp4'], + '/remote/repo/uploads', + { progress: { ...quietHandlers(), isCancelled: () => true } } + ) + return results +} + const identityOf = (entry: Record): Record => ({ byteLength: entry.byteLength, inode: entry.inode, @@ -248,6 +287,264 @@ 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( + { + target: { kind: 'environment' as const, environmentId: '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 }) + // Why: a release that raced ahead of a later file's stream would forget that file's cancel. + const lastUpload = Math.max(...fsUploadExternalFileToRuntime.mock.invocationCallOrder) + expect(fsReleaseRuntimeUpload.mock.invocationCallOrder[0]).toBeGreaterThan(lastUpload) + }) + + 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( + { + target: { kind: 'environment' as const, environmentId: '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>(() => + runtimeEnvironmentCall.mock.calls.some(([call]) => call.method === 'files.createDirNoClobber') + ) + + await expect( + importExternalPathsToRuntime( + { + target: { kind: 'environment' as const, environmentId: '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', cancelled: true }] + }) + + 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('stops an empty folder whose cancel lands while its last directory is being created', async () => { + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/empty', + status: 'staged', + name: 'empty', + kind: 'directory', + entries: [{ relativePath: '', kind: 'directory' }] + } + ] + }) + let cancelled = false + runtimeEnvironmentCall.mockImplementation(async (call: { method: string }) => { + if (call.method === 'files.createDirNoClobber') { + cancelled = true + return okResponse('create-import-root') + } + return call.method === 'files.stat' ? notFoundResponse('stat-miss') : okResponse('ok') + }) + + const { results } = await importExternalPathsToRuntime( + { + target: { kind: 'environment' as const, environmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/empty'], + '/remote/repo/uploads', + { progress: { ...quietHandlers(), isCancelled: () => cancelled } } + ) + + expect(results).toEqual([ + { + sourcePath: '/Users/me/empty', + status: 'failed', + reason: 'Upload cancelled', + cancelled: true + } + ]) + expect(runtimeEnvironmentCall).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: 'files.delete', + params: expect.objectContaining({ relativePath: 'uploads/empty' }) + }) + ) + }) + + it('creates nothing on the runtime when cancel lands before the first directory', async () => { + fsStageExternalPathsForRuntimeUpload.mockResolvedValue({ + sources: [ + { + sourcePath: '/Users/me/empty', + status: 'staged', + name: 'empty', + kind: 'directory', + entries: [{ relativePath: '', kind: 'directory' }] + } + ] + }) + runtimeEnvironmentCall.mockImplementation(async (call: { method: string }) => + call.method === 'files.stat' ? notFoundResponse('stat-miss') : okResponse('ok') + ) + + const { results } = await importExternalPathsToRuntime( + { + target: { kind: 'environment' as const, environmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/remote/repo' + }, + ['/Users/me/empty'], + '/remote/repo/uploads', + { progress: { ...quietHandlers(), isCancelled: () => true } } + ) + + expect(results[0]).toMatchObject({ status: 'failed', cancelled: true }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'files.createDirNoClobber' }) + ) + }) + + it('marks a cancelled file cancelled through the IPC error wrapper', async () => { + const [result] = await importFileThatThrowsAfterCancel( + "Error invoking remote method 'fs:uploadExternalFileToRuntime': Error: Upload cancelled" + ) + + expect(result).toMatchObject({ status: 'failed', cancelled: true }) + }) + + it('reports a failure that came after the cancel click as a failure', async () => { + const [result] = await importFileThatThrowsAfterCancel( + "Error invoking remote method 'fs:uploadExternalFileToRuntime': Error: Remote connection dropped" + ) + + expect(result?.status).toBe('failed') + expect(result).not.toHaveProperty('cancelled') + }) + 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 8ab5365a44fc..71ec93e7785e 100644 --- a/src/renderer/src/runtime/runtime-file-client-test-harness.ts +++ b/src/renderer/src/runtime/runtime-file-client-test-harness.ts @@ -56,6 +56,11 @@ 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 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() @@ -93,6 +98,12 @@ export function installRuntimeFileClientEnvironment(): void { fsStageExternalPathsForRuntimeUpload.mockReset() fsUploadExternalFileToRuntime.mockReset() fsUploadExternalFileToRuntime.mockResolvedValue({ byteLength: 0 }) + fsOnUploadProgress.mockReset() + fsOnUploadProgress.mockReturnValue(() => {}) + fsCancelRuntimeUpload.mockReset() + fsCancelRuntimeUpload.mockResolvedValue(undefined) + fsReleaseRuntimeUpload.mockReset() + fsReleaseRuntimeUpload.mockResolvedValue(undefined) runtimeEnvironmentCall.mockReset() runtimeEnvironmentTransportCall.mockReset() runtimeEnvironmentSubscribe.mockReset() @@ -138,7 +149,10 @@ export function installRuntimeFileClientEnvironment(): void { cancelDownloadedFile: fsCancelDownloadedFile, importExternalPaths: fsImportExternalPaths, stageExternalPathsForRuntimeUpload: fsStageExternalPathsForRuntimeUpload, - uploadExternalFileToRuntime: fsUploadExternalFileToRuntime + uploadExternalFileToRuntime: fsUploadExternalFileToRuntime, + onUploadProgress: fsOnUploadProgress, + cancelRuntimeUpload: fsCancelRuntimeUpload, + releaseRuntimeUpload: fsReleaseRuntimeUpload }, runtime: { call: runtimeCall }, runtimeEnvironments: { diff --git a/src/renderer/src/runtime/runtime-file-client-types.ts b/src/renderer/src/runtime/runtime-file-client-types.ts index e67b5da235bf..49e73bb70f67 100644 --- a/src/renderer/src/runtime/runtime-file-client-types.ts +++ b/src/renderer/src/runtime/runtime-file-client-types.ts @@ -1,3 +1,4 @@ +import type { DownloadProgressTracker } from '@/components/transfer-progress/download-progress-tracker' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { LocalFileAccess } from '../../../shared/local-file-access' @@ -42,3 +43,9 @@ export type RuntimeFileOperationArgs = { export type RuntimeFileDownloadResult = | { canceled: true } | { canceled: false; destinationPath: string } + +/** Lets a runtime download report progress and be canceled. */ +export type RuntimeFileDownloadTransfer = { + signal: AbortSignal + trackProgress: (totalBytes: number | null) => DownloadProgressTracker +} diff --git a/src/renderer/src/runtime/runtime-file-import-client.ts b/src/renderer/src/runtime/runtime-file-import-client.ts index 9275d06b8325..efc5b3e7eb35 100644 --- a/src/renderer/src/runtime/runtime-file-import-client.ts +++ b/src/renderer/src/runtime/runtime-file-import-client.ts @@ -1,3 +1,4 @@ +import { createBrowserUuid } from '@/lib/browser-uuid' import { joinPath } from '@/lib/path' import type { ImportItemResult } from '../../../shared/filesystem-import-result-types' import { deconflictCopyName } from '../../../shared/import-copy-name' @@ -23,6 +24,15 @@ import { import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import type { LocalFileAccess } from '../../../shared/local-file-access' import { localAccess } from './runtime-file-read-client' +import { + createRuntimeUploadProgressTracker, + sumSourceUploadBytes, + type RuntimeImportProgressHandlers, + type RuntimeImportProgressRow, + type RuntimeUploadProgressTracker, + stopIfCancelled, + wasStoppedByCancel +} from './runtime-upload-progress-tracker' export async function importExternalPathsToRuntime( context: RuntimeFileOperationArgs, @@ -33,6 +43,7 @@ export async function importExternalPathsToRuntime( assertCurrent?: () => void /** Local imports only; remote destinations stay root-relative. */ access?: LocalFileAccess + progress?: RuntimeImportProgressHandlers } ): Promise<{ results: ImportItemResult[] }> { const target = context.target @@ -79,99 +90,172 @@ export async function importExternalPathsToRuntime( importSession.assertCurrent() const staged = await window.api.fs.stageExternalPathsForRuntimeUpload({ sourcePaths }) importSession.assertCurrent() + 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. + // 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 [sourceIndex, source] of staged.sources.entries()) { + if (source.status !== 'staged') { + continue + } + const rowUploadId = createBrowserUuid() + uploadIdsBySourceIndex.set(sourceIndex, 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, + kind: source.kind + }) + } + 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, event.fileSequence) + }) + : null const results: ImportItemResult[] = [] const reservedNames = new Set() + let nextFileSequence = 0 - await ensureRuntimeDirectory(context, destinationDir, importSession) + try { + await ensureRuntimeDirectory(context, destinationDir, importSession) - for (const source of staged.sources) { - if (source.status !== 'staged') { - results.push(source) - continue - } - let createdDirectoryImportRoot: string | null = null - try { - const finalName = await deconflictCopyName(source.name, async (n) => { - importSession.assertCurrent() - return ( - (await runtimePathExists( - context, - joinPath(destinationDir, n), - importSession.expectedEnvironmentPairingRevision - )) || reservedNames.has(n) - ) - }) - 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 [sourceIndex, source] of staged.sources.entries()) { + if (source.status !== 'staged') { + results.push(source) + continue + } + let createdDirectoryImportRoot: string | null = null + const sourceUploadId = uploadIdsBySourceIndex.get(sourceIndex) + try { + const finalName = await deconflictCopyName(source.name, async (n) => { + importSession.assertCurrent() + return ( + (await runtimePathExists( + context, + joinPath(destinationDir, n), + importSession.expectedEnvironmentPairingRevision + )) || reservedNames.has(n) + ) + }) + 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') { + // Why: only file streams reach main's cancel registry, so directories check here: + // before a create, so nothing new appears after a cancel, and after it, for one + // cancelled mid-request. + stopIfCancelled(handlers, sourceUploadId) + await callRuntimeFileImportMutation( + importSession, + 'files.createDirNoClobber', + withSshMutationExpectation(context, { + worktree: toRuntimeWorktreeSelector(context.worktreeId), + relativePath: entryRelativePath + }), + 15_000 + ) + if (source.kind === 'directory' && entry.relativePath === '') { + createdDirectoryImportRoot = entryRelativePath + } + stopIfCancelled(handlers, sourceUploadId) + continue + } + const fileSequence = sourceUploadId ? nextFileSequence++ : undefined + if (sourceUploadId && fileSequence !== undefined) { + trackers.get(sourceUploadId)?.beginFile(fileSequence) + } + await uploadRuntimeFileWithoutClobber( + importSession, + context.worktreeId, + entryRelativePath, + { + sourceRootPath: source.sourcePath, + entryRelativePath: entry.relativePath, + expected: { + byteLength: entry.byteLength, + inode: entry.inode, + deviceId: entry.deviceId, + modifiedAtMs: entry.modifiedAtMs + } + }, + context.expectedSshConnectionGeneration, + context.expectedSshTargetId, + context.expectedExecutionHostId ?? + (context.expectedSshTargetId + ? `ssh:${encodeURIComponent(context.expectedSshTargetId)}` + : 'local'), + sourceUploadId, + fileSequence + ) + if (sourceUploadId) { + trackers.get(sourceUploadId)?.completeFile(entry.byteLength) + } + } + reservedNames.add(finalName) + results.push({ + sourcePath: source.sourcePath, + status: 'imported', + destPath, + 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 + // Orca created when a nested runtime upload fails halfway through. await callRuntimeFileImportMutation( importSession, - 'files.createDirNoClobber', + 'files.delete', withSshMutationExpectation(context, { worktree: toRuntimeWorktreeSelector(context.worktreeId), - relativePath: entryRelativePath + relativePath: createdDirectoryImportRoot, + recursive: true }), 15_000 - ) - if (source.kind === 'directory' && entry.relativePath === '') { - createdDirectoryImportRoot = entryRelativePath - } - continue + ).catch(() => {}) + } + const reason = error instanceof Error ? error.message : String(error) + results.push({ + sourcePath: source.sourcePath, + status: 'failed', + reason, + // Why: per source, since a path dropped twice can be cancelled once. + ...(wasStoppedByCancel(handlers, sourceUploadId, reason) ? { cancelled: true } : {}) + }) + // Why: reported as failed even for a cancel; the upload panel maps a failed source the + // user cancelled to 'cancelled', so the user's own action is not relabelled. + if (sourceUploadId) { + handlers?.onRowSettled(sourceUploadId, 'failed') } - await uploadRuntimeFileWithoutClobber( - importSession, - context.worktreeId, - entryRelativePath, - { - sourceRootPath: source.sourcePath, - entryRelativePath: entry.relativePath, - expected: { - byteLength: entry.byteLength, - inode: entry.inode, - deviceId: entry.deviceId, - modifiedAtMs: entry.modifiedAtMs - } - }, - context.expectedSshConnectionGeneration, - context.expectedSshTargetId, - context.expectedExecutionHostId ?? - (context.expectedSshTargetId - ? `ssh:${encodeURIComponent(context.expectedSshTargetId)}` - : 'local') - ) - } - 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. - await callRuntimeFileImportMutation( - importSession, - 'files.delete', - withSshMutationExpectation(context, { - worktree: toRuntimeWorktreeSelector(context.worktreeId), - relativePath: createdDirectoryImportRoot, - recursive: true - }), - 15_000 - ).catch(() => {}) } - results.push({ - sourcePath: source.sourcePath, - status: 'failed', - reason: error instanceof Error ? error.message : String(error) - }) } - } - return { results } + return { results } + } finally { + unsubscribeProgress?.() + 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-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index bf3ac95aebed..5b57971f2042 100644 --- a/src/renderer/src/runtime/runtime-file-read-client.ts +++ b/src/renderer/src/runtime/runtime-file-read-client.ts @@ -1,12 +1,9 @@ -import type { - RuntimeFilePreviewResult, - RuntimeFileReadChunkResult, - RuntimeFileReadResult -} from '../../../shared/runtime-types' +import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../../shared/runtime-types' import { callRuntimeRpc } from './runtime-rpc-client' import { RuntimeRpcCallError } from './runtime-rpc-result' import type { RuntimeFileDownloadResult, + RuntimeFileDownloadTransfer, RuntimeFileOperationArgs, RuntimeFileReadArgs, RuntimeReadableFileContent @@ -28,12 +25,12 @@ import { readRemoteFileInChunks, readRemoteFilePreview } from './runtime-paged-file-read' - -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.' - -type RemoteFileDownloadArgs = NonNullable> +import { + downloadRemoteFileViaPreview, + readRemoteDownloadChunk, + readRemoteDownloadSize, + remoteChunkedDownloadAvailable +} from './runtime-file-remote-download-rpc' // Why local only: an SSH read is the remote host's to check, and its relay never sees this field. export function localAccess( @@ -170,7 +167,8 @@ export async function readRuntimeFilePreview( export async function downloadRuntimeFile( context: RuntimeFileOperationArgs, filePath: string, - suggestedName: string + suggestedName: string, + transfer?: RuntimeFileDownloadTransfer ): Promise { assertExternalSshReadOwnership( context.target, @@ -194,7 +192,10 @@ export async function downloadRuntimeFile( } if (!(await remoteChunkedDownloadAvailable(remoteArgs))) { - return downloadRemoteFileViaPreview(remoteArgs, suggestedName) + // Why: an older server answers in one preview-capped reply (10 MB binary); the panel + // still appears so the user sees it working and can cancel before the save. + transfer?.trackProgress(null).flush() + return downloadRemoteFileViaPreview(remoteArgs, suggestedName, transfer?.signal) } const download = await window.api.fs.startDownloadedFile({ suggestedName }) @@ -203,15 +204,22 @@ export async function downloadRuntimeFile( } let finished = false + // Why: the size only feeds the progress bar, so an old or failing stat must not block the download. + const progress = transfer + ? transfer.trackProgress(await readRemoteDownloadSize(remoteArgs).catch(() => null)) + : undefined try { let offset = 0 for (;;) { + // Why: checked per chunk; a read-only chunk in flight is cheaper to finish than to abort. + transfer?.signal.throwIfAborted() const chunk = await readRemoteDownloadChunk(remoteArgs, offset) if (chunk.bytesRead > 0) { await window.api.fs.appendDownloadedFileChunk({ transferId: download.transferId, contentBase64: chunk.contentBase64 }) + progress?.addBytes(chunk.bytesRead) } offset += chunk.bytesRead if (chunk.eof) { @@ -221,95 +229,15 @@ export async function downloadRuntimeFile( throw new Error('Remote download stalled before reaching EOF') } } + // Why: a cancel during the final append would otherwise still commit the file. + transfer?.signal.throwIfAborted() const result = await window.api.fs.finishDownloadedFile({ transferId: download.transferId }) finished = true return result } finally { + progress?.flush() if (!finished) { await window.api.fs.cancelDownloadedFile({ transferId: download.transferId }).catch(() => {}) } } } - -async function remoteChunkedDownloadAvailable( - remoteArgs: RemoteFileDownloadArgs -): Promise { - try { - await callRuntimeRpc( - remoteArgs.target, - 'files.readChunk', - { - worktree: remoteArgs.worktreeSelector, - relativePath: remoteArgs.relativePath, - offset: 0, - length: 1 - }, - { timeoutMs: 60_000 } - ) - return true - } catch (error) { - // Why: compatible older headless servers may lack chunked downloads while - // still supporting preview-sized file reads that can complete the request. - if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { - return false - } - throw error - } -} - -async function readRemoteDownloadChunk( - remoteArgs: RemoteFileDownloadArgs, - offset: number -): Promise { - return callRuntimeRpc( - remoteArgs.target, - 'files.readChunk', - { - worktree: remoteArgs.worktreeSelector, - relativePath: remoteArgs.relativePath, - offset, - length: REMOTE_DOWNLOAD_CHUNK_BYTES - }, - { timeoutMs: 60_000 } - ) -} - -async function downloadRemoteFileViaPreview( - remoteArgs: RemoteFileDownloadArgs, - suggestedName: string -): Promise { - try { - const result = await callRuntimeRpc( - remoteArgs.target, - 'files.readPreview', - { worktree: remoteArgs.worktreeSelector, relativePath: remoteArgs.relativePath }, - { timeoutMs: 15_000 } - ) - // Why: old servers use an empty, metadata-free binary result to signal an - // unsupported binary; recognized zero-byte previews are still complete. - if (result.isBinary && !result.content && !result.isImage && !result.mimeType) { - throw new Error(REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE) - } - return window.api.fs.saveDownloadedFile({ - suggestedName, - content: result.content, - encoding: result.isBinary ? 'base64' : 'utf8' - }) - } catch (error) { - if (isUnsupportedRemotePreviewDownload(error)) { - throw new Error(REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE) - } - throw error - } -} - -function isUnsupportedRemotePreviewDownload(error: unknown): boolean { - if (!(error instanceof RuntimeRpcCallError)) { - return false - } - return ( - error.code === 'method_not_found' || - (error.code === 'runtime_error' && - (error.message === 'file_too_large' || error.message === 'binary_file')) - ) -} diff --git a/src/renderer/src/runtime/runtime-file-remote-download-rpc.ts b/src/renderer/src/runtime/runtime-file-remote-download-rpc.ts new file mode 100644 index 000000000000..07a27ebc16e5 --- /dev/null +++ b/src/renderer/src/runtime/runtime-file-remote-download-rpc.ts @@ -0,0 +1,110 @@ +import type { + RuntimeFilePreviewResult, + RuntimeFileReadChunkResult +} from '../../../shared/runtime-types' +import { callRuntimeRpc } from './runtime-rpc-client' +import { RuntimeRpcCallError } from './runtime-rpc-result' +import type { RuntimeFileDownloadResult } from './runtime-file-client-types' +import type { getRemoteFileArgs } from './runtime-file-routing' + +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.' + +type RemoteFileDownloadArgs = NonNullable> + +export async function remoteChunkedDownloadAvailable( + remoteArgs: RemoteFileDownloadArgs +): Promise { + try { + await callRuntimeRpc( + remoteArgs.target, + 'files.readChunk', + { + worktree: remoteArgs.worktreeSelector, + relativePath: remoteArgs.relativePath, + offset: 0, + length: 1 + }, + { timeoutMs: 60_000 } + ) + return true + } catch (error) { + // Why: compatible older headless servers may lack chunked downloads while + // still supporting preview-sized file reads that can complete the request. + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + return false + } + throw error + } +} + +export async function readRemoteDownloadChunk( + remoteArgs: RemoteFileDownloadArgs, + offset: number +): Promise { + return callRuntimeRpc( + remoteArgs.target, + 'files.readChunk', + { + worktree: remoteArgs.worktreeSelector, + relativePath: remoteArgs.relativePath, + offset, + length: REMOTE_DOWNLOAD_CHUNK_BYTES + }, + { timeoutMs: 60_000 } + ) +} + +export async function readRemoteDownloadSize(remoteArgs: RemoteFileDownloadArgs): Promise { + const stat = await callRuntimeRpc<{ size: number }>( + remoteArgs.target, + 'files.stat', + { worktree: remoteArgs.worktreeSelector, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) + return stat.size +} + +export async function downloadRemoteFileViaPreview( + remoteArgs: RemoteFileDownloadArgs, + suggestedName: string, + signal?: AbortSignal +): Promise { + try { + const result = await callRuntimeRpc( + remoteArgs.target, + 'files.readPreview', + { worktree: remoteArgs.worktreeSelector, relativePath: remoteArgs.relativePath }, + { timeoutMs: 15_000 } + ) + // Why: old servers use an empty, metadata-free binary result to signal an + // unsupported binary; recognized zero-byte previews are still complete. + if (result.isBinary && !result.content && !result.isImage && !result.mimeType) { + throw new Error(REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE) + } + // Why: older servers return the whole file in one reply, so cancel can only stop the save. + signal?.throwIfAborted() + return window.api.fs.saveDownloadedFile({ + suggestedName, + content: result.content, + encoding: result.isBinary ? 'base64' : 'utf8' + }) + } catch (error) { + if (isUnsupportedRemotePreviewDownload(error)) { + throw new Error(REMOTE_DOWNLOAD_UPDATE_REQUIRED_MESSAGE) + } + throw error + } +} + +function isUnsupportedRemotePreviewDownload(error: unknown): boolean { + if (!(error instanceof RuntimeRpcCallError)) { + return false + } + return ( + error.code === 'method_not_found' || + (error.code === 'runtime_error' && + (error.message === 'file_too_large' || error.message === 'binary_file')) + ) +} diff --git a/src/renderer/src/runtime/runtime-file-upload-client.ts b/src/renderer/src/runtime/runtime-file-upload-client.ts index fc3d0f83a9ae..74e45916a9e8 100644 --- a/src/renderer/src/runtime/runtime-file-upload-client.ts +++ b/src/renderer/src/runtime/runtime-file-upload-client.ts @@ -30,7 +30,9 @@ export async function uploadRuntimeFileWithoutClobber( source: RuntimeUploadSource, expectedSshConnectionGeneration?: number, expectedSshTargetId?: string, - expectedExecutionHostId?: 'local' | `ssh:${string}` + expectedExecutionHostId?: 'local' | `ssh:${string}`, + uploadId?: string, + fileSequence?: number ): Promise { const tempRelativePath = makeRuntimeUploadTempPath(relativePath) try { @@ -45,6 +47,8 @@ export async function uploadRuntimeFileWithoutClobber( expected: source.expected, 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 new file mode 100644 index 000000000000..51acc56bd5b4 --- /dev/null +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' +import { + createRuntimeUploadProgressTracker, + sumSourceUploadBytes +} from './runtime-upload-progress-tracker' + +describe('sumSourceUploadBytes', () => { + it('counts file bytes and ignores directory entries', () => { + expect( + sumSourceUploadBytes({ + entries: [ + { kind: 'directory' }, + { kind: 'file', byteLength: 10 }, + { kind: 'directory' }, + { kind: 'file', byteLength: 32 } + ] + }) + ).toBe(42) + }) + + it('is zero for a source of only empty files', () => { + expect(sumSourceUploadBytes({ entries: [{ kind: 'file', byteLength: 0 }] })).toBe(0) + }) + + it('is zero for a source with no entries at all', () => { + expect(sumSourceUploadBytes({})).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.beginFile(0) + tracker.reportFileProgress(50, 0) + tracker.reportFileProgress(100, 0) + tracker.completeFile(100) + tracker.beginFile(1) + tracker.reportFileProgress(75, 1) + + 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.beginFile(0) + tracker.reportFileProgress(10, 0) + // completeFile does not re-emit: the figure has not moved. + tracker.completeFile(10) + tracker.beginFile(1) + tracker.reportFileProgress(20, 1) + + 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.beginFile(0) + tracker.reportFileProgress(40, 0) + tracker.reportFileProgress(40, 0) + 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.beginFile(0) + tracker.reportFileProgress(250, 0) + + 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 [sequence, size] of [10, 10, 10].entries()) { + tracker.beginFile(sequence) + tracker.reportFileProgress(size, sequence) + tracker.completeFile(size) + } + + 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(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, 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, 0) + + expect(seen).toEqual([]) + }) + + 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..96e0cb80aae0 --- /dev/null +++ b/src/renderer/src/runtime/runtime-upload-progress-tracker.ts @@ -0,0 +1,118 @@ +export type RuntimeUploadProgressReport = { sentBytes: number; totalBytes: number } + +export type RuntimeUploadProgressTracker = { + /** Opens the window in which progress for the next file is accepted. */ + 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 +} + +/** Committed files plus the one in flight; uploads are strictly sequential. */ +export function createRuntimeUploadProgressTracker( + totalBytes: number, + report: (progress: RuntimeUploadProgressReport) => void +): RuntimeUploadProgressTracker { + 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 + // 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 + // 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 { + beginFile: (fileSequence) => { + acceptingProgress = true + currentFileSequence = fileSequence + inFlightBytes = 0 + }, + reportFileProgress: (sentBytes, fileSequence) => { + if (!acceptingProgress) { + return + } + if (fileSequence === undefined || fileSequence !== currentFileSequence) { + return + } + inFlightBytes = sentBytes + emit() + }, + completeFile: (byteLength) => { + acceptingProgress = false + completedBytes += byteLength + inFlightBytes = 0 + emit() + } + } +} + +/** One row in the drop UI: everything the user dropped becomes exactly one. */ +export type RuntimeImportProgressRow = { + uploadId: string + name: string + totalBytes: number + sourcePath: string + kind?: 'file' | 'directory' +} + +export type RuntimeImportProgressHandlers = { + onStart: (rows: RuntimeImportProgressRow[]) => void + 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. */ +export function sumSourceUploadBytes(source: { + entries?: { kind: string; byteLength?: number }[] +}): number { + let total = 0 + for (const entry of source.entries ?? []) { + if (entry.kind === 'file') { + total += entry.byteLength ?? 0 + } + } + return total +} + +// Why: matches main's RuntimeUploadCancelledError, so a cancel reads the same from either side. +const UPLOAD_CANCELLED_REASON = 'Upload cancelled' + +/** The user's cancel is what stopped this source, not a failure that came after the click. */ +export function wasStoppedByCancel( + handlers: RuntimeImportProgressHandlers | undefined, + uploadId: string | undefined, + reason: string +): boolean { + return ( + uploadId !== undefined && + handlers?.isCancelled?.(uploadId) === true && + reason === UPLOAD_CANCELLED_REASON + ) +} + +/** Throws the cancel reason once the user cancelled this source's row. */ +export function stopIfCancelled( + handlers: RuntimeImportProgressHandlers | undefined, + uploadId: string | undefined +): void { + if (uploadId !== undefined && handlers?.isCancelled?.(uploadId) === true) { + throw new Error(UPLOAD_CANCELLED_REASON) + } +} diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index e11ac26b0eb1..469b2822932a 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -167,6 +167,9 @@ export function createFileApi(): NonNullable['fs']> { uploadExternalFileToRuntime: async () => { throw new Error('Uploading local files is not supported in the web client') }, + onUploadProgress: () => noopUnsubscribe, + cancelRuntimeUpload: async () => {}, + releaseRuntimeUpload: async () => {}, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), uploadPathsToAgentSessionAttachments: async () => ({ uploaded: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), 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 diff --git a/src/shared/runtime-upload-staging-contract.ts b/src/shared/runtime-upload-staging-contract.ts index 19a3d2f6261b..b965cf46ec73 100644 --- a/src/shared/runtime-upload-staging-contract.ts +++ b/src/shared/runtime-upload-staging-contract.ts @@ -45,6 +45,10 @@ export type RuntimeUploadFileStreamRequest = { worktree: string /** Destination path on the runtime, relative to the worktree. */ 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