Skip to content

Commit e94b30a

Browse files
authored
Add support for file upload when leaving a PR comment (#8724)
* Add basic image uploading * Move to githubrepository * Add support for file upload when leaving a PR comment * Address CCR comments * Add paste-to-upload (#8725) * Add paste-to-upload * Address CCR feedback * CCR feedback
1 parent 48eca22 commit e94b30a

9 files changed

Lines changed: 555 additions & 89 deletions

File tree

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,6 +1245,12 @@
12451245
"title": "%command.pr.makeSuggestion.title%",
12461246
"category": "%command.pull.request.category%"
12471247
},
1248+
{
1249+
"command": "pr.uploadFile",
1250+
"title": "%command.pr.uploadFile.title%",
1251+
"category": "%command.pull.request.category%",
1252+
"icon": "$(cloud-upload)"
1253+
},
12481254
{
12491255
"command": "pr.startReview",
12501256
"title": "%command.pr.startReview.title%",
@@ -2360,6 +2366,10 @@
23602366
"command": "pr.makeSuggestion",
23612367
"when": "false"
23622368
},
2369+
{
2370+
"command": "pr.uploadFile",
2371+
"when": "false"
2372+
},
23632373
{
23642374
"command": "pr.startReview",
23652375
"when": "false"
@@ -3296,6 +3306,11 @@
32963306
"command": "pr.makeSuggestion",
32973307
"group": "inline@3",
32983308
"when": "commentController =~ /^github-(browse|review)/ && !github:activeCommentHasSuggestion"
3309+
},
3310+
{
3311+
"command": "pr.uploadFile",
3312+
"group": "inline@4",
3313+
"when": "commentController =~ /^github-(browse|review)/"
32993314
}
33003315
],
33013316
"comments/commentThread/additionalActions": [

package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@
263263
"command.pr.createComment.title": "Add Review Comment",
264264
"command.pr.createSingleComment.title": "Add Comment",
265265
"command.pr.makeSuggestion.title": "Make Code Suggestion",
266+
"command.pr.uploadFile.title": "Upload File",
266267
"command.pr.startReview.title": "Start Review",
267268
"command.pr.editComment.title": "Edit Comment",
268269
"command.pr.cancelEditComment.title": "Cancel",

src/commands.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { formatError } from './common/utils';
2020
import { EXTENSION_ID } from './constants';
2121
import { CrossChatSessionWithPR } from './github/copilotApi';
2222
import { CopilotRemoteAgentManager, SessionIdForPr } from './github/copilotRemoteAgent';
23+
import { guessExtensionFromMime, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './github/fileUpload';
2324
import { FolderRepositoryManager } from './github/folderRepositoryManager';
2425
import { GitHubRepository } from './github/githubRepository';
2526
import { Issue } from './github/interface';
@@ -1379,6 +1380,167 @@ ${contents}
13791380
})
13801381
);
13811382

