Skip to content

Commit 645dcad

Browse files
authored
Add paste-to-upload (#8725)
* Add paste-to-upload * Address CCR feedback
1 parent 24cdac0 commit 645dcad

7 files changed

Lines changed: 362 additions & 50 deletions

File tree

src/commands.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +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 { pickFilesForUpload, runFileUploads } from './github/fileUpload';
23+
import { guessExtensionFromMime, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './github/fileUpload';
2424
import { FolderRepositoryManager } from './github/folderRepositoryManager';
2525
import { GitHubRepository } from './github/githubRepository';
2626
import { Issue } from './github/interface';
@@ -1460,6 +1460,87 @@ ${contents}
14601460
})
14611461
);
14621462

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+
14631544
context.subscriptions.push(
14641545
vscode.commands.registerCommand('pr.editComment', async (comment: GHPRComment | TemporaryComment) => {
14651546
/* __GDPR__

src/github/fileUpload.ts

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import * as buffer from 'buffer';
67
import * as path from 'path';
78
import * as vscode from 'vscode';
89
import { GitHubRepository } from './githubRepository';
@@ -15,6 +16,61 @@ export interface FileUploadPlaceholder {
1516
placeholder: string;
1617
}
1718

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+
1874
/**
1975
* Prompt the user for files to upload and compute the placeholder text that
2076
* should be inserted into a comment textarea while the uploads run.
@@ -32,16 +88,9 @@ export async function pickFilesForUpload(): Promise<FileUploadPlaceholder[] | un
3288
return undefined;
3389
}
3490

35-
const used = new Map<string, number>();
36-
return fileUris.map(uri => {
37-
const baseName = path.basename(uri.fsPath);
38-
const count = used.get(baseName) ?? 0;
39-
used.set(baseName, count + 1);
40-
const placeholder = count === 0
41-
? `<!-- Uploading ${baseName} -->`
42-
: `<!-- Uploading ${baseName} (${count + 1}) -->`;
43-
return { uri, name: baseName, placeholder };
44-
});
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 }));
4594
}
4695

4796
/**
@@ -56,7 +105,30 @@ const MAX_CONCURRENT_UPLOADS = 3;
56105
*/
57106
export function runFileUploads(
58107
githubRepository: GitHubRepository,
59-
uploads: FileUploadPlaceholder[],
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[],
60132
logId: string,
61133
onComplete: (placeholder: string, name: string, markdown: string) => void | Promise<void>,
62134
onError: (placeholder: string, name: string, error: string) => void | Promise<void>,
@@ -66,13 +138,15 @@ export function runFileUploads(
66138
const runOne = async (): Promise<void> => {
67139
while (next < uploads.length) {
68140
const u = uploads[next++];
69-
try {
70-
const markdown = await githubRepository.uploadFile(u.uri, u.name);
71-
await onComplete(u.placeholder, u.name, markdown);
72-
} catch (err) {
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 => {
73147
Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, logId);
74-
await onError(u.placeholder, u.name, formatError(err));
75-
}
148+
return onError(u.placeholder, u.name, formatError(err));
149+
});
76150
}
77151
};
78152

src/github/githubRepository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2133,6 +2133,14 @@ export class GitHubRepository extends Disposable {
21332133
if (fileBytes.byteLength > MAX_UPLOAD_SIZE_BYTES) {
21342134
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.`);
21352135
}
2136+
return this.uploadFileBytes(fileBytes, fileName);
2137+
}
2138+
2139+
/**
2140+
* Upload a file's raw bytes to GitHub via the mobile upload policy API.
2141+
* Returns a markdown snippet appropriate for embedding in an issue/PR comment.
2142+
*/
2143+
public async uploadFileBytes(fileBytes: Uint8Array, fileName: string): Promise<string> {
21362144
const contentType = guessContentType(fileName);
21372145

21382146
const { octokit } = await this.ensure();

src/github/issueOverview.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
import * as vscode from 'vscode';
88
import { CloseResult, OpenLocalFileArgs } from '../../common/views';
99
import { openPullRequestOnGitHub } from '../commands';
10-
import { pickFilesForUpload, runFileUploads } from './fileUpload';
10+
import { decodeBase64, guessExtensionFromMime, pickFilesForUpload, placeholdersForNames, runFileUploads, runPendingUploads } from './fileUpload';
1111
import { FolderRepositoryManager } from './folderRepositoryManager';
1212
import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface';
1313
import { IssueModel } from './issueModel';
1414
import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick } from './quickPicks';
1515
import { isInCodespaces, processPermalinks, vscodeDevPrLink } from './utils';
16-
import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply } from './views';
16+
import { ChangeAssigneesReply, DisplayLabel, FileUploadCompletedMessage, Issue, ProjectItemsReply, SubmitReviewReply, UnresolvedIdentity, UploadFilesReply, UploadPastedFilesArgs } from './views';
1717
import { COPILOT_ACCOUNTS, IComment } from '../common/comment';
1818
import { emojify, ensureEmojis } from '../common/emoji';
1919
import Logger from '../common/logger';
@@ -455,6 +455,8 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
455455
return this.webviewDebug(message);
456456
case 'pr.upload-files':
457457
return this.uploadFiles(message);
458+
case 'pr.upload-pasted-files':
459+
return this.uploadPastedFiles(message);
458460
default:
459461
return this.MESSAGE_UNHANDLED;
460462
}
@@ -605,6 +607,41 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
605607
);
606608
}
607609

610+
private async uploadPastedFiles(message: IRequestMessage<UploadPastedFilesArgs>): Promise<void> {
611+
const files = message.args?.files ?? [];
612+
if (files.length === 0) {
613+
const empty: UploadFilesReply = { uploads: [] };
614+
return this._replyMessage(message, empty);
615+
}
616+
617+
const names = files.map(f => f.name.includes('.') ? f.name : `${f.name}${guessExtensionFromMime(f.type)}`);
618+
const placeholders = placeholdersForNames(names);
619+
const reply: UploadFilesReply = { uploads: placeholders };
620+
await this._replyMessage(message, reply);
621+
622+
runPendingUploads(
623+
this._item.githubRepository,
624+
files.map((f, i) => ({
625+
name: placeholders[i].name,
626+
placeholder: placeholders[i].placeholder,
627+
getBytes: () => Promise.resolve(decodeBase64(f.bytesBase64)),
628+
})),
629+
IssueOverviewPanel.ID,
630+
(placeholder, name, markdown) => this._postMessage({
631+
command: 'pr.file-upload-completed',
632+
placeholder,
633+
name,
634+
markdown,
635+
} satisfies FileUploadCompletedMessage),
636+
(placeholder, name, error) => this._postMessage({
637+
command: 'pr.file-upload-completed',
638+
placeholder,
639+
name,
640+
error,
641+
} satisfies FileUploadCompletedMessage),
642+
);
643+
}
644+
608645

609646
/**
610647
* Process code reference links in bodyHTML. Can be overridden by subclasses (e.g., PullRequestOverviewPanel)

src/github/views.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ export interface UploadFilesReply {
188188
uploads: FileUploadPlaceholder[];
189189
}
190190

191+
export interface UploadPastedFilesArgs {
192+
files: { name: string; type: string; bytesBase64: string }[];
193+
}
194+
191195
export interface FileUploadCompletedMessage {
192196
command: 'pr.file-upload-completed';
193197
name: string;

0 commit comments

Comments
 (0)