2.4.0
This commit is contained in:
@@ -27,7 +27,30 @@ export class Config {
|
||||
);
|
||||
|
||||
public get customScriptPaths(): string[] {
|
||||
return this._customScriptPaths.get().map(p => {
|
||||
return this._customScriptPaths.get().map((p) => {
|
||||
const tpl = new SimpleTemplate(p);
|
||||
return tpl.render({
|
||||
workspaceFolder: () => {
|
||||
const workspaceFolder = (workspace.workspaceFolders ||
|
||||
[])[0];
|
||||
if (!workspaceFolder) {
|
||||
throw new Error(
|
||||
`Cannot get workspace folder - '${p}' cannot be evaluated!`
|
||||
);
|
||||
}
|
||||
return workspaceFolder.uri.fsPath;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private readonly _customVisualizerScriptPaths = new VsCodeSetting(
|
||||
"debugVisualizer.customVisualizerScriptPaths",
|
||||
{ serializer: serializerWithDefault<string[]>([]) }
|
||||
);
|
||||
|
||||
public get customVisualizerScriptPaths(): string[] {
|
||||
return this._customVisualizerScriptPaths.get().map((p) => {
|
||||
const tpl = new SimpleTemplate(p);
|
||||
return tpl.render({
|
||||
workspaceFolder: () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
DataExtractionResult,
|
||||
DataResult,
|
||||
getExpressionForDataExtractorApi,
|
||||
getExpressionForDataExtractorApi,
|
||||
getExpressionToInitializeDataExtractorApi,
|
||||
ApiHasNotBeenInitializedCode,
|
||||
} from "@hediet/debug-visualizer-data-extraction";
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
VisualizationBackendBase,
|
||||
} from "./VisualizationBackend";
|
||||
import { Disposable } from "@hediet/std/disposable";
|
||||
import { DebouncedRunner } from "../utils/DebouncedRunner";
|
||||
import { FileWatcher } from "../webview/WebviewConnection";
|
||||
|
||||
registerUpdateReconciler(module);
|
||||
|
||||
@@ -232,199 +234,76 @@ class JsVisualizationBackend extends VisualizationBackendBase {
|
||||
private async initializeCustomScripts(): Promise<void> {
|
||||
this.dispose.untrack(this.customScripts);
|
||||
this.customScripts?.dispose();
|
||||
this.customScripts = this.dispose.track(new CustomScripts(this.debugSession, this.config, () => this.onChangeEmitter.emit()));
|
||||
this.customScripts = this.dispose.track(
|
||||
new CustomScripts(this.debugSession, this.config, () =>
|
||||
this.onChangeEmitter.emit()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomScripts {
|
||||
public readonly dispose = Disposable.fn();
|
||||
|
||||
@observable
|
||||
private filesVersionId: number = 0;
|
||||
|
||||
private readonly fileChangeDebouncedScheduler = new DebouncedScheduler(100);
|
||||
|
||||
constructor(private readonly debugSession: DebugSessionProxy, private readonly config: Config, private readonly changeHandler: () => void) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
const watchers = new Map<string, WatchedFile>();
|
||||
this.dispose.track({
|
||||
dispose: () => {
|
||||
for (const w of watchers.values()) {
|
||||
w.dispose();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.dispose.track({
|
||||
dispose: reaction(
|
||||
() => ({
|
||||
customScriptPaths: [...this.config.customScriptPaths],
|
||||
version: this.filesVersionId,
|
||||
}),
|
||||
async ({ customScriptPaths }) => {
|
||||
const promises = new Array<
|
||||
Promise<[Result, WatchedFile]>
|
||||
>();
|
||||
|
||||
const activePaths = new Set<string>();
|
||||
for (const fsPath of customScriptPaths) {
|
||||
activePaths.add(fsPath);
|
||||
let entry = watchers.get(fsPath);
|
||||
if (!entry) {
|
||||
entry = new WatchedFile(
|
||||
fsPath,
|
||||
this.debugSession,
|
||||
() => {
|
||||
this.fileChangeDebouncedScheduler.run(
|
||||
() => {
|
||||
this.filesVersionId++;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
watchers.set(fsPath, entry);
|
||||
}
|
||||
|
||||
promises.push(entry.update().then((e) => [e, entry!]));
|
||||
}
|
||||
|
||||
for (const [fsPath, entry] of watchers) {
|
||||
if (!activePaths.has(fsPath)) {
|
||||
promises.push(
|
||||
entry.remove().then((e) => [e, entry])
|
||||
);
|
||||
entry.dispose();
|
||||
watchers.delete(fsPath);
|
||||
}
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const errors = results.filter(([r]) => r.error);
|
||||
for (const [e, watcher] of errors) {
|
||||
window.showErrorMessage(
|
||||
`Error while evaluating "${watcher.fsPath}": ${
|
||||
e.error!.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
if (promises.length > 0) {
|
||||
this.changeHandler();
|
||||
}
|
||||
},
|
||||
{
|
||||
fireImmediately: true,
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface Result {
|
||||
error?: { message: string };
|
||||
}
|
||||
|
||||
class WatchedFile {
|
||||
public static nextId = 0;
|
||||
private readonly watcher = watch(
|
||||
this.fsPath,
|
||||
{ encoding: "utf-8" },
|
||||
this.changeHandler
|
||||
);
|
||||
public readonly id = WatchedFile.nextId++;
|
||||
public lastContent: string | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
public readonly fsPath: string,
|
||||
private readonly debugSession: DebugSessionProxy,
|
||||
private readonly changeHandler: () => void
|
||||
) {}
|
||||
debugSession: DebugSessionProxy,
|
||||
config: Config,
|
||||
changeHandler: () => void
|
||||
) {
|
||||
this.dispose.track(
|
||||
new FileWatcher(
|
||||
() => config.customScriptPaths,
|
||||
async (files) => {
|
||||
for (const file of files) {
|
||||
if (!file.fileExists) {
|
||||
window.showErrorMessage(
|
||||
`The file ${file.path} does not exist.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.watcher.close();
|
||||
}
|
||||
let expression = `
|
||||
(
|
||||
runCode => {
|
||||
let fn = undefined;
|
||||
if (runCode) {
|
||||
const module = {};
|
||||
runCode(module);
|
||||
fn = module.exports;
|
||||
}
|
||||
${getExpressionForDataExtractorApi()}.setDataExtractorFn(
|
||||
${JSON.stringify(file.path)},
|
||||
fn
|
||||
);
|
||||
}
|
||||
)
|
||||
(
|
||||
${
|
||||
file.content === undefined
|
||||
? "undefined"
|
||||
: `function (module) { ${file.content} }`
|
||||
}
|
||||
)`;
|
||||
|
||||
public async update(): Promise<Result> {
|
||||
if (!existsSync(this.fsPath)) {
|
||||
return {
|
||||
error: { message: `File does not exist.` },
|
||||
};
|
||||
}
|
||||
try {
|
||||
await debugSession.evaluate({
|
||||
expression,
|
||||
frameId: undefined,
|
||||
context: "repl",
|
||||
});
|
||||
} catch (e) {
|
||||
window.showErrorMessage(
|
||||
'Error while running custom visualization extractor script "' +
|
||||
file.path +
|
||||
'": ' +
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scriptContent = readFileSync(this.fsPath, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
if (this.lastContent === scriptContent) {
|
||||
return {};
|
||||
}
|
||||
this.lastContent = scriptContent;
|
||||
|
||||
const expression = `
|
||||
(
|
||||
load => {
|
||||
var module = {};
|
||||
load(module);
|
||||
${getExpressionForDataExtractorApi()}.registerDataExtractorsSource("${
|
||||
this.id
|
||||
}", module.exports);
|
||||
changeHandler();
|
||||
}
|
||||
)
|
||||
(
|
||||
function (module) { ${scriptContent} }
|
||||
)`;
|
||||
try {
|
||||
await this.debugSession.evaluate({
|
||||
expression,
|
||||
frameId: undefined,
|
||||
context: "repl",
|
||||
});
|
||||
} catch (e) {
|
||||
return { error: { message: e.message } };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public async remove(): Promise<Result> {
|
||||
const expression = `${getExpressionForDataExtractorApi()}.unregisterDataExtractorsSource("${
|
||||
this.id
|
||||
}")`;
|
||||
try {
|
||||
await this.debugSession.evaluate({
|
||||
expression,
|
||||
frameId: undefined,
|
||||
context: "repl",
|
||||
});
|
||||
} catch (e) {
|
||||
return { error: { message: e.message } };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
class DebouncedScheduler {
|
||||
private timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(private readonly debounceTimeout: number) {}
|
||||
|
||||
public run(action: () => void): void {
|
||||
this.clear();
|
||||
this.timeout = setTimeout(action, this.debounceTimeout);
|
||||
}
|
||||
|
||||
private clear() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clear();
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
21
extension/src/utils/DebouncedRunner.ts
Normal file
21
extension/src/utils/DebouncedRunner.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export class DebouncedRunner {
|
||||
private timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor(private readonly debounceTimeout: number) {}
|
||||
|
||||
public run(action: () => void): void {
|
||||
this.clear();
|
||||
this.timeout = setTimeout(action, this.debounceTimeout);
|
||||
}
|
||||
|
||||
private clear() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
30
extension/src/utils/IncrementalMap.ts
Normal file
30
extension/src/utils/IncrementalMap.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Disposable } from "@hediet/std/disposable";
|
||||
import { observable, autorun } from "mobx";
|
||||
|
||||
export class IncrementalMap<TKey, TValue extends Disposable> {
|
||||
public readonly map: Map<TKey, TValue> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly getKeys: () => TKey[],
|
||||
private readonly getValue: (key: TKey) => TValue
|
||||
) {
|
||||
this.dispose.track({
|
||||
dispose: autorun(() => {
|
||||
const keys = this.getKeys();
|
||||
const toRemove = new Set(this.map.keys());
|
||||
for (const key of keys) {
|
||||
toRemove.delete(key);
|
||||
if (!this.map.has(key)) {
|
||||
this.map.set(key, this.getValue(key));
|
||||
}
|
||||
}
|
||||
for (const key of toRemove) {
|
||||
this.map.get(key)!.dispose();
|
||||
this.map.delete(key);
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public readonly dispose = Disposable.fn();
|
||||
}
|
||||
@@ -11,9 +11,15 @@ import { WebviewServer } from "./WebviewServer";
|
||||
import * as open from "open";
|
||||
import chromeLauncher = require("chrome-launcher");
|
||||
import { Config } from "../Config";
|
||||
import { IncrementalMap } from "../utils/IncrementalMap";
|
||||
import { watch, existsSync, readFileSync } from "fs";
|
||||
import { DebouncedRunner } from "../utils/DebouncedRunner";
|
||||
import { getExpressionForDataExtractorApi } from "@hediet/debug-visualizer-data-extraction";
|
||||
import { window } from "vscode";
|
||||
|
||||
export class WebviewConnection {
|
||||
public readonly dispose = Disposable.fn();
|
||||
|
||||
@observable
|
||||
private watcher: VisualizationWatch | undefined = undefined;
|
||||
|
||||
@@ -57,8 +63,8 @@ export class WebviewConnection {
|
||||
|
||||
let oldPreferredDataExtractor: VisualizationWatch["preferredDataExtractor"];
|
||||
if (this.watcher) {
|
||||
oldPreferredDataExtractor = this.watcher
|
||||
.preferredDataExtractor;
|
||||
oldPreferredDataExtractor =
|
||||
this.watcher.preferredDataExtractor;
|
||||
this.dispose.untrack(this.watcher).dispose();
|
||||
}
|
||||
this.watcher = this.dispose.track(
|
||||
@@ -95,10 +101,11 @@ export class WebviewConnection {
|
||||
getCompletions: async ({ text, column }) => {
|
||||
throwIfNotAuthenticated();
|
||||
|
||||
const completions = await evaluationWatchService.getCompletions(
|
||||
text,
|
||||
column
|
||||
);
|
||||
const completions =
|
||||
await evaluationWatchService.getCompletions(
|
||||
text,
|
||||
column
|
||||
);
|
||||
return {
|
||||
completions,
|
||||
};
|
||||
@@ -108,6 +115,36 @@ export class WebviewConnection {
|
||||
|
||||
this.client = client;
|
||||
|
||||
this.dispose.track(
|
||||
new FileWatcher(
|
||||
() => config.customVisualizerScriptPaths,
|
||||
async (files) => {
|
||||
for (const file of files) {
|
||||
if (!file.fileExists) {
|
||||
window.showErrorMessage(
|
||||
`The file ${file.path} does not exist.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await client.setCustomVisualizerScript({
|
||||
id: file.path,
|
||||
jsSource: file.content || null,
|
||||
});
|
||||
} catch (e) {
|
||||
window.showErrorMessage(
|
||||
'Error while running custom visualization extractor script "' +
|
||||
file.path +
|
||||
'": ' +
|
||||
e.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.dispose.track([
|
||||
Disposable.create(
|
||||
autorun(() => {
|
||||
@@ -153,3 +190,95 @@ async function launchChrome(url: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class FileWatcher {
|
||||
public readonly dispose = Disposable.fn();
|
||||
|
||||
constructor(
|
||||
getFilePaths: () => string[],
|
||||
handleFileContents: (
|
||||
files: {
|
||||
path: string;
|
||||
fileExists: boolean;
|
||||
content: string | undefined;
|
||||
}[]
|
||||
) => void
|
||||
) {
|
||||
const scheduleRefresh = new Set<SingleFileWatcher>();
|
||||
const debouncer = new DebouncedRunner(200);
|
||||
|
||||
const map = this.dispose.track(
|
||||
new IncrementalMap(
|
||||
getFilePaths,
|
||||
(path) =>
|
||||
new SingleFileWatcher(path, (w) => {
|
||||
scheduleRefresh.add(w);
|
||||
debouncer.run(() => {
|
||||
const changedWatchers = [...scheduleRefresh].filter(
|
||||
(w) => w.refresh()
|
||||
);
|
||||
handleFileContents(
|
||||
changedWatchers.map((w) => ({
|
||||
path: w.path,
|
||||
content: w.content,
|
||||
fileExists: w.exists,
|
||||
}))
|
||||
);
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
handleFileContents(
|
||||
[...map.map].map(([path, o]) => {
|
||||
return {
|
||||
path,
|
||||
content: o.content,
|
||||
fileExists: o.exists,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SingleFileWatcher {
|
||||
private readonly watcher = watch(this.path, { encoding: "utf-8" }, () =>
|
||||
this.scheduleRefresh(this)
|
||||
);
|
||||
private isDisposed = false;
|
||||
public content: string | undefined;
|
||||
public exists: boolean = true;
|
||||
|
||||
constructor(
|
||||
public readonly path: string,
|
||||
private readonly scheduleRefresh: (self: SingleFileWatcher) => void
|
||||
) {
|
||||
this.scheduleRefresh(this);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.isDisposed = true;
|
||||
this.content = undefined;
|
||||
this.scheduleRefresh(this);
|
||||
this.watcher.close();
|
||||
}
|
||||
|
||||
public refresh() {
|
||||
if (this.isDisposed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!existsSync(this.path)) {
|
||||
this.exists = false;
|
||||
this.content = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = readFileSync(this.path, "utf-8");
|
||||
if (newContent !== this.content) {
|
||||
this.content = newContent;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,13 @@ export const webviewContract = contract({
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
|
||||
setCustomVisualizerScript: notificationContract({
|
||||
params: types.type({
|
||||
id: types.string,
|
||||
jsSource: types.union([types.string, types.null]),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
server: {
|
||||
authenticate: requestContract({
|
||||
@@ -90,8 +97,8 @@ function unchecked<T>(): types.Type<T, any, unknown> {
|
||||
return new types.Type<T, T, unknown>(
|
||||
"unchecked",
|
||||
(u): u is T => true,
|
||||
value => types.success(value as T),
|
||||
value => value
|
||||
(value) => types.success(value as T),
|
||||
(value) => value
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user