From ccc88c74e7a6c808e182a20c2ce89177a20d9e28 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 6 May 2026 12:43:24 +0200 Subject: [PATCH 1/2] Add paste-to-upload --- src/commands.ts | 83 +++++++++++++++++++++++++- src/github/fileUpload.ts | 102 +++++++++++++++++++++++++++----- src/github/githubRepository.ts | 8 +++ src/github/issueOverview.ts | 40 ++++++++++++- src/github/views.ts | 4 ++ webviews/common/context.tsx | 69 +++++++++++++++++---- webviews/components/comment.tsx | 87 ++++++++++++++++++++------- 7 files changed, 346 insertions(+), 47 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 5b204b00f2..0240cce83a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -20,7 +20,7 @@ import { formatError } from './common/utils'; import { EXTENSION_ID } from './constants'; import { CrossChatSessionWithPR } from './github/copilotApi'; import { CopilotRemoteAgentManager, SessionIdForPr } from './github/copilotRemoteAgent'; -import { pickFilesForUpload, runFileUploads } from './github/fileUpload'; +import { guessExtensionFromMime, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './github/fileUpload'; import { FolderRepositoryManager } from './github/folderRepositoryManager'; import { GitHubRepository } from './github/githubRepository'; import { Issue } from './github/interface'; @@ -1460,6 +1460,87 @@ ${contents} }) ); + context.subscriptions.push( + vscode.languages.registerDocumentPasteEditProvider( + { scheme: Schemes.Comment }, + { + async provideDocumentPasteEdits(document, ranges, dataTransfer, _context, token) { + const files: { name: string; getBytes: () => Thenable }[] = []; + let counter = 0; + for (const [mime, item] of dataTransfer) { + const file = item.asFile(); + if (!file) { + continue; + } + const name = file.name || `pasted-file-${++counter}${guessExtensionFromMime(mime)}`; + files.push({ name, getBytes: () => file.data() }); + } + if (files.length === 0 || token.isCancellationRequested) { + return; + } + + const potentialThread = findActiveHandler()?.commentController.activeCommentThread as vscode.CommentThread2 as GHPRCommentThread | undefined; + if (!potentialThread) { + return; + } + const folderManager = reposManager.getManagerForFile(potentialThread.uri); + const githubRepository = folderManager?.activePullRequest?.githubRepository + ?? folderManager?.gitHubRepositories[0]; + if (!githubRepository) { + return; + } + + const placeholders = placeholdersForNames(files.map(f => f.name)); + const placeholdersText = placeholders.map(p => p.placeholder).join('\n'); + + const documentUri = document.uri.toString(); + const replacePlaceholder = async (placeholder: string, replacement: string) => { + const editor = vscode.window.visibleTextEditors.find(e => e.document.uri.toString() === documentUri); + if (!editor) { + return; + } + const text = editor.document.getText(); + const idx = text.indexOf(placeholder); + if (idx < 0) { + return; + } + const start = editor.document.positionAt(idx); + const end = editor.document.positionAt(idx + placeholder.length); + await editor.edit(editBuilder => { + editBuilder.replace(new vscode.Range(start, end), replacement); + }); + }; + + runPendingUploads( + githubRepository, + files.map((f, i) => ({ + name: placeholders[i].name, + placeholder: placeholders[i].placeholder, + getBytes: f.getBytes, + })), + logId, + (placeholder, _name, markdown) => replacePlaceholder(placeholder, markdown), + (placeholder, name, error) => { + vscode.window.showErrorMessage(vscode.l10n.t('Failed to upload {0}: {1}', name, error)); + return replacePlaceholder(placeholder, ''); + }, + ); + + const edit = new vscode.DocumentPasteEdit( + placeholdersText, + vscode.l10n.t('Upload as GitHub attachment'), + vscode.DocumentDropOrPasteEditKind.Empty.append('github', 'attachment'), + ); + return [edit]; + }, + }, + { + providedPasteEditKinds: [vscode.DocumentDropOrPasteEditKind.Empty.append('github', 'attachment')], + pasteMimeTypes: ['files', 'image/*'], + }, + ), + ); + context.subscriptions.push( vscode.commands.registerCommand('pr.editComment', async (comment: GHPRComment | TemporaryComment) => { /* __GDPR__ diff --git a/src/github/fileUpload.ts b/src/github/fileUpload.ts index 87a6b6d344..f993d86535 100644 --- a/src/github/fileUpload.ts +++ b/src/github/fileUpload.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as buffer from 'buffer'; import * as path from 'path'; import * as vscode from 'vscode'; import { GitHubRepository } from './githubRepository'; @@ -15,6 +16,61 @@ export interface FileUploadPlaceholder { placeholder: string; } +export interface PendingFileUpload { + name: string; + placeholder: string; + getBytes(): Thenable; +} + +/** + * Decode a base64 string to a {@linkcode Uint8Array}. + */ +export function decodeBase64(input: string): Uint8Array { + return new Uint8Array(buffer.Buffer.from(input, 'base64')); +} + +/** + * Guess a file extension (including the dot) for a given MIME type, falling back + * to an empty string when no good guess is available. + */ +export function guessExtensionFromMime(mimeType: string): string { + const lower = mimeType.toLowerCase(); + switch (lower) { + case 'image/png': return '.png'; + case 'image/jpeg': return '.jpg'; + case 'image/gif': return '.gif'; + case 'image/webp': return '.webp'; + case 'image/svg+xml': return '.svg'; + case 'image/bmp': return '.bmp'; + case 'image/heic': return '.heic'; + case 'video/mp4': return '.mp4'; + case 'video/quicktime': return '.mov'; + case 'video/webm': return '.webm'; + case 'application/pdf': return '.pdf'; + case 'application/zip': return '.zip'; + case 'application/json': return '.json'; + case 'text/plain': return '.txt'; + case 'text/markdown': return '.md'; + default: return ''; + } +} + +/** + * Compute placeholder strings for the given file names, deduplicating + * by name with `(2)`, `(3)` suffixes. + */ +export function placeholdersForNames(names: readonly string[]): { name: string; placeholder: string }[] { + const used = new Map(); + return names.map(name => { + const count = used.get(name) ?? 0; + used.set(name, count + 1); + const placeholder = count === 0 + ? `` + : ``; + return { name, placeholder }; + }); +} + /** * Prompt the user for files to upload and compute the placeholder text that * should be inserted into a comment textarea while the uploads run. @@ -32,31 +88,49 @@ export async function pickFilesForUpload(): Promise(); - return fileUris.map(uri => { - const baseName = path.basename(uri.fsPath); - const count = used.get(baseName) ?? 0; - used.set(baseName, count + 1); - const placeholder = count === 0 - ? `` - : ``; - return { uri, name: baseName, placeholder }; - }); + const names = fileUris.map(uri => path.basename(uri.fsPath)); + const placeholders = placeholdersForNames(names); + return fileUris.map((uri, i) => ({ uri, name: placeholders[i].name, placeholder: placeholders[i].placeholder })); } /** - * Run the actual file uploads in parallel, invoking the supplied callbacks as - * each upload finishes (or fails). + * Run uploads of files identified by URI in parallel. */ export function runFileUploads( githubRepository: GitHubRepository, - uploads: FileUploadPlaceholder[], + uploads: readonly FileUploadPlaceholder[], + logId: string, + onComplete: (placeholder: string, name: string, markdown: string) => void | Promise, + onError: (placeholder: string, name: string, error: string) => void | Promise, +): void { + runPendingUploads( + githubRepository, + uploads.map(u => ({ + name: u.name, + placeholder: u.placeholder, + getBytes: () => vscode.workspace.fs.readFile(u.uri), + })), + logId, + onComplete, + onError, + ); +} + +/** + * Run uploads in parallel, fetching the bytes lazily via {@linkcode PendingFileUpload.getBytes}. + */ +export function runPendingUploads( + githubRepository: GitHubRepository, + uploads: readonly PendingFileUpload[], logId: string, onComplete: (placeholder: string, name: string, markdown: string) => void | Promise, onError: (placeholder: string, name: string, error: string) => void | Promise, ): void { for (const u of uploads) { - githubRepository.uploadFile(u.uri, u.name).then(markdown => { + (async () => { + const bytes = await u.getBytes(); + return githubRepository.uploadFileBytes(bytes, u.name); + })().then(markdown => { return onComplete(u.placeholder, u.name, markdown); }).catch(err => { Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, logId); diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index 8865360eca..5b00ce5b58 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -2118,6 +2118,14 @@ export class GitHubRepository extends Disposable { */ public async uploadFile(uri: vscode.Uri, fileName: string): Promise { const fileBytes = await vscode.workspace.fs.readFile(uri); + return this.uploadFileBytes(fileBytes, fileName); + } + + /** + * Upload a file's raw bytes to GitHub via the mobile upload policy API. + * Returns a markdown snippet appropriate for embedding in an issue/PR comment. + */ + public async uploadFileBytes(fileBytes: Uint8Array, fileName: string): Promise { const contentType = guessContentType(fileName); const { octokit } = await this.ensure(); diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 05612a89cc..ce7872cd27 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -7,13 +7,13 @@ import * as vscode from 'vscode'; import { CloseResult, OpenLocalFileArgs } from '../../common/views'; import { openPullRequestOnGitHub } from '../commands'; -import { pickFilesForUpload, runFileUploads } from './fileUpload'; +import { decodeBase64, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './fileUpload'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface'; import { IssueModel } from './issueModel'; import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks'; import { isInCodespaces, processPermalinks, vscodeDevPrLink } from './utils'; -import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply } from './views'; +import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply, UploadPastedFilesArgs } from './views'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; import { emojify, ensureEmojis } from '../common/emoji'; import Logger from '../common/logger'; @@ -455,6 +455,8 @@ export class IssueOverviewPanel extends W return this.webviewDebug(message); case 'pr.upload-files': return this.uploadFiles(message); + case 'pr.upload-pasted-files': + return this.uploadPastedFiles(message); default: return this.MESSAGE_UNHANDLED; } @@ -605,6 +607,40 @@ export class IssueOverviewPanel extends W ); } + private async uploadPastedFiles(message: IRequestMessage): Promise { + const files = message.args?.files ?? []; + if (files.length === 0) { + const empty: UploadFilesReply = { uploads: [] }; + return this._replyMessage(message, empty); + } + + const placeholders = placeholdersForNames(files.map(f => f.name)); + const reply: UploadFilesReply = { uploads: placeholders }; + await this._replyMessage(message, reply); + + runPendingUploads( + this._item.githubRepository, + files.map((f, i) => ({ + name: placeholders[i].name, + placeholder: placeholders[i].placeholder, + getBytes: () => Promise.resolve(decodeBase64(f.bytesBase64)), + })), + IssueOverviewPanel.ID, + (placeholder, name, markdown) => this._postMessage({ + command: 'pr.file-upload-completed', + placeholder, + name, + markdown, + } satisfies FileUploadCompletedMessage), + (placeholder, name, error) => this._postMessage({ + command: 'pr.file-upload-completed', + placeholder, + name, + error, + } satisfies FileUploadCompletedMessage), + ); + } + /** * Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel) diff --git a/src/github/views.ts b/src/github/views.ts index 76e6049c88..9dbe6f20bc 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -188,6 +188,10 @@ export interface UploadFilesReply { uploads: FileUploadPlaceholder[]; } +export interface UploadPastedFilesArgs { + files: { name: string; bytesBase64: string }[]; +} + export interface FileUploadCompletedMessage { command: 'pr.file-upload-completed'; name: string; diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 395a0d690b..b9a16eece5 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -189,6 +189,40 @@ export class PRContext { replacePlaceholder: (placeholder: string, markdownOrEmpty: string) => void, ) => { const result: UploadFilesReply | undefined = await this.postMessage({ command: 'pr.upload-files' }); + this._registerUploadHandlers(result, insertPlaceholders, replacePlaceholder); + }; + + /** + * Send a list of files (typically from a clipboard paste or drop) to the host for upload. + */ + public uploadPastedFiles = async ( + files: readonly File[], + insertPlaceholders: (placeholders: string) => void, + replacePlaceholder: (placeholder: string, markdownOrEmpty: string) => void, + ) => { + if (files.length === 0) { + return; + } + const fileArgs = await Promise.all(files.map(async file => { + const buffer = new Uint8Array(await file.arrayBuffer()); + let binary = ''; + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]); + } + return { name: file.name || 'pasted-file', bytesBase64: btoa(binary) }; + })); + const result: UploadFilesReply | undefined = await this.postMessage({ + command: 'pr.upload-pasted-files', + args: { files: fileArgs }, + }); + this._registerUploadHandlers(result, insertPlaceholders, replacePlaceholder); + }; + + private _registerUploadHandlers( + result: UploadFilesReply | undefined, + insertPlaceholders: (placeholders: string) => void, + replacePlaceholder: (placeholder: string, markdownOrEmpty: string) => void, + ) { if (!result || !result.uploads || result.uploads.length === 0) { return; } @@ -204,25 +238,40 @@ export class PRContext { } }); } - }; + } /** * Convenience wrapper that uploads files into the current pending comment text in the PR state. */ public uploadFilesIntoPendingComment = () => { return this.uploadFiles( - placeholders => { - const current = this.pr?.pendingCommentText ?? ''; - const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; - this.updatePR({ pendingCommentText: `${current}${separator}${placeholders}\n` }); - }, - (placeholder, markdown) => { - const current = this.pr?.pendingCommentText ?? ''; - this.updatePR({ pendingCommentText: current.replace(placeholder, markdown) }); - }, + placeholders => this._appendToPendingComment(placeholders), + (placeholder, markdown) => this._replaceInPendingComment(placeholder, markdown), ); }; + /** + * Convenience wrapper that uploads pasted files into the current pending comment text. + */ + public uploadPastedFilesIntoPendingComment = (files: readonly File[]) => { + return this.uploadPastedFiles( + files, + placeholders => this._appendToPendingComment(placeholders), + (placeholder, markdown) => this._replaceInPendingComment(placeholder, markdown), + ); + }; + + private _appendToPendingComment(placeholders: string) { + const current = this.pr?.pendingCommentText ?? ''; + const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; + this.updatePR({ pendingCommentText: `${current}${separator}${placeholders}\n` }); + } + + private _replaceInPendingComment(placeholder: string, markdown: string) { + const current = this.pr?.pendingCommentText ?? ''; + this.updatePR({ pendingCommentText: current.replace(placeholder, markdown) }); + } + private completeFileUpload(message: FileUploadCompletedMessage) { const handler = this._uploadCompletionHandlers.get(message.placeholder); if (handler) { diff --git a/webviews/components/comment.tsx b/webviews/components/comment.tsx index 33d428c5fd..125357ff8c 100644 --- a/webviews/components/comment.tsx +++ b/webviews/components/comment.tsx @@ -34,6 +34,52 @@ const association = ({ authorAssociation }: ReviewEvent, format = (assoc: string ? format(authorAssociation) : null; +function filesFromClipboard(e: React.ClipboardEvent): File[] { + return Array.from(e.clipboardData?.files ?? []); +} + +function onPasteUploadFiles(uploader: (files: readonly File[]) => Promise) { + return (e: React.ClipboardEvent) => { + const files = filesFromClipboard(e); + if (files.length === 0) { + return; + } + e.preventDefault(); + uploader(files); + }; +} + +function insertEditPlaceholders( + form: { current: HTMLFormElement | null | undefined }, + draftComment: { current: { body: string; dirty: boolean } }, + placeholders: string, +) { + const ta = form.current?.elements.namedItem('markdown') as HTMLTextAreaElement | null; + if (!ta) { + return; + } + const current = ta.value; + const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; + ta.value = `${current}${separator}${placeholders}\n`; + draftComment.current.body = ta.value; + draftComment.current.dirty = true; +} + +function replaceEditPlaceholder( + form: { current: HTMLFormElement | null | undefined }, + draftComment: { current: { body: string; dirty: boolean } }, + placeholder: string, + markdown: string, +) { + const ta = form.current?.elements.namedItem('markdown') as HTMLTextAreaElement | null; + if (!ta) { + return; + } + ta.value = ta.value.replace(placeholder, markdown); + draftComment.current.body = ta.value; + draftComment.current.dirty = true; +} + export function CommentView(commentProps: Props) { const { isPRDescription, children, comment, headerInEditMode } = commentProps; const { bodyHTML, body } = comment; @@ -216,7 +262,7 @@ type EditCommentProps = { }; function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommentProps) { - const { updateDraft, pr, generateDescription, cancelGenerateDescription, uploadFiles } = useContext(PullRequestContext); + const { updateDraft, pr, generateDescription, cancelGenerateDescription, uploadFiles, uploadPastedFiles } = useContext(PullRequestContext); const draftComment = useRef<{ body: string; dirty: boolean }>({ body, dirty: false }); const form = useRef(); const [isGenerating, setIsGenerating] = useState(false); @@ -298,29 +344,28 @@ function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommen return; } uploadFiles( - placeholders => { - const current = textarea.value; - const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : ''; - textarea.value = `${current}${separator}${placeholders}\n`; - draftComment.current.body = textarea.value; - draftComment.current.dirty = true; - }, - (placeholder, markdown) => { - if (!form.current) { - return; - } - const ta = form.current.markdown as HTMLTextAreaElement; - ta.value = ta.value.replace(placeholder, markdown); - draftComment.current.body = ta.value; - draftComment.current.dirty = true; - }, + placeholders => insertEditPlaceholders(form, draftComment, placeholders), + (placeholder, markdown) => replaceEditPlaceholder(form, draftComment, placeholder, markdown), ); }, [uploadFiles, form, draftComment]); + const handlePaste = useCallback((e: React.ClipboardEvent) => { + const files = filesFromClipboard(e); + if (files.length === 0) { + return; + } + e.preventDefault(); + uploadPastedFiles( + files, + placeholders => insertEditPlaceholders(form, draftComment, placeholders), + (placeholder, markdown) => replaceEditPlaceholder(form, draftComment, placeholder, markdown), + ); + }, [uploadPastedFiles, form, draftComment]); + return (
} onSubmit={onSubmit}>
-