10.1 KB274 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 {Level} from 'isl-server/src/logger';
9import type {ServerPlatform} from 'isl-server/src/serverPlatform';
10import type {RepositoryContext} from 'isl-server/src/serverTypes';
11import type {SaplingExtensionApi} from './api/types';
12import type {EnabledSCMApiFeature} from './types';
13
14import {makeServerSideTracker} from 'isl-server/src/analytics/serverSideTracker';
15import {Logger} from 'isl-server/src/logger';
16import * as fs from 'node:fs';
17import * as path from 'node:path';
18import * as util from 'node:util';
19import * as vscode from 'vscode';
20import {DeletedFileContentProvider} from './DeletedFileContentProvider';
21import {registerSaplingDiffContentProvider} from './DiffContentProvider';
22import {Internal} from './Internal';
23import {VSCodeReposList} from './VSCodeRepo';
24import {makeExtensionApi} from './api/api';
25import {InlineBlameProvider} from './blame/blame';
26import {registerCommands} from './commands';
27import {getCLICommand} from './config';
28import {ensureTranslationsLoaded} from './i18n';
29import {registerISLCommands} from './islWebviewPanel';
30import {extensionVersion} from './utils';
31import {getVSCodePlatform} from './vscodePlatform';
32
33export 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. */
165async 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
252function createOutputChannelLogger(): [vscode.OutputChannel, Logger] {
253 const outputChannel = vscode.window.createOutputChannel('Grove');
254 const outputChannelLogger = new VSCodeOutputChannelLogger(outputChannel);
255 return [outputChannel, outputChannelLogger];
256}
257
258class 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