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
+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;