Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions resources/icons/codicons/cloud-upload.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 112 additions & 0 deletions src/github/githubRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2111,4 +2111,116 @@ 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<string> {
// 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);

Comment thread
alexr00 marked this conversation as resolved.
const { octokit } = await this.ensure();
Comment thread
alexr00 marked this conversation as resolved.
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<string, string>;
asset: { id: number; name: string; href: string };
asset_upload_url: string;
};

// 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);
}
// The DOM Blob types require Uint8Array<ArrayBuffer>, but vscode.workspace.fs.readFile
// returns Uint8Array<ArrayBufferLike>. 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}`);
}

// 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;
const safeName = escapeMarkdownLinkText(fileName);
if (contentType.startsWith('image/')) {
return `![${safeName}](${url})`;
}
if (contentType.startsWith('video/')) {
return url;
}
return `[${safeName}](${url})`;
}
}

Comment thread
alexr00 marked this conversation as resolved.
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() : '';
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';
}
}
74 changes: 73 additions & 1 deletion src/github/issueOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -452,6 +453,8 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> 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;
}
Expand Down Expand Up @@ -573,6 +576,75 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
Logger.debug(message.args, IssueOverviewPanel.ID);
}

private async uploadFiles(message: IRequestMessage<void>): Promise<void> {
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<string, number>();
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
? `<!-- Uploading ${baseName} -->`
: `<!-- Uploading ${baseName} (${count + 1}) -->`;
Comment thread
alexr00 marked this conversation as resolved.
Comment thread
alexr00 marked this conversation as resolved.
return { uri, name: baseName, placeholder };
});

const reply: UploadFilesReply = { uploads: uploads.map(u => ({ name: u.name, placeholder: u.placeholder })) };
await this._replyMessage(message, reply);

// Run uploads with bounded concurrency to avoid spiking memory/network in the extension host.
const githubRepository = this._item.githubRepository;
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,
};
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',
placeholder: u.placeholder,
name: u.name,
error: formatError(err),
};
await this._postMessage(completed);
}
};
const workers: Promise<void>[] = [];
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));
}


/**
* Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel)
* to provide custom processing logic for different item types.
Expand Down
17 changes: 17 additions & 0 deletions src/github/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions webviews/activityBarView/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Comment thread
alexr00 marked this conversation as resolved.
.textarea-wrapper .comment-upload-button:disabled {
opacity: 0.5;
}
.status-section {
padding-bottom: 16px;
}
Expand Down
59 changes: 58 additions & 1 deletion webviews/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -176,6 +176,61 @@ export class PRContext {

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

private _uploadCompletionHandlers: Map<string, (message: FileUploadCompletedMessage) => 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 ?? '');
}
});
Comment thread
alexr00 marked this conversation as resolved.
}
};

/**
* 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' });
Expand Down Expand Up @@ -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);
}
};

Expand Down
Loading
Loading