| 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 | |
| 8 | import type {Platform} from 'isl/src/platform'; |
| 9 | import type {ThemeColor} from 'isl/src/theme'; |
| 10 | import type {AbsolutePath, MessageBusStatus, RepoRelativePath} from 'isl/src/types'; |
| 11 | import type {Comparison} from 'shared/Comparison'; |
| 12 | import type {Json} from 'shared/typeUtils'; |
| 13 | import type {VSCodeAPI} from './vscodeApi'; |
| 14 | |
| 15 | import {browserClipboardCopy} from 'isl/src/platform/browserPlatformImpl'; |
| 16 | import {registerCleanup} from 'isl/src/utils'; |
| 17 | import {lazy} from 'react'; |
| 18 | import {vscodeApi} from './vscodeApi'; |
| 19 | |
| 20 | import './uncaughtExceptions'; |
| 21 | |
| 22 | const VSCodeSettings = lazy(() => import('./VSCodeSettings')); |
| 23 | const AddMoreCwdsHint = lazy(() => import('./AddMoreCwdsHint')); |
| 24 | |
| 25 | declare global { |
| 26 | interface Window { |
| 27 | islInitialPersistedState: Record<string, Json>; |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | class VSCodeMessageBus { |
| 32 | constructor(private vscode: VSCodeAPI) {} |
| 33 | |
| 34 | onMessage(handler: (event: MessageEvent<string>) => void | Promise<void>): {dispose: () => void} { |
| 35 | window.addEventListener('message', handler); |
| 36 | const dispose = () => window.removeEventListener('message', handler); |
| 37 | return {dispose}; |
| 38 | } |
| 39 | |
| 40 | onChangeStatus(handler: (newStatus: MessageBusStatus) => unknown): {dispose: () => void} { |
| 41 | // VS Code connections don't close or change status (the webview would just be destroyed if closed) |
| 42 | handler({type: 'open'}); |
| 43 | return {dispose: () => {}}; |
| 44 | } |
| 45 | |
| 46 | postMessage(message: string) { |
| 47 | this.vscode.postMessage(message); |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | const persistedState: Record<string, Json> = window.islInitialPersistedState ?? {}; |
| 52 | |
| 53 | const vscodeWebviewPlatform: Platform = { |
| 54 | platformName: 'vscode', |
| 55 | confirm: (message: string, details?: string | undefined) => { |
| 56 | window.clientToServerAPI?.postMessage({type: 'platform/confirm', message, details}); |
| 57 | |
| 58 | // wait for confirmation result |
| 59 | return new Promise<boolean>(res => { |
| 60 | const disposable = window.clientToServerAPI?.onMessageOfType( |
| 61 | 'platform/confirmResult', |
| 62 | event => { |
| 63 | res(event.result); |
| 64 | disposable?.dispose(); |
| 65 | }, |
| 66 | ); |
| 67 | }); |
| 68 | }, |
| 69 | openFile: (path, options) => |
| 70 | window.clientToServerAPI?.postMessage({type: 'platform/openFile', path, options}), |
| 71 | openFiles: (paths, options) => |
| 72 | window.clientToServerAPI?.postMessage({type: 'platform/openFiles', paths, options}), |
| 73 | canCustomizeFileOpener: false, |
| 74 | openDiff: (path: RepoRelativePath, comparison: Comparison) => |
| 75 | window.clientToServerAPI?.postMessage({type: 'platform/openDiff', path, comparison}), |
| 76 | openExternalLink: url => { |
| 77 | window.clientToServerAPI?.postMessage({type: 'platform/openExternal', url}); |
| 78 | }, |
| 79 | upsellExternalMergeTool: false, |
| 80 | |
| 81 | openDedicatedComparison: async (comparison: Comparison): Promise<boolean> => { |
| 82 | const {getComparisonPanelMode} = await import('./state'); |
| 83 | const mode = getComparisonPanelMode(); |
| 84 | if (mode === 'Auto') { |
| 85 | return false; |
| 86 | } |
| 87 | window.clientToServerAPI?.postMessage({ |
| 88 | type: 'platform/executeVSCodeCommand', |
| 89 | command: 'sapling.open-comparison-view', |
| 90 | args: [comparison], |
| 91 | }); |
| 92 | return true; |
| 93 | }, |
| 94 | |
| 95 | clipboardCopy: browserClipboardCopy, |
| 96 | |
| 97 | getPersistedState<T extends Json>(key: string): T | null { |
| 98 | return persistedState[key] as T; |
| 99 | }, |
| 100 | setPersistedState<T extends Json>(key: string, value: T | undefined): void { |
| 101 | if (value === undefined) { |
| 102 | delete persistedState[key]; |
| 103 | } else { |
| 104 | persistedState[key] = value; |
| 105 | } |
| 106 | |
| 107 | window.clientToServerAPI?.postMessage({ |
| 108 | type: 'platform/setPersistedState', |
| 109 | key, |
| 110 | data: value === undefined ? undefined : JSON.stringify(value), |
| 111 | }); |
| 112 | }, |
| 113 | clearPersistedState(): void { |
| 114 | for (const key in persistedState) { |
| 115 | delete persistedState[key]; |
| 116 | window.clientToServerAPI?.postMessage({ |
| 117 | type: 'platform/setPersistedState', |
| 118 | key, |
| 119 | data: undefined, |
| 120 | }); |
| 121 | } |
| 122 | }, |
| 123 | getAllPersistedState(): Json | undefined { |
| 124 | return persistedState; |
| 125 | }, |
| 126 | |
| 127 | theme: { |
| 128 | getTheme, |
| 129 | getThemeName: () => document.body.dataset.vscodeThemeId, |
| 130 | resetCSS: '', |
| 131 | onDidChangeTheme(callback: (theme: ThemeColor) => unknown) { |
| 132 | // VS Code sets the theme inside the webview by adding a class to `document.body`. |
| 133 | // Listen for changes to body to possibly update the theme value. |
| 134 | // This also covers theme name changes, which might keep light / dark the same. |
| 135 | const observer = new MutationObserver((_mutationList: Array<MutationRecord>) => { |
| 136 | callback(getTheme()); |
| 137 | }); |
| 138 | observer.observe(document.body, {attributes: true, childList: false, subtree: false}); |
| 139 | return {dispose: () => observer.disconnect()}; |
| 140 | }, |
| 141 | }, |
| 142 | |
| 143 | suggestedEdits: { |
| 144 | onDidChangeSuggestedEdits(callback) { |
| 145 | window.clientToServerAPI?.postMessage({ |
| 146 | type: 'platform/subscribeToSuggestedEdits', |
| 147 | }); |
| 148 | return ( |
| 149 | window.clientToServerAPI?.onMessageOfType('platform/onDidChangeSuggestedEdits', event => { |
| 150 | callback(event.files); |
| 151 | }) ?? {dispose: () => {}} |
| 152 | ); |
| 153 | }, |
| 154 | resolveSuggestedEdits: (action: 'accept' | 'reject', files: Array<AbsolutePath>) => { |
| 155 | window.clientToServerAPI?.postMessage({ |
| 156 | type: 'platform/resolveSuggestedEdits', |
| 157 | action, |
| 158 | files, |
| 159 | }); |
| 160 | }, |
| 161 | }, |
| 162 | |
| 163 | aiCodeReview: { |
| 164 | onDidChangeAIReviewComments(callback) { |
| 165 | window.clientToServerAPI?.postMessage({ |
| 166 | type: 'platform/subscribeToAIReviewComments', |
| 167 | }); |
| 168 | return ( |
| 169 | window.clientToServerAPI?.onMessageOfType('platform/gotAIReviewComments', event => { |
| 170 | callback(event.comments.value ?? []); |
| 171 | }) ?? {dispose: () => {}} |
| 172 | ); |
| 173 | }, |
| 174 | }, |
| 175 | |
| 176 | AddMoreCwdsHint, |
| 177 | Settings: VSCodeSettings, |
| 178 | |
| 179 | messageBus: new VSCodeMessageBus(vscodeApi), |
| 180 | }; |
| 181 | |
| 182 | function getTheme(): ThemeColor { |
| 183 | return document.body.className.includes('vscode-light') ? 'light' : 'dark'; |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * VS Code has a bug where it will lose focus on webview elements (notably text areas) when tabbing out and back in. |
| 188 | * To mitigate, we save the currently focused element as elements are focused, and refocus it on window focus. |
| 189 | * We limit this to text areas, as in some cases it seems certain keypresses are passed through |
| 190 | * if ISL is visible with a modal input above it, and we don't want to accidentally click buttons. |
| 191 | */ |
| 192 | |
| 193 | let lastFocused: HTMLElement | null = null; |
| 194 | |
| 195 | const handleWindowFocus = () => { |
| 196 | const lastTextArea = lastFocused; |
| 197 | if (isTextInputToPreserveFocusFor(lastTextArea)) { |
| 198 | lastTextArea?.focus?.({preventScroll: true}); |
| 199 | } |
| 200 | }; |
| 201 | |
| 202 | const handleDocFocus = (e: FocusEvent) => { |
| 203 | // Note: we don't clear this in document's blur. This means you could blur the element, |
| 204 | // then blur and refocus the window, and refocus the previous element. |
| 205 | // This is weird, but preferred to losing focus. |
| 206 | lastFocused = e.target as HTMLElement; |
| 207 | }; |
| 208 | |
| 209 | // window focus is when we may need to refocus a previously focused element |
| 210 | window.addEventListener('focus', handleWindowFocus); |
| 211 | // document focus change lets us track what element needs to be refocused. |
| 212 | document.addEventListener('focus', handleDocFocus, {capture: true}); |
| 213 | |
| 214 | registerCleanup( |
| 215 | vscodeWebviewPlatform, |
| 216 | () => { |
| 217 | window.removeEventListener('focus', handleWindowFocus); |
| 218 | document.removeEventListener('focus', handleDocFocus); |
| 219 | }, |
| 220 | import.meta.hot, |
| 221 | ); |
| 222 | |
| 223 | function isTextInputToPreserveFocusFor(el: Element | null) { |
| 224 | if (el == null) { |
| 225 | return false; |
| 226 | } |
| 227 | if (el.tagName === 'INPUT') { |
| 228 | const input = el as HTMLInputElement; |
| 229 | // Don't preserve focus for non-text elements (they may get interacted unexpectedly). |
| 230 | // Also skip for quick commit title, which might cause a quick commit if the Enter key is sent |
| 231 | return input.type === 'text' && input.dataset.testId !== 'quick-commit-title'; |
| 232 | } |
| 233 | if (el.tagName === 'TEXTAREA') { |
| 234 | return true; |
| 235 | } |
| 236 | return false; |
| 237 | } |
| 238 | |
| 239 | // We can't allow this file to hot reload, since it creates global state. |
| 240 | // If we did, we'd accumulate global `messageBus`es, which is buggy. |
| 241 | if (import.meta.hot) { |
| 242 | import.meta.hot?.invalidate(); |
| 243 | } |
| 244 | |
| 245 | window.islPlatform = vscodeWebviewPlatform; |
| 246 | |
| 247 | export default vscodeWebviewPlatform; |
| 248 | |