10.1 KB298 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 {Repository} from 'isl-server/src/Repository';
9import type {RepositoryContext} from 'isl-server/src/serverTypes';
10import type {Operation} from 'isl/src/operations/Operation';
11import type {PartiallySelectedDiffCommit} from 'isl/src/stackEdit/diffSplitTypes';
12import type {AbsolutePath, RepoRelativePath} from 'isl/src/types';
13import type {Comparison} from 'shared/Comparison';
14
15import {repoRelativePathForAbsolutePath} from 'isl-server/src/Repository';
16import {repositoryCache} from 'isl-server/src/RepositoryCache';
17import {findPublicAncestor} from 'isl-server/src/utils';
18import {RevertOperation} from 'isl/src/operations/RevertOperation';
19import fs from 'node:fs';
20import path from 'node:path';
21import {
22 beforeRevsetForComparison,
23 ComparisonType,
24 currRevsetForComparison,
25 labelForComparison,
26} from 'shared/Comparison';
27import * as vscode from 'vscode';
28import {encodeDeletedFileUri} from './DeletedFileContentProvider';
29import {encodeSaplingDiffUri} from './DiffContentProvider';
30import {shouldOpenBeside} from './config';
31import {t} from './i18n';
32
33/**
34 * VS Code Commands registered by the Sapling extension.
35 */
36export const vscodeCommands = {
37 ['sapling.open-file-diff-uncommitted']: commandWithUriOrResourceState((_, uri: vscode.Uri) =>
38 openDiffView(uri, {type: ComparisonType.UncommittedChanges}),
39 ),
40 ['sapling.open-file-diff-head']: commandWithUriOrResourceState((_, uri: vscode.Uri) =>
41 openDiffView(uri, {type: ComparisonType.HeadChanges}),
42 ),
43 ['sapling.open-file-diff-stack']: commandWithUriOrResourceState((_, uri: vscode.Uri) =>
44 openDiffView(uri, {type: ComparisonType.StackChanges}),
45 ),
46 ['sapling.open-file-diff']: (uri: vscode.Uri, comparison: Comparison) =>
47 openDiffView(uri, comparison),
48
49 ['sapling.open-remote-file-link']: commandWithUriOrResourceState(
50 (repo: Repository, uri, path: RepoRelativePath) => openRemoteFileLink(repo, uri, path),
51 ),
52 ['sapling.copy-remote-file-link']: commandWithUriOrResourceState(
53 (repo: Repository, uri, path: RepoRelativePath) => openRemoteFileLink(repo, uri, path, true),
54 ),
55
56 ['sapling.revert-file']: commandWithUriOrResourceState(async function (
57 this: RepositoryContext,
58 repo: Repository,
59 _,
60 path: RepoRelativePath,
61 ) {
62 const choice = await vscode.window.showWarningMessage(
63 'Are you sure you want to revert this file?',
64 'Cancel',
65 'Revert',
66 );
67 if (choice !== 'Revert') {
68 return;
69 }
70 return runOperation(this, repo, new RevertOperation([path]));
71 }),
72};
73
74type surveyMetaData = {
75 diffId: string | undefined;
76};
77
78/** Type definitions for built-in or third-party VS Code commands we want to execute programmatically. */
79type ExternalVSCodeCommands = {
80 'vscode.diff': (
81 left: vscode.Uri,
82 right: vscode.Uri,
83 title: string,
84 opts?: vscode.TextDocumentShowOptions,
85 ) => Thenable<unknown>;
86 'workbench.action.closeSidebar': () => Thenable<void>;
87 'fb.survey.initStateUIByNamespace': (
88 surveyID: string,
89 namespace: string,
90 metadata: surveyMetaData,
91 ) => Thenable<void>;
92 'workbench.action.pinEditor': () => Thenable<void>;
93 'sapling.open-isl': () => Thenable<void>;
94 'sapling.close-isl': () => Thenable<void>;
95 'sapling.isl.focus': () => Thenable<void>;
96 'sapling.open-isl-with-commit-message': (
97 title: string,
98 description: string,
99 mode?: 'commit' | 'amend',
100 hash?: string,
101 ) => Thenable<void>;
102 'sapling.open-split-view-with-commits': (
103 commits: Array<PartiallySelectedDiffCommit>,
104 commitHash?: string,
105 ) => Thenable<void>;
106 'sapling.open-comparison-view': (comparison: Comparison) => Thenable<void>;
107 setContext: (key: string, value: unknown) => Thenable<void>;
108 'fb-hg.open-or-focus-interactive-smartlog': (
109 _: unknown,
110 __?: unknown,
111 forceNoSapling?: boolean,
112 ) => Thenable<void>;
113};
114
115export type VSCodeCommand = typeof vscodeCommands & ExternalVSCodeCommands;
116
117/**
118 * Type-safe programmatic execution of VS Code commands (via `vscode.commands.executeCommand`).
119 * Sapling-provided commands are defined in vscodeCommands.
120 * Built-in or third-party commands may also be typed through this function,
121 * just define them in ExternalVSCodeCommands.
122 */
123export function executeVSCodeCommand<K extends keyof VSCodeCommand>(
124 id: K,
125 ...args: Parameters<VSCodeCommand[K]>
126): ReturnType<VSCodeCommand[K]> {
127 // In tests 'vscode.commands' is not defined.
128 return vscode.commands?.executeCommand(id, ...args) as ReturnType<VSCodeCommand[K]>;
129}
130
131const runOperation = (
132 ctx: RepositoryContext,
133 repo: Repository,
134 operation: Operation,
135): undefined => {
136 repo.runOrQueueOperation(
137 ctx,
138 {
139 args: operation.getArgs(),
140 id: operation.id,
141 runner: operation.runner,
142 trackEventName: operation.trackEventName,
143 },
144 () => undefined, // TODO: Send this progress info to any existing ISL webview if there is one
145 );
146 return undefined;
147};
148
149export function registerCommands(ctx: RepositoryContext): Array<vscode.Disposable> {
150 const disposables: Array<vscode.Disposable> = Object.entries(vscodeCommands).map(
151 ([id, handler]) =>
152 vscode.commands.registerCommand(id, (...args: Parameters<typeof handler>) =>
153 ctx.tracker.operation(
154 'RunVSCodeCommand',
155 'VSCodeCommandError',
156 {extras: {command: id}},
157 () => {
158 return (handler as (...args: Array<unknown>) => unknown).apply(ctx, args);
159 },
160 ),
161 ),
162 );
163 return disposables;
164}
165
166function fileExists(uri: vscode.Uri): Promise<boolean> {
167 return fs.promises
168 .access(uri.fsPath)
169 .then(() => true)
170 .catch(() => false);
171}
172
173async function openDiffView(uri: vscode.Uri, comparison: Comparison): Promise<unknown> {
174 const leftUri = getLeftUri(uri, comparison);
175 const rightUri = await getRightUri(uri, comparison);
176 const title = `${path.basename(uri.fsPath)} (${t(labelForComparison(comparison))})`;
177 const opts = {viewColumn: shouldOpenBeside() ? vscode.ViewColumn.Beside : undefined};
178 return executeVSCodeCommand('vscode.diff', leftUri, rightUri, title, opts);
179}
180
181function getLeftUri(uri: vscode.Uri, comparison: Comparison): vscode.Uri {
182 const leftRev = beforeRevsetForComparison(comparison);
183 return encodeSaplingDiffUri(uri, leftRev);
184}
185
186/**
187 * Get the right side URI of the diff view.
188 *
189 * A raw file:// URI without encoding lets vscode directly open the file on disk.
190 * This is desirable as users can edit the file on the right side of the diff view.
191 *
192 * There are cases, however, where editable right side does NOT make sense:
193 * - comparing against a history commit (since changes on the right side may not be present)
194 * - comparing submodule changes (since both side are commit hashes instead of file content)
195 */
196async function getRightUri(uri: vscode.Uri, comparison: Comparison): Promise<vscode.Uri> {
197 const rightRev = currRevsetForComparison(comparison);
198 if (comparison.type === ComparisonType.Committed || isSubmodule(uri.fsPath)) {
199 return encodeSaplingDiffUri(uri, rightRev);
200 }
201 return (await fileExists(uri)) ? uri : encodeDeletedFileUri(uri);
202}
203
204function isSubmodule(path: AbsolutePath): boolean {
205 const repo = repositoryCache.cachedRepositoryForPath(path);
206 if (repo === undefined) {
207 return false;
208 }
209 const submodulePaths = repo.getSubmodulePathCache();
210 const relPath = repoRelativePathForAbsolutePath(path, repo);
211 return submodulePaths?.has(relPath) ?? false;
212}
213
214function openRemoteFileLink(
215 repo: Repository,
216 uri: vscode.Uri,
217 path: RepoRelativePath,
218 copyToClipboard = false,
219): void {
220 {
221 if (!repo.codeReviewProvider?.getRemoteFileURL) {
222 vscode.window.showErrorMessage(
223 t(`Remote link unsupported for this code review provider ($provider)`).replace(
224 '$provider',
225 repo.codeReviewProvider?.getSummaryName() ?? t('none'),
226 ),
227 );
228 return;
229 }
230
231 // Grab the selection if the command is for the active file (may not be true if triggered via file explorer)
232 const selection =
233 vscode.window.activeTextEditor?.document.uri.fsPath === uri.fsPath
234 ? vscode.window.activeTextEditor?.selection
235 : null;
236
237 const commits = repo.getSmartlogCommits()?.commits.value;
238 const head = repo.getHeadCommit();
239 if (!commits || !head) {
240 vscode.window.showErrorMessage(t(`No commits loaded in this repository yet`));
241 return;
242 }
243 const publicCommit = findPublicAncestor(commits, head);
244 const url = repo.codeReviewProvider.getRemoteFileURL(
245 path,
246 publicCommit?.hash ?? null,
247 selection ? {line: selection.start.line, char: selection.start.character} : undefined,
248 selection ? {line: selection.end.line, char: selection.end.character} : undefined,
249 );
250
251 if (copyToClipboard) {
252 vscode.env.clipboard.writeText(url);
253 } else {
254 vscode.env.openExternal(vscode.Uri.parse(url));
255 }
256 }
257}
258
259/**
260 * Wrap a command implementation so it can be called with any of:
261 * - current active file Uri for use from the command palette
262 * - a vscode Uri for programmatic invocations
263 * - a SourceControlResourceState for use from the VS Code SCM sidebar API
264 */
265function commandWithUriOrResourceState(
266 handler: (
267 repo: Repository,
268 uri: vscode.Uri,
269 path: RepoRelativePath,
270 ) => unknown | Thenable<unknown>,
271) {
272 return function (
273 this: RepositoryContext,
274 uriOrResource: vscode.Uri | vscode.SourceControlResourceState | undefined,
275 ) {
276 const uri =
277 uriOrResource == null
278 ? vscode.window.activeTextEditor?.document.uri
279 : uriOrResource instanceof vscode.Uri
280 ? uriOrResource
281 : uriOrResource.resourceUri;
282 if (uri == null) {
283 vscode.window.showErrorMessage(t(`No active file found`));
284 return;
285 }
286
287 const {fsPath} = uri;
288 const repo = repositoryCache.cachedRepositoryForPath(fsPath);
289 if (repo == null) {
290 vscode.window.showErrorMessage(t(`No repository found for file ${fsPath}`));
291 return;
292 }
293
294 const repoRelativePath = repoRelativePathForAbsolutePath(uri.fsPath, repo);
295 return handler.apply(this, [repo, uri, repoRelativePath]);
296 };
297}
298