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