addons/vscode/extension/extension.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 {Level} from 'isl-server/src/logger';
b69ab319import type {ServerPlatform} from 'isl-server/src/serverPlatform';
b69ab3110import type {RepositoryContext} from 'isl-server/src/serverTypes';
b69ab3111import type {SaplingExtensionApi} from './api/types';
b69ab3112import type {EnabledSCMApiFeature} from './types';
b69ab3113
b69ab3114import {makeServerSideTracker} from 'isl-server/src/analytics/serverSideTracker';
b69ab3115import {Logger} from 'isl-server/src/logger';
1d6bd0716import * as fs from 'node:fs';
1d6bd0717import * as path from 'node:path';
b69ab3118import * as util from 'node:util';
b69ab3119import * as vscode from 'vscode';
b69ab3120import {DeletedFileContentProvider} from './DeletedFileContentProvider';
b69ab3121import {registerSaplingDiffContentProvider} from './DiffContentProvider';
b69ab3122import {Internal} from './Internal';
b69ab3123import {VSCodeReposList} from './VSCodeRepo';
b69ab3124import {makeExtensionApi} from './api/api';
b69ab3125import {InlineBlameProvider} from './blame/blame';
b69ab3126import {registerCommands} from './commands';
b69ab3127import {getCLICommand} from './config';
b69ab3128import {ensureTranslationsLoaded} from './i18n';
b69ab3129import {registerISLCommands} from './islWebviewPanel';
b69ab3130import {extensionVersion} from './utils';
b69ab3131import {getVSCodePlatform} from './vscodePlatform';
b69ab3132
b69ab3133export async function activate(
b69ab3134 context: vscode.ExtensionContext,
b69ab3135): Promise<SaplingExtensionApi | undefined> {
b69ab3136 const start = Date.now();
b69ab3137 const [outputChannel, logger] = createOutputChannelLogger();
b69ab3138 const platform = getVSCodePlatform(context);
b69ab3139 const extensionTracker = makeServerSideTracker(
b69ab3140 logger,
b69ab3141 platform as ServerPlatform,
b69ab3142 extensionVersion,
b69ab3143 );
b69ab3144 try {
b69ab3145 const ctx: RepositoryContext = {
b69ab3146 cmd: getCLICommand(),
b69ab3147 cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(),
b69ab3148 logger,
b69ab3149 tracker: extensionTracker,
b69ab3150 };
b69ab3151 // TODO: This await is in the critical path to loading the ISL webview,
b69ab3152 // but none of these features really apply for the webview. Can we defer this to speed up first ISL load?
b69ab3153 const [, enabledSCMApiFeatures] = await Promise.all([
b69ab3154 ensureTranslationsLoaded(context),
b69ab3155 Internal.getEnabledSCMApiFeatures?.(ctx) ??
b69ab3156 new Set<EnabledSCMApiFeature>(['blame', 'sidebar']),
b69ab3157 ]);
b69ab3158 logger.info('enabled features: ', [...enabledSCMApiFeatures].join(', '));
b69ab3159 context.subscriptions.push(registerISLCommands(context, platform, logger));
b69ab3160 context.subscriptions.push(outputChannel);
c3ed59a61
c3ed59a62 // Grove status bar button to open ISL
c3ed59a63 const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 0);
c3ed59a64 statusBarItem.text = '$(grove-icon) Grove';
c3ed59a65 statusBarItem.command = 'sapling.open-isl-tab';
c3ed59a66 statusBarItem.tooltip = 'Open Interactive Smartlog';
c3ed59a67 statusBarItem.show();
c3ed59a68 context.subscriptions.push(statusBarItem);
b69ab3169 const reposList = new VSCodeReposList(logger, extensionTracker, enabledSCMApiFeatures);
b69ab3170 context.subscriptions.push(reposList);
b69ab3171 if (enabledSCMApiFeatures.has('blame')) {
b69ab3172 context.subscriptions.push(new InlineBlameProvider(reposList, ctx));
b69ab3173 }
b69ab3174 context.subscriptions.push(registerSaplingDiffContentProvider(ctx));
b69ab3175 context.subscriptions.push(new DeletedFileContentProvider());
b69ab3176 let inlineCommentsProvider;
b69ab3177 if (enabledSCMApiFeatures.has('comments') && Internal.inlineCommentsProvider) {
b69ab3178 if (
b69ab3179 enabledSCMApiFeatures.has('newInlineComments') &&
b69ab3180 Internal.registerNewInlineCommentsProvider
b69ab3181 ) {
b69ab3182 context.subscriptions.push(
b69ab3183 ...Internal.registerNewInlineCommentsProvider(
b69ab3184 context,
b69ab3185 extensionTracker,
b69ab3186 logger,
b69ab3187 reposList,
b69ab3188 ),
b69ab3189 );
b69ab3190 } else {
b69ab3191 inlineCommentsProvider = Internal.inlineCommentsProvider(context, reposList, ctx, []);
b69ab3192 if (inlineCommentsProvider != null) {
b69ab3193 context.subscriptions.push(inlineCommentsProvider);
b69ab3194 }
b69ab3195 }
b69ab3196 }
b69ab3197 if (Internal.SaplingISLUriHandler != null) {
b69ab3198 context.subscriptions.push(
b69ab3199 vscode.window.registerUriHandler(
b69ab31100 new Internal.SaplingISLUriHandler(reposList, ctx, inlineCommentsProvider),
b69ab31101 ),
b69ab31102 );
b69ab31103 }
b69ab31104
b69ab31105 context.subscriptions.push(...registerCommands(ctx));
b69ab31106
1d6bd07107 // If a previous grove.init picked a folder and reloaded, run init now
1d6bd07108 if (context.globalState.get('grove.pendingInit')) {
1d6bd07109 await context.globalState.update('grove.pendingInit', undefined);
1d6bd07110 const folderUri = vscode.workspace.workspaceFolders?.[0]?.uri;
1d6bd07111 if (folderUri) {
1d6bd07112 const terminal = vscode.window.createTerminal({name: 'Grove Init', cwd: folderUri.fsPath});
1d6bd07113 terminal.show();
0542c45114 terminal.sendText(
0542c45115 `${getCLICommand()} init --config init.prefer-git=false --config format.use-remotefilelog=true`,
0542c45116 );
1d6bd07117 }
1d6bd07118 }
1d6bd07119
1d6bd07120 context.subscriptions.push(
1d6bd07121 vscode.commands.registerCommand('grove.init', async () => {
1d6bd07122 let folderUri = vscode.workspace.workspaceFolders?.[0]?.uri;
1d6bd07123 if (!folderUri) {
1d6bd07124 const resolvedPath = await pickFolderWithAutocomplete();
1d6bd07125 if (!resolvedPath) {
1d6bd07126 return;
1d6bd07127 }
1d6bd07128 try {
1d6bd07129 vscode.window.showInformationMessage(`Grove: creating "${resolvedPath}"…`);
1d6bd07130 await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath));
1d6bd07131 vscode.window.showInformationMessage(`Grove: opening folder…`);
1d6bd07132 await context.globalState.update('grove.pendingInit', true);
1d6bd07133 await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(resolvedPath));
1d6bd07134 } catch (err) {
1d6bd07135 vscode.window.showErrorMessage(`Grove init failed: ${err}`);
1d6bd07136 }
1d6bd07137 return;
1d6bd07138 }
1d6bd07139 const terminal = vscode.window.createTerminal({
1d6bd07140 name: 'Grove Init',
1d6bd07141 cwd: folderUri.fsPath,
1d6bd07142 });
1d6bd07143 terminal.show();
0542c45144 terminal.sendText(
0542c45145 `${getCLICommand()} init --config init.prefer-git=false --config format.use-remotefilelog=true`,
0542c45146 );
1d6bd07147 }),
1d6bd07148 );
1d6bd07149
b69ab31150 Internal?.registerInternalBugLogsProvider != null &&
b69ab31151 context.subscriptions.push(Internal.registerInternalBugLogsProvider(logger));
b69ab31152
b69ab31153 extensionTracker.track('VSCodeExtensionActivated', {duration: Date.now() - start});
b69ab31154 const api = makeExtensionApi(platform, ctx, reposList);
b69ab31155 return api;
b69ab31156 } catch (error) {
b69ab31157 extensionTracker.error('VSCodeExtensionActivated', 'VSCodeActivationError', error as Error, {
b69ab31158 duration: Date.now() - start,
b69ab31159 });
b69ab31160 return undefined;
b69ab31161 }
b69ab31162}
b69ab31163
1d6bd07164/** Show a QuickPick with filesystem autocomplete, starting from ~. Returns resolved path or undefined. */
1d6bd07165async function pickFolderWithAutocomplete(): Promise<string | undefined> {
1d6bd07166 const home = process.env.HOME ?? '/';
1d6bd07167 return new Promise(resolve => {
1d6bd07168 const qp = vscode.window.createQuickPick();
1d6bd07169 qp.title = 'Initialize Grove Repository';
1d6bd07170 qp.placeholder = 'Type a folder path (new or existing)';
1d6bd07171 qp.ignoreFocusOut = true;
1d6bd07172 qp.value = home + path.sep;
1d6bd07173
1d6bd07174 const btnInitHere: vscode.QuickInputButton = {
1d6bd07175 iconPath: new vscode.ThemeIcon('check'),
1d6bd07176 tooltip: 'Initialize here',
1d6bd07177 };
1d6bd07178 const btnNewFolder: vscode.QuickInputButton = {
1d6bd07179 iconPath: new vscode.ThemeIcon('new-folder'),
1d6bd07180 tooltip: 'Create folder & initialize',
1d6bd07181 };
1d6bd07182
1d6bd07183 let resolved = false;
1d6bd07184 function resolve2(rawPath: string) {
1d6bd07185 const expanded = rawPath.startsWith('~') ? rawPath.replace('~', home) : rawPath;
1d6bd07186 const clean = expanded.endsWith(path.sep) ? expanded.slice(0, -1) : expanded;
1d6bd07187 resolved = true;
1d6bd07188 qp.dispose();
1d6bd07189 resolve(clean);
1d6bd07190 }
1d6bd07191
1d6bd07192 function updateButton(val: string) {
1d6bd07193 const expanded = val.startsWith('~') ? val.replace('~', home) : val;
1d6bd07194 const clean = expanded.endsWith(path.sep) ? expanded.slice(0, -1) : expanded;
1d6bd07195 try {
1d6bd07196 fs.accessSync(clean);
1d6bd07197 qp.buttons = [btnInitHere];
1d6bd07198 } catch {
1d6bd07199 qp.buttons = [btnNewFolder];
1d6bd07200 }
1d6bd07201 }
1d6bd07202
1d6bd07203 function getSuggestions(input: string): vscode.QuickPickItem[] {
1d6bd07204 const expanded = input.startsWith('~') ? input.replace('~', home) : input;
1d6bd07205 try {
1d6bd07206 const dir = expanded.endsWith(path.sep) ? expanded : path.dirname(expanded);
1d6bd07207 const prefix = expanded.endsWith(path.sep) ? '' : path.basename(expanded);
1d6bd07208 return fs
1d6bd07209 .readdirSync(dir, {withFileTypes: true})
1d6bd07210 .filter(e => e.isDirectory() && !e.name.startsWith('.') && e.name.startsWith(prefix))
1d6bd07211 .map(e => ({label: path.join(dir, e.name) + path.sep}));
1d6bd07212 } catch {
1d6bd07213 return [];
1d6bd07214 }
1d6bd07215 }
1d6bd07216
1d6bd07217 updateButton(qp.value);
1d6bd07218 qp.items = getSuggestions(qp.value);
1d6bd07219
1d6bd07220 qp.onDidChangeValue(val => {
1d6bd07221 updateButton(val);
1d6bd07222 qp.items = getSuggestions(val);
1d6bd07223 });
1d6bd07224
1d6bd07225 qp.onDidTriggerButton(() => {
1d6bd07226 resolve2(qp.value);
1d6bd07227 });
1d6bd07228
1d6bd07229 qp.onDidAccept(() => {
1d6bd07230 const selected = qp.activeItems[0]?.label ?? qp.value;
1d6bd07231 // If the selection ends with sep it's a directory — drill into it instead of accepting
1d6bd07232 if (selected.endsWith(path.sep)) {
1d6bd07233 qp.value = selected;
1d6bd07234 updateButton(selected);
1d6bd07235 qp.items = getSuggestions(selected);
1d6bd07236 return;
1d6bd07237 }
1d6bd07238 resolve2(selected);
1d6bd07239 });
1d6bd07240
1d6bd07241 qp.onDidHide(() => {
1d6bd07242 if (!resolved) {
1d6bd07243 resolve(undefined);
1d6bd07244 }
1d6bd07245 qp.dispose();
1d6bd07246 });
1d6bd07247
1d6bd07248 qp.show();
1d6bd07249 });
1d6bd07250}
1d6bd07251
b69ab31252function createOutputChannelLogger(): [vscode.OutputChannel, Logger] {
c3ed59a253 const outputChannel = vscode.window.createOutputChannel('Grove');
b69ab31254 const outputChannelLogger = new VSCodeOutputChannelLogger(outputChannel);
b69ab31255 return [outputChannel, outputChannelLogger];
b69ab31256}
b69ab31257
b69ab31258class VSCodeOutputChannelLogger extends Logger {
b69ab31259 private logFileContents: Array<string> = []; // TODO: we should just move this into Logger itself... and maybe do some rotation or cap memory usage!
b69ab31260 constructor(private outputChannel: vscode.OutputChannel) {
b69ab31261 super();
b69ab31262 }
b69ab31263
b69ab31264 write(level: Level, timeStr: string, ...args: Parameters<typeof console.log>): void {
b69ab31265 const str = util.format('%s%s', timeStr, this.levelToString(level), ...args);
b69ab31266 this.logFileContents.push(str);
b69ab31267 this.outputChannel.appendLine(str);
b69ab31268 }
b69ab31269
b69ab31270 getLogFileContents() {
b69ab31271 return Promise.resolve(this.logFileContents.join('\n'));
b69ab31272 }
b69ab31273}