8.0 KB248 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 {Platform} from 'isl/src/platform';
9import type {ThemeColor} from 'isl/src/theme';
10import type {AbsolutePath, MessageBusStatus, RepoRelativePath} from 'isl/src/types';
11import type {Comparison} from 'shared/Comparison';
12import type {Json} from 'shared/typeUtils';
13import type {VSCodeAPI} from './vscodeApi';
14
15import {browserClipboardCopy} from 'isl/src/platform/browserPlatformImpl';
16import {registerCleanup} from 'isl/src/utils';
17import {lazy} from 'react';
18import {vscodeApi} from './vscodeApi';
19
20import './uncaughtExceptions';
21
22const VSCodeSettings = lazy(() => import('./VSCodeSettings'));
23const AddMoreCwdsHint = lazy(() => import('./AddMoreCwdsHint'));
24
25declare global {
26 interface Window {
27 islInitialPersistedState: Record<string, Json>;
28 }
29}
30
31class 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
51const persistedState: Record<string, Json> = window.islInitialPersistedState ?? {};
52
53const 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
182function 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
193let lastFocused: HTMLElement | null = null;
194
195const handleWindowFocus = () => {
196 const lastTextArea = lastFocused;
197 if (isTextInputToPreserveFocusFor(lastTextArea)) {
198 lastTextArea?.focus?.({preventScroll: true});
199 }
200};
201
202const 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
210window.addEventListener('focus', handleWindowFocus);
211// document focus change lets us track what element needs to be refocused.
212document.addEventListener('focus', handleDocFocus, {capture: true});
213
214registerCleanup(
215 vscodeWebviewPlatform,
216 () => {
217 window.removeEventListener('focus', handleWindowFocus);
218 document.removeEventListener('focus', handleDocFocus);
219 },
220 import.meta.hot,
221);
222
223function 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.
241if (import.meta.hot) {
242 import.meta.hot?.invalidate();
243}
244
245window.islPlatform = vscodeWebviewPlatform;
246
247export default vscodeWebviewPlatform;
248