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:
+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;
|
||||
Reference in New Issue
Block a user