From 22a578f53b2f8edb4ab07078d32d142bc543d651 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Fri, 2 Feb 2024 12:27:15 +0100 Subject: [PATCH] Uses status bar to indicate hot reloading --- scripts/debugger-scripts-api.d.ts | 21 ++- scripts/hot-reload-injected-script.js | 258 +++++++++++++++++++++++--- src/vs/base/common/hotReload.ts | 24 ++- 3 files changed, 267 insertions(+), 36 deletions(-) diff --git a/scripts/debugger-scripts-api.d.ts b/scripts/debugger-scripts-api.d.ts index 0a06ad925e0..b101855f4d0 100644 --- a/scripts/debugger-scripts-api.d.ts +++ b/scripts/debugger-scripts-api.d.ts @@ -3,20 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -type RunFunction = ((debugSession: IDebugSession) => IDisposable) | ((debugSession: IDebugSession) => Promise); +type RunFunction = + | ((debugSession: IDebugSession, context: Context) => IDisposable) + | ((debugSession: IDebugSession, context: Context) => Promise); interface IDebugSession { name: string; - eval(expression: string): Promise; - evalJs(bodyFn: (...args: T) => void, ...args: T): Promise; + eval(expression: string): Promise; + evalJs( + bodyFn: (...args: T) => TResult, + ...args: T + ): Promise; +} + +interface Context { + vscode: typeof import('vscode'); } interface IDisposable { dispose(): void; } +interface HotReloadConfig { + mode?: 'patch-prototype' | undefined; +} + interface GlobalThisAddition { - $hotReload_applyNewExports?(args: { oldExports: Record; newSrc: string }): AcceptNewExportsFn | undefined; + $hotReload_applyNewExports?(args: { oldExports: Record; newSrc: string; config?: HotReloadConfig }): AcceptNewExportsFn | undefined; } type AcceptNewExportsFn = (newExports: Record) => boolean; diff --git a/scripts/hot-reload-injected-script.js b/scripts/hot-reload-injected-script.js index 8d082f1a139..431f11b6a66 100644 --- a/scripts/hot-reload-injected-script.js +++ b/scripts/hot-reload-injected-script.js @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ // @ts-check +/// /// const path = require('path'); @@ -12,28 +13,198 @@ 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) { +/** + * Represents a lazy evaluation container. + * @template T + * @template TArg + */ +class Lazy { + /** + * Creates a new instance of the Lazy class. + * @param {(arg: TArg) => T} _fn - The function to be lazily evaluated. + */ + constructor(_fn) { + this._fn = _fn; + this._value = undefined; + } + + /** + * Gets the lazily evaluated value. + * @param {TArg} arg - The argument passed in to the evaluation function. + * @return {T} + */ + getValue(arg) { + if (!this._value) { + this._value = this._fn(arg); + } + return this._value; + } +} + +/** + * @param {Context['vscode']} vscode + */ +function setupGlobals(vscode) { + /** @type {DisposableStore} */ + const store = globalThis['hot-reload-injected-script-disposables'] ?? (globalThis['hot-reload-injected-script-disposables'] = new DisposableStore()); + store.clear(); + + function getConfig() { + const config = vscode.workspace.getConfiguration('vscode-diagnostic-tools').get('debuggerScriptsConfig', { + 'hotReload.sources': {} + }); + if (!config['hotReload.sources']) { + config['hotReload.sources'] = {}; + } + return config; + } + + /** + * @type {Map void>>} + */ + const enabledRelativePaths = new Map(); + const api = { + /** + * @param {string} relativePath + * @param {() => void} forceReloadFn + */ + reloadFailed: (relativePath, forceReloadFn) => { + const set = enabledRelativePaths.get(relativePath) ?? new Set(); + set.add(forceReloadFn); + enabledRelativePaths.set(relativePath, set); + + update(); + }, + + /** + * @param {string} relativePath + * @returns {HotReloadConfig} + */ + getConfig: (relativePath) => { + const config = getConfig(); + return { mode: config['hotReload.sources'][relativePath] === 'patch-prototype' ? 'patch-prototype' : undefined }; + } + }; + + const item = store.add(vscode.window.createStatusBarItem(undefined, 10000)); + + function update() { + item.hide(); + const e = vscode.window.activeTextEditor; + if (!e) { return; } + + const part = e.document.fileName.replace(/\\/g, '/').replace(/\.ts/, '.js').split('/src/')[1]; + if (!part) { return; } + + const isEnabled = api.getConfig(part)?.mode === 'patch-prototype'; + + if (!enabledRelativePaths.has(part) && !isEnabled) { + return; + } + + if (!isEnabled) { + item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + item.text = '$(sync-ignored) hot reload disabled'; + } else { + item.backgroundColor = undefined; + item.text = '$(sync) hot reload enabled'; + } + + item.command = { + command: 'vscode-diagnostic-tools.hotReload.toggle', + title: 'Toggle hot reload', + arguments: [part], + tooltip: 'Toggle hot reload' + }; + item.tooltip = 'Toggle hot reload'; + item.show(); + } + + store.add(vscode.window.onDidChangeActiveTextEditor(e => { + update(); + })); + + store.add(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('vscode-diagnostic-tools.debuggerScriptsConfig')) { + update(); + } + })); + + update(); + + store.add(vscode.commands.registerCommand('vscode-diagnostic-tools.hotReload.toggle', async (relativePath) => { + let config = getConfig(); + const current = config['hotReload.sources'][relativePath]; + const newValue = current === 'patch-prototype' ? undefined : 'patch-prototype'; + config = { ...config, 'hotReload.sources': { ...config['hotReload.sources'], [relativePath]: newValue } }; + + await vscode.workspace.getConfiguration('vscode-diagnostic-tools').update('debuggerScriptsConfig', config, vscode.ConfigurationTarget.Global); + + if (newValue === 'patch-prototype') { + const reloadFns = enabledRelativePaths.get(relativePath); + console.log(reloadFns); + if (reloadFns) { + for (const fn of reloadFns) { + fn(); + } + } + } + })); + + return api; +} + +const g = new Lazy(setupGlobals); + +/** @type {RunFunction} */ +module.exports.run = async function (debugSession, ctx) { + const store = new DisposableStore(); + + const global = ctx.vscode ? g.getValue(ctx.vscode) : undefined; + + const watcher = store.add(await DirWatcher.watchRecursively(path.join(__dirname, '../out/'))); + + /** + * So that the same file always gets the same reload fn. + * @type {Map void>} + */ + const reloadFns = new Map(); + + store.add(watcher.onDidChange(async changes => { + const supportedChanges = changes + .filter(c => c.path.endsWith('.js') || c.path.endsWith('.css')) + .map(c => { + const relativePath = c.path.replace(/\\/g, '/').split('/out/')[1]; + return { ...c, relativePath, config: global?.getConfig(relativePath) }; + }); + + const result = await 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 }); + /** @type {{ relativePath: string, path: string }[]} */ + const reloadFailedJsFiles = []; + + for (const change of changes) { + handleChange(change.relativePath, change.path, change.newContent, change.config); + } + + return { reloadFailedJsFiles }; + /** + * @param {string} relativePath * @param {string} path * @param {string} newSrc + * @param {HotReloadConfig | undefined} config */ - function handleChange(path, newSrc) { - const relativePath = path.replace(/\\/g, '/').split('/out/')[1]; + function handleChange(relativePath, path, newSrc, config) { if (relativePath.endsWith('.css')) { handleCssChange(relativePath); } else if (relativePath.endsWith('.js')) { - handleJsChange(relativePath, newSrc); + handleJsChange(relativePath, path, newSrc, config); } } @@ -60,8 +231,9 @@ module.exports.run = async function (debugSession) { /** * @param {string} relativePath * @param {string} newSrc + * @param {HotReloadConfig | undefined} config */ - function handleJsChange(relativePath, newSrc) { + function handleJsChange(relativePath, path, newSrc, config) { const moduleIdStr = trimEnd(relativePath, '.js'); /** @type {any} */ @@ -85,11 +257,14 @@ module.exports.run = async function (debugSession) { // A frozen copy of the previous exports const oldExports = Object.freeze({ ...oldModule.exports }); - const reloadFn = g.$hotReload_applyNewExports?.({ oldExports, newSrc }); + const reloadFn = g.$hotReload_applyNewExports?.({ oldExports, newSrc, config }); if (!reloadFn) { console.log(debugSessionName, 'ignoring js change, as module does not support hot-reload', relativePath); hotReloadData.shouldReload = true; + + reloadFailedJsFiles.push({ relativePath, path }); + setMessage(`hot reload not supported for ${formatPath(relativePath)} - ${new Date().toLocaleTimeString()}`); return; } @@ -178,19 +353,19 @@ module.exports.run = async function (debugSession) { return str; } - for (const change of changes) { - handleChange(change.path, change.newContent); - } - }, supportedChanges, debugSession.name.substring(0, 25)); - }); - return { - dispose() { - sub.dispose(); - watcher.dispose(); + for (const failedFile of result.reloadFailedJsFiles) { + const reloadFn = reloadFns.get(failedFile.relativePath) ?? (() => { + console.log('force change'); + watcher.forceChange(failedFile.path); + }); + reloadFns.set(failedFile.relativePath, reloadFn); + global?.reloadFailed(failedFile.relativePath, reloadFn); } - }; + })); + + return store; }; class DirWatcher { @@ -237,16 +412,23 @@ class DirWatcher { } }); const result = await r; - return new DirWatcher(event, () => result.unsubscribe()); + return new DirWatcher(event, () => result.unsubscribe(), path => { + const content = fileContents.get(path); + if (content !== undefined) { + listeners.forEach(l => l([{ path: path, newContent: content }])); + } + }); } /** * @param {(handler: (changes: { path: string, newContent: string }[]) => void) => IDisposable} onDidChange * @param {() => void} unsub + * @param {(path: string) => void} forceChange */ - constructor(onDidChange, unsub) { + constructor(onDidChange, unsub, forceChange) { this.onDidChange = onDidChange; this.unsub = unsub; + this.forceChange = forceChange; } dispose() { @@ -269,3 +451,33 @@ function debounce(fn, delay = 50) { }; } +class DisposableStore { + constructor() { + this._toDispose = new Set(); + this._isDisposed = false; + } + + + /** + * Adds an item to the collection. + * + * @template T + * @param {T} t - The item to add. + * @returns {T} The added item. + */ + add(t) { + this._toDispose.add(t); + return t; + } + dispose() { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + this.clear(); + } + clear() { + this._toDispose.forEach(item => item.dispose()); + this._toDispose.clear(); + } +} diff --git a/src/vs/base/common/hotReload.ts b/src/vs/base/common/hotReload.ts index 194e60da372..94dec8e9b2f 100644 --- a/src/vs/base/common/hotReload.ts +++ b/src/vs/base/common/hotReload.ts @@ -27,8 +27,9 @@ export function registerHotReloadHandler(handler: HotReloadHandler): IDisposable * * If no handler can apply the new exports, the module will not be reloaded. */ -export type HotReloadHandler = (args: { oldExports: Record; newSrc: string }) => AcceptNewExportsHandler | undefined; +export type HotReloadHandler = (args: { oldExports: Record; newSrc: string; config: IHotReloadConfig }) => AcceptNewExportsHandler | undefined; export type AcceptNewExportsHandler = (newExports: Record) => boolean; +export type IHotReloadConfig = HotReloadConfig; function registerGlobalHotReloadHandler() { if (!hotReloadHandlers) { @@ -37,9 +38,11 @@ function registerGlobalHotReloadHandler() { const g = globalThis as unknown as GlobalThisAddition; if (!g.$hotReload_applyNewExports) { - g.$hotReload_applyNewExports = oldExports => { + g.$hotReload_applyNewExports = args => { + const args2 = { config: { mode: undefined }, ...args }; + for (const h of hotReloadHandlers!) { - const result = h(oldExports); + const result = h(args2); if (result) { return result; } } return undefined; @@ -49,22 +52,25 @@ function registerGlobalHotReloadHandler() { return hotReloadHandlers; } -let hotReloadHandlers: Set<(args: { oldExports: Record; newSrc: string }) => AcceptNewExportsFn | undefined> | undefined = undefined; +let hotReloadHandlers: Set<(args: { oldExports: Record; newSrc: string; config: HotReloadConfig }) => AcceptNewExportsFn | undefined> | undefined = undefined; -interface GlobalThisAddition { - $hotReload_applyNewExports?(args: { oldExports: Record; newSrc: string }): AcceptNewExportsFn | undefined; +interface HotReloadConfig { + mode?: 'patch-prototype' | undefined; } +interface GlobalThisAddition { + $hotReload_applyNewExports?(args: { oldExports: Record; newSrc: string; config?: HotReloadConfig }): AcceptNewExportsFn | undefined; +} type AcceptNewExportsFn = (newExports: Record) => boolean; if (isHotReloadEnabled()) { // This code does not run in production. - registerHotReloadHandler(({ oldExports, newSrc }) => { - // Don't match its own source code - if (newSrc.indexOf('/* ' + 'hot-reload:patch-prototype-methods */') === -1) { + registerHotReloadHandler(({ oldExports, newSrc, config }) => { + if (config.mode !== 'patch-prototype') { return undefined; } + return newExports => { for (const key in newExports) { const exportedItem = newExports[key];