From 3d1cf3d871abee72728c454c9435638ab97e1b9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 09:49:57 -0500 Subject: [PATCH 01/10] feat: add explorer uploads --- package.json | 30 ++ scripts/release-contract.js | 3 +- src/commands/index.ts | 17 + src/commands/uploadFiles.ts | 503 +++++++++++++++++++ src/extension.ts | 20 +- src/providers/b2TreeDragAndDropController.ts | 90 ++++ src/services/fileTransfers.ts | 3 + src/test/suite/commands.test.ts | 227 +++++++++ src/test/suite/extension.test.ts | 1 + src/test/suite/windowStubs.ts | 14 +- 10 files changed, 904 insertions(+), 4 deletions(-) create mode 100644 src/commands/uploadFiles.ts create mode 100644 src/providers/b2TreeDragAndDropController.ts diff --git a/package.json b/package.json index 8990010f..8f823297 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,12 @@ "category": "B2", "icon": "$(new-folder)" }, + { + "command": "b2.uploadFiles", + "title": "Upload Files...", + "category": "B2", + "icon": "$(cloud-upload)" + }, { "command": "b2.deleteBucket", "title": "Delete Bucket", @@ -211,6 +217,10 @@ "command": "b2.createFolder", "when": "false" }, + { + "command": "b2.uploadFiles", + "when": "false" + }, { "command": "b2.deleteBucket", "when": "false" @@ -244,6 +254,11 @@ "when": "view == b2Buckets && b2.authenticated", "group": "navigation@2" }, + { + "command": "b2.uploadFiles", + "when": "view == b2Buckets && b2.authenticated", + "group": "navigation@3" + }, { "command": "b2.logout", "when": "view == b2Buckets && b2.authenticated", @@ -281,16 +296,31 @@ "when": "view == b2Buckets && viewItem == bucket", "group": "2_bucket@3" }, + { + "command": "b2.uploadFiles", + "when": "view == b2Buckets && viewItem == bucket", + "group": "2_bucket@4" + }, { "command": "b2.createFolder", "when": "view == b2Buckets && viewItem == folder", "group": "2_bucket@1" }, + { + "command": "b2.uploadFiles", + "when": "view == b2Buckets && viewItem == folder", + "group": "2_bucket@2" + }, { "command": "b2.createFolder", "when": "view == b2Buckets && viewItem =~ /^(bucket|folder)$/", "group": "inline@3" }, + { + "command": "b2.uploadFiles", + "when": "view == b2Buckets && viewItem =~ /^(bucket|folder)$/", + "group": "inline@4" + }, { "command": "b2.deleteBucket", "when": "view == b2Buckets && viewItem == bucket", diff --git a/scripts/release-contract.js b/scripts/release-contract.js index 9c0ecc1b..1df2b5e2 100644 --- a/scripts/release-contract.js +++ b/scripts/release-contract.js @@ -45,6 +45,7 @@ const manifestContract = { "b2.createBucket", "b2.changeBucketVisibility", "b2.createFolder", + "b2.uploadFiles", "b2.deleteBucket", "b2.deleteFolder", "b2.deleteFile", @@ -59,7 +60,7 @@ const manifestContract = { "b2_deleteFile", "b2_presignUrl", ], - contributesSha256: "40a5f09a30986dfcb974032113ec6afaa2a694fa9fd12421b222955dac64eda7", + contributesSha256: "b86e5d80718d26adb5a2bb3862b6cf5b46913a35ef9f8e1d41d6e2eaf99b2f04", }; function stableStringify(value) { diff --git a/src/commands/index.ts b/src/commands/index.ts index ee9b515d..54556f62 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -52,6 +52,11 @@ import { type PublicBucketVisibilityAction, } from "./publicBucketVisibility"; import { renameFileVersion } from "./renameFile"; +import { + isUploadTargetTreeItem, + uploadFilesCommand, + type UploadTargetTreeItem, +} from "./uploadFiles"; const BUCKET_MUTATION_TIMEOUT_MS = 2 * 60 * 1000; const BUCKET_MUTATION_POST_TIMEOUT_SETTLE_MS = 1_000; @@ -257,6 +262,7 @@ export interface CommandServices extends CreateBucketCommandServices { context: vscode.ExtensionContext; getClient: () => B2Client | null; setClient: (client: B2Client | null) => void; + getSelectedUploadTarget?: () => UploadTargetTreeItem | undefined; createClient?: ConfiguredB2ClientFactory; } @@ -658,6 +664,17 @@ export function registerCommands(services: CommandServices): void { ), ); + // ── Upload Files ───────────────────────────────────────────────────── + context.subscriptions.push( + vscode.commands.registerCommand("b2.uploadFiles", async (item?: unknown) => + uploadFilesCommand(isUploadTargetTreeItem(item) ? item : undefined, { + treeProvider, + getClient, + getSelectedUploadTarget: services.getSelectedUploadTarget, + }), + ), + ); + // ── Delete Bucket ───────────────────────────────────────────────────── context.subscriptions.push( vscode.commands.registerCommand("b2.deleteBucket", async (item?: BucketTreeItem) => { diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts new file mode 100644 index 00000000..46d46a6c --- /dev/null +++ b/src/commands/uploadFiles.ts @@ -0,0 +1,503 @@ +/** + * Explorer upload command support. + * + * @module commands/uploadFiles + */ + +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { FileNotPresentError, type B2Client, type Bucket } from "@backblaze-labs/b2-sdk"; +import { BucketTreeItem } from "../models/bucketTreeItem"; +import { FolderTreeItem } from "../models/folderTreeItem"; +import type { B2TreeProvider } from "../providers/b2TreeProvider"; +import { B2PartialFailureError, formatB2UserMessage } from "../errors"; +import { logError } from "../logger"; +import { humanSize } from "../utils/humanSize"; +import { + uploadEmptyObject, + uploadFileFromDisk, + type UploadEmptyObjectOptions, + type UploadFileFromDiskOptions, +} from "../services/fileTransfers"; +import { createTransferProgressReporter } from "../services/transferProgress"; + +export const B2_AUTO_CONTENT_TYPE = "b2/x-auto"; +export const OVERWRITE_UPLOAD_LABEL = "Overwrite"; + +const EMPTY_FOLDER_MARKER = ".bzEmpty"; +const EMPTY_FOLDER_MARKER_CONTENT_TYPE = "application/x-bzEmpty"; + +export type UploadTargetTreeItem = BucketTreeItem | FolderTreeItem; + +export interface UploadFilesCommandServices { + readonly treeProvider: Pick; + readonly getClient: () => B2Client | null; + readonly getSelectedUploadTarget?: () => UploadTargetTreeItem | undefined; +} + +export type LocalUploadEntry = LocalFileUploadEntry | EmptyDirectoryUploadEntry; + +export interface LocalFileUploadEntry { + readonly kind: "file"; + readonly localPath: string; + readonly remotePath: string; + readonly size: number; +} + +export interface EmptyDirectoryUploadEntry { + readonly kind: "emptyDirectory"; + readonly localPath: string; + readonly remotePath: string; + readonly size: 0; +} + +interface BatchProgressReporter { + readonly forEntry: ( + entry: LocalUploadEntry, + index: number, + ) => UploadFileFromDiskOptions["onProgress"]; + markEntryDone(entry: LocalUploadEntry, index: number): void; +} + +export function isUploadTargetTreeItem(item: unknown): item is UploadTargetTreeItem { + return item instanceof BucketTreeItem || item instanceof FolderTreeItem; +} + +export function uploadTargetPrefix(item: UploadTargetTreeItem): string { + return item instanceof FolderTreeItem ? item.prefix : ""; +} + +export function uploadTargetLabel(item: UploadTargetTreeItem): string { + const prefix = uploadTargetPrefix(item); + return prefix ? `b2://${item.bucketName}/${prefix}` : `b2://${item.bucketName}`; +} + +function selectedUploadTarget( + item: UploadTargetTreeItem | undefined, + services: UploadFilesCommandServices, +): UploadTargetTreeItem | undefined { + return item ?? services.getSelectedUploadTarget?.(); +} + +function normalizeB2Prefix(prefix: string): string { + return prefix && !prefix.endsWith("/") ? `${prefix}/` : prefix; +} + +function joinB2Path(prefix: string, relativePath: string): string { + return `${normalizeB2Prefix(prefix)}${relativePath.replace(/^\/+/u, "")}`; +} + +function markerPathForDirectory(remoteDirectoryPath: string): string { + return `${normalizeB2Prefix(remoteDirectoryPath)}${EMPTY_FOLDER_MARKER}`; +} + +async function collectDirectoryUploadEntries( + directoryPath: string, + remoteDirectoryPath: string, + entries: LocalUploadEntry[], +): Promise { + const children = await fs.promises.readdir(directoryPath, { withFileTypes: true }); + children.sort((left, right) => left.name.localeCompare(right.name)); + + let hasUploadableDescendant = false; + for (const child of children) { + const childPath = path.join(directoryPath, child.name); + if (child.isSymbolicLink()) { + throw new Error( + `Local upload path must be a real file or folder, not a symlink: ${childPath}`, + ); + } + + if (child.isDirectory()) { + const childRemoteDirectoryPath = joinB2Path(remoteDirectoryPath, `${child.name}/`); + const childHasEntries = await collectDirectoryUploadEntries( + childPath, + childRemoteDirectoryPath, + entries, + ); + hasUploadableDescendant = childHasEntries || hasUploadableDescendant; + continue; + } + + if (!child.isFile()) { + throw new Error(`Local upload path is not a regular file: ${childPath}`); + } + + const stats = await fs.promises.stat(childPath); + entries.push({ + kind: "file", + localPath: childPath, + remotePath: joinB2Path(remoteDirectoryPath, child.name), + size: stats.size, + }); + hasUploadableDescendant = true; + } + + if (!hasUploadableDescendant) { + entries.push({ + kind: "emptyDirectory", + localPath: directoryPath, + remotePath: markerPathForDirectory(remoteDirectoryPath), + size: 0, + }); + return true; + } + + return true; +} + +export async function collectLocalUploadEntries( + localPaths: readonly string[], + prefix: string, +): Promise { + const entries: LocalUploadEntry[] = []; + + for (const localPath of localPaths) { + const stats = await fs.promises.lstat(localPath); + if (stats.isSymbolicLink()) { + throw new Error( + `Local upload path must be a real file or folder, not a symlink: ${localPath}`, + ); + } + + if (stats.isFile()) { + entries.push({ + kind: "file", + localPath, + remotePath: joinB2Path(prefix, path.basename(localPath)), + size: stats.size, + }); + continue; + } + + if (stats.isDirectory()) { + const directoryName = path.basename(path.resolve(localPath)); + if (!directoryName) { + throw new Error("Cannot upload a filesystem root as a folder."); + } + await collectDirectoryUploadEntries( + localPath, + joinB2Path(prefix, `${directoryName}/`), + entries, + ); + continue; + } + + throw new Error(`Local upload path is not a regular file or folder: ${localPath}`); + } + + return entries; +} + +function uriToLocalPath(uri: vscode.Uri): string { + if (uri.scheme !== "file") { + throw new Error(`Only local file uploads are supported. Unsupported URI scheme: ${uri.scheme}`); + } + return uri.fsPath; +} + +function isRemoteNotFound(error: unknown): boolean { + const details = error as { + readonly status?: unknown; + readonly code?: unknown; + readonly name?: unknown; + }; + return ( + error instanceof FileNotPresentError || + details.name === "FileNotPresentError" || + details.status === 404 || + details.code === "file_not_present" || + details.code === "no_such_file" || + details.code === "not_found" + ); +} + +async function remotePathExists(bucket: Bucket, remotePath: string): Promise { + try { + await bucket.head(remotePath); + return true; + } catch (error) { + if (isRemoteNotFound(error)) { + return false; + } + throw error; + } +} + +function duplicateRemotePaths(entries: readonly LocalUploadEntry[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + + for (const entry of entries) { + if (seen.has(entry.remotePath)) { + duplicates.add(entry.remotePath); + } + seen.add(entry.remotePath); + } + + return [...duplicates]; +} + +async function existingRemotePaths( + bucket: Bucket, + entries: readonly LocalUploadEntry[], +): Promise { + const existing: string[] = []; + const checked = new Set(); + + for (const entry of entries) { + if (checked.has(entry.remotePath)) { + continue; + } + checked.add(entry.remotePath); + if (await remotePathExists(bucket, entry.remotePath)) { + existing.push(entry.remotePath); + } + } + + return existing; +} + +function overwriteWarningMessage(paths: readonly string[]): string { + const preview = paths + .slice(0, 3) + .map((remotePath) => `"${remotePath}"`) + .join(", "); + const suffix = paths.length > 3 ? ` and ${paths.length - 3} more` : ""; + + if (paths.length === 1) { + return `B2: ${preview} already exists. Uploading will overwrite it.`; + } + + return `B2: ${paths.length} upload destinations already exist or are duplicated (${preview}${suffix}). Uploading will overwrite them.`; +} + +async function confirmPotentialOverwrites( + bucket: Bucket, + entries: readonly LocalUploadEntry[], +): Promise { + const overwritePaths = [ + ...new Set([...(await existingRemotePaths(bucket, entries)), ...duplicateRemotePaths(entries)]), + ]; + + if (overwritePaths.length === 0) { + return true; + } + + const answer = await vscode.window.showWarningMessage( + overwriteWarningMessage(overwritePaths), + { modal: true }, + OVERWRITE_UPLOAD_LABEL, + ); + return answer === OVERWRITE_UPLOAD_LABEL; +} + +function createBatchProgressReporter( + progress: vscode.Progress<{ message?: string; increment?: number }>, + entries: readonly LocalUploadEntry[], +): BatchProgressReporter { + const totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0); + let completedBytes = 0; + let previousPercent = 0; + + const report = (bytesTransferred: number, entry: LocalUploadEntry, index: number): void => { + const messagePrefix = `Uploading ${index + 1}/${entries.length}: ${entry.remotePath}`; + if (totalBytes <= 0) { + progress.report({ message: messagePrefix }); + return; + } + + const aggregateBytes = Math.min(totalBytes, completedBytes + bytesTransferred); + const percent = Math.min(100, (aggregateBytes / totalBytes) * 100); + const increment = percent - previousPercent; + previousPercent = percent; + progress.report({ + message: `${messagePrefix} (${humanSize(aggregateBytes)} of ${humanSize(totalBytes)})`, + ...(increment > 0 ? { increment } : {}), + }); + }; + + return { + forEntry(entry, index) { + if (entry.kind === "emptyDirectory") { + return createTransferProgressReporter(progress, 0); + } + return (event) => report(event.bytesTransferred, entry, index); + }, + markEntryDone(entry, index) { + completedBytes = Math.min(totalBytes, completedBytes + entry.size); + report(entry.size, entry, index); + }, + }; +} + +async function uploadEntry( + bucket: Bucket, + entry: LocalUploadEntry, + options: UploadFileFromDiskOptions | UploadEmptyObjectOptions, +): Promise { + if (entry.kind === "emptyDirectory") { + await uploadEmptyObject(bucket, entry.remotePath, { + ...options, + contentType: EMPTY_FOLDER_MARKER_CONTENT_TYPE, + }); + return; + } + + await uploadFileFromDisk(bucket, entry.localPath, entry.remotePath, { + ...options, + contentType: B2_AUTO_CONTENT_TYPE, + }); +} + +async function uploadEntriesWithProgress( + target: UploadTargetTreeItem, + entries: readonly LocalUploadEntry[], + onEntryUploaded: () => void, + token?: vscode.CancellationToken, +): Promise { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Uploading ${entries.length} item(s) to ${uploadTargetLabel(target)}...`, + cancellable: true, + }, + async (progress, progressToken) => { + const controller = new AbortController(); + const disposables: vscode.Disposable[] = []; + const cancel = () => { + if (!controller.signal.aborted) { + controller.abort(new vscode.CancellationError()); + } + }; + + if (token?.isCancellationRequested || progressToken.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + if (token) { + disposables.push(token.onCancellationRequested(cancel)); + } + disposables.push(progressToken.onCancellationRequested(cancel)); + + let uploadedCount = 0; + const reporter = createBatchProgressReporter(progress, entries); + try { + for (const [index, entry] of entries.entries()) { + if (controller.signal.aborted) { + throw new vscode.CancellationError(); + } + + await uploadEntry(target.bucket, entry, { + signal: controller.signal, + onProgress: reporter.forEntry(entry, index), + }); + reporter.markEntryDone(entry, index); + uploadedCount++; + onEntryUploaded(); + } + return uploadedCount; + } catch (error) { + if ( + controller.signal.aborted || + token?.isCancellationRequested || + progressToken.isCancellationRequested + ) { + throw new vscode.CancellationError(); + } + if (uploadedCount > 0) { + throw new B2PartialFailureError( + `Uploaded ${uploadedCount} of ${entries.length} item(s), then upload failed. ${formatB2UserMessage(error)}`, + error, + ); + } + throw error; + } finally { + for (const disposable of disposables) { + disposable.dispose(); + } + } + }, + ); +} + +function showUploadError(prefix: string, error: unknown): void { + logError(prefix, error); + vscode.window.showErrorMessage(`${prefix}. ${formatB2UserMessage(error)}`); +} + +export async function uploadLocalUrisToTarget( + target: UploadTargetTreeItem, + uris: readonly vscode.Uri[], + services: UploadFilesCommandServices, + token?: vscode.CancellationToken, +): Promise { + const client = services.getClient(); + if (!client) { + vscode.window.showErrorMessage("B2: Not authenticated."); + return; + } + + if (uris.length === 0) { + return; + } + + let uploadedCount = 0; + try { + const entries = await collectLocalUploadEntries( + uris.map(uriToLocalPath), + uploadTargetPrefix(target), + ); + if (entries.length === 0) { + vscode.window.showInformationMessage("B2: No files found to upload."); + return; + } + + if (!(await confirmPotentialOverwrites(target.bucket, entries))) { + return; + } + + uploadedCount = await uploadEntriesWithProgress(target, entries, () => uploadedCount++, token); + services.treeProvider.refresh(); + vscode.window.showInformationMessage( + `B2: Uploaded ${uploadedCount} item(s) to ${uploadTargetLabel(target)}.`, + ); + } catch (error) { + if (error instanceof vscode.CancellationError) { + if (uploadedCount > 0) { + services.treeProvider.refresh(); + } + return; + } + if (uploadedCount > 0) { + services.treeProvider.refresh(); + } + showUploadError("B2: Failed to upload files", error); + } +} + +export async function uploadFilesCommand( + item: UploadTargetTreeItem | undefined, + services: UploadFilesCommandServices, +): Promise { + const target = selectedUploadTarget(item, services); + if (!target) { + vscode.window.showErrorMessage("B2: Select a bucket or folder first."); + return; + } + + const uris = await vscode.window.showOpenDialog({ + title: "Upload Files to B2", + openLabel: "Upload", + canSelectFiles: true, + canSelectFolders: true, + canSelectMany: true, + }); + + if (!uris || uris.length === 0) { + return; + } + + await uploadLocalUrisToTarget(target, uris, services); +} diff --git a/src/extension.ts b/src/extension.ts index 092e4387..71787a0d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,9 +22,11 @@ import { import { withTimeout } from "./services/transferTimeout"; import { AuthService } from "./services/authService"; import { cleanupStaleTempFileCache, TempFileManager } from "./services/tempFileManager"; -import { B2TreeProvider } from "./providers/b2TreeProvider"; +import { B2TreeProvider, type B2TreeItem } from "./providers/b2TreeProvider"; +import { B2TreeDragAndDropController } from "./providers/b2TreeDragAndDropController"; import { B2StatusBar } from "./ui/statusBar"; import { registerCommands } from "./commands"; +import { isUploadTargetTreeItem, uploadLocalUrisToTarget } from "./commands/uploadFiles"; import { registerB2Tools } from "./tools/registration"; import { TEMP_DIR_NAME, VIEW_BUCKETS } from "./constants"; import { initLogger, log, logError } from "./logger"; @@ -210,10 +212,23 @@ export async function activate(context: vscode.ExtensionContext): Promise // 2. Tree provider const treeProvider = new B2TreeProvider(authService); + let treeView: vscode.TreeView; + const uploadServices = { + treeProvider, + getClient: () => currentClient, + getSelectedUploadTarget: () => { + const selected = treeView.selection[0]; + return isUploadTargetTreeItem(selected) ? selected : undefined; + }, + }; + const dragAndDropController = new B2TreeDragAndDropController((target, uris, token) => + uploadLocalUrisToTarget(target, uris, uploadServices, token), + ); // 3. Register tree view - const treeView = vscode.window.createTreeView(VIEW_BUCKETS, { + treeView = vscode.window.createTreeView(VIEW_BUCKETS, { treeDataProvider: treeProvider, + dragAndDropController, showCollapseAll: true, }); @@ -229,6 +244,7 @@ export async function activate(context: vscode.ExtensionContext): Promise tempFileManager, isAuthenticated: () => currentClient !== null, getClient: () => currentClient, + getSelectedUploadTarget: uploadServices.getSelectedUploadTarget, setClient: setAuthenticatedClient, }); registerB2Tools(context, () => currentClient); diff --git a/src/providers/b2TreeDragAndDropController.ts b/src/providers/b2TreeDragAndDropController.ts new file mode 100644 index 00000000..1e012ed5 --- /dev/null +++ b/src/providers/b2TreeDragAndDropController.ts @@ -0,0 +1,90 @@ +/** + * Drag-and-drop support for the B2 Buckets tree. + * + * @module providers/b2TreeDragAndDropController + */ + +import * as vscode from "vscode"; +import type { B2TreeItem } from "./b2TreeProvider"; +import { isUploadTargetTreeItem, type UploadTargetTreeItem } from "../commands/uploadFiles"; + +export const FILES_MIME_TYPE = "files"; +export const URI_LIST_MIME_TYPE = "text/uri-list"; + +export type DroppedFileUploader = ( + target: UploadTargetTreeItem, + uris: readonly vscode.Uri[], + token: vscode.CancellationToken, +) => Thenable; + +function addUniqueUri(uris: vscode.Uri[], seen: Set, uri: vscode.Uri): void { + const key = uri.toString(); + if (seen.has(key)) { + return; + } + seen.add(key); + uris.push(uri); +} + +async function addUriList( + uris: vscode.Uri[], + seen: Set, + item: vscode.DataTransferItem | undefined, +): Promise { + if (!item) { + return; + } + + const value = await item.asString(); + for (const line of value.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + addUniqueUri(uris, seen, vscode.Uri.parse(trimmed)); + } +} + +export async function droppedFileUris(dataTransfer: vscode.DataTransfer): Promise { + const uris: vscode.Uri[] = []; + const seen = new Set(); + + for (const [, item] of dataTransfer) { + const file = item.asFile(); + if (file?.uri) { + addUniqueUri(uris, seen, file.uri); + } + } + + await addUriList(uris, seen, dataTransfer.get(URI_LIST_MIME_TYPE)); + return uris; +} + +export class B2TreeDragAndDropController implements vscode.TreeDragAndDropController { + readonly dropMimeTypes = [FILES_MIME_TYPE, URI_LIST_MIME_TYPE]; + readonly dragMimeTypes: readonly string[] = []; + + constructor(private readonly uploadDroppedFiles: DroppedFileUploader) {} + + async handleDrop( + target: B2TreeItem | undefined, + dataTransfer: vscode.DataTransfer, + token: vscode.CancellationToken, + ): Promise { + if (token.isCancellationRequested) { + return; + } + + if (!isUploadTargetTreeItem(target)) { + vscode.window.showErrorMessage("B2: Drop files onto a bucket or folder."); + return; + } + + const uris = await droppedFileUris(dataTransfer); + if (uris.length === 0) { + return; + } + + await this.uploadDroppedFiles(target, uris, token); + } +} diff --git a/src/services/fileTransfers.ts b/src/services/fileTransfers.ts index e9859cd2..79046072 100644 --- a/src/services/fileTransfers.ts +++ b/src/services/fileTransfers.ts @@ -142,6 +142,7 @@ export interface DownloadStreamToNewFileWithinRootOptions extends TransferTimeou } export interface UploadFileFromDiskOptions extends TransferTimeoutOptions { + readonly contentType?: string; readonly onProgress?: ProgressListener; readonly partSize?: number; readonly finalizationTimeoutMs?: number; @@ -196,6 +197,7 @@ export interface UploadBucketHandle { }): Promise; file(fileName: string): { createWriteStream(options?: { + contentType?: string; partSize?: number; fileInfo?: Record; signal?: AbortSignal; @@ -1214,6 +1216,7 @@ export async function uploadFileFromDisk( } const { writable, done } = bucket.file(remotePath).createWriteStream({ + ...(options.contentType !== undefined ? { contentType: options.contentType } : {}), partSize: options.partSize ?? STREAMING_UPLOAD_PART_SIZE, fileInfo: { [UPLOAD_OWNER_INFO_KEY]: "b2-vscode", diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index 1859785b..836ceff1 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -5,6 +5,8 @@ */ import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; import * as vscode from "vscode"; import { B2Client, @@ -33,7 +35,14 @@ import { B2PartialFailureError, isPostRequestB2MutationStateAmbiguous } from ".. import { createAuthenticatedClientSetter } from "../../extension"; import { BucketTreeItem } from "../../models/bucketTreeItem"; import type { FileTreeItem } from "../../models/fileTreeItem"; +import { FolderTreeItem } from "../../models/folderTreeItem"; import type { TempFileManager } from "../../services/tempFileManager"; +import { tempDir } from "../../testSupport/tempDir"; +import { + B2_AUTO_CONTENT_TYPE, + OVERWRITE_UPLOAD_LABEL, + uploadFilesCommand, +} from "../../commands/uploadFiles"; import { withWindowUiStubs } from "./windowStubs"; type CreateBucketOptions = Parameters[0]; @@ -145,6 +154,98 @@ function assertAbortSignalIsEnumerable(options: { readonly signal?: AbortSignal assert.strictEqual({ ...options }.signal, options.signal); } +interface UploadCall { + readonly kind: "stream" | "empty"; + readonly fileName: string; + readonly contentType: string | undefined; + readonly signal: AbortSignal | undefined; + readonly bytes: number; +} + +function notFoundError(): Error & { status: number; code: string } { + const error = new Error("not found") as Error & { status: number; code: string }; + error.status = 404; + error.code = "not_found"; + return error; +} + +function makeUploadBucket(existingPaths: readonly string[] = []): { + readonly bucket: Bucket; + readonly calls: UploadCall[]; +} { + const calls: UploadCall[] = []; + const existing = new Set(existingPaths); + const bucket = { + name: "bucket", + id: "bucket-id", + info: { bucketType: "allPrivate" }, + async head(fileName: string) { + if (existing.has(fileName)) { + return {}; + } + throw notFoundError(); + }, + async upload(options: { + fileName: string; + contentType?: string; + signal?: AbortSignal; + onProgress?: (event: { bytesTransferred: number; totalBytes?: number | null }) => void; + }) { + options.onProgress?.({ bytesTransferred: 0, totalBytes: 0 }); + calls.push({ + kind: "empty", + fileName: options.fileName, + contentType: options.contentType, + signal: options.signal, + bytes: 0, + }); + return { + fileId: `id-${calls.length}`, + fileName: options.fileName, + contentLength: 0, + }; + }, + file(fileName: string) { + return { + createWriteStream(options?: { + contentType?: string; + signal?: AbortSignal; + onProgress?: (event: { bytesTransferred: number; totalBytes?: number | null }) => void; + }) { + let bytes = 0; + let resolveDone: (value: unknown) => void = () => undefined; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + const writable = new WritableStream({ + write(chunk) { + bytes += chunk.byteLength; + options?.onProgress?.({ bytesTransferred: bytes, totalBytes: null }); + }, + close() { + calls.push({ + kind: "stream", + fileName, + contentType: options?.contentType, + signal: options?.signal, + bytes, + }); + resolveDone({ + fileId: `id-${calls.length}`, + fileName, + contentLength: bytes, + }); + }, + }); + return { writable, done }; + }, + }; + }, + } as unknown as Bucket; + + return { bucket, calls }; +} + suite("B2 commands error handling", () => { test("authentication errors surface invalid credential guidance", () => { const message = buildCommandErrorMessage( @@ -329,6 +430,132 @@ suite("B2 commands error handling", () => { } }); + test("uploads picked files into the selected folder target", async () => { + const root = tempDir(); + const filePath = path.join(root, "report.txt"); + fs.writeFileSync(filePath, "hello b2"); + const { bucket, calls } = makeUploadBucket(); + const target = new FolderTreeItem(bucket, "incoming/"); + let refreshes = 0; + + const ui = await withWindowUiStubs( + { + openDialogValues: [[vscode.Uri.file(filePath)]], + }, + () => + uploadFilesCommand(undefined, { + getClient: () => ({}) as unknown as B2Client, + getSelectedUploadTarget: () => target, + treeProvider: { refresh: () => refreshes++ }, + }), + ); + + assert.deepStrictEqual( + calls.map((call) => ({ + kind: call.kind, + fileName: call.fileName, + contentType: call.contentType, + bytes: call.bytes, + })), + [ + { + kind: "stream", + fileName: "incoming/report.txt", + contentType: B2_AUTO_CONTENT_TYPE, + bytes: 8, + }, + ], + ); + assert.ok(calls[0]?.signal); + assert.strictEqual(ui.openDialogs.length, 1); + assert.strictEqual(ui.openDialogs[0]?.canSelectFiles, true); + assert.strictEqual(ui.openDialogs[0]?.canSelectFolders, true); + assert.strictEqual(ui.openDialogs[0]?.canSelectMany, true); + assert.strictEqual(ui.progress.length, 1); + assert.strictEqual(ui.progress[0]?.cancellable, true); + assert.strictEqual(refreshes, 1); + assert.deepStrictEqual(ui.errors, []); + }); + + test("uploads local folders recursively and preserves empty folders", async () => { + const root = tempDir(); + const folderPath = path.join(root, "photos"); + fs.mkdirSync(path.join(folderPath, "nested"), { recursive: true }); + fs.mkdirSync(path.join(folderPath, "empty")); + fs.writeFileSync(path.join(folderPath, "cover.txt"), "cover"); + fs.writeFileSync(path.join(folderPath, "nested", "raw.bin"), Buffer.from([1, 2, 3])); + const { bucket, calls } = makeUploadBucket(); + const target = new BucketTreeItem(bucket); + + const ui = await withWindowUiStubs( + { + openDialogValues: [[vscode.Uri.file(folderPath)]], + }, + () => + uploadFilesCommand(target, { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }), + ); + + assert.deepStrictEqual( + calls.map((call) => ({ + kind: call.kind, + fileName: call.fileName, + contentType: call.contentType, + bytes: call.bytes, + })), + [ + { + kind: "stream", + fileName: "photos/cover.txt", + contentType: B2_AUTO_CONTENT_TYPE, + bytes: 5, + }, + { + kind: "empty", + fileName: "photos/empty/.bzEmpty", + contentType: "application/x-bzEmpty", + bytes: 0, + }, + { + kind: "stream", + fileName: "photos/nested/raw.bin", + contentType: B2_AUTO_CONTENT_TYPE, + bytes: 3, + }, + ], + ); + assert.deepStrictEqual(ui.errors, []); + }); + + test("warns and cancels before overwriting existing B2 objects", async () => { + const root = tempDir(); + const filePath = path.join(root, "report.txt"); + fs.writeFileSync(filePath, "hello b2"); + const { bucket, calls } = makeUploadBucket(["incoming/report.txt"]); + const target = new FolderTreeItem(bucket, "incoming/"); + let refreshes = 0; + + const ui = await withWindowUiStubs( + { + openDialogValues: [[vscode.Uri.file(filePath)]], + }, + () => + uploadFilesCommand(target, { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => refreshes++ }, + }), + ); + + assert.deepStrictEqual(calls, []); + assert.strictEqual(ui.warnings.length, 1); + assert.deepStrictEqual(ui.warnings[0]?.items, [OVERWRITE_UPLOAD_LABEL]); + assert.match(ui.warnings[0]?.message ?? "", /incoming\/report\.txt/); + assert.strictEqual(ui.progress.length, 0); + assert.strictEqual(refreshes, 0); + }); + test("classifies public mutation failures by certainty", () => { assert.strictEqual( isPostRequestB2MutationStateAmbiguous( diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index d651cd32..fbacca1c 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -57,6 +57,7 @@ suite("B2 Extension Test Suite", () => { "b2.createBucket", "b2.changeBucketVisibility", "b2.createFolder", + "b2.uploadFiles", "b2.deleteBucket", "b2.deleteFolder", "b2.deleteFile", diff --git a/src/test/suite/windowStubs.ts b/src/test/suite/windowStubs.ts index 650e4c26..0d84b4e5 100644 --- a/src/test/suite/windowStubs.ts +++ b/src/test/suite/windowStubs.ts @@ -21,6 +21,7 @@ export interface WindowUiCalls { readonly inputs: readonly vscode.InputBoxOptions[]; readonly quickPicks: readonly QuickPickCall[]; readonly warnings: readonly WarningMessageCall[]; + readonly openDialogs: readonly vscode.OpenDialogOptions[]; readonly progress: readonly vscode.ProgressOptions[]; readonly errors: readonly string[]; readonly infos: readonly string[]; @@ -34,6 +35,7 @@ export interface WindowUiStubOptions { readonly inputValues?: readonly (string | undefined)[]; readonly quickPickLabels?: readonly (string | undefined)[]; readonly warningValues?: readonly (string | undefined)[]; + readonly openDialogValues?: readonly (readonly vscode.Uri[] | undefined)[]; } function labelForQuickPickItem(item: unknown): string { @@ -107,6 +109,7 @@ export async function withWindowUiStubs( showInputBox: typeof vscode.window.showInputBox; showQuickPick: typeof vscode.window.showQuickPick; showWarningMessage: typeof vscode.window.showWarningMessage; + showOpenDialog: typeof vscode.window.showOpenDialog; showErrorMessage: typeof vscode.window.showErrorMessage; showInformationMessage: typeof vscode.window.showInformationMessage; withProgress: typeof vscode.window.withProgress; @@ -114,15 +117,18 @@ export async function withWindowUiStubs( const originalShowInputBox = mutableWindow.showInputBox; const originalShowQuickPick = mutableWindow.showQuickPick; const originalShowWarningMessage = mutableWindow.showWarningMessage; + const originalShowOpenDialog = mutableWindow.showOpenDialog; const originalShowErrorMessage = mutableWindow.showErrorMessage; const originalShowInformationMessage = mutableWindow.showInformationMessage; const originalWithProgress = mutableWindow.withProgress; const inputValues = [...(options.inputValues ?? [])]; const quickPickLabels = [...(options.quickPickLabels ?? [])]; const warningValues = [...(options.warningValues ?? [])]; + const openDialogValues = [...(options.openDialogValues ?? [])]; const inputs: vscode.InputBoxOptions[] = []; const quickPicks: QuickPickCall[] = []; const warnings: WarningMessageCall[] = []; + const openDialogs: vscode.OpenDialogOptions[] = []; const progress: vscode.ProgressOptions[] = []; const errors: string[] = []; const infos: string[] = []; @@ -156,6 +162,11 @@ export async function withWindowUiStubs( return Promise.resolve(warningValues.shift()); }) as typeof vscode.window.showWarningMessage; + mutableWindow.showOpenDialog = ((openDialogOptions?: vscode.OpenDialogOptions) => { + openDialogs.push(openDialogOptions ?? {}); + return Promise.resolve(openDialogValues.shift()); + }) as typeof vscode.window.showOpenDialog; + mutableWindow.showErrorMessage = ((message: string) => { errors.push(message); return Promise.resolve(undefined); @@ -184,11 +195,12 @@ export async function withWindowUiStubs( try { await callback(); - return { inputs, quickPicks, warnings, progress, errors, infos }; + return { inputs, quickPicks, warnings, openDialogs, progress, errors, infos }; } finally { mutableWindow.withProgress = originalWithProgress; mutableWindow.showInformationMessage = originalShowInformationMessage; mutableWindow.showErrorMessage = originalShowErrorMessage; + mutableWindow.showOpenDialog = originalShowOpenDialog; mutableWindow.showWarningMessage = originalShowWarningMessage; mutableWindow.showQuickPick = originalShowQuickPick; mutableWindow.showInputBox = originalShowInputBox; From a4c65dfa11467f68211d56a06c45676e1a6c6630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 10:05:59 -0500 Subject: [PATCH 02/10] fix: address upload review issues --- package.json | 5 - scripts/release-contract.js | 2 +- src/commands/index.ts | 7 +- src/commands/uploadFiles.ts | 173 ++++++++++++++---- src/extension.ts | 3 +- src/models/uploadTarget.ts | 23 +++ src/providers/b2TreeDragAndDropController.ts | 31 +--- .../suite/b2TreeDragAndDropController.test.ts | 122 ++++++++++++ src/test/suite/commands.test.ts | 144 ++++++++++++++- src/test/suite/extension.test.ts | 18 ++ 10 files changed, 451 insertions(+), 77 deletions(-) create mode 100644 src/models/uploadTarget.ts create mode 100644 src/test/suite/b2TreeDragAndDropController.test.ts diff --git a/package.json b/package.json index 8f823297..85c3725a 100644 --- a/package.json +++ b/package.json @@ -316,11 +316,6 @@ "when": "view == b2Buckets && viewItem =~ /^(bucket|folder)$/", "group": "inline@3" }, - { - "command": "b2.uploadFiles", - "when": "view == b2Buckets && viewItem =~ /^(bucket|folder)$/", - "group": "inline@4" - }, { "command": "b2.deleteBucket", "when": "view == b2Buckets && viewItem == bucket", diff --git a/scripts/release-contract.js b/scripts/release-contract.js index 1df2b5e2..e640bf2e 100644 --- a/scripts/release-contract.js +++ b/scripts/release-contract.js @@ -60,7 +60,7 @@ const manifestContract = { "b2_deleteFile", "b2_presignUrl", ], - contributesSha256: "b86e5d80718d26adb5a2bb3862b6cf5b46913a35ef9f8e1d41d6e2eaf99b2f04", + contributesSha256: "1d1b39b1cf126691c0b01d073a264c0ef545f29a817fc89f892f5601d83e32cc", }; function stableStringify(value) { diff --git a/src/commands/index.ts b/src/commands/index.ts index 54556f62..4a5ca1c5 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -52,11 +52,8 @@ import { type PublicBucketVisibilityAction, } from "./publicBucketVisibility"; import { renameFileVersion } from "./renameFile"; -import { - isUploadTargetTreeItem, - uploadFilesCommand, - type UploadTargetTreeItem, -} from "./uploadFiles"; +import { isUploadTargetTreeItem, type UploadTargetTreeItem } from "../models/uploadTarget"; +import { uploadFilesCommand } from "./uploadFiles"; const BUCKET_MUTATION_TIMEOUT_MS = 2 * 60 * 1000; const BUCKET_MUTATION_POST_TIMEOUT_SETTLE_MS = 1_000; diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts index 46d46a6c..95337f0e 100644 --- a/src/commands/uploadFiles.ts +++ b/src/commands/uploadFiles.ts @@ -9,10 +9,14 @@ import * as path from "path"; import * as vscode from "vscode"; import { FileNotPresentError, type B2Client, type Bucket } from "@backblaze-labs/b2-sdk"; import { BucketTreeItem } from "../models/bucketTreeItem"; -import { FolderTreeItem } from "../models/folderTreeItem"; +import { + type UploadTargetTreeItem, + uploadTargetLabel, + uploadTargetPrefix, +} from "../models/uploadTarget"; import type { B2TreeProvider } from "../providers/b2TreeProvider"; import { B2PartialFailureError, formatB2UserMessage } from "../errors"; -import { logError } from "../logger"; +import { log, logError } from "../logger"; import { humanSize } from "../utils/humanSize"; import { uploadEmptyObject, @@ -21,14 +25,15 @@ import { type UploadFileFromDiskOptions, } from "../services/fileTransfers"; import { createTransferProgressReporter } from "../services/transferProgress"; +import { DEFAULT_TRANSFER_STALL_TIMEOUT_MS, withTimeout } from "../services/transferTimeout"; export const B2_AUTO_CONTENT_TYPE = "b2/x-auto"; export const OVERWRITE_UPLOAD_LABEL = "Overwrite"; const EMPTY_FOLDER_MARKER = ".bzEmpty"; const EMPTY_FOLDER_MARKER_CONTENT_TYPE = "application/x-bzEmpty"; - -export type UploadTargetTreeItem = BucketTreeItem | FolderTreeItem; +const OVERWRITE_PREFLIGHT_CONCURRENCY = 8; +const OVERWRITE_PREFLIGHT_TIMEOUT_MS = DEFAULT_TRANSFER_STALL_TIMEOUT_MS; export interface UploadFilesCommandServices { readonly treeProvider: Pick; @@ -60,24 +65,58 @@ interface BatchProgressReporter { markEntryDone(entry: LocalUploadEntry, index: number): void; } -export function isUploadTargetTreeItem(item: unknown): item is UploadTargetTreeItem { - return item instanceof BucketTreeItem || item instanceof FolderTreeItem; -} - -export function uploadTargetPrefix(item: UploadTargetTreeItem): string { - return item instanceof FolderTreeItem ? item.prefix : ""; +interface UploadRunOutcome { + readonly completed: boolean; + readonly uploadedCount: number; } -export function uploadTargetLabel(item: UploadTargetTreeItem): string { - const prefix = uploadTargetPrefix(item); - return prefix ? `b2://${item.bucketName}/${prefix}` : `b2://${item.bucketName}`; +class UploadCancellationAmbiguousError extends Error { + constructor( + readonly remotePath: string, + readonly uploadedCount: number, + ) { + super( + `Upload canceled while "${remotePath}" was in progress. It may have been uploaded to B2.`, + ); + this.name = "UploadCancellationAmbiguousError"; + } } -function selectedUploadTarget( +async function selectedUploadTarget( item: UploadTargetTreeItem | undefined, services: UploadFilesCommandServices, -): UploadTargetTreeItem | undefined { - return item ?? services.getSelectedUploadTarget?.(); +): Promise { + const selected = item ?? services.getSelectedUploadTarget?.(); + if (selected) { + return selected; + } + + const client = services.getClient(); + if (!client) { + vscode.window.showErrorMessage("B2: Not authenticated."); + return undefined; + } + + const buckets = await client.listBuckets(); + if (buckets.length === 0) { + vscode.window.showErrorMessage("B2: No buckets available for upload."); + return undefined; + } + + const picked = await vscode.window.showQuickPick( + buckets.map((bucket) => ({ + label: bucket.name, + description: bucket.info.bucketType, + target: new BucketTreeItem(bucket), + })), + { + title: "Upload Destination", + placeHolder: "Select a B2 bucket to upload into", + ignoreFocusOut: true, + }, + ); + + return picked?.target; } function normalizeB2Prefix(prefix: string): string { @@ -213,9 +252,18 @@ function isRemoteNotFound(error: unknown): boolean { ); } -async function remotePathExists(bucket: Bucket, remotePath: string): Promise { +async function remotePathExists( + bucket: Bucket, + remotePath: string, + signal: AbortSignal, +): Promise { try { - await bucket.head(remotePath); + await withTimeout( + (requestSignal) => bucket.head(remotePath, { signal: requestSignal }), + OVERWRITE_PREFLIGHT_TIMEOUT_MS, + `Overwrite check for b2://${bucket.name}/${remotePath}`, + { signal }, + ); return true; } catch (error) { if (isRemoteNotFound(error)) { @@ -241,21 +289,41 @@ function duplicateRemotePaths(entries: readonly LocalUploadEntry[]): string[] { async function existingRemotePaths( bucket: Bucket, - entries: readonly LocalUploadEntry[], + remotePaths: readonly string[], + progress: vscode.Progress<{ message?: string; increment?: number }>, + signal: AbortSignal, ): Promise { const existing: string[] = []; - const checked = new Set(); + let nextIndex = 0; + let checkedCount = 0; - for (const entry of entries) { - if (checked.has(entry.remotePath)) { - continue; - } - checked.add(entry.remotePath); - if (await remotePathExists(bucket, entry.remotePath)) { - existing.push(entry.remotePath); + async function worker(): Promise { + while (true) { + if (signal.aborted) { + throw signal.reason ?? new vscode.CancellationError(); + } + + const index = nextIndex++; + if (index >= remotePaths.length) { + return; + } + + const remotePath = remotePaths[index]; + progress.report({ + message: `Checking for existing B2 files ${checkedCount + 1}/${remotePaths.length}: ${remotePath}`, + }); + if (await remotePathExists(bucket, remotePath, signal)) { + existing.push(remotePath); + } + checkedCount++; } } + await Promise.all( + Array.from({ length: Math.min(OVERWRITE_PREFLIGHT_CONCURRENCY, remotePaths.length) }, () => + worker(), + ), + ); return existing; } @@ -276,9 +344,15 @@ function overwriteWarningMessage(paths: readonly string[]): string { async function confirmPotentialOverwrites( bucket: Bucket, entries: readonly LocalUploadEntry[], + progress: vscode.Progress<{ message?: string; increment?: number }>, + signal: AbortSignal, ): Promise { + const uniqueRemotePaths = [...new Set(entries.map((entry) => entry.remotePath))]; const overwritePaths = [ - ...new Set([...(await existingRemotePaths(bucket, entries)), ...duplicateRemotePaths(entries)]), + ...new Set([ + ...(await existingRemotePaths(bucket, uniqueRemotePaths, progress, signal)), + ...duplicateRemotePaths(entries), + ]), ]; if (overwritePaths.length === 0) { @@ -356,7 +430,7 @@ async function uploadEntriesWithProgress( entries: readonly LocalUploadEntry[], onEntryUploaded: () => void, token?: vscode.CancellationToken, -): Promise { +): Promise { return vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, @@ -382,28 +456,40 @@ async function uploadEntriesWithProgress( disposables.push(progressToken.onCancellationRequested(cancel)); let uploadedCount = 0; + let currentEntry: LocalUploadEntry | undefined; const reporter = createBatchProgressReporter(progress, entries); try { + if ( + !(await confirmPotentialOverwrites(target.bucket, entries, progress, controller.signal)) + ) { + return { completed: false, uploadedCount }; + } + for (const [index, entry] of entries.entries()) { if (controller.signal.aborted) { throw new vscode.CancellationError(); } + currentEntry = entry; await uploadEntry(target.bucket, entry, { signal: controller.signal, onProgress: reporter.forEntry(entry, index), }); + currentEntry = undefined; reporter.markEntryDone(entry, index); uploadedCount++; onEntryUploaded(); } - return uploadedCount; + return { completed: true, uploadedCount }; } catch (error) { if ( controller.signal.aborted || token?.isCancellationRequested || progressToken.isCancellationRequested ) { + if (currentEntry) { + throw new UploadCancellationAmbiguousError(currentEntry.remotePath, uploadedCount); + } throw new vscode.CancellationError(); } if (uploadedCount > 0) { @@ -454,16 +540,27 @@ export async function uploadLocalUrisToTarget( return; } - if (!(await confirmPotentialOverwrites(target.bucket, entries))) { + const outcome = await uploadEntriesWithProgress(target, entries, () => uploadedCount++, token); + uploadedCount = outcome.uploadedCount; + if (!outcome.completed) { return; } - uploadedCount = await uploadEntriesWithProgress(target, entries, () => uploadedCount++, token); services.treeProvider.refresh(); vscode.window.showInformationMessage( `B2: Uploaded ${uploadedCount} item(s) to ${uploadTargetLabel(target)}.`, ); } catch (error) { + if (error instanceof UploadCancellationAmbiguousError) { + services.treeProvider.refresh(); + log( + `Upload canceled while ${error.remotePath} was in flight; the object may have been committed in B2.`, + ); + await vscode.window.showWarningMessage( + `B2: Upload canceled while "${error.remotePath}" was in progress. It may have been uploaded, so the tree was refreshed. Verify before retrying to avoid duplicate versions.`, + ); + return; + } if (error instanceof vscode.CancellationError) { if (uploadedCount > 0) { services.treeProvider.refresh(); @@ -481,14 +578,20 @@ export async function uploadFilesCommand( item: UploadTargetTreeItem | undefined, services: UploadFilesCommandServices, ): Promise { - const target = selectedUploadTarget(item, services); + let target: UploadTargetTreeItem | undefined; + try { + target = await selectedUploadTarget(item, services); + } catch (error) { + showUploadError("B2: Failed to choose upload destination", error); + return; + } + if (!target) { - vscode.window.showErrorMessage("B2: Select a bucket or folder first."); return; } const uris = await vscode.window.showOpenDialog({ - title: "Upload Files to B2", + title: `Upload Files to ${uploadTargetLabel(target)}`, openLabel: "Upload", canSelectFiles: true, canSelectFolders: true, diff --git a/src/extension.ts b/src/extension.ts index 71787a0d..044721d6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,7 +26,8 @@ import { B2TreeProvider, type B2TreeItem } from "./providers/b2TreeProvider"; import { B2TreeDragAndDropController } from "./providers/b2TreeDragAndDropController"; import { B2StatusBar } from "./ui/statusBar"; import { registerCommands } from "./commands"; -import { isUploadTargetTreeItem, uploadLocalUrisToTarget } from "./commands/uploadFiles"; +import { uploadLocalUrisToTarget } from "./commands/uploadFiles"; +import { isUploadTargetTreeItem } from "./models/uploadTarget"; import { registerB2Tools } from "./tools/registration"; import { TEMP_DIR_NAME, VIEW_BUCKETS } from "./constants"; import { initLogger, log, logError } from "./logger"; diff --git a/src/models/uploadTarget.ts b/src/models/uploadTarget.ts new file mode 100644 index 00000000..a22fab18 --- /dev/null +++ b/src/models/uploadTarget.ts @@ -0,0 +1,23 @@ +/** + * Shared upload target helpers for B2 tree items. + * + * @module models/uploadTarget + */ + +import { BucketTreeItem } from "./bucketTreeItem"; +import { FolderTreeItem } from "./folderTreeItem"; + +export type UploadTargetTreeItem = BucketTreeItem | FolderTreeItem; + +export function isUploadTargetTreeItem(item: unknown): item is UploadTargetTreeItem { + return item instanceof BucketTreeItem || item instanceof FolderTreeItem; +} + +export function uploadTargetPrefix(item: UploadTargetTreeItem): string { + return item instanceof FolderTreeItem ? item.prefix : ""; +} + +export function uploadTargetLabel(item: UploadTargetTreeItem): string { + const prefix = uploadTargetPrefix(item); + return prefix ? `b2://${item.bucketName}/${prefix}` : `b2://${item.bucketName}`; +} diff --git a/src/providers/b2TreeDragAndDropController.ts b/src/providers/b2TreeDragAndDropController.ts index 1e012ed5..3a17f3ff 100644 --- a/src/providers/b2TreeDragAndDropController.ts +++ b/src/providers/b2TreeDragAndDropController.ts @@ -6,10 +6,9 @@ import * as vscode from "vscode"; import type { B2TreeItem } from "./b2TreeProvider"; -import { isUploadTargetTreeItem, type UploadTargetTreeItem } from "../commands/uploadFiles"; +import { isUploadTargetTreeItem, type UploadTargetTreeItem } from "../models/uploadTarget"; export const FILES_MIME_TYPE = "files"; -export const URI_LIST_MIME_TYPE = "text/uri-list"; export type DroppedFileUploader = ( target: UploadTargetTreeItem, @@ -26,42 +25,22 @@ function addUniqueUri(uris: vscode.Uri[], seen: Set, uri: vscode.Uri): v uris.push(uri); } -async function addUriList( - uris: vscode.Uri[], - seen: Set, - item: vscode.DataTransferItem | undefined, -): Promise { - if (!item) { - return; - } - - const value = await item.asString(); - for (const line of value.split(/\r?\n/u)) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) { - continue; - } - addUniqueUri(uris, seen, vscode.Uri.parse(trimmed)); - } -} - -export async function droppedFileUris(dataTransfer: vscode.DataTransfer): Promise { +export function droppedFileUris(dataTransfer: vscode.DataTransfer): vscode.Uri[] { const uris: vscode.Uri[] = []; const seen = new Set(); for (const [, item] of dataTransfer) { const file = item.asFile(); - if (file?.uri) { + if (file?.uri?.scheme === "file") { addUniqueUri(uris, seen, file.uri); } } - await addUriList(uris, seen, dataTransfer.get(URI_LIST_MIME_TYPE)); return uris; } export class B2TreeDragAndDropController implements vscode.TreeDragAndDropController { - readonly dropMimeTypes = [FILES_MIME_TYPE, URI_LIST_MIME_TYPE]; + readonly dropMimeTypes = [FILES_MIME_TYPE]; readonly dragMimeTypes: readonly string[] = []; constructor(private readonly uploadDroppedFiles: DroppedFileUploader) {} @@ -80,7 +59,7 @@ export class B2TreeDragAndDropController implements vscode.TreeDragAndDropContro return; } - const uris = await droppedFileUris(dataTransfer); + const uris = droppedFileUris(dataTransfer); if (uris.length === 0) { return; } diff --git a/src/test/suite/b2TreeDragAndDropController.test.ts b/src/test/suite/b2TreeDragAndDropController.test.ts new file mode 100644 index 00000000..81f01699 --- /dev/null +++ b/src/test/suite/b2TreeDragAndDropController.test.ts @@ -0,0 +1,122 @@ +/** + * Tests for B2 tree drag-and-drop boundaries. + * + * @module test/suite/b2TreeDragAndDropController + */ + +import * as assert from "assert"; +import * as vscode from "vscode"; +import type { Bucket } from "@backblaze-labs/b2-sdk"; +import { + B2TreeDragAndDropController, + FILES_MIME_TYPE, +} from "../../providers/b2TreeDragAndDropController"; +import { BucketTreeItem } from "../../models/bucketTreeItem"; + +function makeBucketTreeItem(): BucketTreeItem { + return new BucketTreeItem({ + name: "bucket", + id: "bucket-id", + info: { bucketType: "allPrivate" }, + } as unknown as Bucket); +} + +function dataTransfer( + entries: Array, +): vscode.DataTransfer { + const items = new Map(entries); + return { + get(mimeType: string) { + return items.get(mimeType); + }, + *[Symbol.iterator]() { + yield* items.entries(); + }, + } as unknown as vscode.DataTransfer; +} + +function itemWithFile(uri: vscode.Uri): vscode.DataTransferItem { + return { + asFile() { + return { + name: "upload-source", + uri, + async data() { + return new Uint8Array(); + }, + }; + }, + async asString() { + return uri.toString(); + }, + value: undefined, + } as unknown as vscode.DataTransferItem; +} + +suite("B2 tree drag and drop", () => { + test("ignores synthetic text/uri-list file URIs", async () => { + let uploadCalled = false; + const tokenSource = new vscode.CancellationTokenSource(); + const controller = new B2TreeDragAndDropController(async () => { + uploadCalled = true; + }); + + try { + await controller.handleDrop( + makeBucketTreeItem(), + dataTransfer([ + [ + "text/uri-list", + new vscode.DataTransferItem(vscode.Uri.file("/tmp/secret.txt").toString()), + ], + ]), + tokenSource.token, + ); + + assert.strictEqual(uploadCalled, false); + } finally { + tokenSource.dispose(); + } + }); + + test("ignores file transfer items that do not resolve to local files", async () => { + let uploadCalled = false; + const tokenSource = new vscode.CancellationTokenSource(); + const controller = new B2TreeDragAndDropController(async () => { + uploadCalled = true; + }); + + try { + await controller.handleDrop( + makeBucketTreeItem(), + dataTransfer([[FILES_MIME_TYPE, itemWithFile(vscode.Uri.parse("https://example.com/a"))]]), + tokenSource.token, + ); + + assert.strictEqual(uploadCalled, false); + } finally { + tokenSource.dispose(); + } + }); + + test("uploads local file transfer items", async () => { + const uploadedUris: vscode.Uri[][] = []; + const tokenSource = new vscode.CancellationTokenSource(); + const controller = new B2TreeDragAndDropController(async (_target, uris) => { + uploadedUris.push([...uris]); + }); + const uri = vscode.Uri.file("/tmp/report.txt"); + + try { + await controller.handleDrop( + makeBucketTreeItem(), + dataTransfer([[FILES_MIME_TYPE, itemWithFile(uri)]]), + tokenSource.token, + ); + + assert.deepStrictEqual(uploadedUris, [[uri]]); + } finally { + tokenSource.dispose(); + } + }); +}); diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index 836ceff1..002464e5 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -42,6 +42,7 @@ import { B2_AUTO_CONTENT_TYPE, OVERWRITE_UPLOAD_LABEL, uploadFilesCommand, + uploadLocalUrisToTarget, } from "../../commands/uploadFiles"; import { withWindowUiStubs } from "./windowStubs"; @@ -172,14 +173,17 @@ function notFoundError(): Error & { status: number; code: string } { function makeUploadBucket(existingPaths: readonly string[] = []): { readonly bucket: Bucket; readonly calls: UploadCall[]; + readonly headSignals: Array; } { const calls: UploadCall[] = []; + const headSignals: Array = []; const existing = new Set(existingPaths); const bucket = { name: "bucket", id: "bucket-id", info: { bucketType: "allPrivate" }, - async head(fileName: string) { + async head(fileName: string, options?: { signal?: AbortSignal }) { + headSignals.push(options?.signal); if (existing.has(fileName)) { return {}; } @@ -243,7 +247,31 @@ function makeUploadBucket(existingPaths: readonly string[] = []): { }, } as unknown as Bucket; - return { bucket, calls }; + return { bucket, calls, headSignals }; +} + +function makeCancellationAmbiguousBucket(onUploadStarted: () => void): Bucket { + return { + name: "bucket", + id: "bucket-id", + info: { bucketType: "allPrivate" }, + async head() { + throw notFoundError(); + }, + async upload(options: { signal?: AbortSignal }) { + onUploadStarted(); + return new Promise((_resolve, reject) => { + options.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason ?? new Error("aborted")), + { once: true }, + ); + }); + }, + file() { + throw new Error("streaming upload should not be used for zero-byte entries"); + }, + } as unknown as Bucket; } suite("B2 commands error handling", () => { @@ -434,7 +462,7 @@ suite("B2 commands error handling", () => { const root = tempDir(); const filePath = path.join(root, "report.txt"); fs.writeFileSync(filePath, "hello b2"); - const { bucket, calls } = makeUploadBucket(); + const { bucket, calls, headSignals } = makeUploadBucket(); const target = new FolderTreeItem(bucket, "incoming/"); let refreshes = 0; @@ -467,7 +495,9 @@ suite("B2 commands error handling", () => { ], ); assert.ok(calls[0]?.signal); + assert.ok(headSignals[0]); assert.strictEqual(ui.openDialogs.length, 1); + assert.strictEqual(ui.openDialogs[0]?.title, "Upload Files to b2://bucket/incoming/"); assert.strictEqual(ui.openDialogs[0]?.canSelectFiles, true); assert.strictEqual(ui.openDialogs[0]?.canSelectFolders, true); assert.strictEqual(ui.openDialogs[0]?.canSelectMany, true); @@ -477,6 +507,42 @@ suite("B2 commands error handling", () => { assert.deepStrictEqual(ui.errors, []); }); + test("title upload lets users choose a bucket when no tree target is selected", async () => { + const root = tempDir(); + const filePath = path.join(root, "report.txt"); + fs.writeFileSync(filePath, "hello b2"); + const { bucket, calls } = makeUploadBucket(); + let refreshes = 0; + const client = { + async listBuckets() { + return [bucket]; + }, + } as unknown as B2Client; + + const ui = await withWindowUiStubs( + { + quickPickLabels: ["bucket"], + openDialogValues: [[vscode.Uri.file(filePath)]], + }, + () => + uploadFilesCommand(undefined, { + getClient: () => client, + getSelectedUploadTarget: () => undefined, + treeProvider: { refresh: () => refreshes++ }, + }), + ); + + assert.deepStrictEqual(ui.quickPicks[0]?.labels, ["bucket"]); + assert.strictEqual(ui.quickPicks[0]?.options?.title, "Upload Destination"); + assert.strictEqual(ui.openDialogs[0]?.title, "Upload Files to b2://bucket"); + assert.deepStrictEqual( + calls.map((call) => call.fileName), + ["report.txt"], + ); + assert.strictEqual(refreshes, 1); + assert.deepStrictEqual(ui.errors, []); + }); + test("uploads local folders recursively and preserves empty folders", async () => { const root = tempDir(); const folderPath = path.join(root, "photos"); @@ -526,6 +592,7 @@ suite("B2 commands error handling", () => { }, ], ); + assert.strictEqual(ui.openDialogs[0]?.title, "Upload Files to b2://bucket"); assert.deepStrictEqual(ui.errors, []); }); @@ -552,10 +619,79 @@ suite("B2 commands error handling", () => { assert.strictEqual(ui.warnings.length, 1); assert.deepStrictEqual(ui.warnings[0]?.items, [OVERWRITE_UPLOAD_LABEL]); assert.match(ui.warnings[0]?.message ?? "", /incoming\/report\.txt/); - assert.strictEqual(ui.progress.length, 0); + assert.strictEqual(ui.progress.length, 1); + assert.strictEqual(ui.progress[0]?.cancellable, true); assert.strictEqual(refreshes, 0); }); + test("warns and refreshes when canceling an in-flight zero-byte file upload", async () => { + const root = tempDir(); + const filePath = path.join(root, "empty.txt"); + fs.writeFileSync(filePath, ""); + const tokenSource = new vscode.CancellationTokenSource(); + const bucket = makeCancellationAmbiguousBucket(() => { + setImmediate(() => tokenSource.cancel()); + }); + const target = new BucketTreeItem(bucket); + let refreshes = 0; + + try { + const ui = await withWindowUiStubs({}, () => + uploadLocalUrisToTarget( + target, + [vscode.Uri.file(filePath)], + { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => refreshes++ }, + }, + tokenSource.token, + ), + ); + + assert.strictEqual(refreshes, 1); + assert.strictEqual(ui.warnings.length, 1); + assert.match(ui.warnings[0]?.message ?? "", /empty\.txt/); + assert.match(ui.warnings[0]?.message ?? "", /may have been uploaded/i); + assert.deepStrictEqual(ui.errors, []); + } finally { + tokenSource.dispose(); + } + }); + + test("warns and refreshes when canceling an in-flight empty-folder marker upload", async () => { + const root = tempDir(); + const folderPath = path.join(root, "empty-folder"); + fs.mkdirSync(folderPath); + const tokenSource = new vscode.CancellationTokenSource(); + const bucket = makeCancellationAmbiguousBucket(() => { + setImmediate(() => tokenSource.cancel()); + }); + const target = new BucketTreeItem(bucket); + let refreshes = 0; + + try { + const ui = await withWindowUiStubs({}, () => + uploadLocalUrisToTarget( + target, + [vscode.Uri.file(folderPath)], + { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => refreshes++ }, + }, + tokenSource.token, + ), + ); + + assert.strictEqual(refreshes, 1); + assert.strictEqual(ui.warnings.length, 1); + assert.match(ui.warnings[0]?.message ?? "", /empty-folder\/\.bzEmpty/); + assert.match(ui.warnings[0]?.message ?? "", /may have been uploaded/i); + assert.deepStrictEqual(ui.errors, []); + } finally { + tokenSource.dispose(); + } + }); + test("classifies public mutation failures by certainty", () => { assert.strictEqual( isPostRequestB2MutationStateAmbiguous( diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index fbacca1c..57e57f3c 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -15,6 +15,7 @@ import { MAX_PRESIGN_URL_EXPIRES_IN_SECONDS } from "../../tools/presignUrlLimits interface MenuContribution { command: string; + group: string; when?: string; } @@ -84,6 +85,23 @@ suite("B2 Extension Test Suite", () => { } }); + test("upload command stays out of inline tree item actions", () => { + const extension = vscode.extensions.getExtension("backblaze.b2-vscode"); + assert.ok(extension, "Backblaze B2 extension should be discoverable by ID"); + + const viewItemMenus = extension.packageJSON.contributes.menus[ + "view/item/context" + ] as MenuContribution[]; + const uploadMenus = viewItemMenus.filter((entry) => entry.command === "b2.uploadFiles"); + + assert.ok(uploadMenus.some((entry) => entry.when?.includes("viewItem == bucket") === true)); + assert.ok(uploadMenus.some((entry) => entry.when?.includes("viewItem == folder") === true)); + assert.strictEqual( + uploadMenus.some((entry) => entry.group.startsWith("inline")), + false, + ); + }); + test("listFiles package contribution declares an integer limit schema", () => { const extension = vscode.extensions.getExtension("backblaze.b2-vscode"); assert.ok(extension, "Backblaze B2 extension should be discoverable by ID"); From 2c461ccf011ffc589f0fcccbf31c79cb457f9d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 10:10:18 -0500 Subject: [PATCH 03/10] fix: clarify upload folder wording --- src/commands/uploadFiles.ts | 2 +- src/providers/b2TreeDragAndDropController.ts | 2 +- .../suite/b2TreeDragAndDropController.test.ts | 32 +++++++++++++++++++ src/test/suite/commands.test.ts | 9 ++++-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts index 95337f0e..2e34c7f5 100644 --- a/src/commands/uploadFiles.ts +++ b/src/commands/uploadFiles.ts @@ -591,7 +591,7 @@ export async function uploadFilesCommand( } const uris = await vscode.window.showOpenDialog({ - title: `Upload Files to ${uploadTargetLabel(target)}`, + title: `Upload Files or Folders to ${uploadTargetLabel(target)}`, openLabel: "Upload", canSelectFiles: true, canSelectFolders: true, diff --git a/src/providers/b2TreeDragAndDropController.ts b/src/providers/b2TreeDragAndDropController.ts index 3a17f3ff..8006ad9d 100644 --- a/src/providers/b2TreeDragAndDropController.ts +++ b/src/providers/b2TreeDragAndDropController.ts @@ -55,7 +55,7 @@ export class B2TreeDragAndDropController implements vscode.TreeDragAndDropContro } if (!isUploadTargetTreeItem(target)) { - vscode.window.showErrorMessage("B2: Drop files onto a bucket or folder."); + vscode.window.showErrorMessage("B2: Drop files or folders onto a bucket or folder."); return; } diff --git a/src/test/suite/b2TreeDragAndDropController.test.ts b/src/test/suite/b2TreeDragAndDropController.test.ts index 81f01699..3842898e 100644 --- a/src/test/suite/b2TreeDragAndDropController.test.ts +++ b/src/test/suite/b2TreeDragAndDropController.test.ts @@ -12,6 +12,8 @@ import { FILES_MIME_TYPE, } from "../../providers/b2TreeDragAndDropController"; import { BucketTreeItem } from "../../models/bucketTreeItem"; +import { FileTreeItem } from "../../models/fileTreeItem"; +import { withWindowUiStubs } from "./windowStubs"; function makeBucketTreeItem(): BucketTreeItem { return new BucketTreeItem({ @@ -119,4 +121,34 @@ suite("B2 tree drag and drop", () => { tokenSource.dispose(); } }); + + test("describes file and folder drops for invalid targets", async () => { + let uploadCalled = false; + const tokenSource = new vscode.CancellationTokenSource(); + const controller = new B2TreeDragAndDropController(async () => { + uploadCalled = true; + }); + const fileTarget = new FileTreeItem(makeBucketTreeItem().bucket, { + fileName: "remote/report.txt", + fileId: "file-id", + contentType: "text/plain", + contentLength: 1, + uploadTimestamp: 0, + } as ConstructorParameters[1]); + + try { + const ui = await withWindowUiStubs({}, () => + controller.handleDrop( + fileTarget, + dataTransfer([[FILES_MIME_TYPE, itemWithFile(vscode.Uri.file("/tmp/report.txt"))]]), + tokenSource.token, + ), + ); + + assert.strictEqual(uploadCalled, false); + assert.deepStrictEqual(ui.errors, ["B2: Drop files or folders onto a bucket or folder."]); + } finally { + tokenSource.dispose(); + } + }); }); diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index 002464e5..147d7a02 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -497,7 +497,10 @@ suite("B2 commands error handling", () => { assert.ok(calls[0]?.signal); assert.ok(headSignals[0]); assert.strictEqual(ui.openDialogs.length, 1); - assert.strictEqual(ui.openDialogs[0]?.title, "Upload Files to b2://bucket/incoming/"); + assert.strictEqual( + ui.openDialogs[0]?.title, + "Upload Files or Folders to b2://bucket/incoming/", + ); assert.strictEqual(ui.openDialogs[0]?.canSelectFiles, true); assert.strictEqual(ui.openDialogs[0]?.canSelectFolders, true); assert.strictEqual(ui.openDialogs[0]?.canSelectMany, true); @@ -534,7 +537,7 @@ suite("B2 commands error handling", () => { assert.deepStrictEqual(ui.quickPicks[0]?.labels, ["bucket"]); assert.strictEqual(ui.quickPicks[0]?.options?.title, "Upload Destination"); - assert.strictEqual(ui.openDialogs[0]?.title, "Upload Files to b2://bucket"); + assert.strictEqual(ui.openDialogs[0]?.title, "Upload Files or Folders to b2://bucket"); assert.deepStrictEqual( calls.map((call) => call.fileName), ["report.txt"], @@ -592,7 +595,7 @@ suite("B2 commands error handling", () => { }, ], ); - assert.strictEqual(ui.openDialogs[0]?.title, "Upload Files to b2://bucket"); + assert.strictEqual(ui.openDialogs[0]?.title, "Upload Files or Folders to b2://bucket"); assert.deepStrictEqual(ui.errors, []); }); From 452405afa9e30661956b0ef64dbace97066f484c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 10:20:16 -0500 Subject: [PATCH 04/10] fix: handle file drop lists and progress --- src/commands/uploadFiles.ts | 2 +- src/providers/b2TreeDragAndDropController.ts | 38 +++++++++++++++- .../suite/b2TreeDragAndDropController.test.ts | 44 +++++++++++++++++++ src/test/suite/commands.test.ts | 28 ++++++++++++ src/test/suite/windowStubs.ts | 6 ++- 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts index 2e34c7f5..ee3a948f 100644 --- a/src/commands/uploadFiles.ts +++ b/src/commands/uploadFiles.ts @@ -400,8 +400,8 @@ function createBatchProgressReporter( return (event) => report(event.bytesTransferred, entry, index); }, markEntryDone(entry, index) { - completedBytes = Math.min(totalBytes, completedBytes + entry.size); report(entry.size, entry, index); + completedBytes = Math.min(totalBytes, completedBytes + entry.size); }, }; } diff --git a/src/providers/b2TreeDragAndDropController.ts b/src/providers/b2TreeDragAndDropController.ts index 8006ad9d..93e89d1f 100644 --- a/src/providers/b2TreeDragAndDropController.ts +++ b/src/providers/b2TreeDragAndDropController.ts @@ -25,15 +25,51 @@ function addUniqueUri(uris: vscode.Uri[], seen: Set, uri: vscode.Uri): v uris.push(uri); } +function fileUriFromValue(value: unknown): vscode.Uri | undefined { + if (typeof value !== "object" || value === null || !("uri" in value)) { + return undefined; + } + + const uri = (value as { readonly uri?: unknown }).uri; + return uri instanceof vscode.Uri && uri.scheme === "file" ? uri : undefined; +} + +function addFileValueUris(uris: vscode.Uri[], seen: Set, value: unknown): void { + const directUri = fileUriFromValue(value); + if (directUri) { + addUniqueUri(uris, seen, directUri); + return; + } + + if ( + value === null || + typeof value === "string" || + typeof value !== "object" || + !(Symbol.iterator in value) + ) { + return; + } + + for (const file of value as Iterable) { + const uri = fileUriFromValue(file); + if (uri) { + addUniqueUri(uris, seen, uri); + } + } +} + export function droppedFileUris(dataTransfer: vscode.DataTransfer): vscode.Uri[] { const uris: vscode.Uri[] = []; const seen = new Set(); - for (const [, item] of dataTransfer) { + for (const [mimeType, item] of dataTransfer) { const file = item.asFile(); if (file?.uri?.scheme === "file") { addUniqueUri(uris, seen, file.uri); } + if (mimeType === FILES_MIME_TYPE) { + addFileValueUris(uris, seen, item.value); + } } return uris; diff --git a/src/test/suite/b2TreeDragAndDropController.test.ts b/src/test/suite/b2TreeDragAndDropController.test.ts index 3842898e..ec5a4f59 100644 --- a/src/test/suite/b2TreeDragAndDropController.test.ts +++ b/src/test/suite/b2TreeDragAndDropController.test.ts @@ -55,6 +55,18 @@ function itemWithFile(uri: vscode.Uri): vscode.DataTransferItem { } as unknown as vscode.DataTransferItem; } +function itemWithValue(value: unknown): vscode.DataTransferItem { + return { + asFile() { + return undefined; + }, + async asString() { + return ""; + }, + value, + } as unknown as vscode.DataTransferItem; +} + suite("B2 tree drag and drop", () => { test("ignores synthetic text/uri-list file URIs", async () => { let uploadCalled = false; @@ -122,6 +134,38 @@ suite("B2 tree drag and drop", () => { } }); + test("uploads local URIs from file transfer list values", async () => { + const uploadedUris: vscode.Uri[][] = []; + const tokenSource = new vscode.CancellationTokenSource(); + const controller = new B2TreeDragAndDropController(async (_target, uris) => { + uploadedUris.push([...uris]); + }); + const firstUri = vscode.Uri.file("/tmp/report.txt"); + const secondUri = vscode.Uri.file("/tmp/photos"); + + try { + await controller.handleDrop( + makeBucketTreeItem(), + dataTransfer([ + [ + FILES_MIME_TYPE, + itemWithValue([ + { uri: firstUri }, + { uri: vscode.Uri.parse("https://example.com/a") }, + { uri: secondUri }, + { uri: firstUri }, + ]), + ], + ]), + tokenSource.token, + ); + + assert.deepStrictEqual(uploadedUris, [[firstUri, secondUri]]); + } finally { + tokenSource.dispose(); + } + }); + test("describes file and folder drops for invalid targets", async () => { let uploadCalled = false; const tokenSource = new vscode.CancellationTokenSource(); diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index 147d7a02..651855d1 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -599,6 +599,34 @@ suite("B2 commands error handling", () => { assert.deepStrictEqual(ui.errors, []); }); + test("reports aggregate upload progress without double-counting entries", async () => { + const root = tempDir(); + const firstPath = path.join(root, "a.txt"); + const secondPath = path.join(root, "b.txt"); + fs.writeFileSync(firstPath, "12345"); + fs.writeFileSync(secondPath, "abcde"); + const { bucket } = makeUploadBucket(); + const target = new BucketTreeItem(bucket); + + const ui = await withWindowUiStubs({}, () => + uploadLocalUrisToTarget(target, [vscode.Uri.file(firstPath), vscode.Uri.file(secondPath)], { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }), + ); + const positiveProgressReports = ui.progressReports.filter( + (report): report is { readonly message?: string; readonly increment: number } => + typeof report.increment === "number" && report.increment > 0, + ); + + assert.deepStrictEqual( + positiveProgressReports.map((report) => report.increment), + [50, 50], + ); + assert.match(positiveProgressReports[0]?.message ?? "", /a\.txt/); + assert.match(positiveProgressReports[1]?.message ?? "", /b\.txt/); + }); + test("warns and cancels before overwriting existing B2 objects", async () => { const root = tempDir(); const filePath = path.join(root, "report.txt"); diff --git a/src/test/suite/windowStubs.ts b/src/test/suite/windowStubs.ts index 0d84b4e5..342623c7 100644 --- a/src/test/suite/windowStubs.ts +++ b/src/test/suite/windowStubs.ts @@ -23,6 +23,7 @@ export interface WindowUiCalls { readonly warnings: readonly WarningMessageCall[]; readonly openDialogs: readonly vscode.OpenDialogOptions[]; readonly progress: readonly vscode.ProgressOptions[]; + readonly progressReports: readonly { readonly message?: string; readonly increment?: number }[]; readonly errors: readonly string[]; readonly infos: readonly string[]; } @@ -130,6 +131,7 @@ export async function withWindowUiStubs( const warnings: WarningMessageCall[] = []; const openDialogs: vscode.OpenDialogOptions[] = []; const progress: vscode.ProgressOptions[] = []; + const progressReports: Array<{ readonly message?: string; readonly increment?: number }> = []; const errors: string[] = []; const infos: string[] = []; @@ -187,7 +189,7 @@ export async function withWindowUiStubs( progress.push(progressOptions); const tokenSource = new vscode.CancellationTokenSource(); try { - return await task({ report() {} }, tokenSource.token); + return await task({ report: (report) => progressReports.push(report) }, tokenSource.token); } finally { tokenSource.dispose(); } @@ -195,7 +197,7 @@ export async function withWindowUiStubs( try { await callback(); - return { inputs, quickPicks, warnings, openDialogs, progress, errors, infos }; + return { inputs, quickPicks, warnings, openDialogs, progress, progressReports, errors, infos }; } finally { mutableWindow.withProgress = originalWithProgress; mutableWindow.showInformationMessage = originalShowInformationMessage; From bcf38b5de78888095798e180f5a474730d9ee9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 10:29:31 -0500 Subject: [PATCH 05/10] fix: stabilize overwrite progress counters --- src/commands/uploadFiles.ts | 4 +--- src/test/suite/commands.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts index ee3a948f..403000ed 100644 --- a/src/commands/uploadFiles.ts +++ b/src/commands/uploadFiles.ts @@ -295,7 +295,6 @@ async function existingRemotePaths( ): Promise { const existing: string[] = []; let nextIndex = 0; - let checkedCount = 0; async function worker(): Promise { while (true) { @@ -310,12 +309,11 @@ async function existingRemotePaths( const remotePath = remotePaths[index]; progress.report({ - message: `Checking for existing B2 files ${checkedCount + 1}/${remotePaths.length}: ${remotePath}`, + message: `Checking for existing B2 files ${index + 1}/${remotePaths.length}: ${remotePath}`, }); if (await remotePathExists(bucket, remotePath, signal)) { existing.push(remotePath); } - checkedCount++; } } diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index 651855d1..fc60f8af 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -627,6 +627,28 @@ suite("B2 commands error handling", () => { assert.match(positiveProgressReports[1]?.message ?? "", /b\.txt/); }); + test("reports stable overwrite preflight check counters", async () => { + const root = tempDir(); + const firstPath = path.join(root, "a.txt"); + const secondPath = path.join(root, "b.txt"); + fs.writeFileSync(firstPath, "12345"); + fs.writeFileSync(secondPath, "abcde"); + const { bucket } = makeUploadBucket(); + const target = new BucketTreeItem(bucket); + + const ui = await withWindowUiStubs({}, () => + uploadLocalUrisToTarget(target, [vscode.Uri.file(firstPath), vscode.Uri.file(secondPath)], { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }), + ); + const checkCounters = ui.progressReports + .map((report) => report.message?.match(/Checking for existing B2 files (\d+\/2):/u)?.[1]) + .filter((counter): counter is string => counter !== undefined); + + assert.deepStrictEqual(checkCounters, ["1/2", "2/2"]); + }); + test("warns and cancels before overwriting existing B2 objects", async () => { const root = tempDir(); const filePath = path.join(root, "report.txt"); From 99dec1c6ef4fbedbf1bfe63d66d7bf4ab1cd76d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 10:36:33 -0500 Subject: [PATCH 06/10] fix: sort overwrite warning paths --- src/commands/uploadFiles.ts | 2 +- src/test/suite/commands.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts index 403000ed..d482f7d2 100644 --- a/src/commands/uploadFiles.ts +++ b/src/commands/uploadFiles.ts @@ -351,7 +351,7 @@ async function confirmPotentialOverwrites( ...(await existingRemotePaths(bucket, uniqueRemotePaths, progress, signal)), ...duplicateRemotePaths(entries), ]), - ]; + ].sort((left, right) => left.localeCompare(right)); if (overwritePaths.length === 0) { return true; diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index fc60f8af..215d1b92 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -677,6 +677,25 @@ suite("B2 commands error handling", () => { assert.strictEqual(refreshes, 0); }); + test("sorts overwrite warning paths deterministically", async () => { + const root = tempDir(); + const firstPath = path.join(root, "z.txt"); + const secondPath = path.join(root, "a.txt"); + fs.writeFileSync(firstPath, "first"); + fs.writeFileSync(secondPath, "second"); + const { bucket } = makeUploadBucket(["z.txt", "a.txt"]); + const target = new BucketTreeItem(bucket); + + const ui = await withWindowUiStubs({}, () => + uploadLocalUrisToTarget(target, [vscode.Uri.file(firstPath), vscode.Uri.file(secondPath)], { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }), + ); + + assert.match(ui.warnings[0]?.message ?? "", /\("a\.txt", "z\.txt"\)/); + }); + test("warns and refreshes when canceling an in-flight zero-byte file upload", async () => { const root = tempDir(); const filePath = path.join(root, "empty.txt"); From 29998a96bdad99d132defb975ee9a92f7efbd9de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 11:03:42 -0500 Subject: [PATCH 07/10] fix: harden upload discovery --- src/commands/uploadFiles.ts | 236 +++++++++++++++++++++++++--- src/test/suite/commands.test.ts | 267 +++++++++++++++++++++++++++++++- src/test/suite/windowStubs.ts | 14 +- 3 files changed, 488 insertions(+), 29 deletions(-) diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts index d482f7d2..2b5205c3 100644 --- a/src/commands/uploadFiles.ts +++ b/src/commands/uploadFiles.ts @@ -19,19 +19,25 @@ import { B2PartialFailureError, formatB2UserMessage } from "../errors"; import { log, logError } from "../logger"; import { humanSize } from "../utils/humanSize"; import { + closeUploadSource, + openUploadSourceFile, + sameFileIdentity, uploadEmptyObject, uploadFileFromDisk, type UploadEmptyObjectOptions, type UploadFileFromDiskOptions, + type UploadSourceFile, } from "../services/fileTransfers"; import { createTransferProgressReporter } from "../services/transferProgress"; import { DEFAULT_TRANSFER_STALL_TIMEOUT_MS, withTimeout } from "../services/transferTimeout"; -export const B2_AUTO_CONTENT_TYPE = "b2/x-auto"; -export const OVERWRITE_UPLOAD_LABEL = "Overwrite"; +const B2_AUTO_CONTENT_TYPE = "b2/x-auto"; +const OVERWRITE_UPLOAD_LABEL = "Overwrite"; const EMPTY_FOLDER_MARKER = ".bzEmpty"; const EMPTY_FOLDER_MARKER_CONTENT_TYPE = "application/x-bzEmpty"; +const MAX_LOCAL_UPLOAD_ENTRIES = 10_000; +const MAX_LOCAL_UPLOAD_DEPTH = 64; const OVERWRITE_PREFLIGHT_CONCURRENCY = 8; const OVERWRITE_PREFLIGHT_TIMEOUT_MS = DEFAULT_TRANSFER_STALL_TIMEOUT_MS; @@ -48,6 +54,7 @@ export interface LocalFileUploadEntry { readonly localPath: string; readonly remotePath: string; readonly size: number; + readonly rootRealPath: string; } export interface EmptyDirectoryUploadEntry { @@ -55,6 +62,23 @@ export interface EmptyDirectoryUploadEntry { readonly localPath: string; readonly remotePath: string; readonly size: 0; + readonly rootRealPath: string; +} + +export interface LocalUploadCollectionOptions { + readonly signal?: AbortSignal; + readonly progress?: vscode.Progress<{ message?: string; increment?: number }>; + readonly maxEntries?: number; + readonly maxDepth?: number; +} + +interface LocalUploadCollectionContext { + readonly entries: LocalUploadEntry[]; + readonly signal?: AbortSignal; + readonly progress?: vscode.Progress<{ message?: string; increment?: number }>; + readonly maxEntries: number; + readonly maxDepth: number; + discoveredEntries: number; } interface BatchProgressReporter { @@ -131,54 +155,152 @@ function markerPathForDirectory(remoteDirectoryPath: string): string { return `${normalizeB2Prefix(remoteDirectoryPath)}${EMPTY_FOLDER_MARKER}`; } +function throwIfCollectionCanceled(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw signal.reason ?? new vscode.CancellationError(); + } +} + +function isPathInsideOrEqual(rootRealPath: string, candidateRealPath: string): boolean { + const relative = path.relative(rootRealPath, candidateRealPath); + return ( + relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function localUploadLimitError(description: string): Error { + return new Error(`Local upload selection is too large: ${description}. Select a smaller batch.`); +} + +function assertCollectionCapacity(context: LocalUploadCollectionContext): void { + if (context.entries.length >= context.maxEntries) { + throw localUploadLimitError(`exceeds the ${context.maxEntries} item limit`); + } +} + +function addCollectedEntry(context: LocalUploadCollectionContext, entry: LocalUploadEntry): void { + assertCollectionCapacity(context); + context.entries.push(entry); + context.discoveredEntries++; + context.progress?.report({ + message: `Preparing upload list: ${context.discoveredEntries} item(s) found`, + }); +} + +async function realPathInsideRoot(localPath: string, rootRealPath: string): Promise { + const realPath = await fs.promises.realpath(localPath); + if (!isPathInsideOrEqual(rootRealPath, realPath)) { + throw new Error(`Local upload path resolves outside the selected folder: ${localPath}`); + } + return realPath; +} + +async function verifiedDirectoryStats( + directoryPath: string, + rootRealPath: string, +): Promise { + const stats = await fs.promises.lstat(directoryPath); + if (stats.isSymbolicLink()) { + throw new Error( + `Local upload path must be a real file or folder, not a symlink: ${directoryPath}`, + ); + } + if (!stats.isDirectory()) { + throw new Error(`Local upload path is not a folder: ${directoryPath}`); + } + await realPathInsideRoot(directoryPath, rootRealPath); + return stats; +} + +async function verifiedFileStats(localPath: string, rootRealPath: string): Promise { + const stats = await fs.promises.lstat(localPath); + if (stats.isSymbolicLink()) { + throw new Error(`Local upload path must be a real file or folder, not a symlink: ${localPath}`); + } + if (!stats.isFile()) { + throw new Error(`Local upload path is not a regular file: ${localPath}`); + } + await realPathInsideRoot(localPath, rootRealPath); + return stats; +} + +function createUploadCollectionContext( + options: LocalUploadCollectionOptions, +): LocalUploadCollectionContext { + return { + entries: [], + signal: options.signal, + progress: options.progress, + maxEntries: options.maxEntries ?? MAX_LOCAL_UPLOAD_ENTRIES, + maxDepth: options.maxDepth ?? MAX_LOCAL_UPLOAD_DEPTH, + discoveredEntries: 0, + }; +} + async function collectDirectoryUploadEntries( directoryPath: string, remoteDirectoryPath: string, - entries: LocalUploadEntry[], + rootRealPath: string, + context: LocalUploadCollectionContext, + depth: number, ): Promise { + throwIfCollectionCanceled(context.signal); + if (depth > context.maxDepth) { + throw localUploadLimitError(`folder nesting is deeper than ${context.maxDepth} levels`); + } + await verifiedDirectoryStats(directoryPath, rootRealPath); + const children = await fs.promises.readdir(directoryPath, { withFileTypes: true }); children.sort((left, right) => left.name.localeCompare(right.name)); let hasUploadableDescendant = false; for (const child of children) { + throwIfCollectionCanceled(context.signal); const childPath = path.join(directoryPath, child.name); - if (child.isSymbolicLink()) { + const childStats = await fs.promises.lstat(childPath); + + if (childStats.isSymbolicLink()) { throw new Error( `Local upload path must be a real file or folder, not a symlink: ${childPath}`, ); } - if (child.isDirectory()) { + if (childStats.isDirectory()) { + await verifiedDirectoryStats(childPath, rootRealPath); const childRemoteDirectoryPath = joinB2Path(remoteDirectoryPath, `${child.name}/`); const childHasEntries = await collectDirectoryUploadEntries( childPath, childRemoteDirectoryPath, - entries, + rootRealPath, + context, + depth + 1, ); hasUploadableDescendant = childHasEntries || hasUploadableDescendant; continue; } - if (!child.isFile()) { + if (!childStats.isFile()) { throw new Error(`Local upload path is not a regular file: ${childPath}`); } - const stats = await fs.promises.stat(childPath); - entries.push({ + await realPathInsideRoot(childPath, rootRealPath); + addCollectedEntry(context, { kind: "file", localPath: childPath, remotePath: joinB2Path(remoteDirectoryPath, child.name), - size: stats.size, + size: childStats.size, + rootRealPath, }); hasUploadableDescendant = true; } if (!hasUploadableDescendant) { - entries.push({ + addCollectedEntry(context, { kind: "emptyDirectory", localPath: directoryPath, remotePath: markerPathForDirectory(remoteDirectoryPath), size: 0, + rootRealPath, }); return true; } @@ -189,10 +311,12 @@ async function collectDirectoryUploadEntries( export async function collectLocalUploadEntries( localPaths: readonly string[], prefix: string, + options: LocalUploadCollectionOptions = {}, ): Promise { - const entries: LocalUploadEntry[] = []; + const context = createUploadCollectionContext(options); for (const localPath of localPaths) { + throwIfCollectionCanceled(context.signal); const stats = await fs.promises.lstat(localPath); if (stats.isSymbolicLink()) { throw new Error( @@ -201,16 +325,20 @@ export async function collectLocalUploadEntries( } if (stats.isFile()) { - entries.push({ + const rootRealPath = await fs.promises.realpath(localPath); + addCollectedEntry(context, { kind: "file", localPath, remotePath: joinB2Path(prefix, path.basename(localPath)), size: stats.size, + rootRealPath, }); continue; } if (stats.isDirectory()) { + const rootRealPath = await fs.promises.realpath(localPath); + await verifiedDirectoryStats(localPath, rootRealPath); const directoryName = path.basename(path.resolve(localPath)); if (!directoryName) { throw new Error("Cannot upload a filesystem root as a folder."); @@ -218,7 +346,9 @@ export async function collectLocalUploadEntries( await collectDirectoryUploadEntries( localPath, joinB2Path(prefix, `${directoryName}/`), - entries, + rootRealPath, + context, + 0, ); continue; } @@ -226,7 +356,7 @@ export async function collectLocalUploadEntries( throw new Error(`Local upload path is not a regular file or folder: ${localPath}`); } - return entries; + return context.entries; } function uriToLocalPath(uri: vscode.Uri): string { @@ -410,6 +540,7 @@ async function uploadEntry( options: UploadFileFromDiskOptions | UploadEmptyObjectOptions, ): Promise { if (entry.kind === "emptyDirectory") { + await verifiedDirectoryStats(entry.localPath, entry.rootRealPath); await uploadEmptyObject(bucket, entry.remotePath, { ...options, contentType: EMPTY_FOLDER_MARKER_CONTENT_TYPE, @@ -417,10 +548,72 @@ async function uploadEntry( return; } - await uploadFileFromDisk(bucket, entry.localPath, entry.remotePath, { - ...options, - contentType: B2_AUTO_CONTENT_TYPE, - }); + const realStats = await verifiedFileStats(entry.localPath, entry.rootRealPath); + let source: UploadSourceFile | undefined; + try { + source = await openUploadSourceFile(entry.localPath); + const realPathAfterOpen = await realPathInsideRoot(entry.localPath, entry.rootRealPath); + const realStatsAfterOpen = await fs.promises.stat(realPathAfterOpen); + if ( + !sameFileIdentity(source.stats, realStats) || + !sameFileIdentity(source.stats, realStatsAfterOpen) + ) { + throw new Error(`Local upload path changed while opening upload source: ${entry.localPath}`); + } + + await uploadFileFromDisk(bucket, source, entry.remotePath, { + ...options, + contentType: B2_AUTO_CONTENT_TYPE, + }); + source = undefined; + } finally { + if (source) { + await closeUploadSource(source); + } + } +} + +async function collectLocalUploadEntriesWithProgress( + target: UploadTargetTreeItem, + localPaths: readonly string[], + token?: vscode.CancellationToken, +): Promise { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Preparing upload to ${uploadTargetLabel(target)}...`, + cancellable: true, + }, + async (progress, progressToken) => { + const controller = new AbortController(); + const disposables: vscode.Disposable[] = []; + const cancel = () => { + if (!controller.signal.aborted) { + controller.abort(new vscode.CancellationError()); + } + }; + + if (token?.isCancellationRequested || progressToken.isCancellationRequested) { + throw new vscode.CancellationError(); + } + + if (token) { + disposables.push(token.onCancellationRequested(cancel)); + } + disposables.push(progressToken.onCancellationRequested(cancel)); + + try { + return await collectLocalUploadEntries(localPaths, uploadTargetPrefix(target), { + signal: controller.signal, + progress, + }); + } finally { + for (const disposable of disposables) { + disposable.dispose(); + } + } + }, + ); } async function uploadEntriesWithProgress( @@ -529,9 +722,10 @@ export async function uploadLocalUrisToTarget( let uploadedCount = 0; try { - const entries = await collectLocalUploadEntries( + const entries = await collectLocalUploadEntriesWithProgress( + target, uris.map(uriToLocalPath), - uploadTargetPrefix(target), + token, ); if (entries.length === 0) { vscode.window.showInformationMessage("B2: No files found to upload."); diff --git a/src/test/suite/commands.test.ts b/src/test/suite/commands.test.ts index 215d1b92..8ed28831 100644 --- a/src/test/suite/commands.test.ts +++ b/src/test/suite/commands.test.ts @@ -39,8 +39,7 @@ import { FolderTreeItem } from "../../models/folderTreeItem"; import type { TempFileManager } from "../../services/tempFileManager"; import { tempDir } from "../../testSupport/tempDir"; import { - B2_AUTO_CONTENT_TYPE, - OVERWRITE_UPLOAD_LABEL, + collectLocalUploadEntries, uploadFilesCommand, uploadLocalUrisToTarget, } from "../../commands/uploadFiles"; @@ -57,6 +56,8 @@ const PRIVATE_VISIBILITY_LABEL = "Private"; const PUBLIC_VISIBILITY_LABEL = "Public"; const CONFIRM_PUBLIC_VISIBILITY_LABEL = "Change to Public"; const CONFIRM_PRIVATE_VISIBILITY_LABEL = "Change to Private"; +const EXPECTED_AUTO_CONTENT_TYPE = "b2/x-auto"; +const OVERWRITE_UPLOAD_LABEL = "Overwrite"; function makeCommandServices( client: TClient | null, @@ -489,7 +490,7 @@ suite("B2 commands error handling", () => { { kind: "stream", fileName: "incoming/report.txt", - contentType: B2_AUTO_CONTENT_TYPE, + contentType: EXPECTED_AUTO_CONTENT_TYPE, bytes: 8, }, ], @@ -504,8 +505,10 @@ suite("B2 commands error handling", () => { assert.strictEqual(ui.openDialogs[0]?.canSelectFiles, true); assert.strictEqual(ui.openDialogs[0]?.canSelectFolders, true); assert.strictEqual(ui.openDialogs[0]?.canSelectMany, true); - assert.strictEqual(ui.progress.length, 1); + assert.strictEqual(ui.progress.length, 2); + assert.match(ui.progress[0]?.title ?? "", /Preparing upload/); assert.strictEqual(ui.progress[0]?.cancellable, true); + assert.strictEqual(ui.progress[1]?.cancellable, true); assert.strictEqual(refreshes, 1); assert.deepStrictEqual(ui.errors, []); }); @@ -578,7 +581,7 @@ suite("B2 commands error handling", () => { { kind: "stream", fileName: "photos/cover.txt", - contentType: B2_AUTO_CONTENT_TYPE, + contentType: EXPECTED_AUTO_CONTENT_TYPE, bytes: 5, }, { @@ -590,7 +593,7 @@ suite("B2 commands error handling", () => { { kind: "stream", fileName: "photos/nested/raw.bin", - contentType: B2_AUTO_CONTENT_TYPE, + contentType: EXPECTED_AUTO_CONTENT_TYPE, bytes: 3, }, ], @@ -599,6 +602,193 @@ suite("B2 commands error handling", () => { assert.deepStrictEqual(ui.errors, []); }); + test("rejects symlinked folders during recursive upload", async () => { + const root = tempDir(); + const folderPath = path.join(root, "photos"); + const secretPath = path.join(root, "secret"); + fs.mkdirSync(folderPath); + fs.mkdirSync(secretPath); + fs.writeFileSync(path.join(secretPath, "credentials.txt"), "secret"); + fs.symlinkSync(secretPath, path.join(folderPath, "secrets"), "dir"); + const { bucket, calls } = makeUploadBucket(); + const target = new BucketTreeItem(bucket); + + const ui = await withWindowUiStubs( + { + openDialogValues: [[vscode.Uri.file(folderPath)]], + }, + () => + uploadFilesCommand(target, { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }), + ); + + assert.deepStrictEqual(calls, []); + assert.strictEqual(ui.errors.length, 1); + assert.match(ui.errors[0] ?? "", /Failed to upload files/); + }); + + test("rejects directories swapped to symlinks during folder discovery", async () => { + const root = tempDir(); + const folderPath = path.join(root, "photos"); + const nestedPath = path.join(folderPath, "nested"); + const secretPath = path.join(root, "secret"); + fs.mkdirSync(nestedPath, { recursive: true }); + fs.mkdirSync(secretPath); + fs.writeFileSync(path.join(secretPath, "credentials.txt"), "secret"); + const { bucket, calls } = makeUploadBucket(); + const target = new BucketTreeItem(bucket); + const originalReaddir = fs.promises.readdir; + const callOriginalReaddir = originalReaddir.bind(fs.promises) as ( + ...args: unknown[] + ) => Promise; + const mutablePromises = fs.promises as unknown as { + readdir: (...args: unknown[]) => Promise; + }; + let swapped = false; + + mutablePromises.readdir = async (...args: unknown[]): Promise => { + const result = await callOriginalReaddir(...args); + if (String(args[0]) === folderPath && !swapped) { + fs.rmSync(nestedPath, { recursive: true, force: true }); + fs.symlinkSync(secretPath, nestedPath, "dir"); + swapped = true; + } + return result; + }; + + try { + const ui = await withWindowUiStubs( + { + openDialogValues: [[vscode.Uri.file(folderPath)]], + }, + () => + uploadFilesCommand(target, { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }), + ); + + assert.strictEqual(swapped, true); + assert.deepStrictEqual(calls, []); + assert.strictEqual(ui.errors.length, 1); + assert.match(ui.errors[0] ?? "", /Failed to upload files/); + } finally { + mutablePromises.readdir = originalReaddir as unknown as ( + ...args: unknown[] + ) => Promise; + } + }); + + test("rejects files that escape after upload discovery", async () => { + const root = tempDir(); + const folderPath = path.join(root, "photos"); + const nestedPath = path.join(folderPath, "nested"); + const secretPath = path.join(root, "secret"); + fs.mkdirSync(nestedPath, { recursive: true }); + fs.mkdirSync(secretPath); + fs.writeFileSync(path.join(nestedPath, "inside.txt"), "inside"); + fs.writeFileSync(path.join(secretPath, "inside.txt"), "secret"); + const { bucket, calls } = makeUploadBucket(); + const mutableBucket = bucket as unknown as { + head(fileName: string, options?: { signal?: AbortSignal }): Promise; + }; + let swapped = false; + mutableBucket.head = async () => { + if (!swapped) { + fs.rmSync(nestedPath, { recursive: true, force: true }); + fs.symlinkSync(secretPath, nestedPath, "dir"); + swapped = true; + } + throw notFoundError(); + }; + const target = new BucketTreeItem(bucket); + + const ui = await withWindowUiStubs( + { + openDialogValues: [[vscode.Uri.file(folderPath)]], + }, + () => + uploadFilesCommand(target, { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }), + ); + + assert.strictEqual(swapped, true); + assert.deepStrictEqual(calls, []); + assert.strictEqual(ui.errors.length, 1); + assert.match(ui.errors[0] ?? "", /Failed to upload files/); + }); + + test("bounds collected upload entries", async () => { + const root = tempDir(); + const firstPath = path.join(root, "a.txt"); + const secondPath = path.join(root, "b.txt"); + fs.writeFileSync(firstPath, "a"); + fs.writeFileSync(secondPath, "b"); + + await assert.rejects( + collectLocalUploadEntries([firstPath, secondPath], "", { maxEntries: 1 }), + /exceeds the 1 item limit/, + ); + }); + + test("bounds collected upload depth", async () => { + const root = tempDir(); + const folderPath = path.join(root, "photos"); + const nestedPath = path.join(folderPath, "nested"); + fs.mkdirSync(nestedPath, { recursive: true }); + + await assert.rejects( + collectLocalUploadEntries([folderPath], "", { maxDepth: 0 }), + /deeper than 0 levels/, + ); + }); + + test("cancels folder discovery before uploading", async () => { + const root = tempDir(); + const folderPath = path.join(root, "photos"); + fs.mkdirSync(folderPath); + fs.writeFileSync(path.join(folderPath, "a.txt"), "a"); + fs.writeFileSync(path.join(folderPath, "b.txt"), "b"); + const tokenSource = new vscode.CancellationTokenSource(); + const { bucket, calls } = makeUploadBucket(); + const target = new BucketTreeItem(bucket); + let canceled = false; + + try { + const ui = await withWindowUiStubs( + { + onProgressReport(report) { + if (!canceled && report.message?.startsWith("Preparing upload list")) { + canceled = true; + tokenSource.cancel(); + } + }, + }, + () => + uploadLocalUrisToTarget( + target, + [vscode.Uri.file(folderPath)], + { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }, + tokenSource.token, + ), + ); + + assert.strictEqual(canceled, true); + assert.deepStrictEqual(calls, []); + assert.deepStrictEqual(ui.errors, []); + assert.match(ui.progress[0]?.title ?? "", /Preparing upload/); + } finally { + tokenSource.dispose(); + } + }); + test("reports aggregate upload progress without double-counting entries", async () => { const root = tempDir(); const firstPath = path.join(root, "a.txt"); @@ -649,6 +839,67 @@ suite("B2 commands error handling", () => { assert.deepStrictEqual(checkCounters, ["1/2", "2/2"]); }); + test("cancels delayed overwrite checks before uploading", async () => { + const root = tempDir(); + const filePath = path.join(root, "report.txt"); + fs.writeFileSync(filePath, "hello b2"); + const tokenSource = new vscode.CancellationTokenSource(); + let uploadCalled = false; + let canceled = false; + const bucket = { + name: "bucket", + id: "bucket-id", + info: { bucketType: "allPrivate" }, + async head(_fileName: string, options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(options.signal?.reason ?? new vscode.CancellationError()), + { once: true }, + ); + }); + }, + async upload() { + uploadCalled = true; + return {}; + }, + file() { + uploadCalled = true; + throw new Error("upload should not start"); + }, + } as unknown as Bucket; + const target = new BucketTreeItem(bucket); + + try { + const ui = await withWindowUiStubs( + { + onProgressReport(report) { + if (!canceled && report.message?.startsWith("Checking for existing B2 files")) { + canceled = true; + tokenSource.cancel(); + } + }, + }, + () => + uploadLocalUrisToTarget( + target, + [vscode.Uri.file(filePath)], + { + getClient: () => ({}) as unknown as B2Client, + treeProvider: { refresh: () => undefined }, + }, + tokenSource.token, + ), + ); + + assert.strictEqual(canceled, true); + assert.strictEqual(uploadCalled, false); + assert.deepStrictEqual(ui.errors, []); + } finally { + tokenSource.dispose(); + } + }); + test("warns and cancels before overwriting existing B2 objects", async () => { const root = tempDir(); const filePath = path.join(root, "report.txt"); @@ -672,8 +923,10 @@ suite("B2 commands error handling", () => { assert.strictEqual(ui.warnings.length, 1); assert.deepStrictEqual(ui.warnings[0]?.items, [OVERWRITE_UPLOAD_LABEL]); assert.match(ui.warnings[0]?.message ?? "", /incoming\/report\.txt/); - assert.strictEqual(ui.progress.length, 1); + assert.strictEqual(ui.progress.length, 2); + assert.match(ui.progress[0]?.title ?? "", /Preparing upload/); assert.strictEqual(ui.progress[0]?.cancellable, true); + assert.strictEqual(ui.progress[1]?.cancellable, true); assert.strictEqual(refreshes, 0); }); diff --git a/src/test/suite/windowStubs.ts b/src/test/suite/windowStubs.ts index 342623c7..f0772f6c 100644 --- a/src/test/suite/windowStubs.ts +++ b/src/test/suite/windowStubs.ts @@ -37,6 +37,10 @@ export interface WindowUiStubOptions { readonly quickPickLabels?: readonly (string | undefined)[]; readonly warningValues?: readonly (string | undefined)[]; readonly openDialogValues?: readonly (readonly vscode.Uri[] | undefined)[]; + readonly onProgressReport?: (report: { + readonly message?: string; + readonly increment?: number; + }) => void; } function labelForQuickPickItem(item: unknown): string { @@ -189,7 +193,15 @@ export async function withWindowUiStubs( progress.push(progressOptions); const tokenSource = new vscode.CancellationTokenSource(); try { - return await task({ report: (report) => progressReports.push(report) }, tokenSource.token); + return await task( + { + report: (report) => { + progressReports.push(report); + options.onProgressReport?.(report); + }, + }, + tokenSource.token, + ); } finally { tokenSource.dispose(); } From f4fbe0dd94c83f278b388c50ccdecee12b53bd94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 11:10:10 -0500 Subject: [PATCH 08/10] fix: inline upload auth check --- src/commands/uploadFiles.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts index 2b5205c3..1653c415 100644 --- a/src/commands/uploadFiles.ts +++ b/src/commands/uploadFiles.ts @@ -710,8 +710,7 @@ export async function uploadLocalUrisToTarget( services: UploadFilesCommandServices, token?: vscode.CancellationToken, ): Promise { - const client = services.getClient(); - if (!client) { + if (!services.getClient()) { vscode.window.showErrorMessage("B2: Not authenticated."); return; } From a8da9ad1f7833f1dc956f4ee6f0afc6006873b77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 11:19:13 -0500 Subject: [PATCH 09/10] fix: refine upload directory checks --- src/commands/uploadFiles.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/commands/uploadFiles.ts b/src/commands/uploadFiles.ts index 1653c415..2a3089dd 100644 --- a/src/commands/uploadFiles.ts +++ b/src/commands/uploadFiles.ts @@ -155,6 +155,14 @@ function markerPathForDirectory(remoteDirectoryPath: string): string { return `${normalizeB2Prefix(remoteDirectoryPath)}${EMPTY_FOLDER_MARKER}`; } +function directoryNameForUpload(localPath: string): string { + const resolvedPath = path.resolve(localPath); + if (resolvedPath === path.parse(resolvedPath).root) { + throw new Error("Cannot upload a filesystem root as a folder."); + } + return path.basename(resolvedPath); +} + function throwIfCollectionCanceled(signal: AbortSignal | undefined): void { if (signal?.aborted) { throw signal.reason ?? new vscode.CancellationError(); @@ -266,7 +274,6 @@ async function collectDirectoryUploadEntries( } if (childStats.isDirectory()) { - await verifiedDirectoryStats(childPath, rootRealPath); const childRemoteDirectoryPath = joinB2Path(remoteDirectoryPath, `${child.name}/`); const childHasEntries = await collectDirectoryUploadEntries( childPath, @@ -339,10 +346,7 @@ export async function collectLocalUploadEntries( if (stats.isDirectory()) { const rootRealPath = await fs.promises.realpath(localPath); await verifiedDirectoryStats(localPath, rootRealPath); - const directoryName = path.basename(path.resolve(localPath)); - if (!directoryName) { - throw new Error("Cannot upload a filesystem root as a folder."); - } + const directoryName = directoryNameForUpload(localPath); await collectDirectoryUploadEntries( localPath, joinB2Path(prefix, `${directoryName}/`), From 5a3e65265ff16d09dbb8075d31f76b90632c798f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gonzalo=20Pe=C3=B1a-Castellanos?= Date: Thu, 2 Jul 2026 11:26:24 -0500 Subject: [PATCH 10/10] fix: guard selected upload tree view --- src/extension.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 044721d6..5065bb61 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -213,12 +213,12 @@ export async function activate(context: vscode.ExtensionContext): Promise // 2. Tree provider const treeProvider = new B2TreeProvider(authService); - let treeView: vscode.TreeView; + let treeView: vscode.TreeView | undefined; const uploadServices = { treeProvider, getClient: () => currentClient, getSelectedUploadTarget: () => { - const selected = treeView.selection[0]; + const selected = treeView?.selection[0]; return isUploadTargetTreeItem(selected) ? selected : undefined; }, };