Skip to content

Commit

Permalink
Uses status bar to indicate hot reloading
Browse files Browse the repository at this point in the history
  • Loading branch information
hediet committed Feb 2, 2024
1 parent 942ed9a commit 22a578f
Show file tree
Hide file tree
Showing 3 changed files with 266 additions and 35 deletions.
21 changes: 17 additions & 4 deletions scripts/debugger-scripts-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
256 changes: 234 additions & 22 deletions scripts/hot-reload-injected-script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -12,28 +13,198 @@ const parcelWatcher = require('@parcel/watcher');

// This file is loaded by the vscode-diagnostic-tools extension and injected into the debugger.


/**
* 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) {
const watcher = await DirWatcher.watchRecursively(path.join(__dirname, '../out/'));
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 sub = watcher.onDidChange(changes => {
const supportedChanges = changes.filter(c => c.path.endsWith('.js') || c.path.endsWith('.css'));
debugSession.evalJs(function (changes, debugSessionName) {
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);
}
}

Expand All @@ -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} */
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -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();
}
}
Loading

0 comments on commit 22a578f

Please sign in to comment.