addons/vscode/extension/commands.tsblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {Repository} from 'isl-server/src/Repository';
b69ab319import type {RepositoryContext} from 'isl-server/src/serverTypes';
b69ab3110import type {Operation} from 'isl/src/operations/Operation';
b69ab3111import type {PartiallySelectedDiffCommit} from 'isl/src/stackEdit/diffSplitTypes';
b69ab3112import type {AbsolutePath, RepoRelativePath} from 'isl/src/types';
b69ab3113import type {Comparison} from 'shared/Comparison';
b69ab3114
b69ab3115import {repoRelativePathForAbsolutePath} from 'isl-server/src/Repository';
b69ab3116import {repositoryCache} from 'isl-server/src/RepositoryCache';
b69ab3117import {findPublicAncestor} from 'isl-server/src/utils';
b69ab3118import {RevertOperation} from 'isl/src/operations/RevertOperation';
b69ab3119import fs from 'node:fs';
b69ab3120import path from 'node:path';
b69ab3121import {
b69ab3122 beforeRevsetForComparison,
b69ab3123 ComparisonType,
b69ab3124 currRevsetForComparison,
b69ab3125 labelForComparison,
b69ab3126} from 'shared/Comparison';
b69ab3127import * as vscode from 'vscode';
b69ab3128import {encodeDeletedFileUri} from './DeletedFileContentProvider';
b69ab3129import {encodeSaplingDiffUri} from './DiffContentProvider';
b69ab3130import {shouldOpenBeside} from './config';
b69ab3131import {t} from './i18n';
b69ab3132
b69ab3133/**
b69ab3134 * VS Code Commands registered by the Sapling extension.
b69ab3135 */
b69ab3136export const vscodeCommands = {
b69ab3137 ['sapling.open-file-diff-uncommitted']: commandWithUriOrResourceState((_, uri: vscode.Uri) =>
b69ab3138 openDiffView(uri, {type: ComparisonType.UncommittedChanges}),
b69ab3139 ),
b69ab3140 ['sapling.open-file-diff-head']: commandWithUriOrResourceState((_, uri: vscode.Uri) =>
b69ab3141 openDiffView(uri, {type: ComparisonType.HeadChanges}),
b69ab3142 ),
b69ab3143 ['sapling.open-file-diff-stack']: commandWithUriOrResourceState((_, uri: vscode.Uri) =>
b69ab3144 openDiffView(uri, {type: ComparisonType.StackChanges}),
b69ab3145 ),
b69ab3146 ['sapling.open-file-diff']: (uri: vscode.Uri, comparison: Comparison) =>
b69ab3147 openDiffView(uri, comparison),
b69ab3148
b69ab3149 ['sapling.open-remote-file-link']: commandWithUriOrResourceState(
b69ab3150 (repo: Repository, uri, path: RepoRelativePath) => openRemoteFileLink(repo, uri, path),
b69ab3151 ),
b69ab3152 ['sapling.copy-remote-file-link']: commandWithUriOrResourceState(
b69ab3153 (repo: Repository, uri, path: RepoRelativePath) => openRemoteFileLink(repo, uri, path, true),
b69ab3154 ),
b69ab3155
b69ab3156 ['sapling.revert-file']: commandWithUriOrResourceState(async function (
b69ab3157 this: RepositoryContext,
b69ab3158 repo: Repository,
b69ab3159 _,
b69ab3160 path: RepoRelativePath,
b69ab3161 ) {
b69ab3162 const choice = await vscode.window.showWarningMessage(
b69ab3163 'Are you sure you want to revert this file?',
b69ab3164 'Cancel',
b69ab3165 'Revert',
b69ab3166 );
b69ab3167 if (choice !== 'Revert') {
b69ab3168 return;
b69ab3169 }
b69ab3170 return runOperation(this, repo, new RevertOperation([path]));
b69ab3171 }),
b69ab3172};
b69ab3173
b69ab3174type surveyMetaData = {
b69ab3175 diffId: string | undefined;
b69ab3176};
b69ab3177
b69ab3178/** Type definitions for built-in or third-party VS Code commands we want to execute programmatically. */
b69ab3179type ExternalVSCodeCommands = {
b69ab3180 'vscode.diff': (
b69ab3181 left: vscode.Uri,
b69ab3182 right: vscode.Uri,
b69ab3183 title: string,
b69ab3184 opts?: vscode.TextDocumentShowOptions,
b69ab3185 ) => Thenable<unknown>;
b69ab3186 'workbench.action.closeSidebar': () => Thenable<void>;
b69ab3187 'fb.survey.initStateUIByNamespace': (
b69ab3188 surveyID: string,
b69ab3189 namespace: string,
b69ab3190 metadata: surveyMetaData,
b69ab3191 ) => Thenable<void>;
b69ab3192 'workbench.action.pinEditor': () => Thenable<void>;
b69ab3193 'sapling.open-isl': () => Thenable<void>;
b69ab3194 'sapling.close-isl': () => Thenable<void>;
b69ab3195 'sapling.isl.focus': () => Thenable<void>;
b69ab3196 'sapling.open-isl-with-commit-message': (
b69ab3197 title: string,
b69ab3198 description: string,
b69ab3199 mode?: 'commit' | 'amend',
b69ab31100 hash?: string,
b69ab31101 ) => Thenable<void>;
b69ab31102 'sapling.open-split-view-with-commits': (
b69ab31103 commits: Array<PartiallySelectedDiffCommit>,
b69ab31104 commitHash?: string,
b69ab31105 ) => Thenable<void>;
b69ab31106 'sapling.open-comparison-view': (comparison: Comparison) => Thenable<void>;
b69ab31107 setContext: (key: string, value: unknown) => Thenable<void>;
b69ab31108 'fb-hg.open-or-focus-interactive-smartlog': (
b69ab31109 _: unknown,
b69ab31110 __?: unknown,
b69ab31111 forceNoSapling?: boolean,
b69ab31112 ) => Thenable<void>;
b69ab31113};
b69ab31114
b69ab31115export type VSCodeCommand = typeof vscodeCommands & ExternalVSCodeCommands;
b69ab31116
b69ab31117/**
b69ab31118 * Type-safe programmatic execution of VS Code commands (via `vscode.commands.executeCommand`).
b69ab31119 * Sapling-provided commands are defined in vscodeCommands.
b69ab31120 * Built-in or third-party commands may also be typed through this function,
b69ab31121 * just define them in ExternalVSCodeCommands.
b69ab31122 */
b69ab31123export function executeVSCodeCommand<K extends keyof VSCodeCommand>(
b69ab31124 id: K,
b69ab31125 ...args: Parameters<VSCodeCommand[K]>
b69ab31126): ReturnType<VSCodeCommand[K]> {
b69ab31127 // In tests 'vscode.commands' is not defined.
b69ab31128 return vscode.commands?.executeCommand(id, ...args) as ReturnType<VSCodeCommand[K]>;
b69ab31129}
b69ab31130
b69ab31131const runOperation = (
b69ab31132 ctx: RepositoryContext,
b69ab31133 repo: Repository,
b69ab31134 operation: Operation,
b69ab31135): undefined => {
b69ab31136 repo.runOrQueueOperation(
b69ab31137 ctx,
b69ab31138 {
b69ab31139 args: operation.getArgs(),
b69ab31140 id: operation.id,
b69ab31141 runner: operation.runner,
b69ab31142 trackEventName: operation.trackEventName,
b69ab31143 },
b69ab31144 () => undefined, // TODO: Send this progress info to any existing ISL webview if there is one
b69ab31145 );
b69ab31146 return undefined;
b69ab31147};
b69ab31148
b69ab31149export function registerCommands(ctx: RepositoryContext): Array<vscode.Disposable> {
b69ab31150 const disposables: Array<vscode.Disposable> = Object.entries(vscodeCommands).map(
b69ab31151 ([id, handler]) =>
b69ab31152 vscode.commands.registerCommand(id, (...args: Parameters<typeof handler>) =>
b69ab31153 ctx.tracker.operation(
b69ab31154 'RunVSCodeCommand',
b69ab31155 'VSCodeCommandError',
b69ab31156 {extras: {command: id}},
b69ab31157 () => {
b69ab31158 return (handler as (...args: Array<unknown>) => unknown).apply(ctx, args);
b69ab31159 },
b69ab31160 ),
b69ab31161 ),
b69ab31162 );
b69ab31163 return disposables;
b69ab31164}
b69ab31165
b69ab31166function fileExists(uri: vscode.Uri): Promise<boolean> {
b69ab31167 return fs.promises
b69ab31168 .access(uri.fsPath)
b69ab31169 .then(() => true)
b69ab31170 .catch(() => false);
b69ab31171}
b69ab31172
b69ab31173async function openDiffView(uri: vscode.Uri, comparison: Comparison): Promise<unknown> {
b69ab31174 const leftUri = getLeftUri(uri, comparison);
b69ab31175 const rightUri = await getRightUri(uri, comparison);
b69ab31176 const title = `${path.basename(uri.fsPath)} (${t(labelForComparison(comparison))})`;
b69ab31177 const opts = {viewColumn: shouldOpenBeside() ? vscode.ViewColumn.Beside : undefined};
b69ab31178 return executeVSCodeCommand('vscode.diff', leftUri, rightUri, title, opts);
b69ab31179}
b69ab31180
b69ab31181function getLeftUri(uri: vscode.Uri, comparison: Comparison): vscode.Uri {
b69ab31182 const leftRev = beforeRevsetForComparison(comparison);
b69ab31183 return encodeSaplingDiffUri(uri, leftRev);
b69ab31184}
b69ab31185
b69ab31186/**
b69ab31187 * Get the right side URI of the diff view.
b69ab31188 *
b69ab31189 * A raw file:// URI without encoding lets vscode directly open the file on disk.
b69ab31190 * This is desirable as users can edit the file on the right side of the diff view.
b69ab31191 *
b69ab31192 * There are cases, however, where editable right side does NOT make sense:
b69ab31193 * - comparing against a history commit (since changes on the right side may not be present)
b69ab31194 * - comparing submodule changes (since both side are commit hashes instead of file content)
b69ab31195 */
b69ab31196async function getRightUri(uri: vscode.Uri, comparison: Comparison): Promise<vscode.Uri> {
b69ab31197 const rightRev = currRevsetForComparison(comparison);
b69ab31198 if (comparison.type === ComparisonType.Committed || isSubmodule(uri.fsPath)) {
b69ab31199 return encodeSaplingDiffUri(uri, rightRev);
b69ab31200 }
b69ab31201 return (await fileExists(uri)) ? uri : encodeDeletedFileUri(uri);
b69ab31202}
b69ab31203
b69ab31204function isSubmodule(path: AbsolutePath): boolean {
b69ab31205 const repo = repositoryCache.cachedRepositoryForPath(path);
b69ab31206 if (repo === undefined) {
b69ab31207 return false;
b69ab31208 }
b69ab31209 const submodulePaths = repo.getSubmodulePathCache();
b69ab31210 const relPath = repoRelativePathForAbsolutePath(path, repo);
b69ab31211 return submodulePaths?.has(relPath) ?? false;
b69ab31212}
b69ab31213
b69ab31214function openRemoteFileLink(
b69ab31215 repo: Repository,
b69ab31216 uri: vscode.Uri,
b69ab31217 path: RepoRelativePath,
b69ab31218 copyToClipboard = false,
b69ab31219): void {
b69ab31220 {
b69ab31221 if (!repo.codeReviewProvider?.getRemoteFileURL) {
b69ab31222 vscode.window.showErrorMessage(
b69ab31223 t(`Remote link unsupported for this code review provider ($provider)`).replace(
b69ab31224 '$provider',
b69ab31225 repo.codeReviewProvider?.getSummaryName() ?? t('none'),
b69ab31226 ),
b69ab31227 );
b69ab31228 return;
b69ab31229 }
b69ab31230
b69ab31231 // Grab the selection if the command is for the active file (may not be true if triggered via file explorer)
b69ab31232 const selection =
b69ab31233 vscode.window.activeTextEditor?.document.uri.fsPath === uri.fsPath
b69ab31234 ? vscode.window.activeTextEditor?.selection
b69ab31235 : null;
b69ab31236
b69ab31237 const commits = repo.getSmartlogCommits()?.commits.value;
b69ab31238 const head = repo.getHeadCommit();
b69ab31239 if (!commits || !head) {
b69ab31240 vscode.window.showErrorMessage(t(`No commits loaded in this repository yet`));
b69ab31241 return;
b69ab31242 }
b69ab31243 const publicCommit = findPublicAncestor(commits, head);
b69ab31244 const url = repo.codeReviewProvider.getRemoteFileURL(
b69ab31245 path,
b69ab31246 publicCommit?.hash ?? null,
b69ab31247 selection ? {line: selection.start.line, char: selection.start.character} : undefined,
b69ab31248 selection ? {line: selection.end.line, char: selection.end.character} : undefined,
b69ab31249 );
b69ab31250
b69ab31251 if (copyToClipboard) {
b69ab31252 vscode.env.clipboard.writeText(url);
b69ab31253 } else {
b69ab31254 vscode.env.openExternal(vscode.Uri.parse(url));
b69ab31255 }
b69ab31256 }
b69ab31257}
b69ab31258
b69ab31259/**
b69ab31260 * Wrap a command implementation so it can be called with any of:
b69ab31261 * - current active file Uri for use from the command palette
b69ab31262 * - a vscode Uri for programmatic invocations
b69ab31263 * - a SourceControlResourceState for use from the VS Code SCM sidebar API
b69ab31264 */
b69ab31265function commandWithUriOrResourceState(
b69ab31266 handler: (
b69ab31267 repo: Repository,
b69ab31268 uri: vscode.Uri,
b69ab31269 path: RepoRelativePath,
b69ab31270 ) => unknown | Thenable<unknown>,
b69ab31271) {
b69ab31272 return function (
b69ab31273 this: RepositoryContext,
b69ab31274 uriOrResource: vscode.Uri | vscode.SourceControlResourceState | undefined,
b69ab31275 ) {
b69ab31276 const uri =
b69ab31277 uriOrResource == null
b69ab31278 ? vscode.window.activeTextEditor?.document.uri
b69ab31279 : uriOrResource instanceof vscode.Uri
b69ab31280 ? uriOrResource
b69ab31281 : uriOrResource.resourceUri;
b69ab31282 if (uri == null) {
b69ab31283 vscode.window.showErrorMessage(t(`No active file found`));
b69ab31284 return;
b69ab31285 }
b69ab31286
b69ab31287 const {fsPath} = uri;
b69ab31288 const repo = repositoryCache.cachedRepositoryForPath(fsPath);
b69ab31289 if (repo == null) {
b69ab31290 vscode.window.showErrorMessage(t(`No repository found for file ${fsPath}`));
b69ab31291 return;
b69ab31292 }
b69ab31293
b69ab31294 const repoRelativePath = repoRelativePathForAbsolutePath(uri.fsPath, repo);
b69ab31295 return handler.apply(this, [repo, uri, repoRelativePath]);
b69ab31296 };
b69ab31297}