Compare commits

..
14 Commits
Author SHA1 Message Date
tao.chen 570ed2183b ci: trigger condition
CI / CI (push) Successful in 32m55s
2026-07-22 11:22:20 +08:00
tao.chenandClaude d7a74a6971 ci: drop Upload wheel artifacts step (redundant with release)
CI / CI (push) Successful in 32m9s
artifact-upload served two purposes:
 1. download wheel from Actions UI for debugging
 2. (future) hand off to cross-job tests

Neither is currently used: distribution already goes through the
Gitea release via create-release.sh, and there are no downstream
jobs that need the artifact.

Removes the actions/upload-artifact@v4 step + the if: success()
gate. The build is now: uv build -> release (tag only) -> bark
notify. Cleaner, one fewer thing to fail on.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 10:29:08 +08:00
tao.chen 18963ae03b ci: fix
CI / CI (push) Failing after 44m36s
2026-07-21 19:17:34 +08:00
tao.chenandClaude f8c186fe2c ci: switch runner from astral-uv to ubuntu-latest
CI / CI (push) Failing after 11s
Replaces the self-hosted astral-uv runner (which had uv
pre-installed) with the standard GitHub-hosted ubuntu-latest.
Adds back the astral-sh/setup-uv step that was omitted in the
self-hosted case.

The astral-uv runner was timing out / unreachable from the
GitHub Actions side; ubuntu-latest is universally available.

Trade-off: ubuntu-latest runners start fresh each run (~30s cold
start) and the setup-uv step takes a few seconds to download uv,
versus a self-hosted runner that boots in <1s with uv ready. For
a single CI job that runs on tag push only, the extra ~10s is
not a problem.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 19:14:49 +08:00
tao.chenandClaude d40d19ae96 ci: point uv build at Tsinghua PyPI mirror via UV_DEFAULT_INDEX
CI / CI (push) Failing after 4s
The self-hosted astral-uv runner is in China; pulling build deps
from pypi.org is slow and often times out. Set UV_DEFAULT_INDEX
on the 'Build package' step so uv's build-environment resolution
goes through the Tsinghua mirror.

This complements the [tool.uv.index] url=... block in
pyproject.toml — env wins over the file config when both are
present, so the mirror is used consistently.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 19:06:49 +08:00
tao.chenandClaude ac17a954b0 ci: extract release script to fix YAML parse error (line 51)
CI / CI (push) Canceled after 11m29s
The 'Upload release assets via Gitea API' step embedded a JSON
heredoc inside a YAML block scalar (run: |). Gitea's YAML parser
bailed at line 51 with 'could not find expected :'' when it saw
the leading '{' of the JSON body — it tried to interpret the
heredoc content as a flow mapping instead of literal text.

Fix: move the release logic into .github/scripts/create-release.sh
and pass the GitHub Actions context values as env vars. The step
becomes:

  env:
    GITEA_TOKEN: ...
    TAG_NAME: ...
    API_URL: ...
    REPOSITORY: ...
    SHA: ...
  run: ./.github/scripts/create-release.sh

Validates cleanly with PyYAML (yaml.safe_load). Pattern matches
bark-notify.sh from earlier — CI helpers live in .github/scripts/,
workflows stay slim.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:57:18 +08:00
tao.chenandClaude 72fd363600 ci: move bark-notify.sh to .github/scripts, add Gitea release asset upload
Two changes:

1. Move scripts/bark-notify.sh -> .github/scripts/bark-notify.sh
   (CI helper belongs next to the workflow that calls it). History
   preserved via git mv.

2. Add a 'Upload release assets via Gitea API' step that runs only
   on tag pushes (if: startsWith(github.ref, 'refs/tags/v')). It:
     - POSTs /releases to create a Gitea release bound to the tag
     - extracts the release id from the response (with explicit error
       path if the API call fails)
     - loops over dist/snapshot-*.whl and dist/snapshot-*.tar.gz and
       uploads each as a release asset
   Uses ${{ secrets.GITHUB_TOKEN }} exposed as the GITEA_TOKEN env
   var (the Gitea convention for the repo token secret). The
   Notify Bark step still runs last with if: always() so a release
   upload failure is reported via the phone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:51:57 +08:00
tao.chenandClaude 044c83cd22 ci: bark notification on build success / failure
Adds scripts/bark-notify.sh (deployment detail, hardcoded
device_key + endpoint — per the script header it's a deployment
constant, not a secret) and a final workflow step that pushes
a / notification on every job result.

The notify step:
- runs unconditionally (if: always()) so failures also notify
- is a side-effect — if Bark itself is unreachable, the
  curl failure is swallowed so CI does not turn red on a
  notification outage
- uses portable context vars (github.repository / ref_name /
  sha / actor / job.status) which work on Gitea Actions

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:50:25 +08:00
tao.chenandClaude 121b835494 ci: rewrite workflows for Gitea, use astral-uv runner, uv build only
Replaces the previous five JupyterLab-template workflows
(build / check-release / enforce-label / prep-release /
publish-release / update-integration-tests) with a single minimal
CI workflow tuned for a self-hosted Gitea runner.

What's in the new build.yml:
- Triggers: push to main, push of v* tag, pull_request
- runs-on: astral-uv (self-hosted runner with uv pre-installed;
  we skip the setup-uv step)
- Steps: checkout -> uv build -> upload-artifact
- uv build alone is enough because pyproject.toml's
  [tool.hatch.build.hooks.jupyter-builder] drives jlpm install
  and jlpm build:prod before wheel assembly, and uv caches the
  build environment between runs.

