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/3] 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 `![${fileName}](${url})`; + } + 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} +