dev: initial implementation of notebook-snapshot extension

Manual-commit notebook snapshot extension (JupyterLab 4):

Backend
- Storage adapter layer (Local + S3 via boto3, pip extra)
  - Dumb put/get/list/delete contract; key validation
  - S3Storage: lazy boto3 import, S3ConnectionError with friendly
    messages + connectivity check at startup
- SnapshotStore
  - Strip code outputs/execution_count, MD5 on cleaned JSON
  - Gzipped envelope (id/timestamp/name/description/hash/size/notebook)
  - Append-only manifest.json (rebuildable from version files)
  - SnapshotUnchangedError when new hash matches most recent version
- REST API (commit / list / content) under /snapshot/ namespace
- traitlets config (storage_type, local_root, s3_* + S3_* env fallback)

Frontend
- snapshot:commit command (toolbar button + command palette)
- Dialog for name + description
- SnapshotPanel (left sidebar timeline) + DiffWidget (main area)
- Cell diff: id-match first, LCS fallback; jsdiff for line diff
- Restore via model.fromJSON() + context.save() (no refresh)
- All AGENTS.md hard conventions enforced

Tests: 33 backend pytest passing (storage + store + routes)

Docs: AGENTS.md, design.md, README.md synced with implementation.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-21 17:33:55 +08:00
co-authored by Claude
commit cd1aba6698
67 changed files with 17926 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
/**
* Smoke tests for the typed API wrappers. We mock requestAPI so the
* tests stay independent of the JupyterLab runtime.
*/
jest.mock('../request', () => ({
requestAPI: jest.fn()
}));
import { requestAPI } from '../request';
import { commitSnapshot, listSnapshots, getSnapshotContent } from '../api';
const mockedRequestAPI = requestAPI as jest.MockedFunction<typeof requestAPI>;
const fakeSettings = {} as any;
beforeEach(() => {
mockedRequestAPI.mockReset();
});
describe('api wrappers', () => {
it('commitSnapshot sends POST with JSON body and returns entry', async () => {
mockedRequestAPI.mockResolvedValueOnce({ entry: { id: 'v1' } } as any);
const entry = await commitSnapshot(fakeSettings, 'p.ipynb', { cells: [] }, 'n', 'd');
expect(entry).toEqual({ id: 'v1' });
expect(mockedRequestAPI).toHaveBeenCalledWith(
'notebook-version/commit',
fakeSettings,
expect.objectContaining({ method: 'POST' })
);
const init = mockedRequestAPI.mock.calls[0][2];
expect(init!.headers).toEqual({ 'Content-Type': 'application/json' });
expect(JSON.parse(init!.body as string)).toEqual({
path: 'p.ipynb',
content: { cells: [] },
name: 'n',
description: 'd'
});
});
it('listSnapshots encodes the path query and unwraps versions', async () => {
mockedRequestAPI.mockResolvedValueOnce({ versions: [{ id: 'a' }] } as any);
const versions = await listSnapshots(fakeSettings, 'a b.ipynb');
expect(versions).toEqual([{ id: 'a' }]);
expect(mockedRequestAPI).toHaveBeenCalledWith(
'notebook-version/list?path=a%20b.ipynb',
fakeSettings,
undefined
);
});
it('getSnapshotContent encodes path and id', async () => {
mockedRequestAPI.mockResolvedValueOnce({ cells: [] } as any);
const nb = await getSnapshotContent(fakeSettings, 'p.ipynb', 'v 1');
expect(nb).toEqual({ cells: [] });
expect(mockedRequestAPI).toHaveBeenCalledWith(
'notebook-version/content?path=p.ipynb&id=v%201',
fakeSettings,
undefined
);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { diffNotebook, INotebook } from '../diff';
const mk = (cells: INotebook['cells']): INotebook => ({
cells,
metadata: {},
nbformat: 4,
nbformat_minor: 5
});
describe('diffNotebook', () => {
it('marks all cells unchanged when both notebooks are identical', () => {
const nb = mk([
{ cell_type: 'code', id: 'a', source: 'x = 1' },
{ cell_type: 'markdown', id: 'b', source: '# hi' }
]);
const out = diffNotebook(nb, nb);
expect(out).toHaveLength(2);
expect(out.every(c => c.type === 'unchanged')).toBe(true);
});
it('marks modified cells with a non-empty line diff (by id)', () => {
const oldNb = mk([{ cell_type: 'code', id: 'a', source: 'x = 1' }]);
const newNb = mk([{ cell_type: 'code', id: 'a', source: 'x = 2' }]);
const out = diffNotebook(oldNb, newNb);
expect(out).toHaveLength(1);
expect(out[0].type).toBe('modified');
expect(out[0].lineDiff).toBeDefined();
expect(out[0].lineDiff!.length).toBeGreaterThan(0);
});
it('marks added cells (new id)', () => {
const oldNb = mk([{ cell_type: 'code', id: 'a', source: 'x' }]);
const newNb = mk([
{ cell_type: 'code', id: 'a', source: 'x' },
{ cell_type: 'code', id: 'b', source: 'y' }
]);
const out = diffNotebook(oldNb, newNb);
const added = out.find(c => c.type === 'added');
expect(added).toBeDefined();
expect(added!.newCell!.id).toBe('b');
});
it('marks deleted cells (id not in new)', () => {
const oldNb = mk([
{ cell_type: 'code', id: 'a', source: 'x' },
{ cell_type: 'code', id: 'b', source: 'y' }
]);
const newNb = mk([{ cell_type: 'code', id: 'a', source: 'x' }]);
const out = diffNotebook(oldNb, newNb);
const deleted = out.find(c => c.type === 'deleted');
expect(deleted).toBeDefined();
expect(deleted!.oldCell!.id).toBe('b');
});
it('falls back to LCS alignment when cells have no ids', () => {
const oldNb = mk([{ cell_type: 'code', source: 'a' }]);
const newNb = mk([
{ cell_type: 'code', source: 'a' },
{ cell_type: 'code', source: 'b' }
]);
const out = diffNotebook(oldNb, newNb);
const types = out.map(c => c.type);
expect(types).toContain('unchanged');
expect(types).toContain('added');
});
});
+9
View File
@@ -0,0 +1,9 @@
/**
* Example of [Jest](https://jestjs.io/docs/getting-started) unit tests
*/
describe('snapshot', () => {
it('should be tested', () => {
expect(1 + 1).toEqual(2);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { ServerConnection } from '@jupyterlab/services';
import { requestAPI } from './request';
export interface VersionEntry {
id: string;
timestamp: number;
name: string;
description: string;
hash: string;
size: number;
}
export interface CommitResponse {
entry: VersionEntry | null;
skipped: boolean;
reason: string | null;
message: string | null;
}
export async function commitSnapshot(
serverSettings: ServerConnection.ISettings,
path: string,
content: unknown,
name: string,
description: string
): Promise<CommitResponse> {
return requestAPI<CommitResponse>('notebook-version/commit', serverSettings, {
method: 'POST',
body: JSON.stringify({ path, content, name, description }),
headers: { 'Content-Type': 'application/json' }
});
}
export async function listSnapshots(
serverSettings: ServerConnection.ISettings,
path: string
): Promise<VersionEntry[]> {
const endpoint = `notebook-version/list?path=${encodeURIComponent(path)}`;
const response = await requestAPI<{ versions: VersionEntry[] }>(
endpoint,
serverSettings
);
return response.versions;
}
export async function getSnapshotContent(
serverSettings: ServerConnection.ISettings,
path: string,
id: string
): Promise<unknown> {
const endpoint = `notebook-version/content?path=${encodeURIComponent(path)}&id=${encodeURIComponent(id)}`;
return requestAPI<unknown>(endpoint, serverSettings);
}
+125
View File
@@ -0,0 +1,125 @@
import { JupyterFrontEnd } from '@jupyterlab/application';
import { INotebookTracker, NotebookModel } from '@jupyterlab/notebook';
import { Dialog, Notification, showDialog } from '@jupyterlab/apputils';
import { commitSnapshot, getSnapshotContent } from './api';
import { SnapshotModel } from './model';
import { CommitDialogBody } from './widgets/CommitDialog';
export namespace CommandIDs {
export const commit = 'snapshot:commit';
export const restore = 'snapshot:restore';
}
type RestoreArgs = { id?: string };
export function registerCommands(
app: JupyterFrontEnd,
tracker: INotebookTracker,
model: SnapshotModel
): void {
const { commands } = app;
commands.addCommand(CommandIDs.commit, {
label: '保存快照',
isEnabled: () => {
const panel = tracker.currentWidget;
return !!panel && !panel.context.isDisposed;
},
execute: async () => {
const panel = tracker.currentWidget;
if (!panel) {
return;
}
const path = panel.context.path;
if (!path) {
Notification.warning('请先保存当前 notebook(确定路径)后再创建快照');
return;
}
const notebookModel = panel.content.model;
if (!notebookModel) {
return;
}
const notebook = notebookModel.toJSON();
const body = new CommitDialogBody();
const result = await showDialog({
title: '保存快照',
body,
buttons: [Dialog.cancelButton(), Dialog.okButton({ label: '保存' })]
});
if (!result.button.accept) {
return;
}
const name = body.getName().trim();
if (!name) {
Notification.warning('快照名称不能为空');
return;
}
const description = body.getDescription();
try {
const result = await commitSnapshot(
app.serviceManager.serverSettings,
path,
notebook,
name,
description
);
if (result.skipped) {
// Content hash matches the most recent version — nothing to save.
Notification.warning(result.message ?? '内容未变更,已跳过保存');
} else if (result.entry) {
await model.refresh();
Notification.success(`已保存快照:${name}`);
}
} catch (e) {
Notification.error(`保存快照失败:${(e as Error).message}`);
}
}
});
commands.addCommand(CommandIDs.restore, {
label: '恢复快照',
isEnabled: () => !!tracker.currentWidget,
execute: async (args: Partial<RestoreArgs>) => {
const versionId = args.id;
if (!versionId) {
return;
}
const panel = tracker.currentWidget;
if (!panel) {
return;
}
const path = panel.context.path;
if (!path) {
return;
}
const confirm = await showDialog({
title: '恢复快照',
body:
'恢复旧版本将覆盖当前画布,未保存的修改将丢失。\n' +
'建议先为当前状态创建快照再继续。',
buttons: [Dialog.cancelButton(), Dialog.okButton({ label: '恢复' })]
});
if (!confirm.button.accept) {
return;
}
try {
const notebook = await getSnapshotContent(
app.serviceManager.serverSettings,
path,
versionId
);
const notebookModel = panel.content.model;
if (!notebookModel) {
return;
}
type NotebookContent = Parameters<NotebookModel['fromJSON']>[0];
notebookModel.fromJSON(notebook as NotebookContent);
await panel.context.save();
Notification.success('已恢复快照');
} catch (e) {
Notification.error(`恢复失败:${(e as Error).message}`);
}
}
});
}
+130
View File
@@ -0,0 +1,130 @@
import { diffLines, Change } from 'diff';
export type ChangeType = 'unchanged' | 'added' | 'deleted' | 'modified';
export interface CellChange {
type: ChangeType;
newIndex?: number;
oldIndex?: number;
newCell?: ICell;
oldCell?: ICell;
lineDiff?: Change[];
}
export interface ICell {
cell_type: string;
id?: string;
source: string;
[key: string]: unknown;
}
export interface INotebook {
cells: ICell[];
[key: string]: unknown;
}
const cellId = (c: ICell | undefined): string | undefined => c?.id as string | undefined;
const cellSource = (c: ICell | undefined): string =>
Array.isArray(c?.source) ? (c!.source as unknown as string[]).join('') : (c?.source as string) ?? '';
/**
* Diff two notebooks at the cell level.
*
* Matching order (per hard convention #6): cell id first; LCS is a fallback
* for notebooks without stable cell ids.
*/
export function diffNotebook(oldNb: INotebook, newNb: INotebook): CellChange[] {
const oldCells: ICell[] = oldNb.cells ?? [];
const newCells: ICell[] = newNb.cells ?? [];
const oldById = new Map<string, { cell: ICell; idx: number }>();
oldCells.forEach((c, i) => {
const id = cellId(c);
if (id) oldById.set(id, { cell: c, idx: i });
});
const allHaveIds = newCells.every(c => !!cellId(c)) && oldCells.every(c => !!cellId(c));
if (allHaveIds) {
return diffById(oldById, newCells);
}
return diffByLcs(oldCells, newCells);
}
function diffById(
oldById: Map<string, { cell: ICell; idx: number }>,
newCells: ICell[]
): CellChange[] {
const seenOld = new Set<string>();
const out: CellChange[] = [];
newCells.forEach((nc, ni) => {
const id = cellId(nc)!;
const hit = oldById.get(id);
if (hit) {
seenOld.add(id);
const oldSrc = cellSource(hit.cell);
const newSrc = cellSource(nc);
if (oldSrc === newSrc) {
out.push({ type: 'unchanged', newIndex: ni, oldIndex: hit.idx, newCell: nc, oldCell: hit.cell });
} else {
out.push({
type: 'modified',
newIndex: ni,
oldIndex: hit.idx,
newCell: nc,
oldCell: hit.cell,
lineDiff: diffLines(oldSrc, newSrc)
});
}
} else {
out.push({ type: 'added', newIndex: ni, newCell: nc });
}
});
// Append unmatched old cells as deleted, in their original order.
const deleted: CellChange[] = [];
oldById.forEach(({ cell, idx }, id) => {
if (!seenOld.has(id)) {
deleted.push({ type: 'deleted', oldIndex: idx, oldCell: cell });
}
});
deleted.sort((a, b) => (a.oldIndex ?? 0) - (b.oldIndex ?? 0));
return out.concat(deleted);
}
function diffByLcs(oldCells: ICell[], newCells: ICell[]): CellChange[] {
// Build an LCS on source hashes (content-equivalence), then walk.
const oldKeys = oldCells.map(c => cellSource(c));
const newKeys = newCells.map(c => cellSource(c));
const m = oldKeys.length;
const n = newKeys.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--) {
for (let j = n - 1; j >= 0; j--) {
dp[i][j] = oldKeys[i] === newKeys[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out: CellChange[] = [];
let i = 0;
let j = 0;
while (i < m && j < n) {
if (oldKeys[i] === newKeys[j]) {
out.push({ type: 'unchanged', newIndex: j, oldIndex: i, newCell: newCells[j], oldCell: oldCells[i] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: 'deleted', oldIndex: i, oldCell: oldCells[i] });
i++;
} else {
out.push({ type: 'added', newIndex: j, newCell: newCells[j] });
j++;
}
}
while (i < m) {
out.push({ type: 'deleted', oldIndex: i, oldCell: oldCells[i] });
i++;
}
while (j < n) {
out.push({ type: 'added', newIndex: j, newCell: newCells[j] });
j++;
}
return out;
}
+102
View File
@@ -0,0 +1,102 @@
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
import { ToolbarButton, MainAreaWidget } from '@jupyterlab/apputils';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { requestAPI } from './request';
import { registerCommands, CommandIDs } from './commands';
import { SnapshotModel } from './model';
import { SnapshotPanel } from './widgets/SnapshotPanel';
import { DiffWidget } from './widgets/DiffWidget';
import { INotebook } from './diff';
/**
* Initialization data for the snapshot extension.
*/
const plugin: JupyterFrontEndPlugin<void> = {
id: 'snapshot:plugin',
description: 'Notebook version snapshot extension (manual commit, name + description).',
autoStart: true,
requires: [INotebookTracker],
optional: [ISettingRegistry],
activate: (
app: JupyterFrontEnd,
tracker: INotebookTracker,
settingRegistry: ISettingRegistry | null
) => {
console.log('JupyterLab extension snapshot is activated!');
if (settingRegistry) {
settingRegistry
.load(plugin.id)
.then(settings => {
console.log('snapshot settings loaded:', settings.composite);
})
.catch(reason => {
console.error('Failed to load settings for snapshot.', reason);
});
}
const serverSettings = app.serviceManager.serverSettings;
const model = new SnapshotModel(serverSettings);
const openDiff = (newNb: INotebook, oldNb: INotebook, name: string) => {
const widget = new DiffWidget({ newNb, oldNb });
widget.title.label = name && name.trim() ? name : '快照对比';
widget.title.closable = true;
const host = new MainAreaWidget({ content: widget });
app.shell.add(host, 'main', { mode: 'split-right' });
};
const panel = new SnapshotPanel({
model,
app,
tracker,
serverSettings,
onOpenDiff: openDiff
});
panel.id = 'snapshot:panel';
panel.title.label = '快照历史';
app.shell.add(panel, 'left', { rank: 200 });
// Keep the model in sync with the current notebook.
const syncPath = () => {
const current = tracker.currentWidget;
model.setPath(current?.context.path ?? '');
};
tracker.widgetAdded.connect(() => syncPath());
tracker.currentChanged.connect(() => syncPath());
syncPath();
registerCommands(app, tracker, model);
// Add a "保存快照" button to every notebook panel's toolbar.
// NOTE: docRegistry.addWidgetExtension('Notebook', ...) places its
// returned widget in the *main area* (a new tab), not on the toolbar.
// The toolbar wants panel.toolbar.addItem directly.
const addCommitButton = (panelHost: NotebookPanel) => {
const button = new ToolbarButton({
label: '保存快照',
tooltip: '保存当前 notebook 为快照',
onClick: () => {
void app.commands.execute(CommandIDs.commit);
}
});
button.addClass('jp-snapshot-toolbar-button');
panelHost.toolbar.addItem('snapshot:commit', button);
};
// Add to panels that are already open at activation time.
tracker.forEach(addCommitButton);
// Add to any panel that opens later.
tracker.widgetAdded.connect((_, panelHost) => addCommitButton(panelHost));
// Keep the unused import alive for the build (requestAPI is part of
// the public surface and re-used by the api module).
void requestAPI;
}
};
export default plugin;
+66
View File
@@ -0,0 +1,66 @@
import { ISignal, Signal } from '@lumino/signaling';
import { ServerConnection } from '@jupyterlab/services';
import { listSnapshots, VersionEntry } from './api';
/**
* Front-end state for the snapshot panel of a single notebook path.
*
* Holds the current path + cached version list, and emits
* `versionsChanged` whenever either is updated. The panel renders
* from this state.
*/
export class SnapshotModel {
private _path = '';
private _versions: VersionEntry[] = [];
private _loading = false;
private _versionsChanged = new Signal<this, void>(this);
constructor(private _serverSettings: ServerConnection.ISettings) {}
get path(): string {
return this._path;
}
get versions(): VersionEntry[] {
return this._versions;
}
get loading(): boolean {
return this._loading;
}
get versionsChanged(): ISignal<this, void> {
return this._versionsChanged;
}
setPath(path: string): void {
if (this._path === path) {
return;
}
this._path = path;
if (path) {
void this.refresh();
} else {
this._versions = [];
this._versionsChanged.emit();
}
}
async refresh(): Promise<void> {
if (!this._path) {
return;
}
this._loading = true;
this._versionsChanged.emit();
try {
this._versions = await listSnapshots(this._serverSettings, this._path);
} catch (e) {
console.error('Failed to list snapshots', e);
this._versions = [];
} finally {
this._loading = false;
this._versionsChanged.emit();
}
}
}
+51
View File
@@ -0,0 +1,51 @@
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
/**
* Call the server extension
*
* @param endPoint API REST end point for the extension
* @param serverSettings The server settings to use for the request
* @param init Initial values for the request
* @returns The response body interpreted as JSON
*/
export async function requestAPI<T>(
endPoint: string,
serverSettings: ServerConnection.ISettings,
init: RequestInit = {}
): Promise<T> {
// Make request to Jupyter API
const requestUrl = URLExt.join(
serverSettings.baseUrl,
'snapshot', // our server extension's API namespace
endPoint
);
let response: Response;
try {
response = await ServerConnection.makeRequest(
requestUrl,
init,
serverSettings
);
} catch (error) {
throw new ServerConnection.NetworkError(error as any);
}
let data: any = await response.text();
if (data.length > 0) {
try {
data = JSON.parse(data);
} catch (error) {
console.log('Not a JSON response body.', response);
}
}
if (!response.ok) {
throw new ServerConnection.ResponseError(response, data.message || data);
}
return data;
}
+52
View File
@@ -0,0 +1,52 @@
import { Widget } from '@lumino/widgets';
/**
* Body widget for the manual-commit dialog. Two fields:
* - name (required)
* - description (optional)
*
* Values are read out via `getName()` / `getDescription()` after the
* dialog is dismissed with the OK button.
*/
export class CommitDialogBody extends Widget {
private _name: HTMLInputElement;
private _description: HTMLTextAreaElement;
constructor() {
super();
this.addClass('jp-snapshot-commit-dialog');
this._name = document.createElement('input');
this._name.className = 'jp-snapshot-commit-dialog-name';
this._name.type = 'text';
this._name.placeholder = '快照名称(必填)';
this._name.spellcheck = false;
this._description = document.createElement('textarea');
this._description.className = 'jp-snapshot-commit-dialog-desc';
this._description.placeholder = '快照描述(可选)';
this._description.rows = 4;
const nameLabel = document.createElement('label');
nameLabel.textContent = '名称';
const descLabel = document.createElement('label');
descLabel.textContent = '描述';
this.node.appendChild(nameLabel);
this.node.appendChild(this._name);
this.node.appendChild(descLabel);
this.node.appendChild(this._description);
}
getName(): string {
return this._name.value;
}
getDescription(): string {
return this._description.value;
}
/** Focus the name input on activation for keyboard convenience. */
protected onAfterAttach(): void {
this._name.focus();
}
}
+97
View File
@@ -0,0 +1,97 @@
import * as React from 'react';
import { ReactWidget } from '@jupyterlab/apputils';
import { diffNotebook, CellChange, INotebook } from '../diff';
const TYPE_LABEL: Record<CellChange['type'], string> = {
unchanged: '未变',
added: '新增',
deleted: '删除',
modified: '修改'
};
function renderSource(source: string): React.ReactNode {
// Preserve line structure.
return source.split('\n').map((line, i, arr) => (
<React.Fragment key={i}>
{line}
{i < arr.length - 1 ? '\n' : ''}
</React.Fragment>
));
}
function renderLineDiff(changes: import('diff').Change[]): React.ReactNode {
return (
<pre className="jp-snapshot-diff-pre">
{changes.map((c, i) => {
const cls = c.added
? 'jp-snapshot-diff-line-added'
: c.removed
? 'jp-snapshot-diff-line-removed'
: 'jp-snapshot-diff-line-context';
return (
<span key={i} className={cls}>
{c.added ? '+ ' : c.removed ? '- ' : ' '}
{renderSource(c.value)}
</span>
);
})}
</pre>
);
}
const ChangeRow: React.FC<{ change: CellChange }> = ({ change }) => {
const cell =
change.newCell ?? change.oldCell;
const source =
change.newCell
? Array.isArray(change.newCell.source)
? (change.newCell.source as unknown as string[]).join('')
: (change.newCell.source as string)
: change.oldCell
? Array.isArray(change.oldCell.source)
? (change.oldCell.source as unknown as string[]).join('')
: (change.oldCell.source as string)
: '';
return (
<div className={`jp-snapshot-diff-row jp-snapshot-diff-${change.type}`}>
<div className="jp-snapshot-diff-badge">{TYPE_LABEL[change.type]}</div>
<div className="jp-snapshot-diff-cell-type">{cell?.cell_type ?? ''}</div>
{change.type === 'modified' && change.lineDiff ? (
renderLineDiff(change.lineDiff)
) : (
<pre className="jp-snapshot-diff-pre">{renderSource(source)}</pre>
)}
</div>
);
};
interface DiffWidgetProps {
newNb: INotebook;
oldNb: INotebook;
}
const DiffView: React.FC<DiffWidgetProps> = ({ newNb, oldNb }) => {
const changes = React.useMemo(() => diffNotebook(oldNb, newNb), [oldNb, newNb]);
if (changes.length === 0) {
return <div className="jp-snapshot-diff-empty"></div>;
}
return (
<div className="jp-snapshot-diff-container">
{changes.map((c, i) => (
<ChangeRow key={i} change={c} />
))}
</div>
);
};
export class DiffWidget extends ReactWidget {
constructor(private readonly _props: DiffWidgetProps) {
super();
this.addClass('jp-snapshot-diff-widget');
}
render(): React.ReactElement {
return <DiffView {...this._props} />;
}
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from 'react';
import { ReactWidget } from '@jupyterlab/apputils';
import { ServerConnection } from '@jupyterlab/services';
import { getSnapshotContent } from '../api';
import { INotebook } from '../diff';
import { SnapshotModel } from '../model';
import { CommandIDs } from '../commands';
import { JupyterFrontEnd } from '@jupyterlab/application';
import { INotebookTracker } from '@jupyterlab/notebook';
function formatTimestamp(ts: number): string {
// timestamp is microseconds (see backend store.py)
const ms = ts / 1000;
return new Date(ms).toLocaleString();
}
function formatSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
interface PanelProps {
model: SnapshotModel;
app: JupyterFrontEnd;
tracker: INotebookTracker;
serverSettings: ServerConnection.ISettings;
onOpenDiff: (newNb: INotebook, oldNb: INotebook, name: string) => void;
}
const Panel: React.FC<PanelProps> = ({ model, app, tracker, serverSettings, onOpenDiff }) => {
const [, force] = React.useReducer(x => x + 1, 0);
React.useEffect(() => {
const onChange = () => force();
model.versionsChanged.connect(onChange);
return () => {
model.versionsChanged.disconnect(onChange);
};
}, [model]);
if (!model.path) {
return <div className="jp-snapshot-panel-empty"> notebook</div>;
}
if (model.loading && model.versions.length === 0) {
return <div className="jp-snapshot-panel-empty"></div>;
}
if (model.versions.length === 0) {
return (
<div className="jp-snapshot-panel-empty">
<div></div>
<div className="jp-snapshot-panel-hint"></div>
</div>
);
}
return (
<ul className="jp-snapshot-panel-list">
{model.versions.map(v => (
<li key={v.id} className="jp-snapshot-panel-item">
<div className="jp-snapshot-panel-item-header">
<span className="jp-snapshot-panel-name" title={v.name}>
{v.name}
</span>
<span className="jp-snapshot-panel-size">{formatSize(v.size)}</span>
</div>
{v.description ? (
<div className="jp-snapshot-panel-desc">{v.description}</div>
) : null}
<div className="jp-snapshot-panel-time">{formatTimestamp(v.timestamp)}</div>
<div className="jp-snapshot-panel-actions">
<button
className="jp-snapshot-panel-button"
onClick={async () => {
const panel = tracker.currentWidget;
if (!panel) {
return;
}
const notebookModel = panel.content.model;
if (!notebookModel) {
return;
}
try {
const oldNb = (await getSnapshotContent(
serverSettings,
model.path,
v.id
)) as INotebook;
const newNb = notebookModel.toJSON() as unknown as INotebook;
onOpenDiff(newNb, oldNb, v.name);
} catch (e) {
console.error('Diff failed', e);
}
}}
>
</button>
<button
className="jp-snapshot-panel-button"
onClick={() => {
void app.commands.execute(CommandIDs.restore, { id: v.id });
}}
>
</button>
</div>
</li>
))}
</ul>
);
};
export class SnapshotPanel extends ReactWidget {
constructor(
private readonly _props: PanelProps
) {
super();
this.addClass('jp-snapshot-panel');
}
render(): React.ReactElement {
return <Panel {...this._props} />;
}
}