diff --git a/.vscode/launch.json b/.vscode/launch.json index 3bea8e7c076..b2e25927a8a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -256,7 +256,11 @@ "browserLaunchLocation": "workspace", "presentation": { "hidden": true, - } + }, + // This is read by the vscode-diagnostic-tools extension + "vscode-diagnostic-tools.debuggerScripts": [ + "${workspaceFolder}/scripts/hot-reload-injected-script.js" + ] }, { "type": "node", diff --git a/scripts/debugger-scripts-api.d.ts b/scripts/debugger-scripts-api.d.ts new file mode 100644 index 00000000000..149912bc04f --- /dev/null +++ b/scripts/debugger-scripts-api.d.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +type RunFunction = ((debugSession: IDebugSession) => IDisposable) | ((debugSession: IDebugSession) => Promise); + +interface IDebugSession { + name: string; + eval(expression: string): Promise; + evalJs(bodyFn: (...args: T) => void, ...args: T): Promise; +} + +interface IDisposable { + dispose(): void; +} + +interface GlobalThisAddition extends globalThis { + $hotReload_applyNewExports?(oldExports: Record): AcceptNewExportsFn | undefined; +} + +type AcceptNewExportsFn = (newExports: Record) => boolean; diff --git a/scripts/hot-reload-injected-script.js b/scripts/hot-reload-injected-script.js new file mode 100644 index 00000000000..c6311f3b9c9 --- /dev/null +++ b/scripts/hot-reload-injected-script.js @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check +/// + +const path = require('path'); +const fsPromise = require('fs/promises'); +const parcelWatcher = require('@parcel/watcher'); + +// This file is loaded by the vscode-diagnostic-tools extension and injected into the debugger. + +/** @type {RunFunction} */ +module.exports.run = async function (debugSession) { + const watcher = await DirWatcher.watchRecursively(path.join(__dirname, '../out/')); + + const sub = watcher.onDidChange(changes => { + const supportedChanges = changes.filter(c => c.path.endsWith('.js') || c.path.endsWith('.css')); + debugSession.evalJs(function (changes, debugSessionName) { + // This function is stringified and injected into the debuggee. + + /** @type {{ count: number; originalWindowTitle: any; timeout: any; shouldReload: boolean }} */ + const hotReloadData = globalThis.$hotReloadData || (globalThis.$hotReloadData = { count: 0, messageHideTimeout: undefined, shouldReload: false }); + + /** + * @param {string} path + * @param {string} newSrc + */ + function handleChange(path, newSrc) { + const relativePath = path.replace(/\\/g, '/').split('/out/')[1]; + if (relativePath.endsWith('.css')) { + handleCssChange(relativePath); + } else if (relativePath.endsWith('.js')) { + handleJsChange(relativePath, newSrc); + } + } + + /** + * @param {string} relativePath + */ + function handleCssChange(relativePath) { + if (typeof document === 'undefined') { + return; + } + + const styleSheet = (/** @type {HTMLLinkElement[]} */ ([...document.querySelectorAll(`link[rel='stylesheet']`)])) + .find(l => new URL(l.href, document.location.href).pathname.endsWith(relativePath)); + if (styleSheet) { + setMessage(`reload ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); + console.log(debugSessionName, 'css reloaded', relativePath); + styleSheet.href = styleSheet.href.replace(/\?.*/, '') + '?' + Date.now(); + } else { + setMessage(`could not reload ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); + console.log(debugSessionName, 'ignoring css change, as stylesheet is not loaded', relativePath); + } + } + + /** + * @param {string} relativePath + * @param {string} newSrc + */ + function handleJsChange(relativePath, newSrc) { + const moduleIdStr = trimEnd(relativePath, '.js'); + + /** @type {any} */ + const requireFn = globalThis.require; + const moduleManager = requireFn.moduleManager; + if (!moduleManager) { + console.log(debugSessionName, 'ignoring js change, as moduleManager is not available', relativePath); + return; + } + + const moduleId = moduleManager._moduleIdProvider.getModuleId(moduleIdStr); + const oldModule = moduleManager._modules2[moduleId]; + + if (!oldModule) { + console.log(debugSessionName, 'ignoring js change, as module is not loaded', relativePath); + return; + } + + // Check if we can reload + const g = /** @type {GlobalThisAddition} */ (globalThis); + + // A frozen copy of the previous exports + const oldExports = Object.freeze({ ...oldModule.exports }); + const reloadFn = g.$hotReload_applyNewExports?.(oldExports); + + if (!reloadFn) { + console.log(debugSessionName, 'ignoring js change, as module does not support hot-reload', relativePath); + hotReloadData.shouldReload = true; + setMessage(`hot reload not supported for ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); + return; + } + + const newScript = new Function('define', newSrc); // CodeQL [SM01632] This code is only executed during development. It is required for the hot-reload functionality. + + newScript(/* define */ function (deps, callback) { + // Evaluating the new code was successful. + + // Redefine the module + delete moduleManager._modules2[moduleId]; + moduleManager.defineModule(moduleIdStr, deps, callback); + const newModule = moduleManager._modules2[moduleId]; + + + // Patch the exports of the old module, so that modules using the old module get the new exports + Object.assign(oldModule.exports, newModule.exports); + // We override the exports so that future reloads still patch the initial exports. + newModule.exports = oldModule.exports; + + const successful = reloadFn(newModule.exports); + if (!successful) { + hotReloadData.shouldReload = true; + setMessage(`hot reload failed ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); + console.log(debugSessionName, 'hot reload was not successful', relativePath); + return; + } + + console.log(debugSessionName, 'hot reloaded', moduleIdStr); + setMessage(`successfully reloaded ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); + }); + } + + /** + * @param {string} message + */ + function setMessage(message) { + const domElem = /** @type {HTMLDivElement | undefined} */ (document.querySelector('.titlebar-center .window-title')); + if (!domElem) { return; } + if (!hotReloadData.timeout) { + hotReloadData.originalWindowTitle = domElem.innerText; + } else { + clearTimeout(hotReloadData.timeout); + } + if (hotReloadData.shouldReload) { + message += ' (manual reload required)'; + } + + domElem.innerText = message; + hotReloadData.timeout = setTimeout(() => { + hotReloadData.timeout = undefined; + // If wanted, we can restore the previous title message + // domElem.replaceChildren(hotReloadData.originalWindowTitle); + }, 5000); + } + + /** + * @param {string} path + * @returns {string} + */ + function formatPath(path) { + const parts = path.split('/'); + parts.reverse(); + let result = parts[0]; + parts.shift(); + for (const p of parts) { + if (result.length + p.length > 40) { + break; + } + result = p + '/' + result; + if (result.length > 20) { + break; + } + } + return result; + } + + function trimEnd(str, suffix) { + if (str.endsWith(suffix)) { + return str.substring(0, str.length - suffix.length); + } + return str; + } + + for (const change of changes) { + handleChange(change.path, change.newContent); + } + + }, supportedChanges, debugSession.name.substring(0, 25)); + }); + + return { + dispose() { + sub.dispose(); + watcher.dispose(); + } + }; +}; + +class DirWatcher { + /** + * + * @param {string} dir + * @returns {Promise} + */ + static async watchRecursively(dir) { + /** @type {((changes: { path: string, newContent: string }[]) => void)[]} */ + const listeners = []; + /** @type {Map } */ + const fileContents = new Map(); + /** @type {Map} */ + const changes = new Map(); + /** @type {(handler: (changes: { path: string, newContent: string }[]) => void) => IDisposable} */ + const event = (handler) => { + listeners.push(handler); + return { + dispose: () => { + const idx = listeners.indexOf(handler); + if (idx >= 0) { + listeners.splice(idx, 1); + } + } + }; + }; + const r = parcelWatcher.subscribe(dir, async (err, events) => { + for (const e of events) { + if (e.type === 'update') { + const newContent = await fsPromise.readFile(e.path, 'utf8'); + if (fileContents.get(e.path) !== newContent) { + fileContents.set(e.path, newContent); + changes.set(e.path, { path: e.path, newContent }); + } + } + } + if (changes.size > 0) { + debounce(() => { + const uniqueChanges = Array.from(changes.values()); + changes.clear(); + listeners.forEach(l => l(uniqueChanges)); + })(); + } + }); + const result = await r; + return new DirWatcher(event, () => result.unsubscribe()); + } + + /** + * @param {(handler: (changes: { path: string, newContent: string }[]) => void) => IDisposable} onDidChange + * @param {() => void} unsub + */ + constructor(onDidChange, unsub) { + this.onDidChange = onDidChange; + this.unsub = unsub; + } + + dispose() { + this.unsub(); + } +} + +/** + * Debounce function calls + * @param {() => void} fn + * @param {number} delay + */ +function debounce(fn, delay = 50) { + let timeoutId; + return function (...args) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + fn.apply(this, args); + }, delay); + }; +} + diff --git a/src/vs/base/common/hotReload.ts b/src/vs/base/common/hotReload.ts new file mode 100644 index 00000000000..17724907937 --- /dev/null +++ b/src/vs/base/common/hotReload.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from 'vs/base/common/lifecycle'; +import { env } from 'vs/base/common/process'; + +export function isHotReloadEnabled(): boolean { + return !!env['VSCODE_DEV']; +} +export function registerHotReloadHandler(handler: HotReloadHandler): IDisposable { + if (!isHotReloadEnabled()) { + return { dispose() { } }; + } else { + const handlers = registerGlobalHotReloadHandler(); + + handlers.add(handler); + return { + dispose() { handlers.delete(handler); } + }; + } +} + +/** + * Takes the old exports of the module to reload and returns a function to apply the new exports. + * If `undefined` is returned, this handler is not able to handle the module. + * + * If no handler can apply the new exports, the module will not be reloaded. + */ +export type HotReloadHandler = (oldExports: Record) => AcceptNewExportsHandler | undefined; +export type AcceptNewExportsHandler = (newExports: Record) => boolean; + +function registerGlobalHotReloadHandler() { + if (!hotReloadHandlers) { + hotReloadHandlers = new Set(); + } + + const g = globalThis as unknown as GlobalThisAddition; + if (!g.$hotReload_applyNewExports) { + g.$hotReload_applyNewExports = oldExports => { + for (const h of hotReloadHandlers!) { + const result = h(oldExports); + if (result) { return result; } + } + return undefined; + }; + } + + return hotReloadHandlers; +} + +let hotReloadHandlers: Set<(oldExports: Record) => AcceptNewExportsFn | undefined> | undefined = undefined; + +interface GlobalThisAddition { + $hotReload_applyNewExports?(oldExports: Record): AcceptNewExportsFn | undefined; +} + +type AcceptNewExportsFn = (newExports: Record) => boolean; diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/utils.ts b/src/vs/editor/browser/widget/diffEditorWidget2/utils.ts index 8460b24326e..e2326b84447 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/utils.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/utils.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDimension } from 'vs/base/browser/dom'; +import { isHotReloadEnabled, registerHotReloadHandler } from 'vs/base/common/hotReload'; import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { IObservable, IReader, ISettableObservable, autorun, autorunHandleChanges, autorunOpts, observableFromEvent, observableSignalFromEvent, observableValue, transaction } from 'vs/base/common/observable'; import { ElementSizeObserver } from 'vs/editor/browser/config/elementSizeObserver'; @@ -294,28 +295,21 @@ export function readHotReloadableExport(value: T, reader: IReader | undefined } export function observeHotReloadableExports(values: any[], reader: IReader | undefined): void { - const hotReload_deprecateExports = (globalThis as unknown as { - // This property it defined by the monaco editor playground server - $hotReload_deprecateExports: Set<(oldExports: Record, newExports: Record) => boolean>; - }).$hotReload_deprecateExports; - if (!hotReload_deprecateExports) { - return; + if (isHotReloadEnabled()) { + const o = observableSignalFromEvent( + 'reload', + event => registerHotReloadHandler(oldExports => { + if (![...Object.values(oldExports)].some(v => values.includes(v))) { + return undefined; + } + return (_newExports) => { + event(undefined); + return true; + }; + }) + ); + o.read(reader); } - - const o = observableSignalFromEvent('reload', e => { - function handleExports(oldExports: Record, _newExports: Record) { - if ([...Object.values(oldExports)].some(v => values.includes(v))) { - e(undefined); - return true; - } - return false; - } - hotReload_deprecateExports.add(handleExports); - return { - dispose() { hotReload_deprecateExports.delete(handleExports); } - }; - }); - o.read(reader); } export function applyViewZones(editor: ICodeEditor, viewZones: IObservable, setIsUpdating?: (isUpdatingViewZones: boolean) => void): IDisposable {