1383+
context.subscriptions.push(
1384+
vscode.commands.registerCommand('pr.uploadFile', async (reply: CommentReply | GHPRComment | undefined) => {
1385+
/* __GDPR__
1386+
"pr.uploadFile" : {}
1387+
*/
1388+
telemetry.sendTelemetryEvent('pr.uploadFile');
1389+
1390+
let potentialThread: GHPRCommentThread | undefined;
1391+
if (reply === undefined) {
1392+
potentialThread = findActiveHandler()?.commentController.activeCommentThread as vscode.CommentThread2 as GHPRCommentThread | undefined;
1393+
} else {
1394+
potentialThread = reply instanceof GHPRComment ? reply.parent : reply?.thread;
1395+
}
1396+
1397+
if (!potentialThread) {
1398+
return;
1399+
}
1400+
const thread = potentialThread;
1401+
1402+
const commentEditor = vscode.window.activeTextEditor?.document.uri.scheme === Schemes.Comment ? vscode.window.activeTextEditor
1403+
: vscode.window.visibleTextEditors.find(visible => (visible.document.uri.scheme === Schemes.Comment) && (visible.document.uri.query === ''));
1404+
if (!commentEditor) {
1405+
Logger.error('No comment editor visible for uploading a file.', logId);
1406+
vscode.window.showErrorMessage(vscode.l10n.t('No available comment editor to upload a file in.'));
1407+
return;
1408+
}
1409+
const commentEditorUri = commentEditor.document.uri.toString();
1410+
1411+
const folderManager = reposManager.getManagerForFile(thread.uri);
1412+
const githubRepository = folderManager?.activePullRequest?.githubRepository
1413+
?? folderManager?.gitHubRepositories[0];
1414+
if (!githubRepository) {
1415+
vscode.window.showErrorMessage(vscode.l10n.t('Cannot upload files: no GitHub repository found for this comment.'));
1416+
return;
1417+
}
1418+
1419+
const uploads = await pickFilesForUpload();
1420+
if (!uploads) {
1421+
return;
1422+
}
1423+
1424+
// Insert placeholders at the current cursor position
1425+
const placeholdersText = uploads.map(u => u.placeholder).join('\n');
1426+
const cursor = commentEditor.selection.end;
1427+
const before = commentEditor.document.getText(new vscode.Range(new vscode.Position(0, 0), cursor));
1428+
const separator = before.length > 0 && !before.endsWith('\n') ? '\n' : '';
1429+
await commentEditor.edit(editBuilder => {
1430+
editBuilder.insert(cursor, `${separator}${placeholdersText}\n`);
1431+
});
1432+
1433+
const replacePlaceholder = async (placeholder: string, replacement: string) => {
1434+
const editor = vscode.window.visibleTextEditors.find(e => e.document.uri.toString() === commentEditorUri);
1435+
if (!editor) {
1436+
return;
1437+
}
1438+
const text = editor.document.getText();
1439+
const idx = text.indexOf(placeholder);
1440+
if (idx < 0) {
1441+
return;
1442+
}
1443+
const start = editor.document.positionAt(idx);
1444+
const end = editor.document.positionAt(idx + placeholder.length);
1445+
await editor.edit(editBuilder => {
1446+
editBuilder.replace(new vscode.Range(start, end), replacement);
1447+
});
1448+
};
1449+
1450+
runFileUploads(
1451+
githubRepository,
1452+
uploads,
1453+
logId,
1454+
(placeholder, _name, markdown) => replacePlaceholder(placeholder, markdown),
1455+
(placeholder, name, error) => {
1456+
vscode.window.showErrorMessage(vscode.l10n.t('Failed to upload {0}: {1}', name, error));
1457+
return replacePlaceholder(placeholder, '');
1458+
},
1459+
);
1460+
})
1461+
);
1462+
1463+
context.subscriptions.push(
1464+
vscode.languages.registerDocumentPasteEditProvider(
1465+
{ scheme: Schemes.Comment },
1466+
{
1467+
async provideDocumentPasteEdits(document, ranges, dataTransfer, _context, token) {
1468+
const files: { name: string; getBytes: () => Thenable<Uint8Array> }[] = [];
1469+
let counter = 0;
1470+
for (const [mime, item] of dataTransfer) {
1471+
const file = item.asFile();
1472+
if (!file) {
1473+
continue;
1474+
}
1475+
const name = file.name || `pasted-file-${++counter}${guessExtensionFromMime(mime)}`;
1476+
files.push({ name, getBytes: () => file.data() });
1477+
}
1478+
if (files.length === 0 || token.isCancellationRequested) {
1479+
return;
1480+
}
1481+
1482+
const potentialThread = findActiveHandler()?.commentController.activeCommentThread as vscode.CommentThread2 as GHPRCommentThread | undefined;
1483+
if (!potentialThread) {
1484+
return;
1485+
}
1486+
const folderManager = reposManager.getManagerForFile(potentialThread.uri);
1487+
const githubRepository = folderManager?.activePullRequest?.githubRepository
1488+
?? folderManager?.gitHubRepositories[0];
1489+
if (!githubRepository) {
1490+
return;
1491+
}
1492+
1493+
const placeholders = placeholdersForNames(files.map(f => f.name));
1494+
const placeholdersText = placeholders.map(p => p.placeholder).join('\n');
1495+
1496+
const documentUri = document.uri.toString();
1497+
const replacePlaceholder = async (placeholder: string, replacement: string) => {
1498+
const editor = vscode.window.visibleTextEditors.find(e => e.document.uri.toString() === documentUri);
1499+
if (!editor) {
1500+
return;
1501+
}
1502+
const text = editor.document.getText();
1503+
const idx = text.indexOf(placeholder);
1504+
if (idx < 0) {
1505+
return;
1506+
}
1507+
const start = editor.document.positionAt(idx);
1508+
const end = editor.document.positionAt(idx + placeholder.length);
1509+
await editor.edit(editBuilder => {
1510+
editBuilder.replace(new vscode.Range(start, end), replacement);
1511+
});
1512+
};
1513+
1514+
runPendingUploads(
1515+
githubRepository,
1516+
files.map((f, i) => ({
1517+
name: placeholders[i].name,
1518+
placeholder: placeholders[i].placeholder,
1519+
getBytes: f.getBytes,
1520+
})),
1521+
logId,
1522+
(placeholder, _name, markdown) => replacePlaceholder(placeholder, markdown),
1523+
(placeholder, name, error) => {
1524+
vscode.window.showErrorMessage(vscode.l10n.t('Failed to upload {0}: {1}', name, error));
1525+
return replacePlaceholder(placeholder, '');
1526+
},
1527+
);
1528+
1529+
const edit = new vscode.DocumentPasteEdit(
1530+
placeholdersText,
1531+
vscode.l10n.t('Upload as GitHub attachment'),
1532+
vscode.DocumentDropOrPasteEditKind.Empty.append('github', 'attachment'),
1533+
);
1534+
return [edit];
1535+
},
1536+
},
1537+
{
1538+
providedPasteEditKinds: [vscode.DocumentDropOrPasteEditKind.Empty.append('github', 'attachment')],
1539+
pasteMimeTypes: ['files', 'image/*'],
1540+
},
1541+
),
1542+
);
1543+
13821544
context.subscriptions.push(
13831545
vscode.commands.registerCommand('pr.editComment', async (comment: GHPRComment | TemporaryComment) => {
13841546
/* __GDPR__

src/github/fileUpload.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as buffer from 'buffer';
7+
import * as path from 'path';
8+
import * as vscode from 'vscode';
9+
import { GitHubRepository } from './githubRepository';
10+
import Logger from '../common/logger';
11+
import { formatError } from '../common/utils';
12+
13+
export interface FileUploadPlaceholder {
14+
uri: vscode.Uri;
15+
name: string;
16+
placeholder: string;
17+
}
18+
19+
export interface PendingFileUpload {
20+
name: string;
21+
placeholder: string;
22+
getBytes(): Thenable<Uint8Array>;
23+
}
24+
25+
/**
26+
* Decode a base64 string to a {@linkcode Uint8Array}.
27+
*/
28+
export function decodeBase64(input: string): Uint8Array {
29+
return buffer.Buffer.from(input, 'base64');
30+
}
31+
32+
/**
33+
* Guess a file extension (including the dot) for a given MIME type, falling back
34+
* to an empty string when no good guess is available.
35+
*/
36+
export function guessExtensionFromMime(mimeType: string): string {
37+
const lower = mimeType.toLowerCase();
38+
switch (lower) {
39+
case 'image/png': return '.png';
40+
case 'image/jpeg': return '.jpg';
41+
case 'image/gif': return '.gif';
42+
case 'image/webp': return '.webp';
43+
case 'image/svg+xml': return '.svg';
44+
case 'image/bmp': return '.bmp';
45+
case 'image/heic': return '.heic';
46+
case 'video/mp4': return '.mp4';
47+
case 'video/quicktime': return '.mov';
48+
case 'video/webm': return '.webm';
49+
case 'application/pdf': return '.pdf';
50+
case 'application/zip': return '.zip';
51+
case 'application/json': return '.json';
52+
case 'text/plain': return '.txt';
53+
case 'text/markdown': return '.md';
54+
default: return '';
55+
}
56+
}
57+
58+
/**
59+
* Compute placeholder strings for the given file names, deduplicating
60+
* by name with `(2)`, `(3)` suffixes.
61+
*/
62+
export function placeholdersForNames(names: readonly string[]): { name: string; placeholder: string }[] {
63+
const used = new Map<string, number>();
64+
return names.map(name => {
65+
const count = used.get(name) ?? 0;
66+
used.set(name, count + 1);
67+
const placeholder = count === 0
68+
? `<!-- Uploading ${name} -->`
69+
: `<!-- Uploading ${name} (${count + 1}) -->`;
70+
return { name, placeholder };
71+
});
72+
}
73+
74+
/**
75+
* Prompt the user for files to upload and compute the placeholder text that
76+
* should be inserted into a comment textarea while the uploads run.
77+
* Returns `undefined` when the user cancels.
78+
*/
79+
export async function pickFilesForUpload(): Promise<FileUploadPlaceholder[] | undefined> {
80+
const fileUris = await vscode.window.showOpenDialog({
81+
canSelectMany: true,
82+
canSelectFiles: true,
83+
canSelectFolders: false,
84+
openLabel: vscode.l10n.t('Upload'),
85+
title: vscode.l10n.t('Select files to upload'),
86+
});
87+
if (!fileUris || fileUris.length === 0) {
88+
return undefined;
89+
}
90+
91+
const names = fileUris.map(uri => path.basename(uri.fsPath));
92+
const placeholders = placeholdersForNames(names);
93+
return fileUris.map((uri, i) => ({ uri, name: placeholders[i].name, placeholder: placeholders[i].placeholder }));
94+
}
95+
96+
/**
97+
* Maximum number of file uploads to run in parallel. Limiting concurrency
98+
* avoids memory and network spikes when many files are uploaded at once.
99+
*/
100+
const MAX_CONCURRENT_UPLOADS = 3;
101+
102+
/**
103+
* Run the actual file uploads with limited concurrency, invoking the supplied
104+
* callbacks as each upload finishes (or fails).
105+
*/
106+
export function runFileUploads(
107+
githubRepository: GitHubRepository,
108+
uploads: readonly FileUploadPlaceholder[],
109+
logId: string,
110+
onComplete: (placeholder: string, name: string, markdown: string) => void | Promise<void>,
111+
onError: (placeholder: string, name: string, error: string) => void | Promise<void>,
112+
): void {
113+
runPendingUploads(
114+
githubRepository,
115+
uploads.map(u => ({
116+
name: u.name,
117+
placeholder: u.placeholder,
118+
getBytes: () => vscode.workspace.fs.readFile(u.uri),
119+
})),
120+
logId,
121+
onComplete,
122+
onError,
123+
);
124+
}
125+
126+
/**
127+
* Run uploads in parallel, fetching the bytes lazily via {@linkcode PendingFileUpload.getBytes}.
128+
*/
129+
export function runPendingUploads(
130+
githubRepository: GitHubRepository,
131+
uploads: readonly PendingFileUpload[],
132+
logId: string,
133+
onComplete: (placeholder: string, name: string, markdown: string) => void | Promise<void>,
134+
onError: (placeholder: string, name: string, error: string) => void | Promise<void>,
135+
): void {
136+
let next = 0;
137+
138+
const runOne = async (): Promise<void> => {
139+
while (next < uploads.length) {
140+
const u = uploads[next++];
141+
(async () => {
142+
const bytes = await u.getBytes();
143+
return githubRepository.uploadFileBytes(bytes, u.name);
144+
})().then(markdown => {
145+
return onComplete(u.placeholder, u.name, markdown);
146+
}).catch(err => {
147+
Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, logId);
148+
return onError(u.placeholder, u.name, formatError(err));
149+
});
150+
}
151+
};
152+
153+
const workerCount = Math.min(MAX_CONCURRENT_UPLOADS, uploads.length);
154+
for (let i = 0; i < workerCount; i++) {
155+
void runOne();
156+
}
157+
}

src/github/githubRepository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2130,6 +2130,14 @@ export class GitHubRepository extends Disposable {
21302130
}
21312131

21322132
const fileBytes = await vscode.workspace.fs.readFile(uri);
2133+
return this.uploadFileBytes(fileBytes, fileName);
2134+
}
2135+
2136+
/**
2137+
* Upload a file's raw bytes to GitHub via the mobile upload policy API.
2138+
* Returns a markdown snippet appropriate for embedding in an issue/PR comment.
2139+
*/
2140+
public async uploadFileBytes(fileBytes: Uint8Array, fileName: string): Promise<string> {
21332141
if (fileBytes.byteLength > MAX_UPLOAD_SIZE_BYTES) {
21342142
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.`);
21352143
}

0 commit comments

Comments
 (0)