From 13fc699110e65303f9515bb4402332abe4e4c7cb Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 6 May 2026 11:57:19 +0200 Subject: [PATCH 1/6] Add basic image uploading --- resources/icons/codicons/cloud-upload.svg | 1 + src/github/issueOverview.ts | 136 +++++++++++++++++++++- src/github/views.ts | 17 +++ webviews/activityBarView/index.css | 33 ++++++ webviews/common/context.tsx | 59 +++++++++- webviews/components/comment.tsx | 114 +++++++++++++----- webviews/components/icon.tsx | 1 + webviews/editorWebview/index.css | 37 ++++++ 8 files changed, 367 insertions(+), 31 deletions(-) create mode 100644 resources/icons/codicons/cloud-upload.svg diff --git a/resources/icons/codicons/cloud-upload.svg b/resources/icons/codicons/cloud-upload.svg new file mode 100644 index 0000000000..12791a5d53 --- /dev/null +++ b/resources/icons/codicons/cloud-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 17f8107ad4..61a77739d0 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; +import * as path from 'path'; import * as vscode from 'vscode'; import { CloseResult, OpenLocalFileArgs } from '../../common/views'; import { openPullRequestOnGitHub } from '../commands'; @@ -12,7 +13,7 @@ import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, Repo import { IssueModel } from './issueModel'; import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks'; import { isInCodespaces, processPermalinks, vscodeDevPrLink } from './utils'; -import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity } from './views'; +import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply } from './views'; import { COPILOT_ACCOUNTS, IComment } from '../common/comment'; import { emojify, ensureEmojis } from '../common/emoji'; import Logger from '../common/logger'; @@ -452,6 +453,8 @@ export class IssueOverviewPanel extends W return this.openLocalFile(message); case 'pr.debug': return this.webviewDebug(message); + case 'pr.upload-files': + return this.uploadFiles(message); default: return this.MESSAGE_UNHANDLED; } @@ -573,6 +576,108 @@ export class IssueOverviewPanel extends W Logger.debug(message.args, IssueOverviewPanel.ID); } + private async uploadFiles(message: IRequestMessage): Promise { + const fileUris = await vscode.window.showOpenDialog({ + canSelectMany: true, + canSelectFiles: true, + canSelectFolders: false, + openLabel: 'Upload', + title: 'Select files to upload', + }); + if (!fileUris || fileUris.length === 0) { + const empty: UploadFilesReply = { uploads: [] }; + return this._replyMessage(message, empty); + } + + const used = new Map(); + const uploads = 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 reply: UploadFilesReply = { uploads: uploads.map(u => ({ name: u.name, placeholder: u.placeholder })) }; + await this._replyMessage(message, reply); + + // Kick off uploads in parallel + for (const u of uploads) { + this.doUploadFile(u.uri, u.name).then(markdown => { + const completed: FileUploadCompletedMessage = { + command: 'pr.file-upload-completed', + placeholder: u.placeholder, + name: u.name, + markdown, + }; + return this._postMessage(completed); + }).catch(err => { + Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, IssueOverviewPanel.ID); + const completed: FileUploadCompletedMessage = { + command: 'pr.file-upload-completed', + placeholder: u.placeholder, + name: u.name, + error: formatError(err), + }; + return this._postMessage(completed); + }); + } + } + + private async doUploadFile(uri: vscode.Uri, fileName: string): Promise { + const fileBytes = await vscode.workspace.fs.readFile(uri); + const contentType = guessContentType(fileName); + + const githubRepository = this._item.githubRepository; + const { octokit } = await githubRepository.ensure(); + const metadata = await githubRepository.getMetadata(); + const repositoryId = metadata.id; + + // Step 1: Get upload policy + const policyResponse = await octokit.api.request('POST /mobile/upload/policy', { + name: fileName, + size: fileBytes.byteLength, + content_type: contentType, + repository_id: repositoryId, + headers: { accept: 'application/json' }, + }); + const policy = policyResponse.data as { + upload_url: string; + form: Record; + asset: { id: number; name: string; href: string }; + asset_upload_url: string; + }; + + // Step 2: Upload the file bytes to S3 + const formData = new FormData(); + for (const [key, value] of Object.entries(policy.form)) { + formData.append(key, value); + } + const arrayBuffer = new ArrayBuffer(fileBytes.byteLength); + new Uint8Array(arrayBuffer).set(fileBytes); + formData.append('file', new Blob([arrayBuffer], { type: contentType }), policy.asset.name); + const s3Response = await fetch(policy.upload_url, { method: 'POST', body: formData }); + if (s3Response.status !== 204 && s3Response.status !== 201 && s3Response.status !== 200) { + throw new Error(`Storage upload failed with status ${s3Response.status}`); + } + + // Step 3: Confirm the upload with GitHub + await octokit.api.request(`PUT ${policy.asset_upload_url}`, { + headers: { accept: 'application/json' }, + }); + + const url = policy.asset.href; + if (contentType.startsWith('image/')) { + return ``; + } + if (contentType.startsWith('video/')) { + return url; + } + return `[${fileName}](${url})`; + } + /** * Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel) * to provide custom processing logic for different item types. @@ -884,3 +989,32 @@ export class IssueOverviewPanel extends W return this._item; } } + +function guessContentType(fileName: string): string { + const ext = path.extname(fileName).toLowerCase(); + switch (ext) { + case '.png': return 'image/png'; + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.gif': return 'image/gif'; + case '.webp': return 'image/webp'; + case '.svg': return 'image/svg+xml'; + case '.bmp': return 'image/bmp'; + case '.heic': return 'image/heic'; + case '.mp4': return 'video/mp4'; + case '.mov': return 'video/quicktime'; + case '.webm': return 'video/webm'; + case '.pdf': return 'application/pdf'; + case '.zip': return 'application/zip'; + case '.gz': return 'application/gzip'; + case '.tar': return 'application/x-tar'; + case '.txt': return 'text/plain'; + case '.md': return 'text/markdown'; + case '.json': return 'application/json'; + case '.log': return 'text/plain'; + case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + default: return 'application/octet-stream'; + } +} diff --git a/src/github/views.ts b/src/github/views.ts index fd2f7c907c..76e6049c88 100644 --- a/src/github/views.ts +++ b/src/github/views.ts @@ -179,6 +179,23 @@ export interface CancelCodingAgentReply { events: TimelineEvent[]; } +export interface FileUploadPlaceholder { + name: string; + placeholder: string; +} + +export interface UploadFilesReply { + uploads: FileUploadPlaceholder[]; +} + +export interface FileUploadCompletedMessage { + command: 'pr.file-upload-completed'; + name: string; + placeholder: string; + markdown?: string; + error?: string; +} + export interface BaseContext { 'preventDefaultContextMenuItems': true; owner: string; diff --git a/webviews/activityBarView/index.css b/webviews/activityBarView/index.css index 10c13cefd1..4e1d323195 100644 --- a/webviews/activityBarView/index.css +++ b/webviews/activityBarView/index.css @@ -42,6 +42,39 @@ textarea { padding-bottom: 16px; } +.textarea-wrapper { + position: relative; + display: flex; + width: 100%; +} + +.textarea-wrapper textarea { + flex: 1; + padding-bottom: 32px; +} + +.textarea-wrapper .comment-upload-button { + position: absolute; + left: 6px; + bottom: 8px; + border: none; + background: none; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: var(--vscode-foreground); +} + +.textarea-wrapper .comment-upload-button:hover:not(:disabled) { + cursor: pointer; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.textarea-wrapper .comment-upload-button:disabled { + opacity: 0.5; +} .status-section { padding-bottom: 16px; } diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 7b4fd70b69..395a0d690b 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -11,7 +11,7 @@ import { CloseResult, DescriptionResult, OpenCommitChangesArgs, OpenLocalFileArg import { IComment } from '../../src/common/comment'; import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent'; import { IProjectItem, MergeMethod, PullRequestCheckStatus, ReadyForReview } from '../../src/github/interface'; -import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewReply } from '../../src/github/views'; +import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, FileUploadCompletedMessage, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewReply, UploadFilesReply } from '../../src/github/views'; export class PRContext { constructor( @@ -176,6 +176,61 @@ export class PRContext { public submit = (body: string) => this.submitReviewCommand('pr.submit', body); + private _uploadCompletionHandlers: Map void> = new Map(); + + /** + * Asks the host to prompt the user for files to upload. + * + * @param insertPlaceholders called once with the textual placeholders to insert into the textarea + * @param replacePlaceholder called as each upload finishes (or fails) to replace the placeholder with the resulting markdown (or remove it on error) + */ + public uploadFiles = async ( + insertPlaceholders: (placeholders: string) => void, + replacePlaceholder: (placeholder: string, markdownOrEmpty: string) => void, + ) => { + const result: UploadFilesReply | undefined = await this.postMessage({ command: 'pr.upload-files' }); + if (!result || !result.uploads || result.uploads.length === 0) { + return; + } + const placeholdersText = result.uploads.map(u => u.placeholder).join('\n'); + insertPlaceholders(placeholdersText); + for (const upload of result.uploads) { + this._uploadCompletionHandlers.set(upload.placeholder, message => { + if (message.error) { + replacePlaceholder(message.placeholder, ''); + this.postMessage({ command: 'alert', args: `Failed to upload ${message.name}: ${message.error}` }); + } else { + replacePlaceholder(message.placeholder, message.markdown ?? ''); + } + }); + } + }; + + /** + * 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) }); + }, + ); + }; + + private completeFileUpload(message: FileUploadCompletedMessage) { + const handler = this._uploadCompletionHandlers.get(message.placeholder); + if (handler) { + this._uploadCompletionHandlers.delete(message.placeholder); + handler(message); + } + } + public deleteReview = async () => { try { const result: DeleteReviewResult = await this.postMessage({ command: 'pr.delete-review' }); @@ -446,6 +501,8 @@ export class PRContext { return this.updatePR({ busy: true }); case 'pr.readied-for-review': return this.readyForReviewComplete(message); + case 'pr.file-upload-completed': + return this.completeFileUpload(message); } }; diff --git a/webviews/components/comment.tsx b/webviews/components/comment.tsx index 3f55e29af9..33d428c5fd 100644 --- a/webviews/components/comment.tsx +++ b/webviews/components/comment.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { ContextDropdown } from './contextDropdown'; -import { copyIcon, editIcon, quoteIcon, sparkleIcon, stopCircleIcon, trashIcon } from './icon'; +import { cloudUploadIcon, copyIcon, editIcon, quoteIcon, sparkleIcon, stopCircleIcon, trashIcon } from './icon'; import { nbsp, Spaced } from './space'; import { Timestamp } from './timestamp'; import { AuthorLink, Avatar } from './user'; @@ -216,7 +216,7 @@ type EditCommentProps = { }; function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommentProps) { - const { updateDraft, pr, generateDescription, cancelGenerateDescription } = useContext(PullRequestContext); + const { updateDraft, pr, generateDescription, cancelGenerateDescription, uploadFiles } = useContext(PullRequestContext); const draftComment = useRef<{ body: string; dirty: boolean }>({ body, dirty: false }); const form = useRef(); const [isGenerating, setIsGenerating] = useState(false); @@ -292,6 +292,31 @@ function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommen setIsGenerating(false); }, [cancelGenerateDescription]); + const handleUpload = useCallback(() => { + const textarea = form.current?.markdown as HTMLTextAreaElement | undefined; + if (!textarea) { + 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; + }, + ); + }, [uploadFiles, form, draftComment]); + return ( } onSubmit={onSubmit}> @@ -317,6 +342,15 @@ function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommen ) ) : null} + + {cloudUploadIcon} + @@ -419,7 +453,7 @@ export function AddComment({ repo, number }: PullRequest) { - const { updatePR, requestChanges, approve, close, openOnGitHub, submit } = useContext(PullRequestContext); + const { updatePR, requestChanges, approve, close, openOnGitHub, submit, uploadFilesIntoPendingComment } = useContext(PullRequestContext); const [isBusy, setBusy] = useState(false); const form = useRef(); const textareaRef = useRef(); @@ -489,21 +523,32 @@ export function AddComment({ return ( } className="comment-form main-comment-form" > - } - onInput={({ target }) => updatePR({ pendingCommentText: (target as HTMLTextAreaElement).value })} - onKeyDown={onKeyDown} - value={pendingCommentText} - placeholder="Leave a comment" - onClick={() => { - if (!pendingCommentText && isCopilotOnMyBehalf && !textareaRef.current?.textContent) { - textareaRef.current!.textContent = '@copilot '; - textareaRef.current!.setSelectionRange(9, 9); - } - }} - /> + + } + onInput={({ target }) => updatePR({ pendingCommentText: (target as HTMLTextAreaElement).value })} + onKeyDown={onKeyDown} + value={pendingCommentText} + placeholder="Leave a comment" + onClick={() => { + if (!pendingCommentText && isCopilotOnMyBehalf && !textareaRef.current?.textContent) { + textareaRef.current!.textContent = '@copilot '; + textareaRef.current!.setSelectionRange(9, 9); + } + }} + /> + uploadFilesIntoPendingComment()} + disabled={isBusy || busy} + > + {cloudUploadIcon} + + {(hasWritePermission || isAuthor) ? ( { - const { updatePR, requestChanges, approve, submit, openOnGitHub } = useContext(PullRequestContext); + const { updatePR, requestChanges, approve, submit, openOnGitHub, uploadFilesIntoPendingComment } = useContext(PullRequestContext); const [isBusy, setBusy] = useState(false); const textareaRef = useRef(); let currentSelection: ReviewType = pr.lastReviewType ?? (pr.currentUserReviewState === 'APPROVED' ? ReviewType.Approve : (pr.currentUserReviewState === 'CHANGES_REQUESTED' ? ReviewType.RequestChanges : ReviewType.Comment)); @@ -660,16 +705,27 @@ export const AddCommentSimple = (pr: PullRequest) => { return ( - } - value={pr.pendingCommentText ?? ''} - onChange={onChangeTextarea} - onKeyDown={onKeyDown} - disabled={isBusy || pr.busy} - /> + + } + value={pr.pendingCommentText ?? ''} + onChange={onChangeTextarea} + onKeyDown={onKeyDown} + disabled={isBusy || pr.busy} + /> + uploadFilesIntoPendingComment()} + disabled={isBusy || pr.busy} + > + {cloudUploadIcon} + + makeCommentMenuContext(pr.owner, pr.repo, pr.number, availableActions, pr.pendingCommentText, shouldDisableNonApproveButtons)} diff --git a/webviews/components/icon.tsx b/webviews/components/icon.tsx index 403a9a6d72..daca557f3a 100644 --- a/webviews/components/icon.tsx +++ b/webviews/components/icon.tsx @@ -19,6 +19,7 @@ export const checkAllIcon = ; export const circleFilledIcon = ; export const closeIcon = ; +export const cloudUploadIcon = ; export const commentIcon = ; export const copilotIcon = ; export const copyIcon = ; diff --git a/webviews/editorWebview/index.css b/webviews/editorWebview/index.css index db6020a86f..5ce6863f24 100644 --- a/webviews/editorWebview/index.css +++ b/webviews/editorWebview/index.css @@ -1054,6 +1054,43 @@ textarea { padding: 2px; } +.textarea-wrapper:has(> .comment-upload-button)>textarea { + padding-bottom: 32px; +} + +.textarea-wrapper .comment-upload-button { + position: absolute; + left: 6px; + bottom: 8px; + border: none; + background: none; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: var(--vscode-foreground); +} + +.textarea-wrapper .comment-upload-button:hover:not(:disabled) { + cursor: pointer; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.textarea-wrapper .comment-upload-button:focus { + outline-style: none; +} + +.textarea-wrapper .comment-upload-button:focus-visible { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); +} + +.textarea-wrapper .comment-upload-button:disabled { + opacity: 0.5; +} .editing-form { padding: 5px 0; display: flex; From 5ca16f7be319c33940ede114a098811d4ce8c78f Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 6 May 2026 12:03:43 +0200 Subject: [PATCH 2/6] Move to githubrepository --- src/github/githubRepository.ts | 85 ++++++++++++++++++++++++++++++++++ src/github/issueOverview.ts | 83 +-------------------------------- 2 files changed, 87 insertions(+), 81 deletions(-) diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index e6ea556eb1..8865360eca 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -2111,4 +2111,89 @@ export class GitHubRepository extends Disposable { } return CheckState.Success; } + + /** + * Upload a file to GitHub via the mobile upload policy API. Returns a markdown + * snippet appropriate for embedding in an issue/PR comment. + */ + public async uploadFile(uri: vscode.Uri, fileName: string): Promise { + const fileBytes = await vscode.workspace.fs.readFile(uri); + const contentType = guessContentType(fileName); + + const { octokit } = await this.ensure(); + const metadata = await this.getMetadata(); + const repositoryId = metadata.id; + + // Step 1: Get upload policy + const policyResponse = await octokit.api.request('POST /mobile/upload/policy', { + name: fileName, + size: fileBytes.byteLength, + content_type: contentType, + repository_id: repositoryId, + headers: { accept: 'application/json' }, + }); + const policy = policyResponse.data as { + upload_url: string; + form: Record; + asset: { id: number; name: string; href: string }; + asset_upload_url: string; + }; + + // Step 2: Upload bytes to the storage location returned by the policy + const formData = new FormData(); + for (const [key, value] of Object.entries(policy.form)) { + formData.append(key, value); + } + const arrayBuffer = new ArrayBuffer(fileBytes.byteLength); + new Uint8Array(arrayBuffer).set(fileBytes); + formData.append('file', new Blob([arrayBuffer], { type: contentType }), policy.asset.name); + const s3Response = await fetch(policy.upload_url, { method: 'POST', body: formData }); + if (s3Response.status !== 204 && s3Response.status !== 201 && s3Response.status !== 200) { + throw new Error(`Storage upload failed with status ${s3Response.status}`); + } + + // Step 3: Confirm the upload with GitHub + await octokit.api.request(`PUT ${policy.asset_upload_url}`, { + headers: { accept: 'application/json' }, + }); + + const url = policy.asset.href; + if (contentType.startsWith('image/')) { + return ``; + } + if (contentType.startsWith('video/')) { + return url; + } + return `[${fileName}](${url})`; + } +} + +function guessContentType(fileName: string): string { + const lastDot = fileName.lastIndexOf('.'); + const ext = lastDot >= 0 ? fileName.substring(lastDot).toLowerCase() : ''; + switch (ext) { + case '.png': return 'image/png'; + case '.jpg': + case '.jpeg': return 'image/jpeg'; + case '.gif': return 'image/gif'; + case '.webp': return 'image/webp'; + case '.svg': return 'image/svg+xml'; + case '.bmp': return 'image/bmp'; + case '.heic': return 'image/heic'; + case '.mp4': return 'video/mp4'; + case '.mov': return 'video/quicktime'; + case '.webm': return 'video/webm'; + case '.pdf': return 'application/pdf'; + case '.zip': return 'application/zip'; + case '.gz': return 'application/gzip'; + case '.tar': return 'application/x-tar'; + case '.txt': return 'text/plain'; + case '.md': return 'text/markdown'; + case '.json': return 'application/json'; + case '.log': return 'text/plain'; + case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + default: return 'application/octet-stream'; + } } diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 61a77739d0..644abace9e 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -604,8 +604,9 @@ export class IssueOverviewPanel extends W await this._replyMessage(message, reply); // Kick off uploads in parallel + const githubRepository = this._item.githubRepository; for (const u of uploads) { - this.doUploadFile(u.uri, u.name).then(markdown => { + githubRepository.uploadFile(u.uri, u.name).then(markdown => { const completed: FileUploadCompletedMessage = { command: 'pr.file-upload-completed', placeholder: u.placeholder, @@ -626,57 +627,6 @@ export class IssueOverviewPanel extends W } } - private async doUploadFile(uri: vscode.Uri, fileName: string): Promise { - const fileBytes = await vscode.workspace.fs.readFile(uri); - const contentType = guessContentType(fileName); - - const githubRepository = this._item.githubRepository; - const { octokit } = await githubRepository.ensure(); - const metadata = await githubRepository.getMetadata(); - const repositoryId = metadata.id; - - // Step 1: Get upload policy - const policyResponse = await octokit.api.request('POST /mobile/upload/policy', { - name: fileName, - size: fileBytes.byteLength, - content_type: contentType, - repository_id: repositoryId, - headers: { accept: 'application/json' }, - }); - const policy = policyResponse.data as { - upload_url: string; - form: Record; - asset: { id: number; name: string; href: string }; - asset_upload_url: string; - }; - - // Step 2: Upload the file bytes to S3 - const formData = new FormData(); - for (const [key, value] of Object.entries(policy.form)) { - formData.append(key, value); - } - const arrayBuffer = new ArrayBuffer(fileBytes.byteLength); - new Uint8Array(arrayBuffer).set(fileBytes); - formData.append('file', new Blob([arrayBuffer], { type: contentType }), policy.asset.name); - const s3Response = await fetch(policy.upload_url, { method: 'POST', body: formData }); - if (s3Response.status !== 204 && s3Response.status !== 201 && s3Response.status !== 200) { - throw new Error(`Storage upload failed with status ${s3Response.status}`); - } - - // Step 3: Confirm the upload with GitHub - await octokit.api.request(`PUT ${policy.asset_upload_url}`, { - headers: { accept: 'application/json' }, - }); - - const url = policy.asset.href; - if (contentType.startsWith('image/')) { - return ``; - } - if (contentType.startsWith('video/')) { - return url; - } - return `[${fileName}](${url})`; - } /** * Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel) @@ -989,32 +939,3 @@ export class IssueOverviewPanel extends W return this._item; } } - -function guessContentType(fileName: string): string { - const ext = path.extname(fileName).toLowerCase(); - switch (ext) { - case '.png': return 'image/png'; - case '.jpg': - case '.jpeg': return 'image/jpeg'; - case '.gif': return 'image/gif'; - case '.webp': return 'image/webp'; - case '.svg': return 'image/svg+xml'; - case '.bmp': return 'image/bmp'; - case '.heic': return 'image/heic'; - case '.mp4': return 'video/mp4'; - case '.mov': return 'video/quicktime'; - case '.webm': return 'video/webm'; - case '.pdf': return 'application/pdf'; - case '.zip': return 'application/zip'; - case '.gz': return 'application/gzip'; - case '.tar': return 'application/x-tar'; - case '.txt': return 'text/plain'; - case '.md': return 'text/markdown'; - case '.json': return 'application/json'; - case '.log': return 'text/plain'; - case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; - case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; - default: return 'application/octet-stream'; - } -} From ca468c47ba80396ab67c420f57516b8d317690f5 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 6 May 2026 12:22:58 +0200 Subject: [PATCH 3/6] Add support for file upload when leaving a PR comment --- package.json | 15 +++++++ package.nls.json | 1 + src/commands.ts | 81 +++++++++++++++++++++++++++++++++++++ src/github/fileUpload.ts | 66 ++++++++++++++++++++++++++++++ src/github/issueOverview.ts | 62 +++++++++------------------- 5 files changed, 183 insertions(+), 42 deletions(-) create mode 100644 src/github/fileUpload.ts diff --git a/package.json b/package.json index 88d69d4651..55be59a345 100644 --- a/package.json +++ b/package.json @@ -1245,6 +1245,12 @@ "title": "%command.pr.makeSuggestion.title%", "category": "%command.pull.request.category%" }, + { + "command": "pr.uploadFile", + "title": "%command.pr.uploadFile.title%", + "category": "%command.pull.request.category%", + "icon": "$(cloud-upload)" + }, { "command": "pr.startReview", "title": "%command.pr.startReview.title%", @@ -2360,6 +2366,10 @@ "command": "pr.makeSuggestion", "when": "false" }, + { + "command": "pr.uploadFile", + "when": "false" + }, { "command": "pr.startReview", "when": "false" @@ -3296,6 +3306,11 @@ "command": "pr.makeSuggestion", "group": "inline@3", "when": "commentController =~ /^github-(browse|review)/ && !github:activeCommentHasSuggestion" + }, + { + "command": "pr.uploadFile", + "group": "inline@4", + "when": "commentController =~ /^github-(browse|review)/" } ], "comments/commentThread/additionalActions": [ diff --git a/package.nls.json b/package.nls.json index 080e04dc5e..ed80e9bbf5 100644 --- a/package.nls.json +++ b/package.nls.json @@ -263,6 +263,7 @@ "command.pr.createComment.title": "Add Review Comment", "command.pr.createSingleComment.title": "Add Comment", "command.pr.makeSuggestion.title": "Make Code Suggestion", + "command.pr.uploadFile.title": "Upload File", "command.pr.startReview.title": "Start Review", "command.pr.editComment.title": "Edit Comment", "command.pr.cancelEditComment.title": "Cancel", diff --git a/src/commands.ts b/src/commands.ts index c48ddda7ab..5b204b00f2 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -20,6 +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 { FolderRepositoryManager } from './github/folderRepositoryManager'; import { GitHubRepository } from './github/githubRepository'; import { Issue } from './github/interface'; @@ -1379,6 +1380,86 @@ ${contents} }) ); + context.subscriptions.push( + vscode.commands.registerCommand('pr.uploadFile', async (reply: CommentReply | GHPRComment | undefined) => { + /* __GDPR__ + "pr.uploadFile" : {} + */ + telemetry.sendTelemetryEvent('pr.uploadFile'); + + let potentialThread: GHPRCommentThread | undefined; + if (reply === undefined) { + potentialThread = findActiveHandler()?.commentController.activeCommentThread as vscode.CommentThread2 as GHPRCommentThread | undefined; + } else { + potentialThread = reply instanceof GHPRComment ? reply.parent : reply?.thread; + } + + if (!potentialThread) { + return; + } + const thread = potentialThread; + + const commentEditor = vscode.window.activeTextEditor?.document.uri.scheme === Schemes.Comment ? vscode.window.activeTextEditor + : vscode.window.visibleTextEditors.find(visible => (visible.document.uri.scheme === Schemes.Comment) && (visible.document.uri.query === '')); + if (!commentEditor) { + Logger.error('No comment editor visible for uploading a file.', logId); + vscode.window.showErrorMessage(vscode.l10n.t('No available comment editor to upload a file in.')); + return; + } + const commentEditorUri = commentEditor.document.uri.toString(); + + const folderManager = reposManager.getManagerForFile(thread.uri); + const githubRepository = folderManager?.activePullRequest?.githubRepository + ?? folderManager?.gitHubRepositories[0]; + if (!githubRepository) { + vscode.window.showErrorMessage(vscode.l10n.t('Cannot upload files: no GitHub repository found for this comment.')); + return; + } + + const uploads = await pickFilesForUpload(); + if (!uploads) { + return; + } + + // Insert placeholders at the current cursor position + const placeholdersText = uploads.map(u => u.placeholder).join('\n'); + const cursor = commentEditor.selection.end; + const before = commentEditor.document.getText(new vscode.Range(new vscode.Position(0, 0), cursor)); + const separator = before.length > 0 && !before.endsWith('\n') ? '\n' : ''; + await commentEditor.edit(editBuilder => { + editBuilder.insert(cursor, `${separator}${placeholdersText}\n`); + }); + + const replacePlaceholder = async (placeholder: string, replacement: string) => { + const editor = vscode.window.visibleTextEditors.find(e => e.document.uri.toString() === commentEditorUri); + 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); + }); + }; + + runFileUploads( + githubRepository, + uploads, + 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, ''); + }, + ); + }) + ); + 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 new file mode 100644 index 0000000000..87a6b6d344 --- /dev/null +++ b/src/github/fileUpload.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as path from 'path'; +import * as vscode from 'vscode'; +import { GitHubRepository } from './githubRepository'; +import Logger from '../common/logger'; +import { formatError } from '../common/utils'; + +export interface FileUploadPlaceholder { + uri: vscode.Uri; + name: string; + placeholder: string; +} + +/** + * Prompt the user for files to upload and compute the placeholder text that + * should be inserted into a comment textarea while the uploads run. + * Returns `undefined` when the user cancels. + */ +export async function pickFilesForUpload(): Promise { + const fileUris = await vscode.window.showOpenDialog({ + canSelectMany: true, + canSelectFiles: true, + canSelectFolders: false, + openLabel: vscode.l10n.t('Upload'), + title: vscode.l10n.t('Select files to upload'), + }); + if (!fileUris || fileUris.length === 0) { + return undefined; + } + + const used = new Map(); + 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 }; + }); +} + +/** + * Run the actual file uploads in parallel, invoking the supplied callbacks as + * each upload finishes (or fails). + */ +export function runFileUploads( + githubRepository: GitHubRepository, + uploads: FileUploadPlaceholder[], + 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 => { + return onComplete(u.placeholder, u.name, markdown); + }).catch(err => { + Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, logId); + return onError(u.placeholder, u.name, formatError(err)); + }); + } +} diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 644abace9e..05612a89cc 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import * as path from 'path'; import * as vscode from 'vscode'; import { CloseResult, OpenLocalFileArgs } from '../../common/views'; import { openPullRequestOnGitHub } from '../commands'; +import { pickFilesForUpload, runFileUploads } from './fileUpload'; import { FolderRepositoryManager } from './folderRepositoryManager'; import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface'; import { IssueModel } from './issueModel'; @@ -577,54 +577,32 @@ export class IssueOverviewPanel extends W } private async uploadFiles(message: IRequestMessage): Promise { - const fileUris = await vscode.window.showOpenDialog({ - canSelectMany: true, - canSelectFiles: true, - canSelectFolders: false, - openLabel: 'Upload', - title: 'Select files to upload', - }); - if (!fileUris || fileUris.length === 0) { + const uploads = await pickFilesForUpload(); + if (!uploads) { const empty: UploadFilesReply = { uploads: [] }; return this._replyMessage(message, empty); } - const used = new Map(); - const uploads = 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 reply: UploadFilesReply = { uploads: uploads.map(u => ({ name: u.name, placeholder: u.placeholder })) }; await this._replyMessage(message, reply); - // Kick off uploads in parallel - const githubRepository = this._item.githubRepository; - for (const u of uploads) { - githubRepository.uploadFile(u.uri, u.name).then(markdown => { - const completed: FileUploadCompletedMessage = { - command: 'pr.file-upload-completed', - placeholder: u.placeholder, - name: u.name, - markdown, - }; - return this._postMessage(completed); - }).catch(err => { - Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, IssueOverviewPanel.ID); - const completed: FileUploadCompletedMessage = { - command: 'pr.file-upload-completed', - placeholder: u.placeholder, - name: u.name, - error: formatError(err), - }; - return this._postMessage(completed); - }); - } + runFileUploads( + this._item.githubRepository, + uploads, + 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), + ); } From 117f5b86c1ffff7a6317b0407144e4597a06930a Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 6 May 2026 12:53:36 +0200 Subject: [PATCH 4/6] Address CCR comments --- src/github/githubRepository.ts | 39 ++++++++++++++++++++++++++++------ src/github/issueOverview.ts | 31 +++++++++++++++++++++------ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index 8865360eca..7292404ad8 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -2117,7 +2117,22 @@ export class GitHubRepository extends Disposable { * snippet appropriate for embedding in an issue/PR comment. */ public async uploadFile(uri: vscode.Uri, fileName: string): Promise { + // Guard against very large files: check size before reading the bytes into memory. + let fileSize: number | undefined; + try { + const stat = await vscode.workspace.fs.stat(uri); + fileSize = stat.size; + } catch { + // Fall through; readFile will surface a more specific error if needed. + } + if (fileSize !== undefined && fileSize > MAX_UPLOAD_SIZE_BYTES) { + throw new Error(`File "${fileName}" is too large to upload (${Math.round(fileSize / (1024 * 1024))} MB). The maximum allowed size is ${MAX_UPLOAD_SIZE_BYTES / (1024 * 1024)} MB.`); + } + const fileBytes = await vscode.workspace.fs.readFile(uri); + 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.`); + } const contentType = guessContentType(fileName); const { octokit } = await this.ensure(); @@ -2139,14 +2154,15 @@ export class GitHubRepository extends Disposable { asset_upload_url: string; }; - // Step 2: Upload bytes to the storage location returned by the policy + // Step 2: Upload bytes to the storage location returned by the policy. + // Pass the Uint8Array directly to Blob to avoid an extra full-size copy. const formData = new FormData(); for (const [key, value] of Object.entries(policy.form)) { formData.append(key, value); } - const arrayBuffer = new ArrayBuffer(fileBytes.byteLength); - new Uint8Array(arrayBuffer).set(fileBytes); - formData.append('file', new Blob([arrayBuffer], { type: contentType }), policy.asset.name); + // The DOM Blob types require Uint8Array, but vscode.workspace.fs.readFile + // returns Uint8Array. The runtime accepts it, so cast via unknown to avoid a copy. + formData.append('file', new Blob([fileBytes as unknown as BlobPart], { type: contentType }), policy.asset.name); const s3Response = await fetch(policy.upload_url, { method: 'POST', body: formData }); if (s3Response.status !== 204 && s3Response.status !== 201 && s3Response.status !== 200) { throw new Error(`Storage upload failed with status ${s3Response.status}`); @@ -2158,16 +2174,27 @@ export class GitHubRepository extends Disposable { }); const url = policy.asset.href; + const safeName = escapeMarkdownLinkText(fileName); if (contentType.startsWith('image/')) { - return ``; + return ``; } if (contentType.startsWith('video/')) { return url; } - return `[${fileName}](${url})`; + return `[${safeName}](${url})`; } } +const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB + +/** + * Escape characters that would break a markdown link's text segment (`[text](url)`). + * Filenames may legally contain `[`, `]`, `\`, etc., which can corrupt the rendered link. + */ +function escapeMarkdownLinkText(text: string): string { + return text.replace(/([\\\[\]`])/g, '\\$1'); +} + function guessContentType(fileName: string): string { const lastDot = fileName.lastIndexOf('.'); const ext = lastDot >= 0 ? fileName.substring(lastDot).toLowerCase() : ''; diff --git a/src/github/issueOverview.ts b/src/github/issueOverview.ts index 644abace9e..7938ef94f0 100644 --- a/src/github/issueOverview.ts +++ b/src/github/issueOverview.ts @@ -603,18 +603,21 @@ export class IssueOverviewPanel extends W const reply: UploadFilesReply = { uploads: uploads.map(u => ({ name: u.name, placeholder: u.placeholder })) }; await this._replyMessage(message, reply); - // Kick off uploads in parallel + // Run uploads with bounded concurrency to avoid spiking memory/network in the extension host. const githubRepository = this._item.githubRepository; - for (const u of uploads) { - githubRepository.uploadFile(u.uri, u.name).then(markdown => { + const MAX_CONCURRENT_UPLOADS = 3; + const queue = uploads.slice(); + const runOne = async (u: { uri: vscode.Uri; name: string; placeholder: string }) => { + try { + const markdown = await githubRepository.uploadFile(u.uri, u.name); const completed: FileUploadCompletedMessage = { command: 'pr.file-upload-completed', placeholder: u.placeholder, name: u.name, markdown, }; - return this._postMessage(completed); - }).catch(err => { + await this._postMessage(completed); + } catch (err) { Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, IssueOverviewPanel.ID); const completed: FileUploadCompletedMessage = { command: 'pr.file-upload-completed', @@ -622,9 +625,23 @@ export class IssueOverviewPanel extends W name: u.name, error: formatError(err), }; - return this._postMessage(completed); - }); + await this._postMessage(completed); + } + }; + const workers: Promise[] = []; + for (let i = 0; i < Math.min(MAX_CONCURRENT_UPLOADS, queue.length); i++) { + workers.push((async () => { + while (queue.length > 0) { + const next = queue.shift(); + if (!next) { + break; + } + await runOne(next); + } + })()); } + // Don't await all workers - let them run in the background so this handler returns promptly. + Promise.all(workers).catch(err => Logger.error(`Upload worker error: ${formatError(err)}`, IssueOverviewPanel.ID)); } From 645dcadad329e66790c8aca0a6bc1f6bad617556 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Thu, 7 May 2026 10:04:17 +0200 Subject: [PATCH 5/6] Add paste-to-upload (#8725) * Add paste-to-upload * Address CCR feedback --- src/commands.ts | 83 +++++++++++++++++++++++- src/github/fileUpload.ts | 108 +++++++++++++++++++++++++++----- src/github/githubRepository.ts | 8 +++ src/github/issueOverview.ts | 41 +++++++++++- src/github/views.ts | 4 ++ webviews/common/context.tsx | 81 +++++++++++++++++++++--- webviews/components/comment.tsx | 87 +++++++++++++++++++------ 7 files changed, 362 insertions(+), 50 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 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}> - + {isPRDescription ? ( isGenerating ? ( (); const textareaRef = useRef(); @@ -530,6 +575,7 @@ export function AddComment({ ref={textareaRef as React.MutableRefObject} onInput={({ target }) => updatePR({ pendingCommentText: (target as HTMLTextAreaElement).value })} onKeyDown={onKeyDown} + onPaste={onPasteUploadFiles(uploadPastedFilesIntoPendingComment)} value={pendingCommentText} placeholder="Leave a comment" onClick={() => { @@ -644,7 +690,7 @@ const makeCommentMenuContext = (owner: string, repo: string, number: number, ava }; export const AddCommentSimple = (pr: PullRequest) => { - const { updatePR, requestChanges, approve, submit, openOnGitHub, uploadFilesIntoPendingComment } = useContext(PullRequestContext); + const { updatePR, requestChanges, approve, submit, openOnGitHub, uploadFilesIntoPendingComment, uploadPastedFilesIntoPendingComment } = useContext(PullRequestContext); const [isBusy, setBusy] = useState(false); const textareaRef = useRef(); let currentSelection: ReviewType = pr.lastReviewType ?? (pr.currentUserReviewState === 'APPROVED' ? ReviewType.Approve : (pr.currentUserReviewState === 'CHANGES_REQUESTED' ? ReviewType.RequestChanges : ReviewType.Comment)); @@ -714,6 +760,7 @@ export const AddCommentSimple = (pr: PullRequest) => { value={pr.pendingCommentText ?? ''} onChange={onChangeTextarea} onKeyDown={onKeyDown} + onPaste={onPasteUploadFiles(uploadPastedFilesIntoPendingComment)} disabled={isBusy || pr.busy} /> Date: Thu, 7 May 2026 10:22:09 +0200 Subject: [PATCH 6/6] CCR feedback --- src/github/githubRepository.ts | 6 +++--- webviews/common/context.tsx | 13 +++++++++++++ webviews/components/comment.tsx | 4 ++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/github/githubRepository.ts b/src/github/githubRepository.ts index b92fa52d69..f6c10ee24f 100644 --- a/src/github/githubRepository.ts +++ b/src/github/githubRepository.ts @@ -2130,9 +2130,6 @@ export class GitHubRepository extends Disposable { } const fileBytes = await vscode.workspace.fs.readFile(uri); - 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); } @@ -2141,6 +2138,9 @@ export class GitHubRepository extends Disposable { * Returns a markdown snippet appropriate for embedding in an issue/PR comment. */ public async uploadFileBytes(fileBytes: Uint8Array, fileName: string): Promise { + 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.`); + } const contentType = guessContentType(fileName); const { octokit } = await this.ensure(); diff --git a/webviews/common/context.tsx b/webviews/common/context.tsx index 9815eedd7d..3e6a20f4b8 100644 --- a/webviews/common/context.tsx +++ b/webviews/common/context.tsx @@ -28,6 +28,9 @@ function bytesToBase64(bytes: Uint8Array): string { return btoa(parts.join('')); } +// Keep in sync with MAX_UPLOAD_SIZE_BYTES on the extension host. +const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024; + export class PRContext { constructor( public pr: PullRequest | undefined = getState(), @@ -218,6 +221,16 @@ export class PRContext { if (files.length === 0) { return; } + const oversized = files.find(f => f.size > MAX_UPLOAD_SIZE_BYTES); + if (oversized) { + const limitMb = MAX_UPLOAD_SIZE_BYTES / (1024 * 1024); + const sizeMb = Math.round(oversized.size / (1024 * 1024)); + void this.postMessage({ + command: 'alert', + args: `File "${oversized.name || 'pasted-file'}" is too large to upload (${sizeMb} MB). The maximum allowed size is ${limitMb} MB.`, + }); + return; + } const fileArgs = await Promise.all(files.map(async file => { const buffer = new Uint8Array(await file.arrayBuffer()); const bytesBase64 = bytesToBase64(buffer); diff --git a/webviews/components/comment.tsx b/webviews/components/comment.tsx index 125357ff8c..85e9c8cf48 100644 --- a/webviews/components/comment.tsx +++ b/webviews/components/comment.tsx @@ -45,7 +45,7 @@ function onPasteUploadFiles(uploader: (files: readonly File[]) => Promise) return; } e.preventDefault(); - uploader(files); + void uploader(files); }; } @@ -355,7 +355,7 @@ function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommen return; } e.preventDefault(); - uploadPastedFiles( + void uploadPastedFiles( files, placeholders => insertEditPlaceholders(form, draftComment, placeholders), (placeholder, markdown) => replaceEditPlaceholder(form, draftComment, placeholder, markdown),