| 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 | |
| 8 | import type {Level} from 'isl-server/src/logger'; |
| 9 | import type {ServerPlatform} from 'isl-server/src/serverPlatform'; |
| 10 | import type {RepositoryContext} from 'isl-server/src/serverTypes'; |
| 11 | import type {SaplingExtensionApi} from './api/types'; |
| 12 | import type {EnabledSCMApiFeature} from './types'; |
| 13 | |
| 14 | import {makeServerSideTracker} from 'isl-server/src/analytics/serverSideTracker'; |
| 15 | import {Logger} from 'isl-server/src/logger'; |
| 16 | import * as fs from 'node:fs'; |
| 17 | import * as path from 'node:path'; |
| 18 | import * as util from 'node:util'; |
| 19 | import * as vscode from 'vscode'; |
| 20 | import {DeletedFileContentProvider} from './DeletedFileContentProvider'; |
| 21 | import {registerSaplingDiffContentProvider} from './DiffContentProvider'; |
| 22 | import {Internal} from './Internal'; |
| 23 | import {VSCodeReposList} from './VSCodeRepo'; |
| 24 | import {makeExtensionApi} from './api/api'; |
| 25 | import {InlineBlameProvider} from './blame/blame'; |
| 26 | import {registerCommands} from './commands'; |
| 27 | import {getCLICommand} from './config'; |
| 28 | import {ensureTranslationsLoaded} from './i18n'; |
| 29 | import {registerISLCommands} from './islWebviewPanel'; |
| 30 | import {extensionVersion} from './utils'; |
| 31 | import {getVSCodePlatform} from './vscodePlatform'; |
| 32 | |
| 33 | export async function activate( |
| 34 | context: vscode.ExtensionContext, |
| 35 | ): Promise<SaplingExtensionApi | undefined> { |
| 36 | const start = Date.now(); |
| 37 | const [outputChannel, logger] = createOutputChannelLogger(); |
| 38 | const platform = getVSCodePlatform(context); |
| 39 | const extensionTracker = makeServerSideTracker( |
| 40 | logger, |
| 41 | platform as ServerPlatform, |
| 42 | extensionVersion, |
| 43 | ); |
| 44 | try { |
| 45 | const ctx: RepositoryContext = { |
| 46 | cmd: getCLICommand(), |
| 47 | cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), |
| 48 | logger, |
| 49 | tracker: extensionTracker, |
| 50 | }; |
| 51 | // TODO: This await is in the critical path to loading the ISL webview, |
| 52 | // but none of these features really apply for the webview. Can we defer this to speed up first ISL load? |
| 53 | const [, enabledSCMApiFeatures] = await Promise.all([ |
| 54 | ensureTranslationsLoaded(context), |
| 55 | Internal.getEnabledSCMApiFeatures?.(ctx) ?? |
| 56 | new Set<EnabledSCMApiFeature>(['blame', 'sidebar']), |
| 57 | ]); |
| 58 | logger.info('enabled features: ', [...enabledSCMApiFeatures].join(', ')); |
| 59 | context.subscriptions.push(registerISLCommands(context, platform, logger)); |
| 60 | context.subscriptions.push(outputChannel); |
| 61 | |
| 62 | // Grove status bar button to open ISL |
| 63 | const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 0); |
| 64 | statusBarItem.text = '$(grove-icon) Grove'; |
| 65 | statusBarItem.command = 'sapling.open-isl-tab'; |
| 66 | statusBarItem.tooltip = 'Open Interactive Smartlog'; |
| 67 | statusBarItem.show(); |
| 68 | context.subscriptions.push(statusBarItem); |
| 69 | const reposList = new VSCodeReposList(logger, extensionTracker, enabledSCMApiFeatures); |
| 70 | context.subscriptions.push(reposList); |
| 71 | if (enabledSCMApiFeatures.has('blame')) { |
| 72 | context.subscriptions.push(new InlineBlameProvider(reposList, ctx)); |
| 73 | } |
| 74 | context.subscriptions.push(registerSaplingDiffContentProvider(ctx)); |
| 75 | context.subscriptions.push(new DeletedFileContentProvider()); |
| 76 | let inlineCommentsProvider; |
| 77 | if (enabledSCMApiFeatures.has('comments') && Internal.inlineCommentsProvider) { |
| 78 | if ( |
| 79 | enabledSCMApiFeatures.has('newInlineComments') && |
| 80 | Internal.registerNewInlineCommentsProvider |
| 81 | ) { |
| 82 | context.subscriptions.push( |
| 83 | ...Internal.registerNewInlineCommentsProvider( |
| 84 | context, |
| 85 | extensionTracker, |
| 86 | logger, |
| 87 | reposList, |
| 88 | ), |
| 89 | ); |
| 90 | } else { |
| 91 | inlineCommentsProvider = Internal.inlineCommentsProvider(context, reposList, ctx, []); |
| 92 | if (inlineCommentsProvider != null) { |
| 93 | context.subscriptions.push(inlineCommentsProvider); |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | if (Internal.SaplingISLUriHandler != null) { |
| 98 | context.subscriptions.push( |
| 99 | vscode.window.registerUriHandler( |
| 100 | new Internal.SaplingISLUriHandler(reposList, ctx, inlineCommentsProvider), |
| 101 | ), |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | context.subscriptions.push(...registerCommands(ctx)); |
| 106 | |
| 107 | // If a previous grove.init picked a folder and reloaded, run init now |
| 108 | if (context.globalState.get('grove.pendingInit')) { |
| 109 | await context.globalState.update('grove.pendingInit', undefined); |
| 110 | const folderUri = vscode.workspace.workspaceFolders?.[0]?.uri; |
| 111 | if (folderUri) { |
| 112 | const terminal = vscode.window.createTerminal({name: 'Grove Init', cwd: folderUri.fsPath}); |
| 113 | terminal.show(); |
| 114 | terminal.sendText( |
| 115 | `${getCLICommand()} init --config init.prefer-git=false --config format.use-remotefilelog=true`, |
| 116 | ); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | context.subscriptions.push( |
| 121 | vscode.commands.registerCommand('grove.init', async () => { |
| 122 | let folderUri = vscode.workspace.workspaceFolders?.[0]?.uri; |
| 123 | if (!folderUri) { |
| 124 | const resolvedPath = await pickFolderWithAutocomplete(); |
| 125 | if (!resolvedPath) { |
| 126 | return; |
| 127 | } |
| 128 | try { |
| 129 | vscode.window.showInformationMessage(`Grove: creating "${resolvedPath}"…`); |
| 130 | await vscode.workspace.fs.createDirectory(vscode.Uri.file(resolvedPath)); |
| 131 | vscode.window.showInformationMessage(`Grove: opening folder…`); |
| 132 | await context.globalState.update('grove.pendingInit', true); |
| 133 | await vscode.commands.executeCommand('vscode.openFolder', vscode.Uri.file(resolvedPath)); |
| 134 | } catch (err) { |
| 135 | vscode.window.showErrorMessage(`Grove init failed: ${err}`); |
| 136 | } |
| 137 | return; |
| 138 | } |
| 139 | const terminal = vscode.window.createTerminal({ |
| 140 | name: 'Grove Init', |
| 141 | cwd: folderUri.fsPath, |
| 142 | }); |
| 143 | terminal.show(); |
| 144 | terminal.sendText( |
| 145 | `${getCLICommand()} init --config init.prefer-git=false --config format.use-remotefilelog=true`, |
| 146 | ); |
| 147 | }), |
| 148 | ); |
| 149 | |
| 150 | Internal?.registerInternalBugLogsProvider != null && |
| 151 | context.subscriptions.push(Internal.registerInternalBugLogsProvider(logger)); |
| 152 | |
| 153 | extensionTracker.track('VSCodeExtensionActivated', {duration: Date.now() - start}); |
| 154 | const api = makeExtensionApi(platform, ctx, reposList); |
| 155 | return api; |
| 156 | } catch (error) { |
| 157 | extensionTracker.error('VSCodeExtensionActivated', 'VSCodeActivationError', error as Error, { |
| 158 | duration: Date.now() - start, |
| 159 | }); |
| 160 | return undefined; |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | /** Show a QuickPick with filesystem autocomplete, starting from ~. Returns resolved path or undefined. */ |
| 165 | async function pickFolderWithAutocomplete(): Promise<string | undefined> { |
| 166 | const home = process.env.HOME ?? '/'; |
| 167 | return new Promise(resolve => { |
| 168 | const qp = vscode.window.createQuickPick(); |
| 169 | qp.title = 'Initialize Grove Repository'; |
| 170 | qp.placeholder = 'Type a folder path (new or existing)'; |
| 171 | qp.ignoreFocusOut = true; |
| 172 | qp.value = home + path.sep; |
| 173 | |
| 174 | const btnInitHere: vscode.QuickInputButton = { |
| 175 | iconPath: new vscode.ThemeIcon('check'), |
| 176 | tooltip: 'Initialize here', |
| 177 | }; |
| 178 | const btnNewFolder: vscode.QuickInputButton = { |
| 179 | iconPath: new vscode.ThemeIcon('new-folder'), |
| 180 | tooltip: 'Create folder & initialize', |
| 181 | }; |
| 182 | |
| 183 | let resolved = false; |
| 184 | function resolve2(rawPath: string) { |
| 185 | const expanded = rawPath.startsWith('~') ? rawPath.replace('~', home) : rawPath; |
| 186 | const clean = expanded.endsWith(path.sep) ? expanded.slice(0, -1) : expanded; |
| 187 | resolved = true; |
| 188 | qp.dispose(); |
| 189 | resolve(clean); |
| 190 | } |
| 191 | |
| 192 | function updateButton(val: string) { |
| 193 | const expanded = val.startsWith('~') ? val.replace('~', home) : val; |
| 194 | const clean = expanded.endsWith(path.sep) ? expanded.slice(0, -1) : expanded; |
| 195 | try { |
| 196 | fs.accessSync(clean); |
| 197 | qp.buttons = [btnInitHere]; |
| 198 | } catch { |
| 199 | qp.buttons = [btnNewFolder]; |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | function getSuggestions(input: string): vscode.QuickPickItem[] { |
| 204 | const expanded = input.startsWith('~') ? input.replace('~', home) : input; |
| 205 | try { |
| 206 | const dir = expanded.endsWith(path.sep) ? expanded : path.dirname(expanded); |
| 207 | const prefix = expanded.endsWith(path.sep) ? '' : path.basename(expanded); |
| 208 | return fs |
| 209 | .readdirSync(dir, {withFileTypes: true}) |
| 210 | .filter(e => e.isDirectory() && !e.name.startsWith('.') && e.name.startsWith(prefix)) |
| 211 | .map(e => ({label: path.join(dir, e.name) + path.sep})); |
| 212 | } catch { |
| 213 | return []; |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | updateButton(qp.value); |
| 218 | qp.items = getSuggestions(qp.value); |
| 219 | |
| 220 | qp.onDidChangeValue(val => { |
| 221 | updateButton(val); |
| 222 | qp.items = getSuggestions(val); |
| 223 | }); |
| 224 | |
| 225 | qp.onDidTriggerButton(() => { |
| 226 | resolve2(qp.value); |
| 227 | }); |
| 228 | |
| 229 | qp.onDidAccept(() => { |
| 230 | const selected = qp.activeItems[0]?.label ?? qp.value; |
| 231 | // If the selection ends with sep it's a directory — drill into it instead of accepting |
| 232 | if (selected.endsWith(path.sep)) { |
| 233 | qp.value = selected; |
| 234 | updateButton(selected); |
| 235 | qp.items = getSuggestions(selected); |
| 236 | return; |
| 237 | } |
| 238 | resolve2(selected); |
| 239 | }); |
| 240 | |
| 241 | qp.onDidHide(() => { |
| 242 | if (!resolved) { |
| 243 | resolve(undefined); |
| 244 | } |
| 245 | qp.dispose(); |
| 246 | }); |
| 247 | |
| 248 | qp.show(); |
| 249 | }); |
| 250 | } |
| 251 | |
| 252 | function createOutputChannelLogger(): [vscode.OutputChannel, Logger] { |
| 253 | const outputChannel = vscode.window.createOutputChannel('Grove'); |
| 254 | const outputChannelLogger = new VSCodeOutputChannelLogger(outputChannel); |
| 255 | return [outputChannel, outputChannelLogger]; |
| 256 | } |
| 257 | |
| 258 | class VSCodeOutputChannelLogger extends Logger { |
| 259 | private logFileContents: Array<string> = []; // TODO: we should just move this into Logger itself... and maybe do some rotation or cap memory usage! |
| 260 | constructor(private outputChannel: vscode.OutputChannel) { |
| 261 | super(); |
| 262 | } |
| 263 | |
| 264 | write(level: Level, timeStr: string, ...args: Parameters<typeof console.log>): void { |
| 265 | const str = util.format('%s%s', timeStr, this.levelToString(level), ...args); |
| 266 | this.logFileContents.push(str); |
| 267 | this.outputChannel.appendLine(str); |
| 268 | } |
| 269 | |
| 270 | getLogFileContents() { |
| 271 | return Promise.resolve(this.logFileContents.join('\n')); |
| 272 | } |
| 273 | } |
| 274 | |