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