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 c9836c8305..0f6ed116ba 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 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,16 +88,9 @@ 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 })); } /** @@ -56,7 +105,30 @@ const MAX_CONCURRENT_UPLOADS = 3; */ 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, @@ -66,13 +138,15 @@ export function runFileUploads( const runOne = async (): Promise => { while (next < uploads.length) { const u = uploads[next++]; - try { - const markdown = await githubRepository.uploadFile(u.uri, u.name); - await onComplete(u.placeholder, u.name, markdown); - } catch (err) { + (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); - await onError(u.placeholder, u.name, formatError(err)); - } + return onError(u.placeholder, u.name, formatError(err)); + }); } }; diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index 7292404ad8..b92fa52d69 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -2133,6 +2133,14 @@ export class GitHubRepository extends Disposable { if (fileBytes.byteLength > MAX_UPLOAD_SIZE_BYTES) { throw new Error(`File "${fileName}" is too large to upload (${Math.round(fileBytes.byteLength / (1024 * 1024))} MB). The maximum allowed size is ${MAX_UPLOAD_SIZE_BYTES / (1024 * 1024)} MB.`); } + 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..959276e232 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, guessExtensionFromMime, 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,41 @@ 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 names = files.map(f => f.name.includes('.') ? f.name : `${f.name}${guessExtensionFromMime(f.type)}`); + const placeholders = placeholdersForNames(names); + 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..6a84b56835 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; type: 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..9815eedd7d 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -13,6 +13,21 @@ import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../sr import { IProjectItem, MergeMethod, PullRequestCheckStatus, ReadyForReview } from '../../src/github/interface'; import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, FileUploadCompletedMessage, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewReply, UploadFilesReply } from '../../src/github/views'; +/** + * Encode a {@linkcode Uint8Array} as a base64 string. Uses fixed-size chunks to + * keep the conversion linear in time and avoid huge intermediate string + * allocations when handling large clipboard payloads. + */ +function bytesToBase64(bytes: Uint8Array): string { + const chunkSize = 0x8000; + const parts: string[] = []; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + parts.push(String.fromCharCode.apply(null, chunk as unknown as number[])); + } + return btoa(parts.join('')); +} + export class PRContext { constructor( public pr: PullRequest | undefined = getState(), @@ -189,6 +204,37 @@ 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()); + const bytesBase64 = bytesToBase64(buffer); + return { name: file.name || 'pasted-file', type: file.type ?? '', bytesBase64 }; + })); + 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 +250,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}>
-