15.4 KB435 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {ServerPlatform} from 'isl-server/src/serverPlatform';
9import type {RepositoryContext} from 'isl-server/src/serverTypes';
10import type {
11 AbsolutePath,
12 Diagnostic,
13 DiagnosticSeverity,
14 PlatformSpecificClientToServerMessages,
15 RepoRelativePath,
16 ServerToClientMessage,
17} from 'isl/src/types';
18import type {Json} from 'shared/typeUtils';
19
20import {Repository} from 'isl-server/src/Repository';
21import type {CodeReviewIssue} from 'isl/src/firstPassCodeReview/types';
22import {arraysEqual} from 'isl/src/utils';
23import * as pathModule from 'node:path';
24import * as vscode from 'vscode';
25import {executeVSCodeCommand} from './commands';
26import {PERSISTED_STORAGE_KEY_PREFIX} from './config';
27import {t} from './i18n';
28import {Internal} from './Internal';
29import {openFileInRepo} from './openFile';
30import {ActionTriggerType} from './types';
31
32export type VSCodeServerPlatform = ServerPlatform & {
33 panelOrView: undefined | vscode.WebviewPanel | vscode.WebviewView;
34};
35
36function diagnosticSeverity(severity: vscode.DiagnosticSeverity): DiagnosticSeverity {
37 switch (severity) {
38 case vscode.DiagnosticSeverity.Error:
39 return 'error';
40 case vscode.DiagnosticSeverity.Warning:
41 return 'warning';
42 case vscode.DiagnosticSeverity.Information:
43 return 'info';
44 case vscode.DiagnosticSeverity.Hint:
45 return 'hint';
46 }
47}
48
49export const getVSCodePlatform = (context: vscode.ExtensionContext): VSCodeServerPlatform => ({
50 platformName: 'vscode',
51 sessionId: vscode.env.sessionId,
52 panelOrView: undefined,
53 async handleMessageFromClient(
54 this: VSCodeServerPlatform,
55 repo: Repository | undefined,
56 ctx: RepositoryContext,
57 message: PlatformSpecificClientToServerMessages,
58 postMessage: (message: ServerToClientMessage) => void,
59 onDispose: (cb: () => unknown) => void,
60 ) {
61 try {
62 switch (message.type) {
63 case 'platform/openFiles': {
64 for (const path of message.paths) {
65 if (repo == null) {
66 return;
67 }
68 // don't use preview mode for opening multiple files, since they would overwrite each other
69 openFileInRepo(
70 repo,
71 path,
72 message.options?.line,
73 /* preview */ false,
74 /* onError */ (err: Error) => {
75 // Opening multiple files at once can throw errors even when the files are successfully opened
76 // We check here if the error is unwarranted and the file actually exists in the tab group
77 const uri = vscode.Uri.file(pathModule.join(repo.info.repoRoot, path));
78 const isTabOpen = vscode.window.tabGroups.all
79 .flatMap(group => group.tabs)
80 .some(
81 tab =>
82 tab.input instanceof vscode.TabInputText &&
83 uri.fsPath == tab.input.uri.fsPath,
84 );
85
86 if (!isTabOpen) {
87 vscode.window.showErrorMessage(err.message ?? String(err));
88 }
89 },
90 );
91 }
92 break;
93 }
94 case 'platform/openFile': {
95 if (repo != null) {
96 openFileInRepo(repo, message.path, message.options?.line, undefined);
97 }
98 break;
99 }
100 case 'platform/openDiff': {
101 if (repo == null) {
102 break;
103 }
104 const path: AbsolutePath = pathModule.join(repo.info.repoRoot, message.path);
105 const uri = vscode.Uri.file(path);
106 executeVSCodeCommand('sapling.open-file-diff', uri, message.comparison);
107 break;
108 }
109 case 'platform/openExternal': {
110 vscode.env.openExternal(vscode.Uri.parse(message.url));
111 break;
112 }
113 case 'platform/changeTitle': {
114 if (this.panelOrView != null) {
115 this.panelOrView.title = message.title;
116 }
117 break;
118 }
119 case 'platform/checkForDiagnostics': {
120 const diagnosticMap = new Map<RepoRelativePath, Array<Diagnostic>>();
121 const repoRoot = repo?.info.repoRoot;
122 if (repoRoot) {
123 for (const path of message.paths) {
124 const uri = vscode.Uri.file(pathModule.join(repoRoot, path));
125 const diagnostics = vscode.languages.getDiagnostics(uri);
126 if (diagnostics.length > 0) {
127 diagnosticMap.set(
128 path,
129 diagnostics.map(diagnostic => ({
130 message: diagnostic.message,
131 range: {
132 startLine: diagnostic.range.start.line,
133 startCol: diagnostic.range.start.character,
134 endLine: diagnostic.range.end.line,
135 endCol: diagnostic.range.end.character,
136 },
137 severity: diagnosticSeverity(diagnostic.severity),
138 source: diagnostic.source,
139 code:
140 typeof diagnostic.code === 'object'
141 ? String(diagnostic.code.value)
142 : String(diagnostic.code),
143 })),
144 );
145 }
146 }
147 }
148 postMessage({type: 'platform/gotDiagnostics', diagnostics: diagnosticMap});
149 break;
150 }
151 case 'platform/confirm': {
152 const OKButton = t('isl.confirmModalOK');
153 const result = await vscode.window.showInformationMessage(
154 message.message,
155 {
156 detail: message.details,
157 modal: true,
158 },
159 OKButton,
160 );
161 postMessage({type: 'platform/confirmResult', result: result === OKButton});
162 break;
163 }
164 case 'platform/subscribeToUnsavedFiles': {
165 let previous: Array<{path: RepoRelativePath; uri: string}> = [];
166 const postUnsavedFiles = () => {
167 if (repo == null) {
168 return;
169 }
170 const files = getUnsavedFiles(repo).map(document => {
171 return {
172 path: pathModule.relative(repo.info.repoRoot, document.fileName),
173 uri: document.uri.toString(),
174 };
175 });
176
177 if (!arraysEqual(files, previous)) {
178 postMessage({
179 type: 'platform/unsavedFiles',
180 unsaved: files,
181 });
182 previous = files;
183 }
184 };
185
186 const disposables = [
187 vscode.workspace.onDidChangeTextDocument(postUnsavedFiles),
188 vscode.workspace.onDidSaveTextDocument(postUnsavedFiles),
189 ];
190 postUnsavedFiles();
191 onDispose(() => disposables.forEach(d => d.dispose()));
192 break;
193 }
194 case 'platform/subscribeToSuggestedEdits': {
195 const dispose = Internal.suggestedEdits?.onDidChangeSuggestedEdits(
196 (files: Array<AbsolutePath>) => {
197 postMessage({
198 type: 'platform/onDidChangeSuggestedEdits',
199 files,
200 });
201 },
202 );
203 onDispose(() => dispose?.dispose());
204 break;
205 }
206 case 'platform/resolveSuggestedEdits': {
207 Internal.suggestedEdits?.resolveSuggestedEdits(message.action, message.files);
208 break;
209 }
210 case 'platform/saveAllUnsavedFiles': {
211 if (repo == null) {
212 return;
213 }
214 Promise.all(getUnsavedFiles(repo).map(doc => doc.save())).then(results => {
215 postMessage({
216 type: 'platform/savedAllUnsavedFiles',
217 success: results.every(result => result),
218 });
219 });
220 break;
221 }
222 case 'platform/subscribeToAvailableCwds': {
223 const postAllAvailableCwds = async () => {
224 const options = await Promise.all(
225 (vscode.workspace.workspaceFolders ?? []).map(folder => {
226 const cwd = folder.uri.fsPath;
227 return Repository.getCwdInfo({...ctx, cwd});
228 }),
229 );
230 postMessage({
231 type: 'platform/availableCwds',
232 options,
233 });
234 };
235
236 postAllAvailableCwds();
237 const dispose = vscode.workspace.onDidChangeWorkspaceFolders(postAllAvailableCwds);
238 onDispose(() => dispose.dispose());
239 break;
240 }
241 case 'platform/setVSCodeConfig': {
242 vscode.workspace
243 .getConfiguration()
244 .update(
245 message.config,
246 message.value,
247 message.scope === 'global'
248 ? vscode.ConfigurationTarget.Global
249 : vscode.ConfigurationTarget.Workspace,
250 );
251 break;
252 }
253 case 'platform/setPersistedState': {
254 const {key, data} = message;
255 context.globalState.update(PERSISTED_STORAGE_KEY_PREFIX + key, data);
256 break;
257 }
258 case 'platform/subscribeToVSCodeConfig': {
259 const sendLatestValue = () =>
260 postMessage({
261 type: 'platform/vscodeConfigChanged',
262 config: message.config,
263 value: vscode.workspace.getConfiguration().get<Json>(message.config),
264 });
265 const dispose = vscode.workspace.onDidChangeConfiguration(e => {
266 if (e.affectsConfiguration(message.config)) {
267 sendLatestValue();
268 }
269 });
270 sendLatestValue();
271 onDispose(() => dispose.dispose());
272 break;
273 }
274 case 'platform/executeVSCodeCommand': {
275 vscode.commands.executeCommand(message.command, ...message.args);
276 break;
277 }
278 case 'platform/resolveAllCommentsWithAI': {
279 const {diffId, comments, filePaths, repoPath, userContext} = message;
280 Internal.promptAIAgent?.(
281 {type: 'resolveAllComments', diffId, comments, filePaths, repoPath, userContext},
282 ActionTriggerType.ISL2SmartActions,
283 );
284 break;
285 }
286 case 'platform/resolveFailedSignalsWithAI': {
287 const {diffId, diffVersionNumber, repoPath, userContext} = message;
288 Internal.promptAIAgent?.(
289 {type: 'resolveFailedSignals', diffId, diffVersionNumber, repoPath, userContext},
290 ActionTriggerType.ISL2SmartActions,
291 );
292 break;
293 }
294 case 'platform/fillCommitMessageWithAI': {
295 const {source, userContext} = message;
296 Internal.promptAIAgent?.(
297 {type: 'fillCommitMessage', userContext},
298 source === 'commitInfoView'
299 ? ActionTriggerType.ISL2CommitInfoView
300 : ActionTriggerType.ISL2SmartActions,
301 );
302 break;
303 }
304 case 'platform/splitCommitWithAI': {
305 const {diffCommit, args, repoPath, userContext} = message;
306 Internal.promptAIAgent?.(
307 {type: 'splitCommit', diffCommit, args, repoPath, userContext},
308 ActionTriggerType.ISL2SplitCommit,
309 );
310 break;
311 }
312 case 'platform/createTestForModifiedCodeWithAI': {
313 Internal.promptTestGeneration?.();
314 break;
315 }
316 case 'platform/recommendTestPlanWithAI': {
317 const {commitHash, userContext} = message;
318 Internal.promptAIAgent?.(
319 {type: 'recommendTestPlan', commitHash, userContext},
320 ActionTriggerType.ISL2CommitInfoView,
321 );
322 break;
323 }
324 case 'platform/generateSummaryWithAI': {
325 const {commitHash, userContext} = message;
326 Internal.promptAIAgent?.(
327 {type: 'generateSummary', commitHash, userContext},
328 ActionTriggerType.ISL2CommitInfoView,
329 );
330 break;
331 }
332 case 'platform/validateChangesWithAI': {
333 const {userContext} = message;
334 Internal.promptAIAgent?.(
335 {type: 'validateChanges', userContext},
336 ActionTriggerType.ISL2SmartActions,
337 );
338 break;
339 }
340 case 'platform/resolveAllConflictsWithAI': {
341 const {conflicts, userContext} = message;
342 Internal.promptAIAgent?.(
343 {type: 'resolveAllConflicts', conflicts, repository: repo, context: ctx, userContext},
344 ActionTriggerType.ISL2MergeConflictView,
345 );
346 break;
347 }
348 case 'platform/runAICodeReviewPlatform': {
349 const {cwd} = message;
350 try {
351 const results = await Internal.runAICodeReview?.(cwd);
352 if (results != null) {
353 const aiReviewCommentGroup = Internal.aiReviewCommentGroup?.();
354 if (aiReviewCommentGroup == null) {
355 break;
356 }
357 aiReviewCommentGroup.clearComments();
358 for (const comment of [...results.values()].flat()) {
359 aiReviewCommentGroup.addComment(
360 comment.filepath,
361 comment.startLine,
362 comment.description,
363 );
364 }
365 }
366 } catch (err) {
367 postMessage({
368 type: 'platform/gotAIReviewComments',
369 comments: {
370 error: err as Error,
371 },
372 });
373 }
374 break;
375 }
376 case 'platform/runAICodeReviewChat': {
377 const {source, reviewScope, userContext} = message;
378 await Internal.promptAIAgent?.(
379 {type: 'reviewCode', repoPath: repo?.info.repoRoot, reviewScope, userContext},
380 source === 'commitInfoView'
381 ? ActionTriggerType.ISL2CommitInfoView
382 : ActionTriggerType.ISL2SmartActions,
383 );
384 break;
385 }
386 case 'platform/subscribeToAIReviewComments': {
387 const aiReviewCommentGroup = Internal.aiReviewCommentGroup?.();
388 if (aiReviewCommentGroup == null) {
389 break;
390 }
391 aiReviewCommentGroup.onDidChangeComments(
392 // Avoids importing FB-specific type
393 // TODO: Should we just import the type?
394 (
395 comments: {
396 comment: {
397 id: string;
398 filename: string;
399 line: number;
400 html: string;
401 };
402 }[],
403 ) => {
404 postMessage({
405 type: 'platform/gotAIReviewComments',
406 comments: {
407 value: comments.map(
408 (comment): CodeReviewIssue => ({
409 issueID: comment.comment.id,
410 filepath: comment.comment.filename,
411 startLine: comment.comment.line,
412 endLine: comment.comment.line, // TODO: get actual end line
413 description: comment.comment.html,
414 severity: 'medium', // TODO: get severity from comment
415 }),
416 ),
417 },
418 });
419 },
420 );
421 break;
422 }
423 }
424 } catch (err) {
425 vscode.window.showErrorMessage(`error handling message ${JSON.stringify(message)}\n${err}`);
426 }
427 },
428});
429
430function getUnsavedFiles(repo: Repository): Array<vscode.TextDocument> {
431 return vscode.workspace.textDocuments.filter(
432 document => document.isDirty && repo.isPathInsideRepo(document.fileName),
433 );
434}
435