Uses status bar to indicate hot reloading
parent
942ed9acd7
commit
22a578f53b
|
@ -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<IDisposable>);
|
||||
type RunFunction =
|
||||
| ((debugSession: IDebugSession, context: Context) => IDisposable)
|
||||
| ((debugSession: IDebugSession, context: Context) => Promise<IDisposable>);
|
||||
|
||||
interface IDebugSession {
|
||||
name: string;
|
||||
eval(expression: string): Promise<void>;
|
||||
evalJs<T extends any[]>(bodyFn: (...args: T) => void, ...args: T): Promise<void>;
|
||||
eval(expression: string): Promise<unknown>;
|
||||
evalJs<T extends any[], TResult>(
|
||||
bodyFn: (...args: T) => TResult,
|
||||
...args: T
|
||||
): Promise<TResult>;
|
||||
}
|
||||
|
||||
interface Context {
|
||||
vscode: typeof import('vscode');
|
||||
}
|
||||
|
||||
interface IDisposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface HotReloadConfig {
|
||||
mode?: 'patch-prototype' | undefined;
|
||||
}
|
||||
|
||||
interface GlobalThisAddition {
|
||||
$hotReload_applyNewExports?(args: { oldExports: Record<string, unknown>; newSrc: string }): AcceptNewExportsFn | undefined;
|
||||
$hotReload_applyNewExports?(args: { oldExports: Record<string, unknown>; newSrc: string; config?: HotReloadConfig }): AcceptNewExportsFn | undefined;
|
||||
}
|
||||
|
||||
type AcceptNewExportsFn = (newExports: Record<string, unknown>) => boolean;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// @ts-check
|
||||
/// <reference path='../src/vscode-dts/vscode.d.ts' />
|
||||
/// <reference path='debugger-scripts-api.d.ts' />
|
||||
|
||||
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<string, Set<() => 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<string, () => 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<string, unknown>; newSrc: string }) => AcceptNewExportsHandler | undefined;
|
||||
export type HotReloadHandler = (args: { oldExports: Record<string, unknown>; newSrc: string; config: IHotReloadConfig }) => AcceptNewExportsHandler | undefined;
|
||||
export type AcceptNewExportsHandler = (newExports: Record<string, unknown>) => 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<string, unknown>; newSrc: string }) => AcceptNewExportsFn | undefined> | undefined = undefined;
|
||||
let hotReloadHandlers: Set<(args: { oldExports: Record<string, unknown>; newSrc: string; config: HotReloadConfig }) => AcceptNewExportsFn | undefined> | undefined = undefined;
|
||||
|
||||
interface GlobalThisAddition {
|
||||
$hotReload_applyNewExports?(args: { oldExports: Record<string, unknown>; newSrc: string }): AcceptNewExportsFn | undefined;
|
||||
interface HotReloadConfig {
|
||||
mode?: 'patch-prototype' | undefined;
|
||||
}
|
||||
|
||||
interface GlobalThisAddition {
|
||||
$hotReload_applyNewExports?(args: { oldExports: Record<string, unknown>; newSrc: string; config?: HotReloadConfig }): AcceptNewExportsFn | undefined;
|
||||
}
|
||||
|
||||
type AcceptNewExportsFn = (newExports: Record<string, unknown>) => 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];
|
||||
|
|
Loading…
Reference in New Issue