close
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
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,12 @@
"title": "%command.pr.makeSuggestion.title%",
"category": "%command.pull.request.category%"
},
{
"command": "pr.uploadFile",
"title": "%command.pr.uploadFile.title%",
"category": "%command.pull.request.category%",
"icon": "$(cloud-upload)"
},
{
"command": "pr.startReview",
"title": "%command.pr.startReview.title%",
Expand Down Expand Up @@ -2360,6 +2366,10 @@
"command": "pr.makeSuggestion",
"when": "false"
},
{
"command": "pr.uploadFile",
"when": "false"
},
{
"command": "pr.startReview",
"when": "false"
Expand Down Expand Up @@ -3296,6 +3306,11 @@
"command": "pr.makeSuggestion",
"group": "inline@3",
"when": "commentController =~ /^github-(browse|review)/ && !github:activeCommentHasSuggestion"
},
{
"command": "pr.uploadFile",
"group": "inline@4",
"when": "commentController =~ /^github-(browse|review)/"
}
],
"comments/commentThread/additionalActions": [
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@
"command.pr.createComment.title": "Add Review Comment",
"command.pr.createSingleComment.title": "Add Comment",
"command.pr.makeSuggestion.title": "Make Code Suggestion",
"command.pr.uploadFile.title": "Upload File",
"command.pr.startReview.title": "Start Review",
"command.pr.editComment.title": "Edit Comment",
"command.pr.cancelEditComment.title": "Cancel",
Expand Down
81 changes: 81 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { formatError } from './common/utils';
import { EXTENSION_ID } from './constants';
import { CrossChatSessionWithPR } from './github/copilotApi';
import { CopilotRemoteAgentManager, SessionIdForPr } from './github/copilotRemoteAgent';
import { pickFilesForUpload, runFileUploads } from './github/fileUpload';
import { FolderRepositoryManager } from './github/folderRepositoryManager';
import { GitHubRepository } from './github/githubRepository';
import { Issue } from './github/interface';
Expand Down Expand Up @@ -1379,6 +1380,86 @@ ${contents}
})
);

context.subscriptions.push(
vscode.commands.registerCommand('pr.uploadFile', async (reply: CommentReply | GHPRComment | undefined) => {
/* __GDPR__
"pr.uploadFile" : {}
*/
telemetry.sendTelemetryEvent('pr.uploadFile');

let potentialThread: GHPRCommentThread | undefined;
if (reply === undefined) {
potentialThread = findActiveHandler()?.commentController.activeCommentThread as vscode.CommentThread2 as GHPRCommentThread | undefined;
} else {
potentialThread = reply instanceof GHPRComment ? reply.parent : reply?.thread;
}

if (!potentialThread) {
return;
}
const thread = potentialThread;

const commentEditor = vscode.window.activeTextEditor?.document.uri.scheme === Schemes.Comment ? vscode.window.activeTextEditor
: vscode.window.visibleTextEditors.find(visible => (visible.document.uri.scheme === Schemes.Comment) && (visible.document.uri.query === ''));
if (!commentEditor) {
Logger.error('No comment editor visible for uploading a file.', logId);
vscode.window.showErrorMessage(vscode.l10n.t('No available comment editor to upload a file in.'));
return;
}
const commentEditorUri = commentEditor.document.uri.toString();

const folderManager = reposManager.getManagerForFile(thread.uri);
const githubRepository = folderManager?.activePullRequest?.githubRepository
?? folderManager?.gitHubRepositories[0];
if (!githubRepository) {
vscode.window.showErrorMessage(vscode.l10n.t('Cannot upload files: no GitHub repository found for this comment.'));
return;
}

const uploads = await pickFilesForUpload();
if (!uploads) {
return;
}

// Insert placeholders at the current cursor position
const placeholdersText = uploads.map(u => u.placeholder).join('\n');
const cursor = commentEditor.selection.end;
const before = commentEditor.document.getText(new vscode.Range(new vscode.Position(0, 0), cursor));
const separator = before.length > 0 && !before.endsWith('\n') ? '\n' : '';
await commentEditor.edit(editBuilder => {
editBuilder.insert(cursor, `${separator}${placeholdersText}\n`);
});

const replacePlaceholder = async (placeholder: string, replacement: string) => {
const editor = vscode.window.visibleTextEditors.find(e => e.document.uri.toString() === commentEditorUri);
if (!editor) {
return;
}
const text = editor.document.getText();
const idx = text.indexOf(placeholder);
if (idx < 0) {
return;
}
const start = editor.document.positionAt(idx);
const end = editor.document.positionAt(idx + placeholder.length);
await editor.edit(editBuilder => {
editBuilder.replace(new vscode.Range(start, end), replacement);
});
};

runFileUploads(
githubRepository,
uploads,
logId,
(placeholder, _name, markdown) => replacePlaceholder(placeholder, markdown),
(placeholder, name, error) => {
vscode.window.showErrorMessage(vscode.l10n.t('Failed to upload {0}: {1}', name, error));
return replacePlaceholder(placeholder, '');
},
);
Comment thread
alexr00 marked this conversation as resolved.
})
);

