addons/vscode/extension/islWebviewPanel.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 {Logger} from 'isl-server/src/logger';
b69ab319import type {ServerPlatform} from 'isl-server/src/serverPlatform';
b69ab3110import type {AppMode, ClientToServerMessage, ServerToClientMessage} from 'isl/src/types';
b69ab3111import type {Comparison} from 'shared/Comparison';
b69ab3112import type {WebviewPanel, WebviewView} from 'vscode';
b69ab3113import type {VSCodeServerPlatform} from './vscodePlatform';
b69ab3114
b69ab3115/**
b69ab3116 * Interface representing the result of creating or focusing an ISL webview.
b69ab3117 * Contains both the panel/view and a promise that resolves when the client is ready.
b69ab3118 */
b69ab3119interface ISLWebviewResult<W extends WebviewPanel | WebviewView> {
b69ab3120 panel: W;
b69ab3121 readySignal: Deferred<void>;
b69ab3122}
b69ab3123
b69ab3124import {onClientConnection} from 'isl-server/src';
b69ab3125import {deserializeFromString, serializeToString} from 'isl/src/serialize';
b69ab3126import type {PartiallySelectedDiffCommit} from 'isl/src/stackEdit/diffSplitTypes';
b69ab3127import {ComparisonType, isComparison, labelForComparison} from 'shared/Comparison';
b69ab3128import type {Deferred} from 'shared/utils';
b69ab3129import {defer} from 'shared/utils';
b69ab3130import * as vscode from 'vscode';
b69ab3131import {executeVSCodeCommand} from './commands';
b69ab3132import {getCLICommand, PERSISTED_STORAGE_KEY_PREFIX, shouldOpenBeside} from './config';
b69ab3133import {getWebviewOptions, htmlForWebview} from './htmlForWebview';
b69ab3134import {locale, t} from './i18n';
b69ab3135import {extensionVersion} from './utils';
b69ab3136
b69ab3137/**
b69ab3138 * Expands line ranges to individual line numbers.
b69ab3139 * Input is ALWAYS ranges (from AI agent output), never individual line numbers.
b69ab3140 *
b69ab3141 * Supported formats:
b69ab3142 * 1. Array of ranges: [[0, 100], [150, 200]] -> [0,1,2,...,100,150,151,...,200]
b69ab3143 * 2. Single range: [0, 100] -> [0,1,2,...,100]
b69ab3144 *
b69ab3145 * Ranges are inclusive on both ends: [0, 161] expands to lines 0 through 161.
b69ab3146 */
b69ab3147function expandLineRange(
b69ab3148 lines: ReadonlyArray<number> | ReadonlyArray<[number, number]>,
b69ab3149): ReadonlyArray<number> {
b69ab3150 if (lines.length === 0) {
b69ab3151 return [];
b69ab3152 }
b69ab3153
b69ab3154 const expanded: number[] = [];
b69ab3155
b69ab3156 // Check if it's an array of ranges: [[start, end], [start, end], ...]
b69ab3157 if (Array.isArray(lines[0])) {
b69ab3158 for (const range of lines as ReadonlyArray<[number, number]>) {
b69ab3159 if (Array.isArray(range) && range.length === 2) {
b69ab3160 const [start, end] = range;
b69ab3161 if (typeof start === 'number' && typeof end === 'number') {
b69ab3162 // Expand the range inclusively: [start, end] -> [start, start+1, ..., end]
b69ab3163 for (let i = start; i <= end; i++) {
b69ab3164 expanded.push(i);
b69ab3165 }
b69ab3166 }
b69ab3167 }
b69ab3168 }
b69ab3169 return expanded;
b69ab3170 }
b69ab3171
b69ab3172 // Single range format: [start, end]
b69ab3173 // This MUST be a 2-element array representing a range
b69ab3174 if (lines.length === 2) {
b69ab3175 const [start, end] = lines as [number, number];
b69ab3176 if (typeof start === 'number' && typeof end === 'number') {
b69ab3177 // Expand the range inclusively: [start, end] -> [start, start+1, ..., end]
b69ab3178 for (let i = start; i <= end; i++) {
b69ab3179 expanded.push(i);
b69ab3180 }
b69ab3181 return expanded;
b69ab3182 }
b69ab3183 }
b69ab3184
b69ab3185 // If we get here with lines.length !== 2, the input format is unexpected.
b69ab3186 // This shouldn't happen with proper agent output - log a warning.
b69ab3187 console.warn(
b69ab3188 `expandLineRange received unexpected format with ${lines.length} elements. Expected a range [start, end] or array of ranges.`,
b69ab3189 );
b69ab3190 return lines as ReadonlyArray<number>;
b69ab3191}
b69ab3192
b69ab3193let islPanelOrViewResult: ISLWebviewResult<vscode.WebviewPanel | vscode.WebviewView> | undefined =
b69ab3194 undefined;
b69ab3195let hasOpenedISLWebviewBeforeState = false;
b69ab3196
b69ab3197const islViewType = 'sapling.isl';
b69ab3198const comparisonViewType = 'sapling.comparison';
b69ab3199
b69ab31100/**
b69ab31101 * Creates or focuses the ISL webview and returns both the panel/view and a promise that resolves when the client is ready.
b69ab31102 */
b69ab31103function createOrFocusISLWebview(
b69ab31104 context: vscode.ExtensionContext,
b69ab31105 platform: VSCodeServerPlatform,
b69ab31106 logger: Logger,
b69ab31107 column?: vscode.ViewColumn,
b69ab31108): ISLWebviewResult<vscode.WebviewPanel | vscode.WebviewView> {
b69ab31109 // Try to reuse existing ISL panel/view
b69ab31110 if (islPanelOrViewResult) {
b69ab31111 isPanel(islPanelOrViewResult.panel)
b69ab31112 ? islPanelOrViewResult.panel.reveal()
b69ab31113 : islPanelOrViewResult.panel.show();
b69ab31114 return islPanelOrViewResult;
b69ab31115 }
b69ab31116 // Otherwise, create a new panel/view
b69ab31117
b69ab31118 const viewColumn = column ?? vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One;
b69ab31119
b69ab31120 islPanelOrViewResult = populateAndSetISLWebview(
b69ab31121 context,
b69ab31122 vscode.window.createWebviewPanel(
b69ab31123 islViewType,
b69ab31124 t('isl.title'),
b69ab31125 viewColumn,
b69ab31126 getWebviewOptions(context, 'dist/webview'),
b69ab31127 ),
b69ab31128 platform,
b69ab31129 {mode: 'isl'},
b69ab31130 logger,
b69ab31131 );
b69ab31132
b69ab31133 return islPanelOrViewResult;
b69ab31134}
b69ab31135
b69ab31136function createComparisonWebview(
b69ab31137 context: vscode.ExtensionContext,
b69ab31138 platform: VSCodeServerPlatform,
b69ab31139 comparison: Comparison,
b69ab31140 logger: Logger,
b69ab31141): ISLWebviewResult<vscode.WebviewPanel> {
b69ab31142 // always create a new comparison webview
b69ab31143 const column =
b69ab31144 shouldOpenBeside() &&
b69ab31145 islPanelOrViewResult != null &&
b69ab31146 isPanel(islPanelOrViewResult.panel) &&
b69ab31147 islPanelOrViewResult.panel.active
b69ab31148 ? vscode.ViewColumn.Beside
b69ab31149 : (vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One);
b69ab31150
b69ab31151 const webview = populateAndSetISLWebview(
b69ab31152 context,
b69ab31153 vscode.window.createWebviewPanel(
b69ab31154 comparisonViewType,
b69ab31155 labelForComparison(comparison),
b69ab31156 column,
b69ab31157 getWebviewOptions(context, 'dist/webview'),
b69ab31158 ),
b69ab31159 platform,
b69ab31160 {mode: 'comparison', comparison},
b69ab31161 logger,
b69ab31162 );
b69ab31163 return webview;
b69ab31164}
b69ab31165
b69ab31166function shouldUseWebviewView(): boolean {
b69ab31167 return vscode.workspace.getConfiguration('sapling.isl').get<boolean>('showInSidebar') ?? false;
b69ab31168}
b69ab31169
b69ab31170export function hasOpenedISLWebviewBefore() {
b69ab31171 return hasOpenedISLWebviewBeforeState;
b69ab31172}
b69ab31173
b69ab31174/**
b69ab31175 * If a vscode extension host is restarted while ISL is open, the connection to the webview is severed.
b69ab31176 * If we activate and see pre-existing ISLs, we should either destroy them,
b69ab31177 * or open a fresh ISL in their place.
b69ab31178 * You might expect deserialization to handle this, but it doesn't.
b69ab31179 * See: https://github.com/microsoft/vscode/issues/188257
b69ab31180 */
b69ab31181function replaceExistingOrphanedISLWindows(
b69ab31182 context: vscode.ExtensionContext,
b69ab31183 platform: VSCodeServerPlatform,
b69ab31184 logger: Logger,
b69ab31185) {
b69ab31186 const orphanedTabs = vscode.window.tabGroups.all
b69ab31187 .flatMap(tabGroup => tabGroup.tabs)
b69ab31188 .filter(tab => (tab.input as vscode.TabInputWebview)?.viewType?.includes(islViewType));
b69ab31189 logger.info(`Found ${orphanedTabs.length} orphaned ISL tabs`);
b69ab31190 if (orphanedTabs.length > 0) {
b69ab31191 for (const tab of orphanedTabs) {
b69ab31192 // We only remake the ISL tab if it's active, since otherwise it will focus it.
b69ab31193 // The exception is if you had ISL pinned, since your pin would get destroyed which is annoying.
b69ab31194 // It does mean that the pinned ISL steals focus, but I think that's reasonable during an exthost restart.
b69ab31195 if ((tab.isActive || tab.isPinned) && !shouldUseWebviewView()) {
b69ab31196 // Make sure we use the matching ViewColumn so it feels like we recreate ISL in the same place.
b69ab31197 const {viewColumn} = tab.group;
b69ab31198 logger.info(` > Replacing orphaned ISL with fresh one for view column ${viewColumn}`);
b69ab31199 try {
b69ab31200 // We only expect there to be at most one "active" tab, but even if there were,
b69ab31201 // this command would still reuse the existing ISL.
b69ab31202 createOrFocusISLWebview(context, platform, logger, viewColumn);
b69ab31203 } catch (err: unknown) {
b69ab31204 vscode.window.showErrorMessage(`error opening isl: ${err}`);
b69ab31205 }
b69ab31206
b69ab31207 if (tab.isPinned) {
b69ab31208 executeVSCodeCommand('workbench.action.pinEditor');
b69ab31209 }
b69ab31210 }
b69ab31211 // Regardless of if we opened a new ISL, reap the old one. It wouldn't work if you clicked on it.
b69ab31212 vscode.window.tabGroups.close(orphanedTabs);
b69ab31213 }
b69ab31214 }
b69ab31215}
b69ab31216
b69ab31217export function registerISLCommands(
b69ab31218 context: vscode.ExtensionContext,
b69ab31219 platform: VSCodeServerPlatform,
b69ab31220 logger: Logger,
b69ab31221): vscode.Disposable {
b69ab31222 const webviewViewProvider = new ISLWebviewViewProvider(context, platform, logger);
b69ab31223 replaceExistingOrphanedISLWindows(context, platform, logger);
b69ab31224
b69ab31225 const createComparisonWebviewCommand = (comparison: Comparison) => {
b69ab31226 try {
b69ab31227 createComparisonWebview(context, platform, comparison, logger);
b69ab31228 } catch (err: unknown) {
b69ab31229 vscode.window.showErrorMessage(
b69ab31230 `error opening ${labelForComparison(comparison)} comparison: ${err}`,
b69ab31231 );
b69ab31232 }
b69ab31233 };
b69ab31234 return vscode.Disposable.from(
b69ab31235 vscode.commands.registerCommand('sapling.open-isl', () => {
b69ab31236 if (shouldUseWebviewView()) {
b69ab31237 // just open the sidebar view
b69ab31238 executeVSCodeCommand('sapling.isl.focus');
b69ab31239 return;
b69ab31240 }
b69ab31241 try {
b69ab31242 createOrFocusISLWebview(context, platform, logger);
b69ab31243 } catch (err: unknown) {
b69ab31244 vscode.window.showErrorMessage(`error opening isl: ${err}`);
b69ab31245 }
b69ab31246 }),
c3ed59a247 vscode.commands.registerCommand('sapling.open-isl-tab', () => {
c3ed59a248 try {
c3ed59a249 createOrFocusISLWebview(context, platform, logger);
c3ed59a250 } catch (err: unknown) {
c3ed59a251 vscode.window.showErrorMessage(`error opening isl: ${err}`);
c3ed59a252 }
c3ed59a253 }),
b69ab31254 vscode.commands.registerCommand(
b69ab31255 'sapling.open-isl-with-commit-message',
b69ab31256 async (title: string, description: string, mode?: 'commit' | 'amend', hash?: string) => {
b69ab31257 try {
b69ab31258 let readySignal: Deferred<void>;
b69ab31259
b69ab31260 if (shouldUseWebviewView()) {
b69ab31261 executeVSCodeCommand('sapling.isl.focus');
b69ab31262 // For webview views, use the readySignal from the provider
b69ab31263 readySignal = webviewViewProvider.readySignal;
b69ab31264 } else {
b69ab31265 const result = createOrFocusISLWebview(context, platform, logger);
b69ab31266 readySignal = result.readySignal;
b69ab31267 }
b69ab31268
b69ab31269 await readySignal.promise;
b69ab31270
b69ab31271 const currentPanelOrViewResult = islPanelOrViewResult;
b69ab31272 if (currentPanelOrViewResult) {
b69ab31273 const message: ServerToClientMessage = {
b69ab31274 type: 'updateDraftCommitMessage',
b69ab31275 title,
b69ab31276 description,
b69ab31277 mode,
b69ab31278 hash,
b69ab31279 };
b69ab31280
b69ab31281 currentPanelOrViewResult.panel.webview.postMessage(serializeToString(message));
b69ab31282 }
b69ab31283 } catch (err: unknown) {
b69ab31284 vscode.window.showErrorMessage(`Error opening ISL with commit message: ${err}`);
b69ab31285 }
b69ab31286 },
b69ab31287 ),
b69ab31288 vscode.commands.registerCommand(
b69ab31289 'sapling.open-split-view-with-commits',
b69ab31290 async (commits: Array<PartiallySelectedDiffCommit>, commitHash?: string) => {
b69ab31291 try {
b69ab31292 let readySignal: Deferred<void>;
b69ab31293
b69ab31294 if (shouldUseWebviewView()) {
b69ab31295 executeVSCodeCommand('sapling.isl.focus');
b69ab31296 readySignal = webviewViewProvider.readySignal;
b69ab31297 } else {
b69ab31298 const result = createOrFocusISLWebview(context, platform, logger);
b69ab31299 readySignal = result.readySignal;
b69ab31300 }
b69ab31301 await readySignal.promise;
b69ab31302
b69ab31303 const currentPanelOrViewResult = islPanelOrViewResult;
b69ab31304 if (currentPanelOrViewResult) {
b69ab31305 if (commitHash) {
b69ab31306 // Expand line ranges [start, end] to individual line numbers before sending
b69ab31307 const expandedCommits = commits.map(commit => ({
b69ab31308 ...commit,
b69ab31309 files: commit.files.map(file => ({
b69ab31310 ...file,
b69ab31311 aLines: expandLineRange(file.aLines),
b69ab31312 bLines: expandLineRange(file.bLines),
b69ab31313 })),
b69ab31314 }));
b69ab31315
b69ab31316 // Send a single message that opens the split view and applies commits after loading
b69ab31317 const openSplitMessage: ServerToClientMessage = {
b69ab31318 type: 'openSplitViewForCommit',
b69ab31319 commitHash,
b69ab31320 commits: expandedCommits,
b69ab31321 };
b69ab31322 currentPanelOrViewResult.panel.webview.postMessage(
b69ab31323 serializeToString(openSplitMessage),
b69ab31324 );
b69ab31325 } else {
b69ab31326 vscode.window.showErrorMessage(`Error opening split view: no commit hash provided`);
b69ab31327 }
b69ab31328 }
b69ab31329 } catch (err: unknown) {
b69ab31330 vscode.window.showErrorMessage(`Error opening split view: ${err}`);
b69ab31331 }
b69ab31332 },
b69ab31333 ),
b69ab31334 vscode.commands.registerCommand('sapling.close-isl', () => {
b69ab31335 if (!islPanelOrViewResult) {
b69ab31336 return;
b69ab31337 }
b69ab31338 if (isPanel(islPanelOrViewResult.panel)) {
b69ab31339 islPanelOrViewResult.panel.dispose();
b69ab31340 } else {
b69ab31341 // close sidebar entirely
b69ab31342 executeVSCodeCommand('workbench.action.closeSidebar');
b69ab31343 }
b69ab31344 }),
b69ab31345 vscode.commands.registerCommand('sapling.open-comparison-view-uncommitted', () => {
b69ab31346 createComparisonWebviewCommand({type: ComparisonType.UncommittedChanges});
b69ab31347 }),
b69ab31348 vscode.commands.registerCommand('sapling.open-comparison-view-head', () => {
b69ab31349 createComparisonWebviewCommand({type: ComparisonType.HeadChanges});
b69ab31350 }),
b69ab31351 vscode.commands.registerCommand('sapling.open-comparison-view-stack', () => {
b69ab31352 createComparisonWebviewCommand({type: ComparisonType.StackChanges});
b69ab31353 }),
b69ab31354 /** Command that opens the provided Comparison argument. Intended to be used programmatically. */
b69ab31355 vscode.commands.registerCommand('sapling.open-comparison-view', (comparison: unknown) => {
b69ab31356 if (!isComparison(comparison)) {
b69ab31357 return;
b69ab31358 }
b69ab31359 createComparisonWebviewCommand(comparison);
b69ab31360 }),
b69ab31361 registerDeserializer(context, platform, logger),
b69ab31362 vscode.window.registerWebviewViewProvider(islViewType, webviewViewProvider, {
b69ab31363 webviewOptions: {
b69ab31364 retainContextWhenHidden: true,
b69ab31365 },
b69ab31366 }),
b69ab31367 vscode.workspace.onDidChangeConfiguration(e => {
b69ab31368 // if we start using ISL as a view, dispose the panel
b69ab31369 if (e.affectsConfiguration('sapling.isl.showInSidebar')) {
b69ab31370 if (islPanelOrViewResult && isPanel(islPanelOrViewResult.panel) && shouldUseWebviewView()) {
b69ab31371 islPanelOrViewResult.panel.dispose();
b69ab31372 }
b69ab31373 }
b69ab31374 }),
b69ab31375 );
b69ab31376}
b69ab31377
b69ab31378function registerDeserializer(
b69ab31379 context: vscode.ExtensionContext,
b69ab31380 platform: VSCodeServerPlatform,
b69ab31381 logger: Logger,
b69ab31382) {
b69ab31383 // Make sure we register a serializer in activation event
b69ab31384 return vscode.window.registerWebviewPanelSerializer(islViewType, {
b69ab31385 deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, _state: unknown) {
b69ab31386 if (shouldUseWebviewView()) {
b69ab31387 // if we try to deserialize a panel while we're trying to use view, destroy the panel and open the sidebar instead
b69ab31388 webviewPanel.dispose();
b69ab31389 executeVSCodeCommand('sapling.isl.focus');
b69ab31390 return Promise.resolve();
b69ab31391 }
b69ab31392 // Reset the webview options so we use latest uri for `localResourceRoots`.
b69ab31393 webviewPanel.webview.options = getWebviewOptions(context, 'dist/webview');
b69ab31394 populateAndSetISLWebview(context, webviewPanel, platform, {mode: 'isl'}, logger);
b69ab31395 return Promise.resolve();
b69ab31396 },
b69ab31397 });
b69ab31398}
b69ab31399
b69ab31400/**
b69ab31401 * Provides the ISL webview contents as a VS Code Webview View, aka a webview that lives in the sidebar/bottom
b69ab31402 * rather than an editor pane. We always register this provider, even if the user doesn't have the config enabled
b69ab31403 * that shows this view.
b69ab31404 */
b69ab31405class ISLWebviewViewProvider implements vscode.WebviewViewProvider {
b69ab31406 // Signal that resolves when the webview view is ready
b69ab31407 public readySignal: Deferred<void> = defer<void>();
b69ab31408
b69ab31409 constructor(
b69ab31410 private extensionContext: vscode.ExtensionContext,
b69ab31411 private platform: VSCodeServerPlatform,
b69ab31412 private logger: Logger,
b69ab31413 ) {}
b69ab31414
b69ab31415 resolveWebviewView(webviewView: vscode.WebviewView): void | Thenable<void> {
b69ab31416 webviewView.webview.options = getWebviewOptions(this.extensionContext, 'dist/webview');
b69ab31417 const result = populateAndSetISLWebview(
b69ab31418 this.extensionContext,
b69ab31419 webviewView,
b69ab31420 this.platform,
b69ab31421 {mode: 'isl'},
b69ab31422 this.logger,
b69ab31423 );
b69ab31424
b69ab31425 this.readySignal = result.readySignal;
b69ab31426 }
b69ab31427}
b69ab31428
b69ab31429function isPanel(
b69ab31430 panelOrView: vscode.WebviewPanel | vscode.WebviewView,
b69ab31431): panelOrView is vscode.WebviewPanel {
b69ab31432 // panels have a .reveal property, views have .show
b69ab31433 return (panelOrView as vscode.WebviewPanel).reveal !== undefined;
b69ab31434}
b69ab31435
b69ab31436/**
b69ab31437 * Populates and sets up an ISL webview panel or view.
b69ab31438 * Returns both the panel/view and a Deferred that resolves when the client signals it's ready.
b69ab31439 */
b69ab31440function populateAndSetISLWebview<W extends vscode.WebviewPanel | vscode.WebviewView>(
b69ab31441 context: vscode.ExtensionContext,
b69ab31442 panelOrView: W,
b69ab31443 platform: VSCodeServerPlatform,
b69ab31444 mode: AppMode,
b69ab31445 logger: Logger,
b69ab31446): ISLWebviewResult<W> {
b69ab31447 const readySignal = defer<void>();
b69ab31448 logger.info(`Populating ISL webview ${isPanel(panelOrView) ? 'panel' : 'view'}`);
b69ab31449 hasOpenedISLWebviewBeforeState = true;
b69ab31450 if (mode.mode === 'isl') {
b69ab31451 islPanelOrViewResult = {panel: panelOrView, readySignal};
b69ab31452 }
b69ab31453 if (isPanel(panelOrView)) {
4fe1f34454 const iconUri = vscode.Uri.joinPath(
b69ab31455 context.extensionUri,
b69ab31456 'resources',
b69ab31457 'Sapling_favicon-light-green-transparent.svg',
b69ab31458 );
4fe1f34459 panelOrView.iconPath = {light: iconUri, dark: iconUri};
b69ab31460 }
b69ab31461 panelOrView.webview.html = htmlForWebview({
b69ab31462 context,
b69ab31463 extensionRelativeBase: 'dist/webview',
b69ab31464 entryPointFile: 'webview.js',
b69ab31465 cssEntryPointFile: 'res/style.css', // TODO: this is global to all webviews, but should instead be per webview
b69ab31466 devModeScripts: ['/webview/islWebviewEntry.tsx'],
b69ab31467 title: t('isl.title'),
b69ab31468 rootClass: `webview-${isPanel(panelOrView) ? 'panel' : 'view'}`,
b69ab31469 webview: panelOrView.webview,
b69ab31470 extraStyles: '',
b69ab31471 initialScript: nonce => `
b69ab31472 <script nonce="${nonce}" type="text/javascript">
b69ab31473 window.saplingLanguage = "${locale /* important: locale has already been validated */}";
b69ab31474 window.islAppMode = ${JSON.stringify(mode)};
b69ab31475 </script>
b69ab31476 ${getInitialStateJs(context, logger, nonce)}
b69ab31477 `,
b69ab31478 });
b69ab31479 const updatedPlatform = {...platform, panelOrView} as VSCodeServerPlatform as ServerPlatform;
b69ab31480
b69ab31481 const disposeConnection = onClientConnection({
b69ab31482 postMessage(message: string) {
b69ab31483 return panelOrView.webview.postMessage(message) as Promise<boolean>;
b69ab31484 },
b69ab31485 onDidReceiveMessage(handler) {
b69ab31486 return panelOrView.webview.onDidReceiveMessage(m => {
b69ab31487 const isBinary = m instanceof ArrayBuffer;
b69ab31488 handler(m, isBinary);
b69ab31489 });
b69ab31490 },
b69ab31491 cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), // TODO
b69ab31492 platform: updatedPlatform,
b69ab31493 appMode: mode,
b69ab31494 logger,
b69ab31495 command: getCLICommand(),
b69ab31496 version: extensionVersion,
b69ab31497 readySignal,
b69ab31498 });
b69ab31499
b69ab31500 panelOrView.onDidDispose(() => {
b69ab31501 if (isPanel(panelOrView)) {
b69ab31502 logger.info('Disposing ISL panel');
b69ab31503 islPanelOrViewResult = undefined;
b69ab31504 } else {
b69ab31505 logger.info('Disposing ISL view');
b69ab31506 }
b69ab31507 disposeConnection();
b69ab31508 });
b69ab31509
b69ab31510 return {panel: panelOrView, readySignal};
b69ab31511}
b69ab31512
b69ab31513export function fetchUIState(): Promise<{state: string} | undefined> {
b69ab31514 if (islPanelOrViewResult == null) {
b69ab31515 return Promise.resolve(undefined);
b69ab31516 }
b69ab31517
b69ab31518 return new Promise(resolve => {
b69ab31519 let dispose: vscode.Disposable | undefined =
b69ab31520 islPanelOrViewResult?.panel.webview.onDidReceiveMessage((m: string) => {
b69ab31521 try {
b69ab31522 const data = deserializeFromString(m) as ClientToServerMessage;
b69ab31523 if (data.type === 'gotUiState') {
b69ab31524 dispose?.dispose();
b69ab31525 dispose = undefined;
b69ab31526 resolve({state: data.state});
b69ab31527 }
b69ab31528 } catch {}
b69ab31529 });
b69ab31530
b69ab31531 islPanelOrViewResult?.panel.webview.postMessage(
b69ab31532 serializeToString({type: 'getUiState'} as ServerToClientMessage),
b69ab31533 );
b69ab31534 });
b69ab31535}
b69ab31536
b69ab31537/**
b69ab31538 * To persist state, we store data in extension globalStorage.
b69ab31539 * In order to access this synchronously at startup inside the webview,
b69ab31540 * we need to inject this initial state into the webview HTML.
b69ab31541 * This gives the javascript snippet that can be safely put into a webview HTML <script> tag.
b69ab31542 */
b69ab31543function getInitialStateJs(context: vscode.ExtensionContext, logger: Logger, nonce: string) {
b69ab31544 // Previously, all state was stored in a single global storage key.
b69ab31545 // This meant we read and wrote the entire state on every change,
b69ab31546 // notably the webview sent the entire state to the extension on every change.
b69ab31547 // Now, we store each piece of state in its own key, and only send the changed keys to the extension.
b69ab31548
b69ab31549 const legacyKey = 'isl-persisted';
b69ab31550
b69ab31551 const legacyStateStr = context.globalState.get<string>(legacyKey);
b69ab31552 let parsed: {[key: string]: unknown};
b69ab31553 if (legacyStateStr != null) {
b69ab31554 // We migrate to the new system if we see data in the old key.
b69ab31555 // This can be deleted after some time to let clients update.
b69ab31556 logger.info('Legacy persisted state format found, migrating to individual keys');
b69ab31557
b69ab31558 try {
b69ab31559 parsed = JSON.parse(legacyStateStr);
b69ab31560
b69ab31561 // This snippet is injected directly as javascript, much like `eval`.
b69ab31562 // Therefore, it's very important that the stateStr is validated to be safe to be injected.
b69ab31563 if (typeof parsed !== 'object' || parsed == null) {
b69ab31564 // JSON is not in the format we expect
b69ab31565 logger.info('Found INVALID JSON for initial persisted state for webview: ', legacyStateStr);
b69ab31566 // Move forward with empty data (eventually deleting the legacy key)
b69ab31567 parsed = {};
b69ab31568 }
b69ab31569
b69ab31570 for (const key in parsed) {
b69ab31571 context.globalState.update(PERSISTED_STORAGE_KEY_PREFIX + key, parsed[key]);
b69ab31572 }
b69ab31573 logger.info(`Migrated ${Object.keys(parsed).length} keys from legacy persisted state`);
b69ab31574 } catch {
b69ab31575 logger.info('Found INVALID (legacy) initial persisted state for webview: ', legacyStateStr);
b69ab31576 return '';
b69ab31577 } finally {
b69ab31578 // Delete the legacy data either way
b69ab31579 context.globalState.update(legacyKey, undefined);
b69ab31580 logger.info('Deleted legacy persisted state');
b69ab31581 }
b69ab31582 } else {
b69ab31583 logger.info('No legacy persisted state found');
b69ab31584
b69ab31585 const allDataKeys = context.globalState.keys();
b69ab31586 parsed = {};
b69ab31587
b69ab31588 for (const fullKey of allDataKeys) {
b69ab31589 if (fullKey.startsWith(PERSISTED_STORAGE_KEY_PREFIX)) {
b69ab31590 const keyWithoutPrefix = fullKey.slice(PERSISTED_STORAGE_KEY_PREFIX.length);
b69ab31591 const found = context.globalState.get<string>(fullKey);
b69ab31592 if (found) {
b69ab31593 try {
b69ab31594 parsed[keyWithoutPrefix] = JSON.parse(found);
b69ab31595 } catch (err) {
b69ab31596 logger.error(
b69ab31597 `Failed to parse persisted state for key ${keyWithoutPrefix}. Skipping. ${err}`,
b69ab31598 );
b69ab31599 }
b69ab31600 }
b69ab31601 }
b69ab31602 }
b69ab31603
b69ab31604 logger.info(`Loaded persisted data for ${allDataKeys.length} keys`);
b69ab31605 }
b69ab31606
b69ab31607 try {
b69ab31608 // validated is injected not as a string, but directly as a javascript object into a dedicated tag
b69ab31609 const validated = JSON.stringify(parsed);
b69ab31610 const escaped = validated.replace(/</g, '\\u003c');
b69ab31611 logger.info('Found valid initial persisted state for webview: ', validated);
b69ab31612 return `
b69ab31613 <script type="application/json" id="isl-persisted-state">
b69ab31614 ${escaped}
b69ab31615 </script>
b69ab31616 <script nonce="${nonce}" type="text/javascript">
b69ab31617 try {
b69ab31618 const stateElement = document.getElementById('isl-persisted-state');
b69ab31619 window.islInitialPersistedState = JSON.parse(stateElement.textContent);
b69ab31620 } catch (e) {
b69ab31621 console.error('Failed to parse initial persisted state: ', e);
b69ab31622 window.islInitialPersistedState = {};
b69ab31623 }
b69ab31624 </script>
b69ab31625 `;
b69ab31626 } catch {
b69ab31627 logger.info('Found INVALID initial persisted state for webview: ', parsed);
b69ab31628 return '';
b69ab31629 }
b69ab31630}