Skip to content

Commit 35bb21b

Browse files
authored
Add basic image uploading in the webview (#8723)
* Add basic image uploading * Move to githubrepository * Address CCR comments
1 parent 8b6d536 commit 35bb21b

9 files changed

Lines changed: 417 additions & 31 deletions

File tree

Lines changed: 1 addition & 0 deletions
Loading

src/github/githubRepository.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2111,4 +2111,116 @@ export class GitHubRepository extends Disposable {
21112111
}
21122112
return CheckState.Success;
21132113
}
2114+
2115+
/**
2116+
* Upload a file to GitHub via the mobile upload policy API. Returns a markdown
2117+
* snippet appropriate for embedding in an issue/PR comment.
2118+
*/
2119+
public async uploadFile(uri: vscode.Uri, fileName: string): Promise<string> {
2120+
// Guard against very large files: check size before reading the bytes into memory.
2121+
let fileSize: number | undefined;
2122+
try {
2123+
const stat = await vscode.workspace.fs.stat(uri);
2124+
fileSize = stat.size;
2125+
} catch {
2126+
// Fall through; readFile will surface a more specific error if needed.
2127+
}
2128+
if (fileSize !== undefined && fileSize > MAX_UPLOAD_SIZE_BYTES) {
2129+
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.`);
2130+
}
2131+
2132+
const fileBytes = await vscode.workspace.fs.readFile(uri);
2133+
if (fileBytes.byteLength > MAX_UPLOAD_SIZE_BYTES) {
2134+
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.`);
2135+
}
2136+
const contentType = guessContentType(fileName);
2137+
2138+
const { octokit } = await this.ensure();
2139+
const metadata = await this.getMetadata();
2140+
const repositoryId = metadata.id;
2141+
2142+
// Step 1: Get upload policy
2143+
const policyResponse = await octokit.api.request('POST /mobile/upload/policy', {
2144+
name: fileName,
2145+
size: fileBytes.byteLength,
2146+
content_type: contentType,
2147+
repository_id: repositoryId,
2148+
headers: { accept: 'application/json' },
2149+
});
2150+
const policy = policyResponse.data as {
2151+
upload_url: string;
2152+
form: Record<string, string>;
2153+
asset: { id: number; name: string; href: string };
2154+
asset_upload_url: string;
2155+
};
2156+
2157+
// Step 2: Upload bytes to the storage location returned by the policy.
2158+
// Pass the Uint8Array directly to Blob to avoid an extra full-size copy.
2159+
const formData = new FormData();
2160+
for (const [key, value] of Object.entries(policy.form)) {
2161+
formData.append(key, value);
2162+
}
2163+
// The DOM Blob types require Uint8Array<ArrayBuffer>, but vscode.workspace.fs.readFile
2164+
// returns Uint8Array<ArrayBufferLike>. The runtime accepts it, so cast via unknown to avoid a copy.
2165+
formData.append('file', new Blob([fileBytes as unknown as BlobPart], { type: contentType }), policy.asset.name);
2166+
const s3Response = await fetch(policy.upload_url, { method: 'POST', body: formData });
2167+
if (s3Response.status !== 204 && s3Response.status !== 201 && s3Response.status !== 200) {
2168+
throw new Error(`Storage upload failed with status ${s3Response.status}`);
2169+
}
2170+
2171+
// Step 3: Confirm the upload with GitHub
2172+
await octokit.api.request(`PUT ${policy.asset_upload_url}`, {
2173+
headers: { accept: 'application/json' },
2174+
});
2175+
2176+
const url = policy.asset.href;
2177+
const safeName = escapeMarkdownLinkText(fileName);
2178+
if (contentType.startsWith('image/')) {
2179+
return `![${safeName}](${url})`;
2180+
}
2181+
if (contentType.startsWith('video/')) {
2182+
return url;
2183+
}
2184+
return `[${safeName}](${url})`;
2185+
}
2186+
}
2187+
2188+
const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB
2189+
2190+
/**
2191+
* Escape characters that would break a markdown link's text segment (`[text](url)`).
2192+
* Filenames may legally contain `[`, `]`, `\`, etc., which can corrupt the rendered link.
2193+
*/
2194+
function escapeMarkdownLinkText(text: string): string {
2195+
return text.replace(/([\\\[\]`])/g, '\\$1');
2196+
}
2197+
2198+
function guessContentType(fileName: string): string {
2199+
const lastDot = fileName.lastIndexOf('.');
2200+
const ext = lastDot >= 0 ? fileName.substring(lastDot).toLowerCase() : '';
2201+
switch (ext) {
2202+
case '.png': return 'image/png';
2203+
case '.jpg':
2204+
case '.jpeg': return 'image/jpeg';
2205+
case '.gif': return 'image/gif';
2206+
case '.webp': return 'image/webp';
2207+
case '.svg': return 'image/svg+xml';
2208+
case '.bmp': return 'image/bmp';
2209+
case '.heic': return 'image/heic';
2210+
case '.mp4': return 'video/mp4';
2211+
case '.mov': return 'video/quicktime';
2212+
case '.webm': return 'video/webm';
2213+
case '.pdf': return 'application/pdf';
2214+
case '.zip': return 'application/zip';
2215+
case '.gz': return 'application/gzip';
2216+
case '.tar': return 'application/x-tar';
2217+
case '.txt': return 'text/plain';
2218+
case '.md': return 'text/markdown';
2219+
case '.json': return 'application/json';
2220+
case '.log': return 'text/plain';
2221+
case '.docx': return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
2222+
case '.xlsx': return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
2223+
case '.pptx': return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
2224+
default: return 'application/octet-stream';
2225+
}
21142226
}

src/github/issueOverview.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55
'use strict';
66

7+
import * as path from 'path';
78
import * as vscode from 'vscode';
89
import { CloseResult, OpenLocalFileArgs } from '../../common/views';
910
import { openPullRequestOnGitHub } from '../commands';
@@ -12,7 +13,7 @@ import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, Repo
1213
import { IssueModel } from './issueModel';
1314
import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks';
1415
import { isInCodespaces, processPermalinks, vscodeDevPrLink } from './utils';
15-
import { ChangeAssigneesReply, DisplayLabel, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity } from './views';
16+
import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply } from './views';
1617
import { COPILOT_ACCOUNTS, IComment } from '../common/comment';
1718
import { emojify, ensureEmojis } from '../common/emoji';
1819
import Logger from '../common/logger';
@@ -452,6 +453,8 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
452453
return this.openLocalFile(message);
453454
case 'pr.debug':
454455
return this.webviewDebug(message);
456+
case 'pr.upload-files':
457+
return this.uploadFiles(message);
455458
default:
456459
return this.MESSAGE_UNHANDLED;
457460
}
@@ -573,6 +576,75 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
573576
Logger.debug(message.args, IssueOverviewPanel.ID);
574577
}
575578

579+
private async uploadFiles(message: IRequestMessage<void>): Promise<void> {
580+
const fileUris = await vscode.window.showOpenDialog({
581+
canSelectMany: true,
582+
canSelectFiles: true,
583+
canSelectFolders: false,
584+
openLabel: 'Upload',
585+
title: 'Select files to upload',
586+
});
587+
if (!fileUris || fileUris.length === 0) {
588+
const empty: UploadFilesReply = { uploads: [] };
589+
return this._replyMessage(message, empty);
590+
}
591+
592+
const used = new Map<string, number>();
593+
const uploads = fileUris.map(uri => {
594+
const baseName = path.basename(uri.fsPath);
595+
const count = used.get(baseName) ?? 0;
596+
used.set(baseName, count + 1);
597+
const placeholder = count === 0
598+
? `<!-- Uploading ${baseName} -->`
599+
: `<!-- Uploading ${baseName} (${count + 1}) -->`;
600+
return { uri, name: baseName, placeholder };
601+
});
602+
603+
const reply: UploadFilesReply = { uploads: uploads.map(u => ({ name: u.name, placeholder: u.placeholder })) };
604+
await this._replyMessage(message, reply);
605+
606+
// Run uploads with bounded concurrency to avoid spiking memory/network in the extension host.
607+
const githubRepository = this._item.githubRepository;
608+
const MAX_CONCURRENT_UPLOADS = 3;
609+
const queue = uploads.slice();
610+
const runOne = async (u: { uri: vscode.Uri; name: string; placeholder: string }) => {
611+
try {
612+
const markdown = await githubRepository.uploadFile(u.uri, u.name);
613+
const completed: FileUploadCompletedMessage = {
614+
command: 'pr.file-upload-completed',
615+
placeholder: u.placeholder,
616+
name: u.name,
617+
markdown,
618+
};
619+
await this._postMessage(completed);
620+
} catch (err) {
621+
Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, IssueOverviewPanel.ID);
622+
const completed: FileUploadCompletedMessage = {
623+
command: 'pr.file-upload-completed',
624+
placeholder: u.placeholder,
625+
name: u.name,
626+
error: formatError(err),
627+
};
628+
await this._postMessage(completed);
629+
}
630+
};
631+
const workers: Promise<void>[] = [];
632+
for (let i = 0; i < Math.min(MAX_CONCURRENT_UPLOADS, queue.length); i++) {
633+
workers.push((async () => {
634+
while (queue.length > 0) {
635+
const next = queue.shift();
636+
if (!next) {
637+
break;
638+
}
639+
await runOne(next);
640+
}
641+
})());
642+
}
643+
// Don't await all workers - let them run in the background so this handler returns promptly.
644+
Promise.all(workers).catch(err => Logger.error(`Upload worker error: ${formatError(err)}`, IssueOverviewPanel.ID));
645+
}
646+
647+
576648
/**
577649
* Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel)
578650
* to provide custom processing logic for different item types.

src/github/views.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,23 @@ export interface CancelCodingAgentReply {
179179
events: TimelineEvent[];
180180
}
181181

182+
export interface FileUploadPlaceholder {
183+
name: string;
184+
placeholder: string;
185+
}
186+
187+
export interface UploadFilesReply {
188+
uploads: FileUploadPlaceholder[];
189+
}
190+
191+
export interface FileUploadCompletedMessage {
192+
command: 'pr.file-upload-completed';
193+
name: string;
194+
placeholder: string;
195+
markdown?: string;
196+
error?: string;
197+
}
198+
182199
export interface BaseContext {
183200
'preventDefaultContextMenuItems': true;
184201
owner: string;

webviews/activityBarView/index.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,39 @@ textarea {
4242
padding-bottom: 16px;
4343
}
4444

45+
.textarea-wrapper {
46+
position: relative;
47+
display: flex;
48+
width: 100%;
49+
}
50+
51+
.textarea-wrapper textarea {
52+
flex: 1;
53+
padding-bottom: 32px;
54+
}
55+
56+
.textarea-wrapper .comment-upload-button {
57+
position: absolute;
58+
left: 6px;
59+
bottom: 8px;
60+
border: none;
61+
background: none;
62+
padding: 4px;
63+
display: flex;
64+
align-items: center;
65+
justify-content: center;
66+
border-radius: 4px;
67+
color: var(--vscode-foreground);
68+
}
69+
70+
.textarea-wrapper .comment-upload-button:hover:not(:disabled) {
71+
cursor: pointer;
72+
background-color: var(--vscode-toolbar-hoverBackground);
73+
}
74+
75+
.textarea-wrapper .comment-upload-button:disabled {
76+
opacity: 0.5;
77+
}
4578
.status-section {
4679
padding-bottom: 16px;
4780
}

webviews/common/context.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { CloseResult, DescriptionResult, OpenCommitChangesArgs, OpenLocalFileArg
1111
import { IComment } from '../../src/common/comment';
1212
import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent';
1313
import { IProjectItem, MergeMethod, PullRequestCheckStatus, ReadyForReview } from '../../src/github/interface';
14-
import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewReply } from '../../src/github/views';
14+
import { CancelCodingAgentReply, ChangeAssigneesReply, ChangeBaseReply, ConvertToDraftReply, DeleteReviewResult, FileUploadCompletedMessage, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, ReadyForReviewReply, SubmitReviewReply, UploadFilesReply } from '../../src/github/views';
1515

1616
export class PRContext {
1717
constructor(
@@ -176,6 +176,61 @@ export class PRContext {
176176

177177
public submit = (body: string) => this.submitReviewCommand('pr.submit', body);
178178

179+
private _uploadCompletionHandlers: Map<string, (message: FileUploadCompletedMessage) => void> = new Map();
180+
181+
/**
182+
* Asks the host to prompt the user for files to upload.
183+
*
184+
* @param insertPlaceholders called once with the textual placeholders to insert into the textarea
185+
* @param replacePlaceholder called as each upload finishes (or fails) to replace the placeholder with the resulting markdown (or remove it on error)
186+
*/
187+
public uploadFiles = async (
188+
insertPlaceholders: (placeholders: string) => void,
189+
replacePlaceholder: (placeholder: string, markdownOrEmpty: string) => void,
190+
) => {
191+
const result: UploadFilesReply | undefined = await this.postMessage({ command: 'pr.upload-files' });
192+
if (!result || !result.uploads || result.uploads.length === 0) {
193+
return;
194+
}
195+
const placeholdersText = result.uploads.map(u => u.placeholder).join('\n');
196+
insertPlaceholders(placeholdersText);
197+
for (const upload of result.uploads) {
198+
this._uploadCompletionHandlers.set(upload.placeholder, message => {
199+
if (message.error) {
200+
replacePlaceholder(message.placeholder, '');
201+
this.postMessage({ command: 'alert', args: `Failed to upload ${message.name}: ${message.error}` });
202+
} else {
203+
replacePlaceholder(message.placeholder, message.markdown ?? '');
204+
}
205+
});
206+
}
207+
};
208+
209+
/**
210+
* Convenience wrapper that uploads files into the current pending comment text in the PR state.
211+
*/
212+
public uploadFilesIntoPendingComment = () => {
213+
return this.uploadFiles(
214+
placeholders => {
215+
const current = this.pr?.pendingCommentText ?? '';
216+
const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
217+
this.updatePR({ pendingCommentText: `${current}${separator}${placeholders}\n` });
218+
},
219+
(placeholder, markdown) => {
220+
const current = this.pr?.pendingCommentText ?? '';
221+
this.updatePR({ pendingCommentText: current.replace(placeholder, markdown) });
222+
},
223+
);
224+
};
225+
226+
private completeFileUpload(message: FileUploadCompletedMessage) {
227+
const handler = this._uploadCompletionHandlers.get(message.placeholder);
228+
if (handler) {
229+
this._uploadCompletionHandlers.delete(message.placeholder);
230+
handler(message);
231+
}
232+
}
233+
179234
public deleteReview = async () => {
180235
try {
181236
const result: DeleteReviewResult = await this.postMessage({ command: 'pr.delete-review' });
@@ -446,6 +501,8 @@ export class PRContext {
446501
return this.updatePR({ busy: true });
447502
case 'pr.readied-for-review':
448503
return this.readyForReviewComplete(message);
504+
case 'pr.file-upload-completed':
505+
return this.completeFileUpload(message);
449506
}
450507
};
451508

0 commit comments

Comments
 (0)