context.subscriptions.push(
vscode.commands.registerCommand('pr.editComment', async (comment: GHPRComment | TemporaryComment) => {
/* __GDPR__
Expand Down
83 changes: 83 additions & 0 deletions src/github/fileUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as path from 'path';
import * as vscode from 'vscode';
import { GitHubRepository } from './githubRepository';
import Logger from '../common/logger';
import { formatError } from '../common/utils';

export interface FileUploadPlaceholder {
uri: vscode.Uri;
name: string;
placeholder: string;
}

/**
* Prompt the user for files to upload and compute the placeholder text that
* should be inserted into a comment textarea while the uploads run.
* Returns `undefined` when the user cancels.
*/
export async function pickFilesForUpload(): Promise<FileUploadPlaceholder[] | undefined> {
const fileUris = await vscode.window.showOpenDialog({
canSelectMany: true,
canSelectFiles: true,
canSelectFolders: false,
openLabel: vscode.l10n.t('Upload'),
title: vscode.l10n.t('Select files to upload'),
});
if (!fileUris || fileUris.length === 0) {
return undefined;
}
Comment thread
alexr00 marked this conversation as resolved.

const used = new Map<string, number>();
return 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}) -->`;
return { uri, name: baseName, placeholder };
});
}

/**
* Maximum number of file uploads to run in parallel. Limiting concurrency
* avoids memory and network spikes when many files are uploaded at once.
*/
const MAX_CONCURRENT_UPLOADS = 3;

/**
* Run the actual file uploads with limited concurrency, invoking the supplied
* callbacks as each upload finishes (or fails).
*/
export function runFileUploads(
githubRepository: GitHubRepository,
uploads: FileUploadPlaceholder[],
logId: string,
onComplete: (placeholder: string, name: string, markdown: string) => void | Promise<void>,
onError: (placeholder: string, name: string, error: string) => void | Promise<void>,
): void {
let next = 0;

const runOne = async (): Promise<void> => {
while (next < uploads.length) {
const u = uploads[next++];
try {
const markdown = await githubRepository.uploadFile(u.uri, u.name);
await onComplete(u.placeholder, u.name, markdown);
} catch (err) {
Logger.error(`Failed to upload file ${u.name}: ${formatError(err)}`, logId);
await onError(u.placeholder, u.name, formatError(err));
}
}
};

const workerCount = Math.min(MAX_CONCURRENT_UPLOADS, uploads.length);
for (let i = 0; i < workerCount; i++) {
void runOne();
}
}
79 changes: 20 additions & 59 deletions src/github/issueOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
*--------------------------------------------------------------------------------------------*/
'use strict';

import * as path from 'path';
import * as vscode from 'vscode';
import { CloseResult, OpenLocalFileArgs } from '../../common/views';
import { openPullRequestOnGitHub } from '../commands';
import { pickFilesForUpload, runFileUploads } from './fileUpload';
import { FolderRepositoryManager } from './folderRepositoryManager';
import { GithubItemStateEnum, IAccount, IMilestone, IProject, IProjectItem, RepoAccessAndMergeMethods } from './interface';
import { IssueModel } from './issueModel';
Expand Down Expand Up @@ -577,71 +577,32 @@ export class IssueOverviewPanel<TItem extends IssueModel = IssueModel> extends W
}

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 uploads = await pickFilesForUpload();
if (!uploads) {
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}) -->`;
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));
runFileUploads(
this._item.githubRepository,
uploads,
IssueOverviewPanel.ID,
(placeholder, name, markdown) => this._postMessage({
command: 'pr.file-upload-completed',
placeholder,
name,
markdown,
} satisfies FileUploadCompletedMessage),
(placeholder, name, error) => this._postMessage({
command: 'pr.file-upload-completed',
placeholder,
name,
error,
} satisfies FileUploadCompletedMessage),
);
}


Expand Down