Skip to content

feat(hub-client): import a project from a ZIP archive#253

Merged
cscheid merged 3 commits into
mainfrom
feature/import-from-zip
Jun 2, 2026
Merged

feat(hub-client): import a project from a ZIP archive#253
cscheid merged 3 commits into
mainfrom
feature/import-from-zip

Conversation

@cscheid
Copy link
Copy Markdown
Member

@cscheid cscheid commented Jun 2, 2026

Summary

Adds an "Import from ZIP" action to the hub-client project-selector landing page: a user uploads a .zip and it becomes a brand-new hub-client project. This is the inverse of the existing "Export to ZIP" on ProjectTab.

The import reuses the existing project-creation pipeline end-to-end — onProjectCreatedApp.handleProjectCreatedcreateNewProject — with no changes to the Automerge layer. createNewProject already accepts a mixed text/base64-binary file list, so import is just "parse ZIP → build that list → run the existing create path."

Tracked as bd-apv23. Design notes: claude-notes/plans/2026-06-01-import-from-zip.md.

What's in it

  • ts-packages/quarto-sync-client/src/import-zip.ts — new pure parseProjectZip() (mirrors export-zip.ts). Unzips with fflate, then:
    • strips a single common top-level directory (GitHub-style repo-main/ downloads),
    • drops directory entries and __MACOSX/ · .DS_Store · .git/ junk,
    • rejects zip-slip paths (absolute / ..),
    • classifies text vs binary by extension, with a UTF-8/NUL sniff fallback for unknown extensions; binary content is base64-encoded for createNewProject.
  • ts-packages/preview-runtime — new importProjectFromZip() wrapper mapping to the snake_case ProjectFile shape, symmetric with exportProjectAsZip.
  • hub-client ProjectSelector — new "Import from ZIP" button + form (file picker, title prefilled from the filename, sync-server). Project type is passed as an 'imported' sentinel that App.handleProjectCreated already ignores.

Testing

TDD throughout (tests written first, confirmed failing, then implemented):

  • Parser — 20 unit tests (text/binary, Unicode, nested paths, top-level-dir stripping, junk filtering, unknown-extension sniffing, zip-slip rejection, empty/corrupt errors, export→import round-trip).
  • Wrapper — 2 shape-conversion / error-propagation tests.
  • Component — 5 tests (button reveals form, filename prefills title, submit reads bytes → parser → onProjectCreated, parse errors surfaced, submit gated on a chosen file).
  • E2E (hub-client/e2e/import-zip.spec.ts) — drives real Chromium against a live hub: upload a fixture zip (_quarto.yml, index.qmd, a real PNG) → new project created → both files appear in the sidebar → index.qmd renders the imported content in the preview iframe. Confirms text and binary round-trip through the real fflate → createNewProject → Automerge → render path.

Gates run green: hub-client unit 561 / integration 66 / wasm 79, production vite build, and the dependency packages (quarto-sync-client 136, preview-runtime 62). No Rust changes.

Note on the jsdom component test

The component test mocks importProjectFromZip rather than calling real fflate. Under vitest's jsdom environment, jsdom's TextEncoder.encode() returns a Uint8Array from a different JS realm, so x instanceof Uint8Array is false (jsdom/jsdom#2524). fflate's strToU8 uses TextEncoder, and its zipSync flatten step decides file-vs-directory with val instanceof u8 — so a strToU8-produced array is misclassified as a directory and the archive comes out corrupt. Real browsers are single-realm, so this is a test-environment artifact only (the e2e exercises the real path). Real ZIP parsing is therefore asserted in node-env suites.

Out of scope (possible follow-ups)

  • Importing into an existing project (merge/overlay) — this only creates a new project.
  • Async/worker unzip for very large archives.
  • Drag-and-drop of a ZIP onto the landing page.

🤖 Generated with Claude Code

cscheid and others added 3 commits June 2, 2026 06:31
Add an "Import from ZIP" action to the project-selector landing page that
creates a new hub-client project from an uploaded .zip — the inverse of
the existing "Export to ZIP".

- ts-packages/quarto-sync-client: new pure parseProjectZip() that unzips
  (fflate) into the CreateProjectOptions.files shape. Strips a common
  top-level dir (GitHub-style downloads), drops __MACOSX/.DS_Store/.git
  and directory entries, rejects zip-slip paths, and classifies text vs
  binary by extension with a UTF-8/NUL sniff fallback for unknown
  extensions (binary content base64-encoded for createNewProject).
- ts-packages/preview-runtime: new importProjectFromZip() wrapper that
  maps to the snake_case ProjectFile shape, symmetric with
  exportProjectAsZip.
- hub-client ProjectSelector: new "Import from ZIP" button + form (file
  picker, title prefilled from filename, sync-server). Reuses the
  existing onProjectCreated path unchanged; project type passed as an
  'imported' sentinel that App.handleProjectCreated already ignores.

Tests (TDD): import-zip parser (20), wrapper conversion (2), ProjectSelector
component wiring (5), and an e2e (e2e/import-zip.spec.ts) that drives the
real browser pipeline end-to-end (upload -> create -> render, text + binary).

The jsdom component test mocks the parser: under vitest's jsdom env,
TextEncoder returns a foreign-realm Uint8Array, so fflate's zipSync
instanceof leaf-check misclassifies strToU8 output (jsdom/jsdom#2524).
Real browsers are single-realm; the e2e confirms production is unaffected.

Plan: claude-notes/plans/2026-06-01-import-from-zip.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cscheid cscheid merged commit 06e7905 into main Jun 2, 2026
4 of 5 checks passed
@cscheid cscheid deleted the feature/import-from-zip branch June 2, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant