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:
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user