addons/vscode/webview/vscodeWebviewPlatform.tsxblame
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 {Platform} from 'isl/src/platform';
b69ab319import type {ThemeColor} from 'isl/src/theme';
b69ab3110import type {AbsolutePath, MessageBusStatus, RepoRelativePath} from 'isl/src/types';
b69ab3111import type {Comparison} from 'shared/Comparison';
b69ab3112import type {Json} from 'shared/typeUtils';
b69ab3113import type {VSCodeAPI} from './vscodeApi';
b69ab3114
b69ab3115import {browserClipboardCopy} from 'isl/src/platform/browserPlatformImpl';
b69ab3116import {registerCleanup} from 'isl/src/utils';
b69ab3117import {lazy} from 'react';
b69ab3118import {vscodeApi} from './vscodeApi';
b69ab3119
b69ab3120import './uncaughtExceptions';
b69ab3121
b69ab3122const VSCodeSettings = lazy(() => import('./VSCodeSettings'));
b69ab3123const AddMoreCwdsHint = lazy(() => import('./AddMoreCwdsHint'));
b69ab3124
b69ab3125declare global {
b69ab3126 interface Window {
b69ab3127 islInitialPersistedState: Record<string, Json>;
b69ab3128 }
b69ab3129}
b69ab3130
b69ab3131class VSCodeMessageBus {
b69ab3132 constructor(private vscode: VSCodeAPI) {}
b69ab3133
b69ab3134 onMessage(handler: (event: MessageEvent<string>) => void | Promise<void>): {dispose: () => void} {
b69ab3135 window.addEventListener('message', handler);
b69ab3136 const dispose = () => window.removeEventListener('message', handler);
b69ab3137 return {dispose};
b69ab3138 }
b69ab3139
b69ab3140 onChangeStatus(handler: (newStatus: MessageBusStatus) => unknown): {dispose: () => void} {
b69ab3141 // VS Code connections don't close or change status (the webview would just be destroyed if closed)
b69ab3142 handler({type: 'open'});
b69ab3143 return {dispose: () => {}};
b69ab3144 }
b69ab3145
b69ab3146 postMessage(message: string) {
b69ab3147 this.vscode.postMessage(message);
b69ab3148 }
b69ab3149}
b69ab3150
b69ab3151const persistedState: Record<string, Json> = window.islInitialPersistedState ?? {};
b69ab3152
b69ab3153const vscodeWebviewPlatform: Platform = {
b69ab3154 platformName: 'vscode',
b69ab3155 confirm: (message: string, details?: string | undefined) => {
b69ab3156 window.clientToServerAPI?.postMessage({type: 'platform/confirm', message, details});
b69ab3157
b69ab3158 // wait for confirmation result
b69ab3159 return new Promise<boolean>(res => {
b69ab3160 const disposable = window.clientToServerAPI?.onMessageOfType(
b69ab3161 'platform/confirmResult',
b69ab3162 event => {
b69ab3163 res(event.result);
b69ab3164 disposable?.dispose();
b69ab3165 },
b69ab3166 );
b69ab3167 });
b69ab3168 },
b69ab3169 openFile: (path, options) =>
b69ab3170 window.clientToServerAPI?.postMessage({type: 'platform/openFile', path, options}),
b69ab3171 openFiles: (paths, options) =>
b69ab3172 window.clientToServerAPI?.postMessage({type: 'platform/openFiles', paths, options}),
b69ab3173 canCustomizeFileOpener: false,
b69ab3174 openDiff: (path: RepoRelativePath, comparison: Comparison) =>
b69ab3175 window.clientToServerAPI?.postMessage({type: 'platform/openDiff', path, comparison}),
b69ab3176 openExternalLink: url => {
b69ab3177 window.clientToServerAPI?.postMessage({type: 'platform/openExternal', url});
b69ab3178 },
b69ab3179 upsellExternalMergeTool: false,
b69ab3180
b69ab3181 openDedicatedComparison: async (comparison: Comparison): Promise<boolean> => {
b69ab3182 const {getComparisonPanelMode} = await import('./state');
b69ab3183 const mode = getComparisonPanelMode();
b69ab3184 if (mode === 'Auto') {
b69ab3185 return false;
b69ab3186 }
b69ab3187 window.clientToServerAPI?.postMessage({
b69ab3188 type: 'platform/executeVSCodeCommand',
b69ab3189 command: 'sapling.open-comparison-view',
b69ab3190 args: [comparison],
b69ab3191 });
b69ab3192 return true;
b69ab3193 },
b69ab3194
b69ab3195 clipboardCopy: browserClipboardCopy,
b69ab3196
b69ab3197 getPersistedState<T extends Json>(key: string): T | null {
b69ab3198 return persistedState[key] as T;
b69ab3199 },
b69ab31100 setPersistedState<T extends Json>(key: string, value: T | undefined): void {
b69ab31101 if (value === undefined) {
b69ab31102 delete persistedState[key];
b69ab31103 } else {
b69ab31104 persistedState[key] = value;
b69ab31105 }
b69ab31106
b69ab31107 window.clientToServerAPI?.postMessage({
b69ab31108 type: 'platform/setPersistedState',
b69ab31109 key,
b69ab31110 data: value === undefined ? undefined : JSON.stringify(value),
b69ab31111 });
b69ab31112 },
b69ab31113 clearPersistedState(): void {
b69ab31114 for (const key in persistedState) {
b69ab31115 delete persistedState[key];
b69ab31116 window.clientToServerAPI?.postMessage({
b69ab31117 type: 'platform/setPersistedState',
b69ab31118 key,
b69ab31119 data: undefined,
b69ab31120 });
b69ab31121 }
b69ab31122 },
b69ab31123 getAllPersistedState(): Json | undefined {
b69ab31124 return persistedState;
b69ab31125 },
b69ab31126
b69ab31127 theme: {
b69ab31128 getTheme,
b69ab31129 getThemeName: () => document.body.dataset.vscodeThemeId,
b69ab31130 resetCSS: '',
b69ab31131 onDidChangeTheme(callback: (theme: ThemeColor) => unknown) {
b69ab31132 // VS Code sets the theme inside the webview by adding a class to `document.body`.
b69ab31133 // Listen for changes to body to possibly update the theme value.
b69ab31134 // This also covers theme name changes, which might keep light / dark the same.
b69ab31135 const observer = new MutationObserver((_mutationList: Array<MutationRecord>) => {
b69ab31136 callback(getTheme());
b69ab31137 });
b69ab31138 observer.observe(document.body, {attributes: true, childList: false, subtree: false});
b69ab31139 return {dispose: () => observer.disconnect()};
b69ab31140 },
b69ab31141 },
b69ab31142
b69ab31143 suggestedEdits: {
b69ab31144 onDidChangeSuggestedEdits(callback) {
b69ab31145 window.clientToServerAPI?.postMessage({
b69ab31146 type: 'platform/subscribeToSuggestedEdits',
b69ab31147 });
b69ab31148 return (
b69ab31149 window.clientToServerAPI?.onMessageOfType('platform/onDidChangeSuggestedEdits', event => {
b69ab31150 callback(event.files);
b69ab31151 }) ?? {dispose: () => {}}
b69ab31152 );
b69ab31153 },
b69ab31154 resolveSuggestedEdits: (action: 'accept' | 'reject', files: Array<AbsolutePath>) => {
b69ab31155 window.clientToServerAPI?.postMessage({
b69ab31156 type: 'platform/resolveSuggestedEdits',
b69ab31157 action,
b69ab31158 files,
b69ab31159 });
b69ab31160 },
b69ab31161 },
b69ab31162
b69ab31163 aiCodeReview: {
b69ab31164 onDidChangeAIReviewComments(callback) {
b69ab31165 window.clientToServerAPI?.postMessage({
b69ab31166 type: 'platform/subscribeToAIReviewComments',
b69ab31167 });
b69ab31168 return (
b69ab31169 window.clientToServerAPI?.onMessageOfType('platform/gotAIReviewComments', event => {
b69ab31170 callback(event.comments.value ?? []);
b69ab31171 }) ?? {dispose: () => {}}
b69ab31172 );
b69ab31173 },
b69ab31174 },
b69ab31175
b69ab31176 AddMoreCwdsHint,
b69ab31177 Settings: VSCodeSettings,
b69ab31178
b69ab31179 messageBus: new VSCodeMessageBus(vscodeApi),
b69ab31180};
b69ab31181
b69ab31182function getTheme(): ThemeColor {
b69ab31183 return document.body.className.includes('vscode-light') ? 'light' : 'dark';
b69ab31184}
b69ab31185
b69ab31186/**
b69ab31187 * VS Code has a bug where it will lose focus on webview elements (notably text areas) when tabbing out and back in.
b69ab31188 * To mitigate, we save the currently focused element as elements are focused, and refocus it on window focus.
b69ab31189 * We limit this to text areas, as in some cases it seems certain keypresses are passed through
b69ab31190 * if ISL is visible with a modal input above it, and we don't want to accidentally click buttons.
b69ab31191 */
b69ab31192
b69ab31193let lastFocused: HTMLElement | null = null;
b69ab31194
b69ab31195const handleWindowFocus = () => {
b69ab31196 const lastTextArea = lastFocused;
b69ab31197 if (isTextInputToPreserveFocusFor(lastTextArea)) {
b69ab31198 lastTextArea?.focus?.({preventScroll: true});
b69ab31199 }
b69ab31200};
b69ab31201
b69ab31202const handleDocFocus = (e: FocusEvent) => {
b69ab31203 // Note: we don't clear this in document's blur. This means you could blur the element,
b69ab31204 // then blur and refocus the window, and refocus the previous element.
b69ab31205 // This is weird, but preferred to losing focus.
b69ab31206 lastFocused = e.target as HTMLElement;
b69ab31207};
b69ab31208
b69ab31209// window focus is when we may need to refocus a previously focused element
b69ab31210window.addEventListener('focus', handleWindowFocus);
b69ab31211// document focus change lets us track what element needs to be refocused.
b69ab31212document.addEventListener('focus', handleDocFocus, {capture: true});
b69ab31213
b69ab31214registerCleanup(
b69ab31215 vscodeWebviewPlatform,
b69ab31216 () => {
b69ab31217 window.removeEventListener('focus', handleWindowFocus);
b69ab31218 document.removeEventListener('focus', handleDocFocus);
b69ab31219 },
b69ab31220 import.meta.hot,
b69ab31221);
b69ab31222
b69ab31223function isTextInputToPreserveFocusFor(el: Element | null) {
b69ab31224 if (el == null) {
b69ab31225 return false;
b69ab31226 }
b69ab31227 if (el.tagName === 'INPUT') {
b69ab31228 const input = el as HTMLInputElement;
b69ab31229 // Don't preserve focus for non-text elements (they may get interacted unexpectedly).
b69ab31230 // Also skip for quick commit title, which might cause a quick commit if the Enter key is sent
b69ab31231 return input.type === 'text' && input.dataset.testId !== 'quick-commit-title';
b69ab31232 }
b69ab31233 if (el.tagName === 'TEXTAREA') {
b69ab31234 return true;
b69ab31235 }
b69ab31236 return false;
b69ab31237}
b69ab31238
b69ab31239// We can't allow this file to hot reload, since it creates global state.
b69ab31240// If we did, we'd accumulate global `messageBus`es, which is buggy.
b69ab31241if (import.meta.hot) {
b69ab31242 import.meta.hot?.invalidate();
b69ab31243}
b69ab31244
b69ab31245window.islPlatform = vscodeWebviewPlatform;
b69ab31246
b69ab31247export default vscodeWebviewPlatform;