What's removed: all jupyter-server/jupyter_releaser steps and the
check-links / update-snapshots / test_isolated / integration-tests
jobs from the template — they depended on jupyterlab/maintainer-tools
and GitHub-specific secrets (GITHUB_TOKEN, APP_PRIVATE_KEY, NPM_TOKEN)
that don't apply to a Gitea deployment.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:48:15 +08:00
tao.chenandClaude 5ae9f94023 docs: add TODO.md (pending items, prioritized by user value)
Captures the open work items raised across earlier sessions:
- S3 consistency fix (return list in commit response — the real
  workaround behind the 'refresh button' shim)
- POST /notebook-version/restore endpoint (deferred in design.md)
- Settings schema (schema/plugin.json is currently empty)
- jest + pnpm linker incompatibility (blocks jlpm test)
- Frontend diff.ts unit tests (blocked on the jest fix)
- ui-tests (Playwright + Galata)
- CI pytest step
- v0.0.1 tag + PyPI release

Each item is labeled with trigger conditions (what real user need
would justify doing it) so we don't gold-plate features.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:44:03 +08:00
tao.chenandClaude 263232bea4 feat: replace text buttons with icons (tooltip on hover)
Replaces the two visible 'button-like' affordances with LabIcon glyphs
so the sidebar and toolbar stay compact; the explanatory text moves
to the hover tooltip.

Changes:
- Toolbar 'Save Snapshot' button: text label -> saveIcon (floppy),
  tooltip '保存当前 notebook 为快照'
- Sidebar panel '快照历史' text label -> saveIcon + caption='快照历史'
  (the panel tab is now icon-only)
- Sidebar refresh button: text -> refreshIcon with a 1s rotate animation
  while loading

Dependencies:
- Adds @jupyterlab/ui-components to package.json (source of the
  standard LabIcon set: saveIcon, refreshIcon, historyIcon)

Style:
- New .jp-snapshot-panel-refresh-button sizing (28x24 fixed box,
  centered icon) and .is-loading spin keyframes
- @keyframes jp-snapshot-spin applied to the icon's .jp-Icon element

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:41:01 +08:00
tao.chenandClaude 2fc584d70f chore: bump version to 0.0.1 to match CHANGELOG
Build / Check Links (push) Failing after 5s
Build / build (push) Failing after 8s
Build / test_isolated (push) Skipped
Build / Integration tests (push) Skipped
Check Release / check_release (push) Failing after 10s
package.json was still at the copier-template default 0.1.0
while CHANGELOG and DEVELOPER.md document 0.0.1. pyproject.toml
sources the version from package.json via hatch-nodejs-version, so
no edit needed there.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:29:05 +08:00
tao.chenandClaude 6e6c79f43a docs: add v0.0.1 changelog entry
Records the initial development release covering all features
shipped in commits cd1aba6 / bc1828b / 3476517: manual snapshot
commit flow, sidebar timeline, cell diff, restore, skip-on-duplicate,
Local + S3 storage adapters, S3ConnectionError diagnostics, envelope-
embedded snapshot files, traitlets config, and the DEVELOPER.md
manual.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:28:22 +08:00
tao.chenandClaude 3476517479 docs: add DEVELOPER.md (architecture, build, debug, packaging)
Developer-facing manual covering:

1. Architecture overview with the four-layer data flow diagram
2. Repository layout (src/, snapshot/, storage/, etc.)
3. Dev environment setup (one-time + daily) and the
   'what to restart after what type of change' table
4. Common commands quick reference
5. Debugging: backend (server logs, curl), frontend (browser
   DevTools, console.log diagnostic points, React/Lumino
   inspection), and end-to-end symptom -> cause table
6. Packaging and release (PyPI wheel via hatch + jupyter-builder,
   version sourced from package.json, git tag flow, CI)
7. Known pitfalls: jest+pnpm linker incompatibility, S3 strong
   consistency variance, conda/npm gotchas
8. 5-minute quickstart for new contributors

