22.8 KB631 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 {Logger} from 'isl-server/src/logger';
9import type {ServerPlatform} from 'isl-server/src/serverPlatform';
10import type {AppMode, ClientToServerMessage, ServerToClientMessage} from 'isl/src/types';
11import type {Comparison} from 'shared/Comparison';
12import type {WebviewPanel, WebviewView} from 'vscode';
13import type {VSCodeServerPlatform} from './vscodePlatform';
14
15/**
16 * Interface representing the result of creating or focusing an ISL webview.
17 * Contains both the panel/view and a promise that resolves when the client is ready.
18 */
19interface ISLWebviewResult<W extends WebviewPanel | WebviewView> {
20 panel: W;
21 readySignal: Deferred<void>;
22}
23
24import {onClientConnection} from 'isl-server/src';
25import {deserializeFromString, serializeToString} from 'isl/src/serialize';
26import type {PartiallySelectedDiffCommit} from 'isl/src/stackEdit/diffSplitTypes';
27import {ComparisonType, isComparison, labelForComparison} from 'shared/Comparison';
28import type {Deferred} from 'shared/utils';
29import {defer} from 'shared/utils';
30import * as vscode from 'vscode';
31import {executeVSCodeCommand} from './commands';
32import {getCLICommand, PERSISTED_STORAGE_KEY_PREFIX, shouldOpenBeside} from './config';
33import {getWebviewOptions, htmlForWebview} from './htmlForWebview';
34import {locale, t} from './i18n';
35import {extensionVersion} from './utils';
36
37/**
38 * Expands line ranges to individual line numbers.
39 * Input is ALWAYS ranges (from AI agent output), never individual line numbers.
40 *
41 * Supported formats:
42 * 1. Array of ranges: [[0, 100], [150, 200]] -> [0,1,2,...,100,150,151,...,200]
43 * 2. Single range: [0, 100] -> [0,1,2,...,100]
44 *
45 * Ranges are inclusive on both ends: [0, 161] expands to lines 0 through 161.
46 */
47function expandLineRange(
48 lines: ReadonlyArray<number> | ReadonlyArray<[number, number]>,
49): ReadonlyArray<number> {
50 if (lines.length === 0) {
51 return [];
52 }
53
54 const expanded: number[] = [];
55
56 // Check if it's an array of ranges: [[start, end], [start, end], ...]
57 if (Array.isArray(lines[0])) {
58 for (const range of lines as ReadonlyArray<[number, number]>) {
59 if (Array.isArray(range) && range.length === 2) {
60 const [start, end] = range;
61 if (typeof start === 'number' && typeof end === 'number') {
62 // Expand the range inclusively: [start, end] -> [start, start+1, ..., end]
63 for (let i = start; i <= end; i++) {
64 expanded.push(i);
65 }
66 }
67 }
68 }
69 return expanded;
70 }
71
72 // Single range format: [start, end]
73 // This MUST be a 2-element array representing a range
74 if (lines.length === 2) {
75 const [start, end] = lines as [number, number];
76 if (typeof start === 'number' && typeof end === 'number') {
77 // Expand the range inclusively: [start, end] -> [start, start+1, ..., end]
78 for (let i = start; i <= end; i++) {
79 expanded.push(i);
80 }
81 return expanded;
82 }
83 }
84
85 // If we get here with lines.length !== 2, the input format is unexpected.
86 // This shouldn't happen with proper agent output - log a warning.
87 console.warn(
88 `expandLineRange received unexpected format with ${lines.length} elements. Expected a range [start, end] or array of ranges.`,
89 );
90 return lines as ReadonlyArray<number>;
91}
92
93let islPanelOrViewResult: ISLWebviewResult<vscode.WebviewPanel | vscode.WebviewView> | undefined =
94 undefined;
95let hasOpenedISLWebviewBeforeState = false;
96
97const islViewType = 'sapling.isl';
98const comparisonViewType = 'sapling.comparison';
99
100/**
101 * Creates or focuses the ISL webview and returns both the panel/view and a promise that resolves when the client is ready.
102 */
103function createOrFocusISLWebview(
104 context: vscode.ExtensionContext,
105 platform: VSCodeServerPlatform,
106 logger: Logger,
107 column?: vscode.ViewColumn,
108): ISLWebviewResult<vscode.WebviewPanel | vscode.WebviewView> {
109 // Try to reuse existing ISL panel/view
110 if (islPanelOrViewResult) {
111 isPanel(islPanelOrViewResult.panel)
112 ? islPanelOrViewResult.panel.reveal()
113 : islPanelOrViewResult.panel.show();
114 return islPanelOrViewResult;
115 }
116 // Otherwise, create a new panel/view
117
118 const viewColumn = column ?? vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One;
119
120 islPanelOrViewResult = populateAndSetISLWebview(
121 context,
122 vscode.window.createWebviewPanel(
123 islViewType,
124 t('isl.title'),
125 viewColumn,
126 getWebviewOptions(context, 'dist/webview'),
127 ),
128 platform,
129 {mode: 'isl'},
130 logger,
131 );
132
133 return islPanelOrViewResult;
134}
135
136function createComparisonWebview(
137 context: vscode.ExtensionContext,
138 platform: VSCodeServerPlatform,
139 comparison: Comparison,
140 logger: Logger,
141): ISLWebviewResult<vscode.WebviewPanel> {
142 // always create a new comparison webview
143 const column =
144 shouldOpenBeside() &&
145 islPanelOrViewResult != null &&
146 isPanel(islPanelOrViewResult.panel) &&
147 islPanelOrViewResult.panel.active
148 ? vscode.ViewColumn.Beside
149 : (vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One);
150
151 const webview = populateAndSetISLWebview(
152 context,
153 vscode.window.createWebviewPanel(
154 comparisonViewType,
155 labelForComparison(comparison),
156 column,
157 getWebviewOptions(context, 'dist/webview'),
158 ),
159 platform,
160 {mode: 'comparison', comparison},
161 logger,
162 );
163 return webview;
164}
165
166function shouldUseWebviewView(): boolean {
167 return vscode.workspace.getConfiguration('sapling.isl').get<boolean>('showInSidebar') ?? false;
168}
169
170export function hasOpenedISLWebviewBefore() {
171 return hasOpenedISLWebviewBeforeState;
172}
173
174/**
175 * If a vscode extension host is restarted while ISL is open, the connection to the webview is severed.
176 * If we activate and see pre-existing ISLs, we should either destroy them,
177 * or open a fresh ISL in their place.
178 * You might expect deserialization to handle this, but it doesn't.
179 * See: https://github.com/microsoft/vscode/issues/188257
180 */
181function replaceExistingOrphanedISLWindows(
182 context: vscode.ExtensionContext,
183 platform: VSCodeServerPlatform,
184 logger: Logger,
185) {
186 const orphanedTabs = vscode.window.tabGroups.all
187 .flatMap(tabGroup => tabGroup.tabs)
188 .filter(tab => (tab.input as vscode.TabInputWebview)?.viewType?.includes(islViewType));
189 logger.info(`Found ${orphanedTabs.length} orphaned ISL tabs`);
190 if (orphanedTabs.length > 0) {
191 for (const tab of orphanedTabs) {
192 // We only remake the ISL tab if it's active, since otherwise it will focus it.
193 // The exception is if you had ISL pinned, since your pin would get destroyed which is annoying.
194 // It does mean that the pinned ISL steals focus, but I think that's reasonable during an exthost restart.
195 if ((tab.isActive || tab.isPinned) && !shouldUseWebviewView()) {
196 // Make sure we use the matching ViewColumn so it feels like we recreate ISL in the same place.
197 const {viewColumn} = tab.group;
198 logger.info(` > Replacing orphaned ISL with fresh one for view column ${viewColumn}`);
199 try {
200 // We only expect there to be at most one "active" tab, but even if there were,
201 // this command would still reuse the existing ISL.
202 createOrFocusISLWebview(context, platform, logger, viewColumn);
203 } catch (err: unknown) {
204 vscode.window.showErrorMessage(`error opening isl: ${err}`);
205 }
206
207 if (tab.isPinned) {
208 executeVSCodeCommand('workbench.action.pinEditor');
209 }
210 }
211 // Regardless of if we opened a new ISL, reap the old one. It wouldn't work if you clicked on it.
212 vscode.window.tabGroups.close(orphanedTabs);
213 }
214 }
215}
216
217export function registerISLCommands(
218 context: vscode.ExtensionContext,
219 platform: VSCodeServerPlatform,
220 logger: Logger,
221): vscode.Disposable {
222 const webviewViewProvider = new ISLWebviewViewProvider(context, platform, logger);
223 replaceExistingOrphanedISLWindows(context, platform, logger);
224
225 const createComparisonWebviewCommand = (comparison: Comparison) => {
226 try {
227 createComparisonWebview(context, platform, comparison, logger);
228 } catch (err: unknown) {
229 vscode.window.showErrorMessage(
230 `error opening ${labelForComparison(comparison)} comparison: ${err}`,
231 );
232 }
233 };
234 return vscode.Disposable.from(
235 vscode.commands.registerCommand('sapling.open-isl', () => {
236 if (shouldUseWebviewView()) {
237 // just open the sidebar view
238 executeVSCodeCommand('sapling.isl.focus');
239 return;
240 }
241 try {
242 createOrFocusISLWebview(context, platform, logger);
243 } catch (err: unknown) {
244 vscode.window.showErrorMessage(`error opening isl: ${err}`);
245 }
246 }),
247 vscode.commands.registerCommand('sapling.open-isl-tab', () => {
248 try {
249 createOrFocusISLWebview(context, platform, logger);
250 } catch (err: unknown) {
251 vscode.window.showErrorMessage(`error opening isl: ${err}`);
252 }
253 }),
254 vscode.commands.registerCommand(
255 'sapling.open-isl-with-commit-message',
256 async (title: string, description: string, mode?: 'commit' | 'amend', hash?: string) => {
257 try {
258 let readySignal: Deferred<void>;
259
260 if (shouldUseWebviewView()) {
261 executeVSCodeCommand('sapling.isl.focus');
262 // For webview views, use the readySignal from the provider
263 readySignal = webviewViewProvider.readySignal;
264 } else {
265 const result = createOrFocusISLWebview(context, platform, logger);
266 readySignal = result.readySignal;
267 }
268
269 await readySignal.promise;
270
271 const currentPanelOrViewResult = islPanelOrViewResult;
272 if (currentPanelOrViewResult) {
273 const message: ServerToClientMessage = {
274 type: 'updateDraftCommitMessage',
275 title,
276 description,
277 mode,
278 hash,
279 };
280
281 currentPanelOrViewResult.panel.webview.postMessage(serializeToString(message));
282 }
283 } catch (err: unknown) {
284 vscode.window.showErrorMessage(`Error opening ISL with commit message: ${err}`);
285 }
286 },
287 ),
288 vscode.commands.registerCommand(
289 'sapling.open-split-view-with-commits',
290 async (commits: Array<PartiallySelectedDiffCommit>, commitHash?: string) => {
291 try {
292 let readySignal: Deferred<void>;
293
294 if (shouldUseWebviewView()) {
295 executeVSCodeCommand('sapling.isl.focus');
296 readySignal = webviewViewProvider.readySignal;
297 } else {
298 const result = createOrFocusISLWebview(context, platform, logger);
299 readySignal = result.readySignal;
300 }
301 await readySignal.promise;
302
303 const currentPanelOrViewResult = islPanelOrViewResult;
304 if (currentPanelOrViewResult) {
305 if (commitHash) {
306 // Expand line ranges [start, end] to individual line numbers before sending
307 const expandedCommits = commits.map(commit => ({
308 ...commit,
309 files: commit.files.map(file => ({
310 ...file,
311 aLines: expandLineRange(file.aLines),
312 bLines: expandLineRange(file.bLines),
313 })),
314 }));
315
316 // Send a single message that opens the split view and applies commits after loading
317 const openSplitMessage: ServerToClientMessage = {
318 type: 'openSplitViewForCommit',
319 commitHash,
320 commits: expandedCommits,
321 };
322 currentPanelOrViewResult.panel.webview.postMessage(
323 serializeToString(openSplitMessage),
324 );
325 } else {
326 vscode.window.showErrorMessage(`Error opening split view: no commit hash provided`);
327 }
328 }
329 } catch (err: unknown) {
330 vscode.window.showErrorMessage(`Error opening split view: ${err}`);
331 }
332 },
333 ),
334 vscode.commands.registerCommand('sapling.close-isl', () => {
335 if (!islPanelOrViewResult) {
336 return;
337 }
338 if (isPanel(islPanelOrViewResult.panel)) {
339 islPanelOrViewResult.panel.dispose();
340 } else {
341 // close sidebar entirely
342 executeVSCodeCommand('workbench.action.closeSidebar');
343 }
344 }),
345 vscode.commands.registerCommand('sapling.open-comparison-view-uncommitted', () => {
346 createComparisonWebviewCommand({type: ComparisonType.UncommittedChanges});
347 }),
348 vscode.commands.registerCommand('sapling.open-comparison-view-head', () => {
349 createComparisonWebviewCommand({type: ComparisonType.HeadChanges});
350 }),
351 vscode.commands.registerCommand('sapling.open-comparison-view-stack', () => {
352 createComparisonWebviewCommand({type: ComparisonType.StackChanges});
353 }),
354 /** Command that opens the provided Comparison argument. Intended to be used programmatically. */
355 vscode.commands.registerCommand('sapling.open-comparison-view', (comparison: unknown) => {
356 if (!isComparison(comparison)) {
357 return;
358 }
359 createComparisonWebviewCommand(comparison);
360 }),
361 registerDeserializer(context, platform, logger),
362 vscode.window.registerWebviewViewProvider(islViewType, webviewViewProvider, {
363 webviewOptions: {
364 retainContextWhenHidden: true,
365 },
366 }),
367 vscode.workspace.onDidChangeConfiguration(e => {
368 // if we start using ISL as a view, dispose the panel
369 if (e.affectsConfiguration('sapling.isl.showInSidebar')) {
370 if (islPanelOrViewResult && isPanel(islPanelOrViewResult.panel) && shouldUseWebviewView()) {
371 islPanelOrViewResult.panel.dispose();
372 }
373 }
374 }),
375 );
376}
377
378function registerDeserializer(
379 context: vscode.ExtensionContext,
380 platform: VSCodeServerPlatform,
381 logger: Logger,
382) {
383 // Make sure we register a serializer in activation event
384 return vscode.window.registerWebviewPanelSerializer(islViewType, {
385 deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, _state: unknown) {
386 if (shouldUseWebviewView()) {
387 // if we try to deserialize a panel while we're trying to use view, destroy the panel and open the sidebar instead
388 webviewPanel.dispose();
389 executeVSCodeCommand('sapling.isl.focus');
390 return Promise.resolve();
391 }
392 // Reset the webview options so we use latest uri for `localResourceRoots`.
393 webviewPanel.webview.options = getWebviewOptions(context, 'dist/webview');
394 populateAndSetISLWebview(context, webviewPanel, platform, {mode: 'isl'}, logger);
395 return Promise.resolve();
396 },
397 });
398}
399
400/**
401 * Provides the ISL webview contents as a VS Code Webview View, aka a webview that lives in the sidebar/bottom
402 * rather than an editor pane. We always register this provider, even if the user doesn't have the config enabled
403 * that shows this view.
404 */
405class ISLWebviewViewProvider implements vscode.WebviewViewProvider {
406 // Signal that resolves when the webview view is ready
407 public readySignal: Deferred<void> = defer<void>();
408
409 constructor(
410 private extensionContext: vscode.ExtensionContext,
411 private platform: VSCodeServerPlatform,
412 private logger: Logger,
413 ) {}
414
415 resolveWebviewView(webviewView: vscode.WebviewView): void | Thenable<void> {
416 webviewView.webview.options = getWebviewOptions(this.extensionContext, 'dist/webview');
417 const result = populateAndSetISLWebview(
418 this.extensionContext,
419 webviewView,
420 this.platform,
421 {mode: 'isl'},
422 this.logger,
423 );
424
425 this.readySignal = result.readySignal;
426 }
427}
428
429function isPanel(
430 panelOrView: vscode.WebviewPanel | vscode.WebviewView,
431): panelOrView is vscode.WebviewPanel {
432 // panels have a .reveal property, views have .show
433 return (panelOrView as vscode.WebviewPanel).reveal !== undefined;
434}
435
436/**
437 * Populates and sets up an ISL webview panel or view.
438 * Returns both the panel/view and a Deferred that resolves when the client signals it's ready.
439 */
440function populateAndSetISLWebview<W extends vscode.WebviewPanel | vscode.WebviewView>(
441 context: vscode.ExtensionContext,
442 panelOrView: W,
443 platform: VSCodeServerPlatform,
444 mode: AppMode,
445 logger: Logger,
446): ISLWebviewResult<W> {
447 const readySignal = defer<void>();
448 logger.info(`Populating ISL webview ${isPanel(panelOrView) ? 'panel' : 'view'}`);
449 hasOpenedISLWebviewBeforeState = true;
450 if (mode.mode === 'isl') {
451 islPanelOrViewResult = {panel: panelOrView, readySignal};
452 }
453 if (isPanel(panelOrView)) {
454 const iconUri = vscode.Uri.joinPath(
455 context.extensionUri,
456 'resources',
457 'Sapling_favicon-light-green-transparent.svg',
458 );
459 panelOrView.iconPath = {light: iconUri, dark: iconUri};
460 }
461 panelOrView.webview.html = htmlForWebview({
462 context,
463 extensionRelativeBase: 'dist/webview',
464 entryPointFile: 'webview.js',
465 cssEntryPointFile: 'res/style.css', // TODO: this is global to all webviews, but should instead be per webview
466 devModeScripts: ['/webview/islWebviewEntry.tsx'],
467 title: t('isl.title'),
468 rootClass: `webview-${isPanel(panelOrView) ? 'panel' : 'view'}`,
469 webview: panelOrView.webview,
470 extraStyles: '',
471 initialScript: nonce => `
472 <script nonce="${nonce}" type="text/javascript">
473 window.saplingLanguage = "${locale /* important: locale has already been validated */}";
474 window.islAppMode = ${JSON.stringify(mode)};
475 </script>
476 ${getInitialStateJs(context, logger, nonce)}
477 `,
478 });
479 const updatedPlatform = {...platform, panelOrView} as VSCodeServerPlatform as ServerPlatform;
480
481 const disposeConnection = onClientConnection({
482 postMessage(message: string) {
483 return panelOrView.webview.postMessage(message) as Promise<boolean>;
484 },
485 onDidReceiveMessage(handler) {
486 return panelOrView.webview.onDidReceiveMessage(m => {
487 const isBinary = m instanceof ArrayBuffer;
488 handler(m, isBinary);
489 });
490 },
491 cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), // TODO
492 platform: updatedPlatform,
493 appMode: mode,
494 logger,
495 command: getCLICommand(),
496 version: extensionVersion,
497 readySignal,
498 });
499
500 panelOrView.onDidDispose(() => {
501 if (isPanel(panelOrView)) {
502 logger.info('Disposing ISL panel');
503 islPanelOrViewResult = undefined;
504 } else {
505 logger.info('Disposing ISL view');
506 }
507 disposeConnection();
508 });
509
510 return {panel: panelOrView, readySignal};
511}
512
513export function fetchUIState(): Promise<{state: string} | undefined> {
514 if (islPanelOrViewResult == null) {
515 return Promise.resolve(undefined);
516 }
517
518 return new Promise(resolve => {
519 let dispose: vscode.Disposable | undefined =
520 islPanelOrViewResult?.panel.webview.onDidReceiveMessage((m: string) => {
521 try {
522 const data = deserializeFromString(m) as ClientToServerMessage;
523 if (data.type === 'gotUiState') {
524 dispose?.dispose();
525 dispose = undefined;
526 resolve({state: data.state});
527 }
528 } catch {}
529 });
530
531 islPanelOrViewResult?.panel.webview.postMessage(
532 serializeToString({type: 'getUiState'} as ServerToClientMessage),
533 );
534 });
535}
536
537/**
538 * To persist state, we store data in extension globalStorage.
539 * In order to access this synchronously at startup inside the webview,
540 * we need to inject this initial state into the webview HTML.
541 * This gives the javascript snippet that can be safely put into a webview HTML <script> tag.
542 */
543function getInitialStateJs(context: vscode.ExtensionContext, logger: Logger, nonce: string) {
544 // Previously, all state was stored in a single global storage key.
545 // This meant we read and wrote the entire state on every change,
546 // notably the webview sent the entire state to the extension on every change.
547 // Now, we store each piece of state in its own key, and only send the changed keys to the extension.
548
549 const legacyKey = 'isl-persisted';
550
551 const legacyStateStr = context.globalState.get<string>(legacyKey);
552 let parsed: {[key: string]: unknown};
553 if (legacyStateStr != null) {
554 // We migrate to the new system if we see data in the old key.
555 // This can be deleted after some time to let clients update.
556 logger.info('Legacy persisted state format found, migrating to individual keys');
557
558 try {
559 parsed = JSON.parse(legacyStateStr);
560
561 // This snippet is injected directly as javascript, much like `eval`.
562 // Therefore, it's very important that the stateStr is validated to be safe to be injected.
563 if (typeof parsed !== 'object' || parsed == null) {
564 // JSON is not in the format we expect
565 logger.info('Found INVALID JSON for initial persisted state for webview: ', legacyStateStr);
566 // Move forward with empty data (eventually deleting the legacy key)
567 parsed = {};
568 }
569
570 for (const key in parsed) {
571 context.globalState.update(PERSISTED_STORAGE_KEY_PREFIX + key, parsed[key]);
572 }
573 logger.info(`Migrated ${Object.keys(parsed).length} keys from legacy persisted state`);
574 } catch {
575 logger.info('Found INVALID (legacy) initial persisted state for webview: ', legacyStateStr);
576 return '';
577 } finally {
578 // Delete the legacy data either way
579 context.globalState.update(legacyKey, undefined);
580 logger.info('Deleted legacy persisted state');
581 }
582 } else {
583 logger.info('No legacy persisted state found');
584
585 const allDataKeys = context.globalState.keys();
586 parsed = {};
587
588 for (const fullKey of allDataKeys) {
589 if (fullKey.startsWith(PERSISTED_STORAGE_KEY_PREFIX)) {
590 const keyWithoutPrefix = fullKey.slice(PERSISTED_STORAGE_KEY_PREFIX.length);
591 const found = context.globalState.get<string>(fullKey);
592 if (found) {
593 try {
594 parsed[keyWithoutPrefix] = JSON.parse(found);
595 } catch (err) {
596 logger.error(
597 `Failed to parse persisted state for key ${keyWithoutPrefix}. Skipping. ${err}`,
598 );
599 }
600 }
601 }
602 }
603
604 logger.info(`Loaded persisted data for ${allDataKeys.length} keys`);
605 }
606
607 try {
608 // validated is injected not as a string, but directly as a javascript object into a dedicated tag
609 const validated = JSON.stringify(parsed);
610 const escaped = validated.replace(/</g, '\\u003c');
611 logger.info('Found valid initial persisted state for webview: ', validated);
612 return `
613 <script type="application/json" id="isl-persisted-state">
614 ${escaped}
615 </script>
616 <script nonce="${nonce}" type="text/javascript">
617 try {
618 const stateElement = document.getElementById('isl-persisted-state');
619 window.islInitialPersistedState = JSON.parse(stateElement.textContent);
620 } catch (e) {
621 console.error('Failed to parse initial persisted state: ', e);
622 window.islInitialPersistedState = {};
623 }
624 </script>
625 `;
626 } catch {
627 logger.info('Found INVALID initial persisted state for webview: ', parsed);
628 return '';
629 }
630}
631