Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 179 additions & 76 deletions src/interactive/manager.ts

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/interactive/notebookOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { NotebookCellExecution, NotebookCellOutput } from 'vscode';

/** Keep existing output identities while an execution appends or updates results. */
export async function updateNotebookOutputs(
execution: Pick<NotebookCellExecution, 'replaceOutput' | 'replaceOutputItems' | 'appendOutput'>,
previous: readonly NotebookCellOutput[],
next: readonly NotebookCellOutput[],
): Promise<void> {
const compatible =
previous.length <= next.length &&
previous.every(
(output, index) =>
JSON.stringify(output.metadata) === JSON.stringify(next[index].metadata) &&
(typeof output.metadata?.rDisplayId === 'string' ||
output.items.map((item) => item.mime).join('\n') ===
next[index].items.map((item) => item.mime).join('\n')),
);
if (!compatible) {
await execution.replaceOutput(next);
return;
}
for (let index = 0; index < previous.length; index++) {
const output = previous[index],
items = next[index].items;
if (
output.items.length !== items.length ||
output.items.some(
(item, itemIndex) =>
item.mime !== items[itemIndex].mime ||
Buffer.compare(item.data, items[itemIndex].data) !== 0,
)
) {
await execution.replaceOutputItems(items, output);
}
}
if (next.length > previous.length) {
await execution.appendOutput(next.slice(previous.length));
}
}
91 changes: 91 additions & 0 deletions src/interactive/outputQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/** Coalesce output-related work without blocking incoming runtime events. */
export class OutputQueue {
private pending = new Set<string>();
private timer?: NodeJS.Timeout;
private running?: Promise<void>;
private disposed = false;
private paused = 0;

constructor(
private update: (id: string) => Promise<void>,
private report: (error: unknown) => void,
) {}

schedule(id: string, immediate = false): void {
if (this.disposed) {
return;
}
this.pending.add(id);
if (immediate) {
clearTimeout(this.timer);
this.timer = undefined;
void this.start();
} else if (!this.paused && !this.running && !this.timer) {
this.timer = setTimeout(() => {
this.timer = undefined;
void this.start();
}, 40);
}
}

has(id: string): boolean {
return this.pending.has(id);
}

async flush(): Promise<void> {
clearTimeout(this.timer);
this.timer = undefined;
while (this.running || (!this.paused && this.pending.size)) {
await this.start();
}
}

async pause(): Promise<void> {
this.paused++;
clearTimeout(this.timer);
this.timer = undefined;
await this.running;
}

resume(): void {
if (this.paused) {
this.paused--;
}
void this.start();
}

dispose(): void {
this.disposed = true;
this.pending.clear();
clearTimeout(this.timer);
this.timer = undefined;
}

private start(): Promise<void> {
if (this.running) {
return this.running;
}
if (this.disposed || this.paused || !this.pending.size) {
return Promise.resolve();
}
this.running = Promise.resolve()
.then(async () => {
while (!this.disposed && !this.paused && this.pending.size) {
const id = this.pending.values().next().value as string;
this.pending.delete(id);
try {
await this.update(id);
} catch (error) {
this.report(error);
}
}
})
.finally(() => {
this.running = undefined;
if (!this.paused && this.pending.size) {
void this.start();
}
});
return this.running;
}
}
105 changes: 105 additions & 0 deletions src/interactive/plotImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/// <reference lib="dom" />

export function plotImageSource(page: Record<string, unknown>): string {
// The notebook already carries small/portable images. Using them also avoids
// a second transfer through the remote asset tunnel and its failure modes.
if (typeof page.imageData === 'string' && page.imageData) {
return `data:${String(page.mime)};base64,${page.imageData}`;
}
if (typeof page.svgData === 'string' && page.svgData) {
return `data:image/svg+xml;base64,${page.svgData}`;
}
return typeof page.url === 'string' ? page.url : '';
}

/** Load one snapshot at a time, coalescing updates without clearing the visible plot. */
export class PlotImage {
image?: HTMLImageElement;
private loading?: HTMLImageElement;
private desired?: { source: string; width: number; height: number; alt: string };
private shown?: string;
private disposed = false;

constructor(
private element: HTMLElement,
private status: (error: boolean) => void,
) {}

ready(): boolean {
return (
!!this.image?.complete &&
this.image.naturalWidth > 0 &&
(this.shown === this.desired?.source || this.image.src === this.desired?.source)
);
}

update(page: Record<string, unknown>, alt: string): void {
this.desired = {
source: plotImageSource(page),
width: Number(page.width),
height: Number(page.height),
alt,
};
if (this.shown === this.desired.source && this.image) {
this.image.alt = alt;
}
this.load();
}

dispose(): void {
this.disposed = true;
if (this.loading) {
this.loading.onload = null;
this.loading.onerror = null;
this.loading.removeAttribute('src');
}
this.loading = undefined;
}

private load(): void {
if (this.disposed || this.loading || !this.desired || this.shown === this.desired.source) {
return;
}
const requested = this.desired;
const image = document.createElement('img');
image.crossOrigin = 'anonymous';
image.style.cssText = 'display:block;max-width:100%;height:auto';
image.alt = requested.alt;
if (requested.width > 0 && requested.height > 0) {
image.width = requested.width;
image.height = requested.height;
}
this.loading = image;
// Reserve space on the first frame; subsequent frames load off screen.
if (!this.image) {
this.image = image;
this.element.append(image);
}
const complete = (error: boolean): void => {
if (this.disposed || this.loading !== image) {
return;
}
this.loading = undefined;
image.onload = null;
image.onerror = null;
if (this.desired?.source !== requested.source) {
this.load();
return;
}
if (!error) {
image.width = image.naturalWidth;
image.height = image.naturalHeight;
image.alt = this.desired.alt;
if (this.image !== image) {
this.image!.replaceWith(image);
this.image = image;
}
this.shown = requested.source;
}
this.status(error);
};
image.onload = () => complete(false);
image.onerror = () => complete(true);
image.src = requested.source;
}
}
Loading
Loading