User-facing concerns (install/config/troubleshoot) stay in README.md;
design goals stay in design.md; AI coding rules stay in AGENTS.md.
DEVELOPER.md slots in for engineer handoff / onboarding.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 18:26:11 +08:00
17 changed files with 561 additions and 350 deletions
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# bark-notify.sh — push a notification to the local Bark server.
#
# Usage: bark-notify.sh <title> <body>
#
# The Bark endpoint, device_key, and group are pinned in this script
# (it is a deployment detail, not a secret). The group is "Gitea" —
# change it here if you want to sort notifications by category.
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "usage: $0 <title> <body>" >&2
exit 1
fi
TITLE="$1"
BODY="$2"
PAYLOAD=$(cat <<EOF
{
"device_key": "6ZFVsxM95cuAjYoNLWSWaN",
"title": "${TITLE}",
"body": "${BODY}",
"group": "Gitea",
"isArchive": 1,
"ttl": 3600
}
EOF
)
curl -s \
-X POST http://101.43.40.124:81/push \
-H "Content-Type: application/json; charset=utf-8" \
-d "${PAYLOAD}"
echo "bark notified: ${TITLE}"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# create-release.sh — create a Gitea release and attach dist/ assets.
#
# Required env vars (set by the workflow):
# GITEA_TOKEN Gitea API token with release write scope
# TAG_NAME tag name (e.g. v0.0.1-beta)
# API_URL Gitea API base URL (e.g. http://gitea.local/api/v1)
# REPOSITORY owner/repo
# SHA commit SHA the tag points at
set -euo pipefail
PAYLOAD=$(cat <<EOF
{
"tag_name": "${TAG_NAME}",
"target_commitish": "${SHA}",
"name": "Release ${TAG_NAME}",
"body": "Auto-generated release assets.",
"draft": false,
"prerelease": false
}
EOF
)
echo "Creating Gitea release for tag ${TAG_NAME}..."
RELEASE_RESPONSE=$(curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases" \
-H "accept: application/json" \
-H "authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}")
RELEASE_ID=$(printf '%s' "${RELEASE_RESPONSE}" | grep -oP '"id":\s*\K[0-9]+' | head -n 1)
if [ -z "${RELEASE_ID}" ]; then
echo "ERROR: failed to create release. Response:"
echo "${RELEASE_RESPONSE}"
exit 1
fi
echo "Release created with ID ${RELEASE_ID}"
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
if [ -f "${asset}" ]; then
echo "Uploading ${asset}..."
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "accept: application/json" \
-H "authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${asset}"
echo
else
echo "Skipping ${asset} (not found)"
fi
done
+52 -150
View File
@@ -1,162 +1,64 @@
name: Build
name: CI
on:
push:
branches: main
# branches:
# - main
tags:
- 'v*'
pull_request:
branches: '*'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
ci:
name: CI
# Standard GitHub-hosted ubuntu runner. uv is installed via the
# astral-sh/setup-uv action (one extra step vs. using a self-hosted
# runner with uv pre-installed, but matches what GitHub-hosted CI
# provides out of the box).
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Checkout
uses: actions/checkout@v4
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Set up uv
uses: https://github.com/astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install dependencies
run: python -m pip install -U "jupyterlab>=4.0.0,<5"
- name: Build package
env:
# Use the Tsinghua PyPI mirror for build-environment resolution;
# the runner is in China and the default pypi.org is too slow
# / unreliable for build dependency install.
UV_DEFAULT_INDEX: "https://pypi.tuna.tsinghua.edu.cn/simple/"
run: uv build
- name: Lint the extension
run: |
set -eux
jlpm
jlpm run lint:check
# Only create a release + attach dist/ assets when pushing a v* tag.
# On branch pushes / pull requests this step is skipped entirely.
# The release logic is in a separate script to avoid embedding a JSON
# heredoc inside a YAML block scalar (the previous version of this
# step was rejected by Gitea's YAML parser at line 51).
- name: Upload release assets via Gitea API
if: startsWith(github.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
API_URL: ${{ github.api_url }}
REPOSITORY: ${{ github.repository }}
SHA: ${{ github.sha }}
run: ./.github/scripts/create-release.sh
- name: Test the extension
run: |
set -eux
jlpm run test
- name: Build the extension
run: |
set -eux
python -m pip install .[test]
pytest -vv -r ap --cov snapshot
jupyter server extension list
jupyter server extension list 2>&1 | grep -ie "snapshot.*OK"
jupyter labextension list
jupyter labextension list 2>&1 | grep -ie "snapshot.*OK"
python -m jupyterlab.browser_check
- name: Package the extension
run: |
set -eux
pip install build
python -m build
pip uninstall -y "snapshot" jupyterlab
- name: Upload extension packages
uses: actions/upload-artifact@v4
with:
name: extension-artifacts
path: dist/snapshot*
if-no-files-found: error
test_isolated:
needs: build
runs-on: ubuntu-latest
steps:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
architecture: 'x64'
- uses: actions/download-artifact@v4
with:
name: extension-artifacts
- name: Install and Test
run: |
set -eux
# Remove NodeJS, twice to take care of system and locally installed node versions.
sudo rm -rf $(which node)
sudo rm -rf $(which node)
pip install "jupyterlab>=4.0.0,<5" snapshot*.whl
jupyter server extension list
jupyter server extension list 2>&1 | grep -ie "snapshot.*OK"
jupyter labextension list
jupyter labextension list 2>&1 | grep -ie "snapshot.*OK"
python -m jupyterlab.browser_check --no-browser-test
integration-tests:
name: Integration tests
needs: build
runs-on: ubuntu-latest
env:
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Download extension package
uses: actions/download-artifact@v4
with:
name: extension-artifacts
- name: Install the extension
run: |
set -eux
python -m pip install "jupyterlab>=4.0.0,<5" snapshot*.whl
- name: Install dependencies
working-directory: ui-tests
env:
YARN_ENABLE_IMMUTABLE_INSTALLS: 0
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
run: jlpm install
- name: Set up browser cache
uses: actions/cache@v4
with:
path: |
${{ github.workspace }}/pw-browsers
key: ${{ runner.os }}-${{ hashFiles('ui-tests/yarn.lock') }}
- name: Install browser
run: |
set -eux
jlpm playwright install chromium --only-shell
working-directory: ui-tests
- name: Execute integration tests
working-directory: ui-tests
run: |
jlpm playwright test
- name: Upload Playwright Test report
if: always()
uses: actions/upload-artifact@v4
with:
name: snapshot-playwright-tests
path: |
ui-tests/test-results
ui-tests/playwright-report
check_links:
name: Check Links
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1
# Always run, regardless of job status. The script is a side-effect;
# if the notification itself fails we swallow the error so CI does
# not turn red on a Bark outage.
- name: Notify Bark
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then
TITLE="✅ CI: ${{ github.repository }}@${{ github.ref_name }}"
BODY="Build OK for ${{ github.sha }} by ${{ github.actor }}"
else
TITLE="❌ CI: ${{ github.repository }}@${{ github.ref_name }}"
BODY="Build FAILED for ${{ github.sha }} by ${{ github.actor }}"
fi
./.github/scripts/bark-notify.sh "$TITLE" "$BODY" || echo "Bark notify failed (non-fatal)"
-30
View File
@@ -1,30 +0,0 @@
name: Check Release
on:
push:
branches: ["main"]
pull_request:
branches: ["*"]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
check_release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Check Release
uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Distributions
uses: actions/upload-artifact@v4
with:
name: snapshot-releaser-dist-${{ github.run_number }}
path: .jupyter_releaser_checkout/dist
-13
View File
@@ -1,13 +0,0 @@
name: Enforce PR label
on:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
jobs:
enforce-label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: enforce-triage-label
uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1
-48
View File
@@ -1,48 +0,0 @@
name: "Step 1: Prep Release"
on:
workflow_dispatch:
inputs:
version_spec:
description: "New Version Specifier"
default: "next"
required: false
branch:
description: "The branch to target"
required: false
post_version_spec:
description: "Post Version Specifier"
required: false
# silent:
# description: "Set a placeholder in the changelog and don't publish the release."
# required: false
# type: boolean
since:
description: "Use PRs with activity since this date or git reference"
required: false
since_last_stable:
description: "Use PRs with activity since the last stable git tag"
required: false
type: boolean
jobs:
prep_release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Prep Release
id: prep-release
uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
version_spec: ${{ github.event.inputs.version_spec }}
# silent: ${{ github.event.inputs.silent }}
post_version_spec: ${{ github.event.inputs.post_version_spec }}
branch: ${{ github.event.inputs.branch }}
since: ${{ github.event.inputs.since }}
since_last_stable: ${{ github.event.inputs.since_last_stable }}
- name: "** Next Step **"
run: |
echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}"
-58
View File
@@ -1,58 +0,0 @@
name: "Step 2: Publish Release"
on:
workflow_dispatch:
inputs:
branch:
description: "The target branch"
required: false
release_url:
description: "The URL of the draft GitHub release"
required: false
steps_to_skip:
description: "Comma separated list of steps to skip"
required: false
jobs:
publish_release:
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
steps:
- uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Populate Release
id: populate-release
uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2
with:
token: ${{ steps.app-token.outputs.token }}
branch: ${{ github.event.inputs.branch }}
release_url: ${{ github.event.inputs.release_url }}
steps_to_skip: ${{ github.event.inputs.steps_to_skip }}
- name: Finalize Release
id: finalize-release
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2
with:
token: ${{ steps.app-token.outputs.token }}
release_url: ${{ steps.populate-release.outputs.release_url }}
- name: "** Next Step **"
if: ${{ success() }}
run: |
echo "Verify the final release"
echo ${{ steps.finalize-release.outputs.release_url }}
- name: "** Failure Message **"
if: ${{ failure() }}
run: |
echo "Failed to Publish the Draft Release Url:"
echo ${{ steps.populate-release.outputs.release_url }}
@@ -1,39 +0,0 @@
name: Update Playwright Snapshots
on:
issue_comment:
types: [created, edited]
permissions:
contents: write
pull-requests: write
jobs:
update-snapshots:
runs-on: ubuntu-latest
if: github.event.issue.pull_request && contains(github.event.comment.body, 'please update snapshots')
steps:
- uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots-checkout@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Base Setup
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
- name: Install dependencies
run: python -m pip install -U "jupyterlab>=4.0.0,<5"
- name: Install extension
run: |
set -eux
jlpm
python -m pip install .
- uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# Playwright knows how to start JupyterLab server
start_server_script: 'null'
test_folder: ui-tests
npm_client: jlpm
+25
View File
@@ -2,4 +2,29 @@
<!-- <START NEW CHANGELOG ENTRY> -->
## v0.0.1
2026-07-21 — initial development release.
### Added
- Manual snapshot commit (notebook toolbar button / command palette `snapshot:commit`) with name + description dialog
- Left sidebar timeline (`SnapshotPanel`) showing all snapshots for the current notebook
- Cell-level diff (cell-id matching first, LCS fallback; jsdiff for line diff)
- One-click restore via `model.fromJSON()` + `context.save()` (no page refresh)
- Skip-on-duplicate: commits with the same content hash as the most recent version raise a warning instead of saving
- Storage adapter layer with Local (atomic tmp+rename) and S3 (boto3 via optional `[s3]` extra)
- `S3ConnectionError` with friendly diagnostic at startup (classifies endpoint unreachable / credentials missing / permission denied / create failed)
- Snapshot files embed an envelope (id, timestamp, name, description, hash, size, notebook) so the manifest is fully rebuildable
- traitlets config: `storage_type`, `local_root`, `s3_*` (with `S3_*` env-var fallbacks for the credentials)
- Manual refresh button in the sidebar (workaround for occasional S3 list lag behind the commit)
- Three REST endpoints under `/snapshot/notebook-version/`: `commit` / `list` / `content`
- `DEVELOPER.md` covering architecture, build, debug, and packaging
### Changed
- Renamed `minio` storage to `s3` (boto3 is the generic S3 SDK; the same config covers AWS S3 and MinIO)
- The "save snapshot" toolbar button now uses `panel.toolbar.addItem` (was previously routed through `docRegistry.addWidgetExtension`, which created a new main-area tab instead of a toolbar button)
### Fixed
- `Notification.warning` for skipped commits now uses `autoClose: false` and includes a `console.log` diagnostic plus a fallback warning for unexpected response shapes
<!-- <END NEW CHANGELOG ENTRY> -->
+275
View File
@@ -0,0 +1,275 @@
# Developer Manual
开发笔记本快照扩展的工程手册。面向**接手开发**的人:架构、构建、调试、打包全流程。
> 用户视角(安装 / 配置 / 排错)见 [README.md](README.md)。设计目标见 [design.md](design.md)。AI 编码规范见 [AGENTS.md](AGENTS.md)。
## 1. 架构概览
四层结构,数据自下而上:
```
┌──────────────────────────────────────────────────────────────┐
│ JupyterLab 前端 (src/) │
│ ├─ commands / api / model / diff / widgets │
│ └─ 与后端走 HTTP (POST commit / GET list / GET content) │
└──────────────────┬───────────────────────────────────────────┘
│ JSON over HTTP
┌──────────────────┴───────────────────────────────────────────┐
│ Server Extension (snapshot/) │
│ ├─ routes.py REST handlers │
│ ├─ store.py SnapshotStore(清洗 + hash + envelope + │
│ │ manifest 原子写 + 重建) │
│ ├─ config.py traitlets 配置 │
│ └─ __init__.py _load_jupyter_server_extension 入口 │
└──────────────────┬───────────────────────────────────────────┘
┌──────────────────┴───────────────────────────────────────────┐
│ Storage Adapter (snapshot/storage/) │
│ ├─ base.py StorageAdapter ABCput/get/list/delete
│ ├─ local.py LocalFileStorage(原子写 tmp+rename
│ └─ s3.py S3Storageboto3,懒加载) │
└──────────────────────────────────────────────────────────────┘
```
**关键设计决策**(实现时不得违背,见 [AGENTS.md](AGENTS.md)):
1. 存储适配器是**哑字节桶**——不知道 notebook / 版本 / manifest 这些概念
2. MD5 在清洗后 JSON 上计算;hash 与最近版本相同则跳过保存
3. 快照文件是 **envelope**`{id, timestamp, name, description, hash, size, notebook}`),name/description 内嵌以便 manifest 损坏时从版本文件重建
4. manifest 是可重建的索引,原真相在 envelope 里
5. 手动提交,不绑定 Ctrl+S**不**自动 commit
6. Cell diff 按 id 优先匹配,LCS 兜底
## 2. 仓库布局
```
.
├── src/ 前端 TypeScript / React
│ ├── index.ts 插件入口(注册命令、挂面板、装工具栏)
│ ├── commands.ts 命令实现(snapshot:commit / restore
│ ├── api.ts 类型化 HTTP 包装
│ ├── model.ts SnapshotModelpath + versions + 信号)
│ ├── diff.ts Cell 级 diffid 优先 + LCS 兜底)
│ ├── request.ts requestAPI(带 /snapshot 前缀)
│ └── widgets/ ReactWidget 组件
│ ├── CommitDialog.ts 名称/描述输入
│ ├── SnapshotPanel.tsx 左侧栏时间轴
│ └── DiffWidget.tsx 主区 diff 视图
├── snapshot/ 后端 Python
│ ├── __init__.py _load_jupyter_server_extension
│ ├── routes.py 三个 REST 处理器
│ ├── store.py SnapshotStore(核心业务)
│ ├── config.py traitlets 配置
│ ├── storage/ 存储适配器
│ │ ├── base.py ABC
│ │ ├── local.py
│ │ └── s3.py
│ └── tests/ pytest
├── style/ CSSbase.css 命名空间 jp-snapshot-*
├── schema/plugin.json 前端 settings schema(目前空)
├── ui-tests/ Playwright + Galata 集成测试
├── design.md 架构设计文档(单一事实源)
├── AGENTS.md / CLAUDE.md AI 编码规范(CLAUDE.md 是符号链接)
├── README.md 用户文档
└── DEVELOPER.md 本文件
```
## 3. 开发环境搭建
一次性:
```bash
# 后端
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev,test,s3]"
# 前端
.venv/bin/jlpm install
# 注册扩展到 JupyterLab
jupyter server extension enable snapshot
jupyter labextension develop . --overwrite
```
每日:
```bash
source .venv/bin/activate
.venv/bin/jlpm watch # 终端 1:自动重编
jupyter lab # 终端 2:启动 JupyterLab
```
**改了 Python**:重启 `jupyter lab`(不需要 rebuild 前端)
**改了 TypeScript**`jlpm watch` 自动重编 → 浏览器**硬刷新**Cmd+Shift+R)拿新 bundle
**改了 CSS**`jlpm watch` 不会自动 rebuild——手动 `jlpm build` 后刷新
## 4. 常用命令
| 操作 | 命令 |
|------|------|
| 后端测试 | `.venv/bin/pytest snapshot/tests/ -vv` |
| 后端单测 | `.venv/bin/pytest snapshot/tests/test_store.py::test_xxx -vv` |
| 前端构建 | `.venv/bin/jlpm build` |
| 前端打包 | `.venv/bin/jlpm build:prod` |
| Lint | `.venv/bin/jlpm lint:check` |
| 看日志 | 启动 jupyter lab 的那个终端 |
| 跑 ui-tests | `cd ui-tests && yarn install && yarn test` |
| 清理 | `.venv/bin/jlpm clean:all` |
## 5. 调试
### 5.1 后端(Python
`jupyter lab` 的终端会打印所有 server extension 的日志,包括本扩展。
**结构化日志**(在 `_load_jupyter_server_extension` 里):
```python
server_app.log.info(f"Activated snapshot storage backend: {config.storage_type}")
# S3 模式还会打:
# server_app.log.info(f"Snapshot S3 storage ready: endpoint=..., bucket=...")
```
**S3 连接失败**:会抛 `S3ConnectionError`(带 endpoint / bucket / 修复建议),JupyterLab 启动失败。看 traceback。
**手动看 API**(不通过前端):
```bash
# 启动后
.venv/bin/jupyter server --port 8888 &
# 拿到 token from jupyter_server 的输出
TOK=...
# 提交
curl -X POST -H "Authorization: token $TOK" -H "Content-Type: application/json" \
-d '{"path":"test.ipynb","content":{"cells":[],"metadata":{},"nbformat":4,"nbformat_minor":5},"name":"v1","description":""}' \
http://localhost:8888/snapshot/notebook-version/commit
# 列表
curl -H "Authorization: token $TOK" \
"http://localhost:8888/snapshot/notebook-version/list?path=test.ipynb"
```
### 5.2 前端(TypeScript / React
**1. 浏览器 DevTools Console**
打开 DevTools (F12 或 Cmd+Option+I) → Console。
代码里有 `console.log` 诊断日志:
- `'JupyterLab extension snapshot is activated!'` — 插件加载
- `'snapshot settings loaded:'` — settings 加载
- `'[snapshot] commit response:'` — commit 响应(最近调试加的)
- `'Failed to list snapshots'` — list 失败
- `'Diff failed'` — diff 失败
**2. 看 React 组件树**:装 React DevTools 扩展
**3. 看 Lumino Widget 树**:在 Console 跑 `JupyterLab.shell.widgets()` 看主区 widget,左区 widget 列表
**4. 改前端后必须硬刷新**JupyterLab 缓存 labextension**软刷新可能拿到旧 bundle**。Cmd+Shift+R 清缓存。
### 5.3 端到端问题排查
| 症状 | 排查 |
|------|------|
| 工具栏没「保存快照」按钮 | (1) 硬刷新浏览器;(2) `jupyter labextension list` 看 snapshot 状态 |
| 提交没反应 | 看 Console 有无 `[snapshot] commit response:` 日志,看 jupyter 终端有无 traceback |
| 「内容未变更」warning 不弹 | Console 里看 commit response 实际是什么 |
| 保存后历史列表没新条目 | 点侧边栏「刷新」按钮(手动 list);检查 S3 manifest 写入(用 `aws s3 ls` 或 MinIO 控制台) |
| Diff 视图空白 | 检查 `model.versions` 是否有目标 id;后端 `/content?path=&id=` 单独 curl 一次 |
| Restore 失败 | Console 报具体错;检查 `notebook.model.fromJSON` 的入参结构 |
### 5.4 测试覆盖现状
```
backend pytest: 33 passed (13 storage + 9 store + 6 routes_api + 5 misc)
frontend jest: ❌ pnpm 链接器下跑不起来(见下方"已知坑")
ui-tests: 跑通需先 `cd ui-tests && yarn install`,未在 CI 中
```
**新增后端测试**`snapshot/tests/test_*.py`,命名 `test_<模块>_<场景>`.py
**新增前端测试**jest 暂时不可用。如需单测 `src/diff.ts` 的纯逻辑,可:
- 临时方案:把 diff 函数复制到 `__tests__/diff.spec.ts` 跑(在已知坑解决前)
- 正确方案:改 `.yarnrc.yml``nodeLinker``node-modules`,再重装
## 6. 打包与发布
### 6.1 版本管理
- **单一事实源**`package.json``version` 字段
- `pyproject.toml` 通过 `hatch-nodejs-version` 自动从 `package.json` 同步
- 手动修改 `pyproject.toml` 的版本会被覆盖
### 6.2 后端 wheel
```bash
.venv/bin/pip install build hatch hatch-jupyter-builder hatch-nodejs-version
.venv/bin/python -m build
# 产物:dist/snapshot-0.1.0-py3-none-any.whl + dist/snapshot-0.1.0.tar.gz
```
`pyproject.toml``[tool.hatch.build.hooks.jupyter-builder]` 会自动 build 前端并打进 wheel 的 `snapshot/labextension/`
发布到 PyPI
```bash
.venv/bin/python -m twine upload dist/*
```
### 6.3 前端 labextension
前端**不单独发布**。build 产物(`snapshot/labextension/`)通过 hatch hook 嵌进 wheel。
本地 dev 模式:`jupyter labextension develop . --overwrite` 建立符号链接,改前端代码即时生效。
### 6.4 发布 checklist
1.`package.json` 的 version(不要改 `pyproject.toml`
2. 更新 `CHANGELOG.md`
3. `git tag v0.1.0`
4. 跑全套验证:`pytest` + `jlpm build:prod` + `jlpm lint:check`
5. `python -m build` 出 wheel
6. (可选) `twine upload dist/*` 发 PyPI
7. `git push --tags`
CI 流程见 `.github/workflows/build.yml``publish-release.yml`
## 7. 已知坑
### 7.1 Jest 在 pnpm 链接器下跑不起来
`jlpm test``Cannot find module 'jest-util'`stack 指向 `ts-jest/dist/legacy/config/config-set.js`
**根因**`yarnLinker: pnpm``.yarnrc.yml`)的虚拟存储把 `ts-jest``jest-util` 隔开,pnpm 严格隔离破坏了 require 路径。
**绕过**
- 暂时不修——前端用 `jlpm build`tsc 严格模式 + rspack bundle)验证类型和模块等价
- 真要跑单测,改 `.yarnrc.yml``nodeLinker: node-modules`(影响整个 monorepo 解析)
- 或完全重写 `jest.config.js` 不依赖 `@jupyterlab/testing` 的 baseConfig
### 7.2 S3 强一致性
AWS S3 自 2020 年起强一致(读写单对象强一致)。**但 MinIO / Ceph RGW / 阿里云 OSS 等 S3 兼容服务实现各异**。
表现:commit 写入 manifest 成功,但紧随其后的 list 偶尔返回旧 manifest。
**临时缓解**:侧边栏有「刷新」按钮,用户手动拉一次。
**彻底修复**(未做):commit 响应里直接返回最新 list,省掉独立 list 调用。
### 7.3 `.yarnrc.yml` / `.venv` / lockfile
- 仓库用 **uv/venv**(不是 conda):`source .venv/bin/activate`
- 前端用 **Yarn Berry**(不是 npm / yarn classic):`jlpm`,不要 `npm install`
- 不要混 `package-lock.json``yarn.lock`
## 8. 给新 contributor 的 5 分钟清单
1. `source .venv/bin/activate && pip install -e ".[dev,test,s3]"`
2. `.venv/bin/jlpm install && .venv/bin/jlpm build`
3. `jupyter server extension enable snapshot && jupyter labextension develop . --overwrite`
4. `jupyter lab` → 打开任意 `.ipynb` → 看工具栏「保存快照」按钮
5. 点按钮 → 填 name → 看左侧栏时间轴;改 cell 再保存 → 时间轴更新
如果第 4 步没看到按钮:**硬刷新**浏览器(Cmd+Shift+R)。
+74
View File
@@ -0,0 +1,74 @@
# TODO
待办项,**按用户需求驱动**——别凭空虚构。完成一项后:勾选 + 改 CHANGELOG + 在 commit message 引用本节。
> 用户视角(配置 / 安装)见 [README.md](README.md)。设计目标见 [design.md](design.md)。工程手册见 [DEVELOPER.md](DEVELOPER.md)。
## 后端
- [ ] **`POST /notebook-version/restore` 端点**design.md 里标"暂缓"
- 现状:restore 走纯前端 `model.fromJSON()` + `context.save()`,只能恢复**当前打开的** notebook
- 端点能力:脚本/CLI 调 API 恢复任意路径的 notebook;删了的 notebook 从 S3 拉回
- 必须走 `ContentsManager.write`(不能直接写文件),要处理权限 / 文件锁 / 并发
- 估计:~50 行 Python + ~30 行测试
- 触发条件:团队有 CI 灾备 / 批量恢复需求
- [ ] **修复 S3 提交后 list 偶发返回旧 manifest**
- 根因:commit 写 manifest 后前端独立发 list;某些 S3 兼容服务(MinIO / Ceph RGW / 阿里云 OSS 等)实现各异,强一致性不保证
- 方案:把 list 塞进 commit 响应(commit 写完立刻在同进程内 list 一定是最新),省掉独立 GET
- 估计:~30 行(改 commit 响应 + 前端 `setVersions` 直写 + 测试)
- 临时缓解:侧边栏「刷新」按钮(已加)
## 前端
- [ ] **`schema/plugin.json` settings schema**
- 现状:空 schema,插件激活时只 `console.log` settings,无任何可配置项
- 候选配置:`confirmRestore` / `defaultDescription` / `maxVersionsToShow` / `commitShortcut` / `skipDuplicate`
- 估计:~100 行(schema.json + 前端读 settings + 一个真用上的配置项)
- 触发条件:真有用户想调参再加
- [ ] **修复 jest 在 pnpm 链接器下跑不起来**
- 根因:`.yarnrc.yml``yarnLinker: pnpm`,虚拟存储把 `ts-jest``jest-util` 隔开
- 当前绕过:`jlpm build`tsc 严格 + rspack)验证前端
- 方案 A:改 `.yarnrc.yml``nodeLinker: node-modules`(影响整个 monorepo 解析)
- 方案 B:完全重写 `jest.config.js` 不依赖 `@jupyterlab/testing` 的 baseConfig
- 估计:A 1 行;B 半日
- [ ] **前端 diff 逻辑单测**
- `src/diff.ts` 是纯函数,最值得测;目前没有单测跑(受上面 jest 故障牵连)
- 估计:跟 jest 修复一起做,~50 行测试
## 测试 / CI
- [ ] **ui-testsPlaywright + Galata)跑通**
- 当前:`ui-tests/` 目录有但 README 里有 yarn.lock 是空的,从未跑过
- 前置:先把 jest 修通,CI 工作流 `.github/workflows/build.yml` 跑通
- 估计:跟 CI 一起做,~2-3 天
- [ ] **CI 流水线加后端 pytest**
- 当前 `.github/workflows/build.yml` 没看到 pytest 步骤
- 估计:~10 行 yaml
## 发布
- [ ] **v0.0.1 tag + 推送到 origin**
```bash
git tag v0.0.1
git push origin main --tags
```
- [ ] **v0.0.1 wheel 发布到 PyPI**(如需)
- `python -m build` 出 wheelhatch 自动嵌前端)
- `twine upload dist/*`
- 触发条件:要让别人 `pip install snapshot` 装上
## 优先级
按"真实价值"排:
1. 修 S3 一致性(用户已报告的 bug 根因)
2. v0.0.1 tag(当前未发版)
3. jest 修复 + 前端单测(解锁 `jlpm test` 工作流)
4. CI 加 pytest
5. settings schema / restore 端点(按需)
6. ui-tests(锦上添花)
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "snapshot",
"version": "0.1.0",
"version": "0.0.1",
"description": "A JupyterLab extension.",
"keywords": [
"jupyter",
@@ -61,6 +61,7 @@
"@jupyterlab/notebook": "^4.0.0",
"@jupyterlab/services": "^7.0.0",
"@jupyterlab/settingregistry": "^4.0.0",
"@jupyterlab/ui-components": "^4.0.0",
"@lumino/signaling": "^2.0.0",
"@lumino/widgets": "^2.0.0",
"diff": "^5.2.0",
+9 -3
View File
@@ -5,6 +5,7 @@ import {
import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
import { ToolbarButton, MainAreaWidget } from '@jupyterlab/apputils';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { saveIcon } from '@jupyterlab/ui-components';
import { requestAPI } from './request';
import { registerCommands, CommandIDs } from './commands';
@@ -59,7 +60,11 @@ const plugin: JupyterFrontEndPlugin<void> = {
onOpenDiff: openDiff
});
panel.id = 'snapshot:panel';
panel.title.label = '快照历史';
// Icon-only panel tab: the title text becomes a hover caption so the
// sidebar stays compact. saveIcon is reused for visual consistency with
// the commit toolbar button (both signal "save" semantics).
panel.title.icon = saveIcon;
panel.title.caption = '快照历史';
app.shell.add(panel, 'left', { rank: 200 });
// Keep the model in sync with the current notebook.
@@ -73,13 +78,14 @@ const plugin: JupyterFrontEndPlugin<void> = {
registerCommands(app, tracker, model);
// Add a "保存快照" button to every notebook panel's toolbar.
// Add an icon-only "保存快照" button to every notebook panel's toolbar.
// The tooltip on hover carries the full description.
// 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: '保存快照',
icon: saveIcon,
tooltip: '保存当前 notebook 为快照',
onClick: () => {
void app.commands.execute(CommandIDs.commit);
+7 -2
View File
@@ -1,6 +1,7 @@
import * as React from 'react';
import { ReactWidget } from '@jupyterlab/apputils';
import { ServerConnection } from '@jupyterlab/services';
import { refreshIcon } from '@jupyterlab/ui-components';
import { getSnapshotContent } from '../api';
import { INotebook } from '../diff';
@@ -50,14 +51,18 @@ const Panel: React.FC<PanelProps> = ({ model, app, tracker, serverSettings, onOp
<div className="jp-snapshot-panel-container">
<div className="jp-snapshot-panel-toolbar">
<button
className="jp-snapshot-panel-refresh-button"
className={
'jp-snapshot-panel-refresh-button' +
(model.loading ? ' is-loading' : '')
}
onClick={() => {
void model.refresh();
}}
disabled={model.loading}
title="重新拉取版本列表"
aria-label="刷新"
>
{model.loading ? '刷新中…' : '刷新'}
<refreshIcon.react className="jp-Icon jp-Icon-16" tag="span" />
</button>
</div>
{model.loading && model.versions.length === 0 ? (
+21 -2
View File
@@ -26,13 +26,18 @@
}
.jp-snapshot-panel-refresh-button {
font-size: 0.8em;
padding: 2px 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
padding: 0;
border: 1px solid var(--jp-border-color2, #ccc);
border-radius: 3px;
background: var(--jp-layout-color2, #f5f5f5);
color: var(--jp-ui-font-color1, #333);
cursor: pointer;
line-height: 0;
}
.jp-snapshot-panel-refresh-button:hover:not(:disabled) {
@@ -44,6 +49,20 @@
cursor: not-allowed;
}
.jp-snapshot-panel-refresh-button.is-loading svg,
.jp-snapshot-panel-refresh-button.is-loading .jp-Icon {
animation: jp-snapshot-spin 1s linear infinite;
}
@keyframes jp-snapshot-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.jp-snapshot-panel-empty {
color: var(--jp-ui-font-color2, #555);
padding: 12px;
Generated
+5 -3
View File
@@ -1969,7 +1969,6 @@ wheels = [
name = "snapshot"
source = { editable = "." }
dependencies = [
{ name = "boto3" },
{ name = "jupyter-server" },
]
@@ -1978,6 +1977,9 @@ dev = [
{ name = "jupyter-builder" },
{ name = "jupyterlab" },
]
s3 = [
{ name = "boto3" },
]
test = [
{ name = "coverage" },
{ name = "pytest" },
@@ -1988,7 +1990,7 @@ test = [
[package.metadata]
requires-dist = [
{ name = "boto3", specifier = ">=1.43.52" },
{ name = "boto3", marker = "extra == 's3'", specifier = ">=1.34.0" },
{ name = "coverage", marker = "extra == 'test'" },
{ name = "jupyter-builder", marker = "extra == 'dev'", specifier = ">=1.0.0" },
{ name = "jupyter-server", specifier = ">=2.4.0,<3" },
@@ -1998,7 +2000,7 @@ requires-dist = [
{ name = "pytest-cov", marker = "extra == 'test'" },
{ name = "pytest-jupyter", extras = ["server"], marker = "extra == 'test'", specifier = ">=0.6.0" },
]
provides-extras = ["dev", "test"]
provides-extras = ["dev", "s3", "test"]
[[package]]
name = "soupsieve"
+2 -1
View File
@@ -2785,7 +2785,7 @@ __metadata:
languageName: node
linkType: hard
"@jupyterlab/ui-components@npm:^4.6.1":
"@jupyterlab/ui-components@npm:^4.0.0, @jupyterlab/ui-components@npm:^4.6.1":
version: 4.6.1
resolution: "@jupyterlab/ui-components@npm:4.6.1"
dependencies:
@@ -9222,6 +9222,7 @@ __metadata:
"@jupyterlab/settingregistry": ^4.0.0
"@jupyterlab/testing": ^4.6.1
"@jupyterlab/testutils": ^4.0.0
"@jupyterlab/ui-components": ^4.0.0
"@lumino/signaling": ^2.0.0
"@lumino/widgets": ^2.0.0
"@module-federation/runtime-tools": ^2.0.0