From 23580a2953f7db65c1af6db4ae03e50398038d16 Mon Sep 17 00:00:00 2001 From: intellild Date: Mon, 27 Jul 2026 17:52:02 +0800 Subject: [PATCH 1/5] feat: add project history management --- docs/plan/history-drawer-shadcn.md | 69 +++ docs/plan/project-history-single-project.md | 63 +++ docs/plan/project-history.md | 72 ++++ package.json | 6 +- pnpm-lock.yaml | 438 +++++++++++++++++++- src/components/Editor/index.tsx | 17 +- src/components/Header.tsx | 16 +- src/components/HistoryDrawer.tsx | 406 ++++++++++++++++++ src/components/ui/drawer.tsx | 118 ++++++ src/components/ui/input.tsx | 21 + src/components/ui/popover.tsx | 79 ++++ src/lib/history.ts | 229 ++++++++++ src/store/bundler.ts | 23 + 13 files changed, 1537 insertions(+), 20 deletions(-) create mode 100644 docs/plan/history-drawer-shadcn.md create mode 100644 docs/plan/project-history-single-project.md create mode 100644 docs/plan/project-history.md create mode 100644 src/components/HistoryDrawer.tsx create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/lib/history.ts diff --git a/docs/plan/history-drawer-shadcn.md b/docs/plan/history-drawer-shadcn.md new file mode 100644 index 0000000..2593670 --- /dev/null +++ b/docs/plan/history-drawer-shadcn.md @@ -0,0 +1,69 @@ +# shadcn history drawer controls + +## Goal + +Replace the hand-positioned Dialog used by project history with the project's shadcn/Radix Drawer component. Replace the delete AlertDialog with a contextual shadcn/Radix Popover anchored to the delete button, so confirmation stays close to the user's pointer. + +## Existing UI conventions + +The project already uses shadcn-style components backed by Radix primitives (`dialog.tsx`, `select.tsx`, `alert-dialog.tsx`) and Tailwind CSS variables. Keep the same Radix component family and visual conventions rather than introducing Base UI primitives into only this feature. + +## Design + +### Shared UI components + +- Add the standard shadcn `src/components/ui/drawer.tsx` wrapper around the Radix-compatible Drawer dependency, exporting `Drawer`, `DrawerContent`, `DrawerHeader`, `DrawerTitle`, `DrawerDescription`, `DrawerFooter`, `DrawerClose`, `DrawerOverlay`, `DrawerPortal`, and `DrawerTrigger` as appropriate. +- Add the standard shadcn `src/components/ui/popover.tsx` wrapper around `@radix-ui/react-popover`, exporting the root, trigger, content, anchor, header/title/description helpers needed by the feature. +- Add only the dependencies required by those standard components and update `pnpm-lock.yaml`. + +### History drawer + +- Use a controlled ``. +- Set the drawer swipe direction to `right` and keep the current full-height, right-side, responsive width behavior through `DrawerContent` classes. +- Move the current history header and scrollable list into `DrawerHeader` and the drawer content body. Keep the existing loading, empty, error, restore, and real-time refresh behavior. +- Preserve accessible `DrawerTitle` and `DrawerDescription`. + +### Delete confirmation + +- Remove `AlertDialog` and its modal confirmation state. +- For each history row, wrap the delete icon button in a controlled or row-local `Popover` with `PopoverTrigger asChild` and `PopoverContent` aligned to the end of the button. +- Put a concise confirmation message and Cancel/Delete actions in the Popover. Delete should call the existing async handler, close the popover, and preserve busy-state disabling/toasts. +- Keep the popover small and close to the delete control; it must not require moving the pointer to a centered modal. + +## Acceptance criteria + +1. `HistoryDrawer` imports and renders the shadcn Drawer component, not the existing Dialog for the side panel. +2. The drawer remains right-sided, full-height, accessible, and scrollable. +3. Delete confirmation uses a shadcn Popover anchored to each row's delete button and no AlertDialog remains in this flow. +4. Restore, delete, loading/error/empty states, active project selection, and history refresh behavior remain unchanged. +5. `pnpm lint:check`, `pnpm format:check`, `pnpm exec tsc --noEmit --ignoreDeprecations 6.0`, and `pnpm build` pass. + +## Follow-up: reactive history updates + +The history list must observe the Dexie table through its built-in `liveQuery()` API. The Drawer subscribes while open, so inserts, updates, and deletes update the list from the database without manually reloading or maintaining a separate change-notification set. The delete handler only performs the database mutation; the live query owns the UI refresh. + +## Follow-up: manually start a new project + +Set the history Drawer width to `30rem`. Add a compact plus button in the Drawer header for starting a new project. Clicking it clears `currentProjectIdAtom`, starts a bundle for the current source files so the existing save path creates a new history record, and closes the Drawer. The previous project remains in history; subsequent compiles update the newly created project. Starting the bundle also invalidates an older in-flight bundle request through the existing request-id mechanism. + +## Follow-up: editable project titles + +Add a persisted `title` field to each history record. Upgrade the Dexie schema and give existing/new records the default title `Untitled project`; subsequent source compiles preserve the title. Add `renameHistory()` to update only the title. In each Drawer row, display the title and place a pencil edit button immediately to its right. Editing is inline with a text input and save/cancel controls, rejects blank titles, and relies on the existing live query to update the row. + +Acceptance criteria for this follow-up: + +1. `HistoryDrawer` receives list updates from a Dexie live query rather than calling `listHistory()` in mutation callbacks. +2. The custom history subscriber/notification mechanism is removed. +3. Deleting an entry removes it from IndexedDB and the open Drawer automatically. + +Acceptance criteria for this follow-up: + +1. The right-side history Drawer is `30rem` wide while remaining responsive on small screens. +2. The header has an accessible plus button that starts a new project without deleting or overwriting the current history entry. +3. After starting a new project, the current source is saved as a new project and later compiles update that new project. + +Acceptance criteria for this follow-up: + +1. Existing records receive a default title through a Dexie schema migration, and new records persist the same default. +2. Each history item shows its title with an accessible edit button immediately beside it. +3. Saving a non-empty edited title persists it; canceling or submitting an empty title does not change the stored title. diff --git a/docs/plan/project-history-single-project.md b/docs/plan/project-history-single-project.md new file mode 100644 index 0000000..eed9dd4 --- /dev/null +++ b/docs/plan/project-history-single-project.md @@ -0,0 +1,63 @@ +# Single-record project history + +## Problem correction + +The previous implementation treated every compile as a new history record. The required model is one IndexedDB record per project: + +- Opening a new playground page starts a new project and creates one history record. +- Every later compile updates that record's ZIP archive and metadata. +- Restoring/selecting a history item switches the active project ID; later compiles update the selected record instead of creating another one. + +## Design + +### Project identity + +Add a `currentProjectIdAtom: number | null` to the Jotai store. It starts as `null` on a fresh page load and is set to the ID returned by the first project save. It is set to the selected record's ID when a history item is restored. Deleting the active record clears the ID so the next compile can create a replacement project. + +The initial compile is already triggered automatically when the editor mounts. Its first persistence operation creates the new project record, so the page opens with a history entry even if the user has not edited a file yet. Subsequent compile operations always save through the existing project ID, including compiles caused by source edits or Rspack version changes. + +### Dexie and ZIP storage + +Keep the existing Dexie database and zip.js archive format, but change the persistence API to accept an optional project ID: + +- Create when the ID is `null`. +- Update the existing record when the ID is present, preserving `createdAt` and replacing the archive, `rspackVersion`, and `fileCount`. +- Add `updatedAt` and sort the drawer by it. Bump/migrate the Dexie schema so records created by the earlier implementation use `createdAt` as their initial `updatedAt`. +- If an active record was removed before an update reaches IndexedDB, fall back to creating a new record and return its new ID. + +Persistence errors must be caught by the compile action, shown through the existing toast mechanism, and must not fail compilation. + +### Compile and switch flow + +Remove the dirty-only/new-record-per-compile behavior. The compile action captures the current project ID and, after the latest bundle result is accepted, saves the current source files into that project. It updates the atom with the returned ID only when the current project selection has not changed during the async operation, so a user switching history while a compile is in flight cannot be overwritten by an older request. + +Restoring a history item should: + +1. Unpack its ZIP and load the stored Rspack version. +2. Set the source files and `currentProjectIdAtom` to the selected record. +3. Compile normally so the selected record remains the persistence target. +4. Close the drawer after the restore succeeds. + +Do not pass a `skipHistory` flag for restore; that would leave the project selection disconnected from normal compile persistence. + +### Drawer behavior + +Keep the existing right-side drawer, list, empty/loading/error states, restore action, and delete confirmation. Display each project's last updated time, Rspack version, and file count. When deleting the active project, clear `currentProjectIdAtom`. + +## Acceptance criteria + +1. A fresh page load creates exactly one history project when its automatic initial compile completes. +2. Editing and recompiling updates the same record ID; the drawer still contains one project entry rather than one entry per compile. +3. Rspack version changes also update the active project record. +4. Restoring a history item changes the active project ID and subsequent edits update that selected record. +5. Deleting the active project clears the selection; the next compile creates one new project. +6. Existing ZIP source round-tripping, error handling, and drawer UX continue to work. + +## Verification + +Run: + +- `pnpm lint:check` +- `pnpm format:check` +- `pnpm exec tsc --noEmit --ignoreDeprecations 6.0` +- `pnpm build` diff --git a/docs/plan/project-history.md b/docs/plan/project-history.md new file mode 100644 index 0000000..64bc5fa --- /dev/null +++ b/docs/plan/project-history.md @@ -0,0 +1,72 @@ +# Project history snapshots + +> Superseded by [`project-history-single-project.md`](./project-history-single-project.md): the current design keeps one record per project and updates it on subsequent compiles. + +## Goal + +Add local project history to the Rspack Playground. After the user changes source files, the next compilation should persist a snapshot of the current project. Snapshots are stored in IndexedDB through Dexie, with source files compressed into a ZIP archive through zip.js. A header button opens a right-side drawer where the user can inspect, restore, or remove snapshots. + +## Existing flow + +- `inputFilesAtom` owns the editable `SourceFile[]`. +- `Editor` updates that atom for content edits, file creation/deletion, and renames, then calls `useBundle` through a 300 ms debounce. +- `bundleActionAtom` is the single compile entry point, including initial compilation, preset reset compilation, and Rspack version changes. +- `Header` owns the global controls and is the natural location for the history drawer trigger. + +## Design + +### Storage service + +Create a small history persistence module under `src/lib/history.ts`: + +- Define a Dexie database with a `history` table keyed by an auto-incrementing `id`. +- Store `createdAt`, the selected `rspackVersion`, the source `fileCount`, and an `archive` Blob. +- Build an archive with zip.js `ZipWriter`/`BlobWriter` and `TextReader`, adding each source file by its filename and excluding no editor source files. +- Read an archive with `ZipReader`/`BlobReader` and `TextWriter`, returning the original `SourceFile[]`. +- Expose list, save, restore, and delete operations. List newest snapshots first. +- Notify subscribers after writes/deletes so an already-open drawer refreshes without requiring a reopen. + +The persistence module must keep ZIP and IndexedDB failures as rejected promises. Callers should surface failures as a toast and leave the editor state usable. + +### Compile integration + +Track whether the current editor state has been modified since its last recorded compile. The initial compile must not create a history entry. All editor file mutations, including create/delete/rename and preset reset, should mark the state dirty. A compile captures the dirty state and source array for that request; after the request completes for the current/latest state, it saves that source array with the Rspack version and clears the dirty flag only if the editor still points at that same source array. This prevents an edit made while a compile is running from being lost, and superseded compile requests must not clear the dirty flag. + +Normal bundling results that contain compiler errors still represent a compile and should be eligible for history persistence. Persistence errors must not fail the bundle action. + +Restoring a snapshot should replace the editor files and Rspack version, compile the restored state, and avoid immediately creating a duplicate snapshot for the restore operation. + +### Drawer UI + +Add a history icon button beside the existing header actions. The button opens an accessible right-side drawer built from the existing Radix Dialog primitives (or a small reusable sheet wrapper). + +The drawer should: + +- Load and display the newest records on open and refresh while open. +- Show a useful empty state, loading state, and error state. +- Display the snapshot time, Rspack version, and file count. +- Offer restore and delete actions for each record, with confirmation before deletion if needed. +- Close after a successful restore and use existing `sonner` toasts for success/failure feedback. + +Keep the drawer responsive so it remains usable on narrow screens. + +## Acceptance criteria + +1. The project declares Dexie and `@zip.js/zip.js` dependencies and the lockfile is updated. +2. Loading the playground and its initial compile does not add a history record. +3. Editing, creating, deleting, or renaming a source file and waiting for compilation adds one ZIP-backed record for the compiled source state. +4. A failed IndexedDB/ZIP write does not break compilation and reports an error to the user. +5. The header history button opens a right-side drawer showing records newest-first, including an empty state when there are none. +6. Restoring a record reconstructs all source files, restores its Rspack version, recompiles, and does not create a redundant history entry. +7. Deleting a record removes it from IndexedDB and the open drawer immediately. +8. Existing download, share, reset, version switching, and editor behavior continue to work. + +## Verification + +Run the repository's available static checks and production build after implementation: + +- `pnpm lint:check` +- `pnpm format:check` +- `pnpm build` + +If the implementation adds focused tests, run those as well. diff --git a/package.json b/package.json index c5b8ddb..9fbcdeb 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,15 @@ "@radix-ui/react-dialog": "^1.1.17", "@radix-ui/react-dropdown-menu": "^2.1.18", "@radix-ui/react-label": "^2.1.10", + "@radix-ui/react-popover": "^1.1.23", "@radix-ui/react-select": "^2.3.1", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-tooltip": "^1.2.10", + "@zip.js/zip.js": "^2.8.34", "ansis": "^4.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dexie": "^4.4.4", "jotai": "^2.20.1", "jszip": "^3.10.1", "lodash-es": "^4.18.1", @@ -39,7 +42,8 @@ "source-map-js": "^1.2.1", "tailwind-merge": "^3.6.0", "terser-webpack-plugin": "^5.6.1", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2" }, "devDependencies": { "@rsbuild/core": "^2.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f8dfa4..d2b82f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.10 version: 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-select': specifier: ^2.3.1 version: 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -35,6 +38,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@zip.js/zip.js': + specifier: ^2.8.34 + version: 2.8.34 ansis: specifier: ^4.3.1 version: 4.3.1 @@ -44,6 +50,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dexie: + specifier: ^4.4.4 + version: 4.4.4 jotai: specifier: ^2.20.1 version: 2.20.1(@types/react@19.2.17)(react@19.2.7) @@ -83,6 +92,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) devDependencies: '@rsbuild/core': specifier: ^2.0.15 @@ -508,6 +520,9 @@ packages: '@radix-ui/primitive@1.1.4': resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + '@radix-ui/react-alert-dialog@1.1.17': resolution: {integrity: sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==} peerDependencies: @@ -534,6 +549,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-checkbox@1.3.5': resolution: {integrity: sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==} peerDependencies: @@ -569,6 +597,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.4': resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} peerDependencies: @@ -578,6 +615,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.17': resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} peerDependencies: @@ -613,6 +659,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dropdown-menu@2.1.18': resolution: {integrity: sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==} peerDependencies: @@ -635,6 +694,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-focus-scope@1.1.10': resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} peerDependencies: @@ -648,6 +716,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.2': resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} peerDependencies: @@ -657,6 +738,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-label@2.1.10': resolution: {integrity: sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==} peerDependencies: @@ -683,6 +773,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.23': + resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.3.1': resolution: {integrity: sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==} peerDependencies: @@ -696,6 +799,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.12': resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} peerDependencies: @@ -709,6 +825,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.6': resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} peerDependencies: @@ -722,6 +864,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.6': resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} peerDependencies: @@ -770,6 +925,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-tooltip@1.2.10': resolution: {integrity: sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==} peerDependencies: @@ -792,6 +956,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.3': resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} peerDependencies: @@ -801,6 +974,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.3': resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} peerDependencies: @@ -810,6 +992,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.2': resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} peerDependencies: @@ -828,6 +1019,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.2': resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} peerDependencies: @@ -846,6 +1046,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-size@1.1.2': resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} peerDependencies: @@ -855,6 +1064,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.2.6': resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} peerDependencies: @@ -871,6 +1089,9 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@rsbuild/core@2.0.15': resolution: {integrity: sha512-O8vmMhZu1YImO6jOqt/K/vlJSvkq7UtSq5YM1DIlcEd9LW8Gf6/dkQ1B2KPI6F+hSMFBnTTTumdcIowSLCw97g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1230,6 +1451,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zip.js/zip.js@2.8.34': + resolution: {integrity: sha512-+6a3lyqq69rpseLbvDPiVIWsZ/HdTGAAD6afFtug6ECPDGttb2dHnPC6cJgdPofYkzL9OvXizegq+DQVfL2rnA==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -1328,6 +1553,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dexie@4.4.4: + resolution: {integrity: sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==} + dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} @@ -1337,10 +1565,6 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - enhanced-resolve@5.21.4: - resolution: {integrity: sha512-wE4fDO8OjJhrPFH69HUQStq5oKvGRTNXEyW+k5C/pUQLASSsTu7obd2V3GvCDgPcY9AWjhJ4jz9Kh7iRvrxhJg==} - engines: {node: '>=10.13.0'} - enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -1921,6 +2145,12 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -2254,6 +2484,8 @@ snapshots: '@radix-ui/primitive@1.1.4': {} + '@radix-ui/primitive@1.1.7': {} + '@radix-ui/react-alert-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -2276,6 +2508,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-checkbox@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -2310,12 +2551,24 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -2357,6 +2610,19 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-dropdown-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -2378,6 +2644,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) @@ -2389,6 +2661,17 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -2396,6 +2679,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-label@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2431,6 +2721,29 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-popper@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2449,6 +2762,24 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2459,6 +2790,25 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -2468,6 +2818,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) @@ -2531,6 +2890,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-tooltip@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -2557,6 +2923,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) @@ -2565,6 +2937,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -2572,6 +2953,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -2585,6 +2973,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -2598,6 +2992,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -2605,6 +3006,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2616,6 +3024,8 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@radix-ui/rect@1.1.3': {} + '@rsbuild/core@2.0.15': dependencies: '@rspack/core': 2.0.8(@swc/helpers@0.5.23) @@ -2954,6 +3364,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zip.js/zip.js@2.8.34': {} + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -3031,6 +3443,8 @@ snapshots: detect-node-es@1.1.0: {} + dexie@4.4.4: {} + dompurify@3.2.7: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -3039,11 +3453,6 @@ snapshots: emoji-regex@10.6.0: {} - enhanced-resolve@5.21.4: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -3501,6 +3910,15 @@ snapshots: util-deprecate@1.0.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -3520,7 +3938,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.1 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.21.4 + enhanced-resolve: 5.21.6 es-module-lexer: 2.0.0 eslint-scope: 5.1.1 events: 3.3.0 diff --git a/src/components/Editor/index.tsx b/src/components/Editor/index.tsx index 020b54a..9092d29 100644 --- a/src/components/Editor/index.tsx +++ b/src/components/Editor/index.tsx @@ -1,5 +1,5 @@ import ansis from "ansis"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { debounce } from "lodash-es"; import { Check, Settings2, X } from "lucide-react"; import type * as Monaco from "monaco-editor"; @@ -234,7 +234,8 @@ function OutputPanel({ } function Editor() { - const [inputFiles, _setInputFiles] = useAtom(inputFilesAtom); + const inputFiles = useAtomValue(inputFilesAtom); + const setInputFiles = useSetAtom(inputFilesAtom); const [activeInputFile, setActiveInputFile] = useAtom(activeInputFileAtom); const [activeOutputFile, setActiveOutputFile] = useAtom(activeOutputFileAtom); const [enableDeps, setEnableDeps] = useAtom(enableDependenciesAtom); @@ -277,8 +278,8 @@ function Editor() { handleBundle(inputFiles); }, []); - const setInputFiles = (files: SourceFile[]) => { - _setInputFiles(files); + const updateInputFiles = (files: SourceFile[]) => { + setInputFiles(files); debouncedHandleBundle(files); }; @@ -287,7 +288,7 @@ function Editor() { filename, text: "", }; - setInputFiles([...inputFiles, newFile]); + updateInputFiles([...inputFiles, newFile]); setActiveInputFile(inputFiles.length); }; @@ -295,7 +296,7 @@ function Editor() { if (inputFiles.length <= 1) return; const newFiles = inputFiles.filter((_, i) => i !== index); - setInputFiles(newFiles); + updateInputFiles(newFiles); if (activeInputFile >= newFiles.length) { setActiveInputFile(newFiles.length - 1); @@ -307,13 +308,13 @@ function Editor() { const handleInputFileRename = (index: number, newName: string) => { const newFiles = [...inputFiles]; newFiles[index] = { ...newFiles[index], filename: newName }; - setInputFiles(newFiles); + updateInputFiles(newFiles); }; const handleInputContentChange = (index: number, content: string) => { const newFiles = [...inputFiles]; newFiles[index] = { ...newFiles[index], text: content }; - setInputFiles(newFiles); + updateInputFiles(newFiles); }; const handleInputEditorMount = useCallback((editor: Monaco.editor.IStandaloneCodeEditor) => { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 565c8cb..169d7b1 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,9 +1,10 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { Clock, Download, RotateCcw, Share2 } from "lucide-react"; +import { Clock, Download, History, RotateCcw, Share2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import Github from "@/components/icon/Github"; import Logo from "@/components/icon/Rspack"; +import HistoryDrawer from "@/components/HistoryDrawer"; import { ModeToggle } from "@/components/ModeToggle"; import Preview from "@/components/Preview"; import { @@ -88,6 +89,7 @@ export default function Header() { const downloadProject = useDownloadProject(); const [selectedPreset, setSelectedPreset] = useState(presets[0].name); + const [historyOpen, setHistoryOpen] = useState(false); const handleReset = () => { const preset = getPresetByName(selectedPreset); @@ -223,6 +225,17 @@ export default function Header() {
+
+ ); } diff --git a/src/components/HistoryDrawer.tsx b/src/components/HistoryDrawer.tsx new file mode 100644 index 0000000..7b502dc --- /dev/null +++ b/src/components/HistoryDrawer.tsx @@ -0,0 +1,406 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { Check, History, LoaderCircle, Pencil, Plus, RotateCcw, Trash2, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverFooter, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; +import useBundle from "@/hooks/use-bundle"; +import { getShareUrl } from "@/lib/share"; +import { + deleteHistory, + historyObservable, + renameHistory, + restoreHistory, + type HistorySnapshot, +} from "@/lib/history"; +import { currentProjectIdAtom, inputFilesAtom } from "@/store/bundler"; +import { rspackVersionAtom } from "@/store/version"; + +interface HistoryDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +function formatSnapshotDate(timestamp: number) { + return dateFormatter.format(new Date(timestamp)); +} + +export default function HistoryDrawer({ open, onOpenChange }: HistoryDrawerProps) { + const [snapshots, setSnapshots] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + const [snapshotToDelete, setSnapshotToDelete] = useState(null); + const [retryNonce, setRetryNonce] = useState(0); + const [editingSnapshotId, setEditingSnapshotId] = useState(null); + const [editingTitle, setEditingTitle] = useState(""); + const [titleError, setTitleError] = useState(null); + const [isStartingNewProject, setIsStartingNewProject] = useState(false); + const inputFiles = useAtomValue(inputFilesAtom); + const setInputFiles = useSetAtom(inputFilesAtom); + const currentProjectId = useAtomValue(currentProjectIdAtom); + const setCurrentProjectId = useSetAtom(currentProjectIdAtom); + const rspackVersion = useAtomValue(rspackVersionAtom); + const setRspackVersion = useSetAtom(rspackVersionAtom); + const handleBundle = useBundle(); + + useEffect(() => { + if (!open) { + return; + } + + setIsLoading(true); + setError(null); + const subscription = historyObservable.subscribe({ + next: (nextSnapshots) => { + setSnapshots(nextSnapshots); + setIsLoading(false); + setError(null); + }, + error: (loadError) => { + console.error("Failed to load project history:", loadError); + setIsLoading(false); + setError("History could not be loaded. Please try again."); + }, + }); + + return () => subscription.unsubscribe(); + }, [open, retryNonce]); + + const handleRestore = async (snapshot: HistorySnapshot) => { + setBusyId(snapshot.id); + try { + const restored = await restoreHistory(snapshot.id); + setInputFiles(restored.files); + setCurrentProjectId(snapshot.id); + setRspackVersion(restored.rspackVersion); + window.history.replaceState( + null, + "", + getShareUrl({ + rspackVersion: restored.rspackVersion, + inputFiles: restored.files, + }), + ); + await handleBundle(restored.files, restored.rspackVersion); + toast.success("Project restored from history"); + onOpenChange(false); + } catch (restoreError) { + console.error("Failed to restore project history:", restoreError); + toast.error("Failed to restore project history"); + } finally { + setBusyId(null); + } + }; + + const handleDelete = async (snapshot: HistorySnapshot) => { + setBusyId(snapshot.id); + try { + await deleteHistory(snapshot.id); + if (snapshot.id === currentProjectId) { + setCurrentProjectId(null); + } + toast.success("History entry deleted"); + } catch (deleteError) { + console.error("Failed to delete project history:", deleteError); + toast.error("Failed to delete history entry"); + } finally { + setBusyId(null); + setSnapshotToDelete(null); + } + }; + + const handleStartNewProject = async () => { + if (busyId !== null || isStartingNewProject) { + return; + } + + setIsStartingNewProject(true); + setCurrentProjectId(null); + setSnapshotToDelete(null); + setEditingSnapshotId(null); + try { + await handleBundle(inputFiles, rspackVersion); + onOpenChange(false); + } catch (bundleError) { + console.error("Failed to start a new project:", bundleError); + toast.error("Failed to start a new project"); + } finally { + setIsStartingNewProject(false); + } + }; + + const handleStartEditing = (snapshot: HistorySnapshot) => { + setSnapshotToDelete(null); + setEditingSnapshotId(snapshot.id); + setEditingTitle(snapshot.title); + setTitleError(null); + }; + + const handleCancelEditing = () => { + setEditingSnapshotId(null); + setEditingTitle(""); + setTitleError(null); + }; + + const handleRename = async (snapshot: HistorySnapshot) => { + const trimmedTitle = editingTitle.trim(); + if (!trimmedTitle) { + setTitleError("Project title cannot be empty"); + toast.error("Project title cannot be empty"); + return; + } + + setBusyId(snapshot.id); + try { + await renameHistory(snapshot.id, trimmedTitle); + handleCancelEditing(); + } catch (renameError) { + console.error("Failed to rename project history:", renameError); + setTitleError("Failed to rename project"); + toast.error("Failed to rename project"); + } finally { + setBusyId(null); + } + }; + + return ( + <> + + + +
+
+ Project History + Restore or remove saved project snapshots. +
+ +
+
+
+ {isLoading ? ( +
+ + Loading history… +
+ ) : error ? ( +
+

{error}

+ +
+ ) : snapshots.length === 0 ? ( +
+ +
+

No history yet

+

Compile to save the current project here.

+
+
+ ) : ( +
+ {snapshots.map((snapshot) => { + const isBusy = busyId === snapshot.id; + return ( +
+
+
+ {editingSnapshotId === snapshot.id ? ( +
+
+ { + setEditingTitle(event.target.value); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleRename(snapshot); + } else if (event.key === "Escape") { + event.preventDefault(); + handleCancelEditing(); + } + }} + className="h-7 text-sm" + aria-label="Project title" + autoFocus + disabled={busyId !== null} + /> + + +
+ {titleError && ( +

{titleError}

+ )} +
+ ) : ( +
+

+ {snapshot.title} +

+ +
+ )} +

+ {formatSnapshotDate(snapshot.updatedAt)} +

+

+ Rspack v{snapshot.rspackVersion} · {snapshot.fileCount} files +

+
+ { + if (busyId === null) { + setSnapshotToDelete(nextOpen ? snapshot : null); + } + }} + > + + + + + + Delete history entry? + + This snapshot will be permanently removed from this browser. + + + + + + + + +
+ +
+ ); + })} +
+ )} +
+
+
+ + ); +} diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..b471bf5 --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +import { Drawer as DrawerPrimitive } from "vaul"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Drawer({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerClose({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerPortal, + DrawerTitle, + DrawerTrigger, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..73ea867 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..dc8d52e --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,79 @@ +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Popover({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function PopoverFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function PopoverDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDescription, + PopoverFooter, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +}; diff --git a/src/lib/history.ts b/src/lib/history.ts new file mode 100644 index 0000000..2b97a2d --- /dev/null +++ b/src/lib/history.ts @@ -0,0 +1,229 @@ +import Dexie, { liveQuery, type Table } from "dexie"; +import { + BlobReader, + BlobWriter, + TextReader, + TextWriter, + ZipReader, + ZipWriter, +} from "@zip.js/zip.js"; +import type { SourceFile } from "@/store/bundler"; + +const defaultProjectTitle = "Untitled project"; + +export interface HistorySnapshot { + id: number; + createdAt: number; + updatedAt: number; + title: string; + rspackVersion: string; + fileCount: number; +} + +interface StoredHistorySnapshot extends Omit { + id?: number; + archive: Blob; +} + +class HistoryDatabase extends Dexie { + history!: Table; + + constructor() { + super("rspack-playground-history"); + this.version(1).stores({ + history: "++id, createdAt", + }); + this.version(2) + .stores({ + history: "++id, createdAt, updatedAt", + }) + .upgrade((transaction) => + transaction + .table("history") + .toCollection() + .modify((record: StoredHistorySnapshot) => { + record.updatedAt ??= record.createdAt; + }), + ); + this.version(3) + .stores({ + history: "++id, createdAt, updatedAt", + }) + .upgrade((transaction) => + transaction + .table("history") + .toCollection() + .modify((record: StoredHistorySnapshot) => { + record.title ??= defaultProjectTitle; + }), + ); + } +} + +const database = new HistoryDatabase(); +let pendingProjectCreation: Promise | null = null; + +async function createArchive(files: SourceFile[]) { + const blobWriter = new BlobWriter("application/zip"); + const zipWriter = new ZipWriter(blobWriter); + + for (const file of files) { + await zipWriter.add(file.filename, new TextReader(file.text)); + } + + await zipWriter.close(); + return blobWriter.getData(); +} + +function getSnapshotMetadata(record: StoredHistorySnapshot): HistorySnapshot { + if (record.id === undefined) { + throw new Error("History snapshot is missing its id"); + } + + return { + id: record.id, + createdAt: record.createdAt, + updatedAt: record.updatedAt ?? record.createdAt, + title: record.title ?? defaultProjectTitle, + rspackVersion: record.rspackVersion, + fileCount: record.fileCount, + }; +} + +export async function listHistory(): Promise { + const records = await database.history.orderBy("updatedAt").reverse().toArray(); + return records.map(getSnapshotMetadata); +} + +export const historyObservable = liveQuery(() => listHistory()); + +export async function saveHistory( + projectId: number | null, + files: SourceFile[], + rspackVersion: string, +): Promise { + if (projectId === null) { + if (pendingProjectCreation) { + const snapshot = await pendingProjectCreation; + return saveHistory(snapshot.id, files, rspackVersion); + } + + const creation = createProjectRecord(files, rspackVersion); + pendingProjectCreation = creation; + try { + const snapshot = await creation; + return snapshot; + } finally { + if (pendingProjectCreation === creation) { + pendingProjectCreation = null; + } + } + } + + const archive = await createArchive(files); + const updatedAt = Date.now(); + const existing = await database.history.get(projectId); + let snapshot: HistorySnapshot; + + if (existing) { + const updated = await database.history.update(projectId, { + archive, + rspackVersion, + fileCount: files.length, + updatedAt, + }); + if (updated > 0) { + snapshot = { + id: projectId, + createdAt: existing.createdAt, + updatedAt, + title: existing.title ?? defaultProjectTitle, + rspackVersion, + fileCount: files.length, + }; + } else { + snapshot = await createHistoryRecord(archive, updatedAt, rspackVersion, files.length); + } + } else { + snapshot = await createHistoryRecord(archive, updatedAt, rspackVersion, files.length); + } + + return snapshot; +} + +async function createProjectRecord( + files: SourceFile[], + rspackVersion: string, +): Promise { + const archive = await createArchive(files); + const timestamp = Date.now(); + return createHistoryRecord(archive, timestamp, rspackVersion, files.length); +} + +async function createHistoryRecord( + archive: Blob, + timestamp: number, + rspackVersion: string, + fileCount: number, +): Promise { + const id = await database.history.add({ + archive, + createdAt: timestamp, + updatedAt: timestamp, + title: defaultProjectTitle, + rspackVersion, + fileCount, + }); + return { + id, + createdAt: timestamp, + updatedAt: timestamp, + title: defaultProjectTitle, + rspackVersion, + fileCount, + }; +} + +export async function restoreHistory( + id: number, +): Promise<{ files: SourceFile[]; rspackVersion: string }> { + const record = await database.history.get(id); + if (!record) { + throw new Error("History snapshot not found"); + } + + const zipReader = new ZipReader(new BlobReader(record.archive)); + try { + const entries = await zipReader.getEntries(); + const files: SourceFile[] = []; + for (const entry of entries) { + if (entry.directory || !entry.getData) { + continue; + } + files.push({ + filename: entry.filename, + text: await entry.getData(new TextWriter()), + }); + } + + return { files, rspackVersion: record.rspackVersion }; + } finally { + await zipReader.close(); + } +} + +export async function deleteHistory(id: number): Promise { + await database.history.delete(id); +} + +export async function renameHistory(id: number, title: string): Promise { + const trimmedTitle = title.trim(); + if (!trimmedTitle) { + throw new Error("Project title cannot be empty"); + } + + const updated = await database.history.update(id, { title: trimmedTitle }); + if (updated === 0) { + throw new Error("History snapshot not found"); + } +} diff --git a/src/store/bundler.ts b/src/store/bundler.ts index 8ab9e91..a3f7744 100644 --- a/src/store/bundler.ts +++ b/src/store/bundler.ts @@ -1,5 +1,7 @@ import { atom } from "jotai"; +import { toast } from "sonner"; import { bundle } from "@/lib/bundle"; +import { saveHistory } from "@/lib/history"; import type { RspackChunkGroupInfo, RspackChunkInfo, @@ -60,6 +62,7 @@ export const bindingLoadingAtom = atom(false); export const isBundlingAtom = atom(false); export const latestBundleRequestIdAtom = atom(0); export const inputFilesAtom = atom(getInitFiles()); +export const currentProjectIdAtom = atom(null); export const bundleResultAtom = atom(null); export const enableFormatCode = atom(true); @@ -70,9 +73,14 @@ export const bundleActionAtom = atom( set, { files, versionOverride }: { files: SourceFile[]; versionOverride?: string }, ) => { + if (files !== get(inputFilesAtom)) { + return; + } + const requestId = get(latestBundleRequestIdAtom) + 1; set(latestBundleRequestIdAtom, requestId); + const projectId = get(currentProjectIdAtom); const targetVersion = versionOverride ?? (await get(rspackVersionAtom)); const shouldLoadBinding = get(bindingLoadedAtom) !== targetVersion; const isLatestRequest = () => requestId === get(latestBundleRequestIdAtom); @@ -94,6 +102,21 @@ export const bundleActionAtom = atom( set(bindingLoadedAtom, targetVersion); } + if (!isLatestRequest()) { + return; + } + + const projectIdForSave = projectId === null ? get(currentProjectIdAtom) : projectId; + try { + const snapshot = await saveHistory(projectIdForSave, files, targetVersion); + if (get(currentProjectIdAtom) === projectIdForSave) { + set(currentProjectIdAtom, snapshot.id); + } + } catch (error) { + console.error("Failed to save project history:", error); + toast.error("Failed to save project history"); + } + const activeOutputFile = get(activeOutputFileAtom); if (result.output.length > 0 && activeOutputFile >= result.output.length) { set(activeOutputFileAtom, 0); From d2e4132b780e8da958fb6f0edfc4010949dc87ea Mon Sep 17 00:00:00 2001 From: intellild Date: Tue, 28 Jul 2026 12:00:55 +0800 Subject: [PATCH 2/5] fix: sort project history by creation order --- docs/plan/history-drawer-shadcn.md | 69 -------------------- docs/plan/project-history-single-project.md | 63 ------------------ docs/plan/project-history.md | 72 --------------------- src/lib/history.ts | 2 +- 4 files changed, 1 insertion(+), 205 deletions(-) delete mode 100644 docs/plan/history-drawer-shadcn.md delete mode 100644 docs/plan/project-history-single-project.md delete mode 100644 docs/plan/project-history.md diff --git a/docs/plan/history-drawer-shadcn.md b/docs/plan/history-drawer-shadcn.md deleted file mode 100644 index 2593670..0000000 --- a/docs/plan/history-drawer-shadcn.md +++ /dev/null @@ -1,69 +0,0 @@ -# shadcn history drawer controls - -## Goal - -Replace the hand-positioned Dialog used by project history with the project's shadcn/Radix Drawer component. Replace the delete AlertDialog with a contextual shadcn/Radix Popover anchored to the delete button, so confirmation stays close to the user's pointer. - -## Existing UI conventions - -The project already uses shadcn-style components backed by Radix primitives (`dialog.tsx`, `select.tsx`, `alert-dialog.tsx`) and Tailwind CSS variables. Keep the same Radix component family and visual conventions rather than introducing Base UI primitives into only this feature. - -## Design - -### Shared UI components - -- Add the standard shadcn `src/components/ui/drawer.tsx` wrapper around the Radix-compatible Drawer dependency, exporting `Drawer`, `DrawerContent`, `DrawerHeader`, `DrawerTitle`, `DrawerDescription`, `DrawerFooter`, `DrawerClose`, `DrawerOverlay`, `DrawerPortal`, and `DrawerTrigger` as appropriate. -- Add the standard shadcn `src/components/ui/popover.tsx` wrapper around `@radix-ui/react-popover`, exporting the root, trigger, content, anchor, header/title/description helpers needed by the feature. -- Add only the dependencies required by those standard components and update `pnpm-lock.yaml`. - -### History drawer - -- Use a controlled ``. -- Set the drawer swipe direction to `right` and keep the current full-height, right-side, responsive width behavior through `DrawerContent` classes. -- Move the current history header and scrollable list into `DrawerHeader` and the drawer content body. Keep the existing loading, empty, error, restore, and real-time refresh behavior. -- Preserve accessible `DrawerTitle` and `DrawerDescription`. - -### Delete confirmation - -- Remove `AlertDialog` and its modal confirmation state. -- For each history row, wrap the delete icon button in a controlled or row-local `Popover` with `PopoverTrigger asChild` and `PopoverContent` aligned to the end of the button. -- Put a concise confirmation message and Cancel/Delete actions in the Popover. Delete should call the existing async handler, close the popover, and preserve busy-state disabling/toasts. -- Keep the popover small and close to the delete control; it must not require moving the pointer to a centered modal. - -## Acceptance criteria - -1. `HistoryDrawer` imports and renders the shadcn Drawer component, not the existing Dialog for the side panel. -2. The drawer remains right-sided, full-height, accessible, and scrollable. -3. Delete confirmation uses a shadcn Popover anchored to each row's delete button and no AlertDialog remains in this flow. -4. Restore, delete, loading/error/empty states, active project selection, and history refresh behavior remain unchanged. -5. `pnpm lint:check`, `pnpm format:check`, `pnpm exec tsc --noEmit --ignoreDeprecations 6.0`, and `pnpm build` pass. - -## Follow-up: reactive history updates - -The history list must observe the Dexie table through its built-in `liveQuery()` API. The Drawer subscribes while open, so inserts, updates, and deletes update the list from the database without manually reloading or maintaining a separate change-notification set. The delete handler only performs the database mutation; the live query owns the UI refresh. - -## Follow-up: manually start a new project - -Set the history Drawer width to `30rem`. Add a compact plus button in the Drawer header for starting a new project. Clicking it clears `currentProjectIdAtom`, starts a bundle for the current source files so the existing save path creates a new history record, and closes the Drawer. The previous project remains in history; subsequent compiles update the newly created project. Starting the bundle also invalidates an older in-flight bundle request through the existing request-id mechanism. - -## Follow-up: editable project titles - -Add a persisted `title` field to each history record. Upgrade the Dexie schema and give existing/new records the default title `Untitled project`; subsequent source compiles preserve the title. Add `renameHistory()` to update only the title. In each Drawer row, display the title and place a pencil edit button immediately to its right. Editing is inline with a text input and save/cancel controls, rejects blank titles, and relies on the existing live query to update the row. - -Acceptance criteria for this follow-up: - -1. `HistoryDrawer` receives list updates from a Dexie live query rather than calling `listHistory()` in mutation callbacks. -2. The custom history subscriber/notification mechanism is removed. -3. Deleting an entry removes it from IndexedDB and the open Drawer automatically. - -Acceptance criteria for this follow-up: - -1. The right-side history Drawer is `30rem` wide while remaining responsive on small screens. -2. The header has an accessible plus button that starts a new project without deleting or overwriting the current history entry. -3. After starting a new project, the current source is saved as a new project and later compiles update that new project. - -Acceptance criteria for this follow-up: - -1. Existing records receive a default title through a Dexie schema migration, and new records persist the same default. -2. Each history item shows its title with an accessible edit button immediately beside it. -3. Saving a non-empty edited title persists it; canceling or submitting an empty title does not change the stored title. diff --git a/docs/plan/project-history-single-project.md b/docs/plan/project-history-single-project.md deleted file mode 100644 index eed9dd4..0000000 --- a/docs/plan/project-history-single-project.md +++ /dev/null @@ -1,63 +0,0 @@ -# Single-record project history - -## Problem correction - -The previous implementation treated every compile as a new history record. The required model is one IndexedDB record per project: - -- Opening a new playground page starts a new project and creates one history record. -- Every later compile updates that record's ZIP archive and metadata. -- Restoring/selecting a history item switches the active project ID; later compiles update the selected record instead of creating another one. - -## Design - -### Project identity - -Add a `currentProjectIdAtom: number | null` to the Jotai store. It starts as `null` on a fresh page load and is set to the ID returned by the first project save. It is set to the selected record's ID when a history item is restored. Deleting the active record clears the ID so the next compile can create a replacement project. - -The initial compile is already triggered automatically when the editor mounts. Its first persistence operation creates the new project record, so the page opens with a history entry even if the user has not edited a file yet. Subsequent compile operations always save through the existing project ID, including compiles caused by source edits or Rspack version changes. - -### Dexie and ZIP storage - -Keep the existing Dexie database and zip.js archive format, but change the persistence API to accept an optional project ID: - -- Create when the ID is `null`. -- Update the existing record when the ID is present, preserving `createdAt` and replacing the archive, `rspackVersion`, and `fileCount`. -- Add `updatedAt` and sort the drawer by it. Bump/migrate the Dexie schema so records created by the earlier implementation use `createdAt` as their initial `updatedAt`. -- If an active record was removed before an update reaches IndexedDB, fall back to creating a new record and return its new ID. - -Persistence errors must be caught by the compile action, shown through the existing toast mechanism, and must not fail compilation. - -### Compile and switch flow - -Remove the dirty-only/new-record-per-compile behavior. The compile action captures the current project ID and, after the latest bundle result is accepted, saves the current source files into that project. It updates the atom with the returned ID only when the current project selection has not changed during the async operation, so a user switching history while a compile is in flight cannot be overwritten by an older request. - -Restoring a history item should: - -1. Unpack its ZIP and load the stored Rspack version. -2. Set the source files and `currentProjectIdAtom` to the selected record. -3. Compile normally so the selected record remains the persistence target. -4. Close the drawer after the restore succeeds. - -Do not pass a `skipHistory` flag for restore; that would leave the project selection disconnected from normal compile persistence. - -### Drawer behavior - -Keep the existing right-side drawer, list, empty/loading/error states, restore action, and delete confirmation. Display each project's last updated time, Rspack version, and file count. When deleting the active project, clear `currentProjectIdAtom`. - -## Acceptance criteria - -1. A fresh page load creates exactly one history project when its automatic initial compile completes. -2. Editing and recompiling updates the same record ID; the drawer still contains one project entry rather than one entry per compile. -3. Rspack version changes also update the active project record. -4. Restoring a history item changes the active project ID and subsequent edits update that selected record. -5. Deleting the active project clears the selection; the next compile creates one new project. -6. Existing ZIP source round-tripping, error handling, and drawer UX continue to work. - -## Verification - -Run: - -- `pnpm lint:check` -- `pnpm format:check` -- `pnpm exec tsc --noEmit --ignoreDeprecations 6.0` -- `pnpm build` diff --git a/docs/plan/project-history.md b/docs/plan/project-history.md deleted file mode 100644 index 64bc5fa..0000000 --- a/docs/plan/project-history.md +++ /dev/null @@ -1,72 +0,0 @@ -# Project history snapshots - -> Superseded by [`project-history-single-project.md`](./project-history-single-project.md): the current design keeps one record per project and updates it on subsequent compiles. - -## Goal - -Add local project history to the Rspack Playground. After the user changes source files, the next compilation should persist a snapshot of the current project. Snapshots are stored in IndexedDB through Dexie, with source files compressed into a ZIP archive through zip.js. A header button opens a right-side drawer where the user can inspect, restore, or remove snapshots. - -## Existing flow - -- `inputFilesAtom` owns the editable `SourceFile[]`. -- `Editor` updates that atom for content edits, file creation/deletion, and renames, then calls `useBundle` through a 300 ms debounce. -- `bundleActionAtom` is the single compile entry point, including initial compilation, preset reset compilation, and Rspack version changes. -- `Header` owns the global controls and is the natural location for the history drawer trigger. - -## Design - -### Storage service - -Create a small history persistence module under `src/lib/history.ts`: - -- Define a Dexie database with a `history` table keyed by an auto-incrementing `id`. -- Store `createdAt`, the selected `rspackVersion`, the source `fileCount`, and an `archive` Blob. -- Build an archive with zip.js `ZipWriter`/`BlobWriter` and `TextReader`, adding each source file by its filename and excluding no editor source files. -- Read an archive with `ZipReader`/`BlobReader` and `TextWriter`, returning the original `SourceFile[]`. -- Expose list, save, restore, and delete operations. List newest snapshots first. -- Notify subscribers after writes/deletes so an already-open drawer refreshes without requiring a reopen. - -The persistence module must keep ZIP and IndexedDB failures as rejected promises. Callers should surface failures as a toast and leave the editor state usable. - -### Compile integration - -Track whether the current editor state has been modified since its last recorded compile. The initial compile must not create a history entry. All editor file mutations, including create/delete/rename and preset reset, should mark the state dirty. A compile captures the dirty state and source array for that request; after the request completes for the current/latest state, it saves that source array with the Rspack version and clears the dirty flag only if the editor still points at that same source array. This prevents an edit made while a compile is running from being lost, and superseded compile requests must not clear the dirty flag. - -Normal bundling results that contain compiler errors still represent a compile and should be eligible for history persistence. Persistence errors must not fail the bundle action. - -Restoring a snapshot should replace the editor files and Rspack version, compile the restored state, and avoid immediately creating a duplicate snapshot for the restore operation. - -### Drawer UI - -Add a history icon button beside the existing header actions. The button opens an accessible right-side drawer built from the existing Radix Dialog primitives (or a small reusable sheet wrapper). - -The drawer should: - -- Load and display the newest records on open and refresh while open. -- Show a useful empty state, loading state, and error state. -- Display the snapshot time, Rspack version, and file count. -- Offer restore and delete actions for each record, with confirmation before deletion if needed. -- Close after a successful restore and use existing `sonner` toasts for success/failure feedback. - -Keep the drawer responsive so it remains usable on narrow screens. - -## Acceptance criteria - -1. The project declares Dexie and `@zip.js/zip.js` dependencies and the lockfile is updated. -2. Loading the playground and its initial compile does not add a history record. -3. Editing, creating, deleting, or renaming a source file and waiting for compilation adds one ZIP-backed record for the compiled source state. -4. A failed IndexedDB/ZIP write does not break compilation and reports an error to the user. -5. The header history button opens a right-side drawer showing records newest-first, including an empty state when there are none. -6. Restoring a record reconstructs all source files, restores its Rspack version, recompiles, and does not create a redundant history entry. -7. Deleting a record removes it from IndexedDB and the open drawer immediately. -8. Existing download, share, reset, version switching, and editor behavior continue to work. - -## Verification - -Run the repository's available static checks and production build after implementation: - -- `pnpm lint:check` -- `pnpm format:check` -- `pnpm build` - -If the implementation adds focused tests, run those as well. diff --git a/src/lib/history.ts b/src/lib/history.ts index 2b97a2d..d2df8ac 100644 --- a/src/lib/history.ts +++ b/src/lib/history.ts @@ -91,7 +91,7 @@ function getSnapshotMetadata(record: StoredHistorySnapshot): HistorySnapshot { } export async function listHistory(): Promise { - const records = await database.history.orderBy("updatedAt").reverse().toArray(); + const records = await database.history.orderBy("createdAt").reverse().toArray(); return records.map(getSnapshotMetadata); } From 98f8f51799d13aac910d50bf7589e9464c6590ef Mon Sep 17 00:00:00 2001 From: intellild Date: Tue, 28 Jul 2026 12:49:27 +0800 Subject: [PATCH 3/5] feat: add project history copy action --- src/components/HistoryDrawer.tsx | 155 +++++++++++++++++++++---------- src/lib/history.ts | 27 ++++++ 2 files changed, 133 insertions(+), 49 deletions(-) diff --git a/src/components/HistoryDrawer.tsx b/src/components/HistoryDrawer.tsx index 7b502dc..958f584 100644 --- a/src/components/HistoryDrawer.tsx +++ b/src/components/HistoryDrawer.tsx @@ -1,5 +1,15 @@ import { useAtomValue, useSetAtom } from "jotai"; -import { Check, History, LoaderCircle, Pencil, Plus, RotateCcw, Trash2, X } from "lucide-react"; +import { + Check, + Copy, + History, + LoaderCircle, + Pencil, + Plus, + RotateCcw, + Trash2, + X, +} from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -24,6 +34,7 @@ import useBundle from "@/hooks/use-bundle"; import { getShareUrl } from "@/lib/share"; import { deleteHistory, + duplicateHistory, historyObservable, renameHistory, restoreHistory, @@ -131,6 +142,33 @@ export default function HistoryDrawer({ open, onOpenChange }: HistoryDrawerProps } }; + const handleCopy = async (snapshot: HistorySnapshot) => { + setBusyId(snapshot.id); + try { + const copiedSnapshot = await duplicateHistory(snapshot.id); + const copiedProject = await restoreHistory(copiedSnapshot.id); + setInputFiles(copiedProject.files); + setCurrentProjectId(copiedSnapshot.id); + setRspackVersion(copiedProject.rspackVersion); + window.history.replaceState( + null, + "", + getShareUrl({ + rspackVersion: copiedProject.rspackVersion, + inputFiles: copiedProject.files, + }), + ); + await handleBundle(copiedProject.files, copiedProject.rspackVersion); + toast.success("Project copied to a new project"); + onOpenChange(false); + } catch (copyError) { + console.error("Failed to copy project history:", copyError); + toast.error("Failed to copy project history"); + } finally { + setBusyId(null); + } + }; + const handleStartNewProject = async () => { if (busyId !== null || isStartingNewProject) { return; @@ -193,7 +231,9 @@ export default function HistoryDrawer({ open, onOpenChange }: HistoryDrawerProps
Project History - Restore or remove saved project snapshots. + + Restore, copy, or remove saved project snapshots. +
- { - if (busyId === null) { - setSnapshotToDelete(nextOpen ? snapshot : null); - } - }} - > - - - - + + {isBusy ? ( + + ) : ( + + )} + + { + if (busyId === null) { + setSnapshotToDelete(nextOpen ? snapshot : null); + } + }} + > + - - - + + + + Delete history entry? + + This snapshot will be permanently removed from this browser. + + + + + + + + +
+
+ +
+ {isLoading ? ( +
+ + Loading history… +
+ ) : error ? ( +
+

{error}

- -
- {isLoading ? ( -
- - Loading history… + ) : snapshots.length === 0 ? ( +
+ +
+

No history yet

+

Compile to save the current project here.

- ) : error ? ( -
-

{error}

- -
- ) : snapshots.length === 0 ? ( -
- -
-

No history yet

-

Compile to save the current project here.

-
-
- ) : ( -
- {snapshots.map((snapshot) => { - const isBusy = busyId === snapshot.id; - return ( -
-
-
- {editingSnapshotId === snapshot.id ? ( -
-
- { - setEditingTitle(event.target.value); - if (titleError) { - setTitleError(null); - } - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void handleRename(snapshot); - } else if (event.key === "Escape") { - event.preventDefault(); - handleCancelEditing(); - } - }} - className="h-7 text-sm" - aria-label="Project title" - autoFocus - disabled={busyId !== null} - /> - - -
- {titleError && ( -

{titleError}

- )} -
- ) : ( -
-

- {snapshot.title} -

+
+ ) : ( +
+ {snapshots.map((snapshot) => { + const isBusy = busyId === snapshot.id; + return ( +
+
+
+ {editingSnapshotId === snapshot.id ? ( +
+
+ { + setEditingTitle(event.target.value); + if (titleError) { + setTitleError(null); + } + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleRename(snapshot); + } else if (event.key === "Escape") { + event.preventDefault(); + handleCancelEditing(); + } + }} + className="h-7 text-sm" + aria-label="Project title" + autoFocus + disabled={busyId !== null} + /> -
- )} -

- {formatSnapshotDate(snapshot.updatedAt)} -

-

- Rspack v{snapshot.rspackVersion} · {snapshot.fileCount} files -

-
-
- - { - if (busyId === null) { - setSnapshotToDelete(nextOpen ? snapshot : null); - } - }} - > - - - - - Delete history entry? - - This snapshot will be permanently removed from this browser. - - - - - - - - -
-
-
+ {titleError &&

{titleError}

} +
) : ( - +
+

+ {snapshot.title} +

+ +
)} - Restore - +

+ {formatSnapshotDate(snapshot.updatedAt)} +

+

+ Rspack v{snapshot.rspackVersion} · {snapshot.fileCount} files +

+
+
+ + { + if (busyId === null) { + setSnapshotToDelete(nextOpen ? snapshot : null); + } + }} + > + + + + + + Delete history entry? + + This snapshot will be permanently removed from this browser. + + + + + + + + +
- ); - })} -
- )} -
- - - + +
+ ); + })} +
+ )} +
+ + ); } diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index b471bf5..74e67c2 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -46,7 +46,11 @@ function DrawerContent({ | null = null; +let historyOperationTail = Promise.resolve(); + +function enqueueHistoryOperation(operation: () => Promise): Promise { + const previousOperation = historyOperationTail; + let release!: () => void; + historyOperationTail = new Promise((resolve) => { + release = resolve; + }); + + return previousOperation.then(operation).finally(release); +} async function createArchive(files: SourceFile[]) { const blobWriter = new BlobWriter("application/zip"); const zipWriter = new ZipWriter(blobWriter); - for (const file of files) { - await zipWriter.add(file.filename, new TextReader(file.text)); - } + try { + for (const file of files) { + await zipWriter.add(file.filename, new TextReader(file.text)); + } - await zipWriter.close(); - return blobWriter.getData(); + await zipWriter.close(); + return blobWriter.getData(); + } catch (error) { + await zipWriter.close().catch(() => {}); + throw error; + } } function getSnapshotMetadata(record: StoredHistorySnapshot): HistorySnapshot { @@ -108,7 +124,7 @@ export async function saveHistory( return saveHistory(snapshot.id, files, rspackVersion); } - const creation = createProjectRecord(files, rspackVersion); + const creation = enqueueHistoryOperation(() => createProjectRecord(files, rspackVersion)); pendingProjectCreation = creation; try { const snapshot = await creation; @@ -120,6 +136,14 @@ export async function saveHistory( } } + return enqueueHistoryOperation(() => saveExistingProject(projectId, files, rspackVersion)); +} + +async function saveExistingProject( + projectId: number, + files: SourceFile[], + rspackVersion: string, +): Promise { const archive = await createArchive(files); const updatedAt = Date.now(); const existing = await database.history.get(projectId); @@ -213,34 +237,36 @@ export async function restoreHistory( } export async function deleteHistory(id: number): Promise { - await database.history.delete(id); + await enqueueHistoryOperation(() => database.history.delete(id).then(() => undefined)); } export async function duplicateHistory(id: number): Promise { - const record = await database.history.get(id); - if (!record) { - throw new Error("History snapshot not found"); - } + return enqueueHistoryOperation(async () => { + const record = await database.history.get(id); + if (!record) { + throw new Error("History snapshot not found"); + } - const timestamp = Date.now(); - const title = `Copy of ${record.title ?? defaultProjectTitle}`; - const newId = await database.history.add({ - archive: record.archive, - createdAt: timestamp, - updatedAt: timestamp, - title, - rspackVersion: record.rspackVersion, - fileCount: record.fileCount, - }); + const timestamp = Date.now(); + const title = `Copy of ${record.title ?? defaultProjectTitle}`; + const newId = await database.history.add({ + archive: record.archive, + createdAt: timestamp, + updatedAt: timestamp, + title, + rspackVersion: record.rspackVersion, + fileCount: record.fileCount, + }); - return { - id: newId, - createdAt: timestamp, - updatedAt: timestamp, - title, - rspackVersion: record.rspackVersion, - fileCount: record.fileCount, - }; + return { + id: newId, + createdAt: timestamp, + updatedAt: timestamp, + title, + rspackVersion: record.rspackVersion, + fileCount: record.fileCount, + }; + }); } export async function renameHistory(id: number, title: string): Promise { @@ -249,8 +275,10 @@ export async function renameHistory(id: number, title: string): Promise { throw new Error("Project title cannot be empty"); } - const updated = await database.history.update(id, { title: trimmedTitle }); - if (updated === 0) { - throw new Error("History snapshot not found"); - } + await enqueueHistoryOperation(async () => { + const updated = await database.history.update(id, { title: trimmedTitle }); + if (updated === 0) { + throw new Error("History snapshot not found"); + } + }); } diff --git a/src/store/bundler.ts b/src/store/bundler.ts index a3f7744..5bd4936 100644 --- a/src/store/bundler.ts +++ b/src/store/bundler.ts @@ -62,7 +62,42 @@ export const bindingLoadingAtom = atom(false); export const isBundlingAtom = atom(false); export const latestBundleRequestIdAtom = atom(0); export const inputFilesAtom = atom(getInitFiles()); -export const currentProjectIdAtom = atom(null); +const currentProjectIdStorageKey = "rspack-playground-current-project-id"; + +function getStoredCurrentProjectId(): number | null { + try { + const storedProjectId = window.localStorage.getItem(currentProjectIdStorageKey); + if (!storedProjectId) { + return null; + } + + const projectId = Number(storedProjectId); + return Number.isSafeInteger(projectId) && projectId > 0 ? projectId : null; + } catch { + return null; + } +} + +function persistCurrentProjectId(projectId: number | null) { + try { + if (projectId === null) { + window.localStorage.removeItem(currentProjectIdStorageKey); + } else { + window.localStorage.setItem(currentProjectIdStorageKey, String(projectId)); + } + } catch (error) { + console.warn("Failed to persist current project id:", error); + } +} + +const currentProjectIdStateAtom = atom(getStoredCurrentProjectId()); +export const currentProjectIdAtom = atom( + (get) => get(currentProjectIdStateAtom), + (_get, set, projectId: number | null) => { + set(currentProjectIdStateAtom, projectId); + persistCurrentProjectId(projectId); + }, +); export const bundleResultAtom = atom(null); export const enableFormatCode = atom(true); @@ -90,6 +125,7 @@ export const bundleActionAtom = atom( set(bindingLoadingAtom, true); } + let bundleResultPublished = false; try { const result = await bundle(files, targetVersion); if (!isLatestRequest()) { @@ -106,15 +142,22 @@ export const bundleActionAtom = atom( return; } - const projectIdForSave = projectId === null ? get(currentProjectIdAtom) : projectId; - try { - const snapshot = await saveHistory(projectIdForSave, files, targetVersion); - if (get(currentProjectIdAtom) === projectIdForSave) { - set(currentProjectIdAtom, snapshot.id); + set(bindingLoadingAtom, false); + set(isBundlingAtom, false); + bundleResultPublished = true; + + const liveProjectId = get(currentProjectIdAtom); + if (projectId === null || liveProjectId === projectId) { + const projectIdForSave = projectId === null ? liveProjectId : projectId; + try { + const snapshot = await saveHistory(projectIdForSave, files, targetVersion); + if (get(currentProjectIdAtom) === projectIdForSave) { + set(currentProjectIdAtom, snapshot.id); + } + } catch (error) { + console.error("Failed to save project history:", error); + toast.error("Failed to save project history"); } - } catch (error) { - console.error("Failed to save project history:", error); - toast.error("Failed to save project history"); } const activeOutputFile = get(activeOutputFileAtom); @@ -129,7 +172,7 @@ export const bundleActionAtom = atom( const message = error instanceof Error ? error.message : "Failed to load rspack binding"; set(bundleResultAtom, createBundleFailure(message)); } finally { - if (isLatestRequest()) { + if (isLatestRequest() && !bundleResultPublished) { set(bindingLoadingAtom, false); set(isBundlingAtom, false); } From cc9ad6142734c4a2073906943d0140c5e5d3d289 Mon Sep 17 00:00:00 2001 From: intellild Date: Tue, 28 Jul 2026 20:00:29 +0800 Subject: [PATCH 5/5] fix: restore persisted project before bundling --- src/components/Editor/index.tsx | 7 +- src/store/bundler.ts | 115 ++++++++++++++++++++++---------- 2 files changed, 82 insertions(+), 40 deletions(-) diff --git a/src/components/Editor/index.tsx b/src/components/Editor/index.tsx index 9092d29..f94b21a 100644 --- a/src/components/Editor/index.tsx +++ b/src/components/Editor/index.tsx @@ -20,6 +20,7 @@ import { bindingLoadingAtom, bundleResultAtom, enableFormatCode, + initializeProjectAtom, inputFilesAtom, } from "@/store/bundler"; import { activeInputFileAtom, activeOutputFileAtom, enableDependenciesAtom } from "@/store/editor"; @@ -242,6 +243,7 @@ function Editor() { const isLoadingBinding = useAtomValue(bindingLoadingAtom); const bundleResult = useAtomValue(bundleResultAtom); const handleBundle = useBundle(); + const initializeProject = useSetAtom(initializeProjectAtom); const editorContainerRef = useRef(null); const isMobile = useIsMobile(); @@ -273,10 +275,9 @@ function Editor() { const debouncedHandleBundle = useMemo(() => debounce(handleBundle, 300), [handleBundle]); - // biome-ignore lint/correctness/useExhaustiveDependencies: initialize bundle on mount useEffect(() => { - handleBundle(inputFiles); - }, []); + void initializeProject(); + }, [initializeProject]); const updateInputFiles = (files: SourceFile[]) => { setInputFiles(files); diff --git a/src/store/bundler.ts b/src/store/bundler.ts index 5bd4936..d1ddc3d 100644 --- a/src/store/bundler.ts +++ b/src/store/bundler.ts @@ -1,14 +1,15 @@ import { atom } from "jotai"; +import { atomWithStorage, createJSONStorage } from "jotai/utils"; import { toast } from "sonner"; import { bundle } from "@/lib/bundle"; -import { saveHistory } from "@/lib/history"; +import { restoreHistory, saveHistory } from "@/lib/history"; import type { RspackChunkGroupInfo, RspackChunkInfo, RspackModuleDeps, } from "@/lib/bundle/dependency"; import { deserializeShareData } from "@/lib/share"; -import { activeOutputFileAtom } from "./editor"; +import { activeInputFileAtom, activeOutputFileAtom } from "./editor"; import { getPresetFiles, PresetBasicLibrary } from "./presets"; import { getSafeInitRspackVersion, rspackVersionAtom } from "./version"; @@ -56,48 +57,35 @@ function getInitFiles() { return getPresetFiles(PresetBasicLibrary, getSafeInitRspackVersion()); } +function areSourceFilesEqual(left: SourceFile[], right: SourceFile[]) { + return ( + left.length === right.length && + left.every( + (file, index) => file.filename === right[index]?.filename && file.text === right[index]?.text, + ) + ); +} + // Bundle export const bindingLoadedAtom = atom(null); export const bindingLoadingAtom = atom(false); export const isBundlingAtom = atom(false); export const latestBundleRequestIdAtom = atom(0); export const inputFilesAtom = atom(getInitFiles()); -const currentProjectIdStorageKey = "rspack-playground-current-project-id"; - -function getStoredCurrentProjectId(): number | null { - try { - const storedProjectId = window.localStorage.getItem(currentProjectIdStorageKey); - if (!storedProjectId) { - return null; - } - - const projectId = Number(storedProjectId); - return Number.isSafeInteger(projectId) && projectId > 0 ? projectId : null; - } catch { - return null; - } -} - -function persistCurrentProjectId(projectId: number | null) { - try { - if (projectId === null) { - window.localStorage.removeItem(currentProjectIdStorageKey); - } else { - window.localStorage.setItem(currentProjectIdStorageKey, String(projectId)); - } - } catch (error) { - console.warn("Failed to persist current project id:", error); - } -} - -const currentProjectIdStateAtom = atom(getStoredCurrentProjectId()); -export const currentProjectIdAtom = atom( - (get) => get(currentProjectIdStateAtom), - (_get, set, projectId: number | null) => { - set(currentProjectIdStateAtom, projectId); - persistCurrentProjectId(projectId); - }, +const currentProjectIdJsonStorage = createJSONStorage(); +// Another tab must not switch this tab's active project without restoring its files. +const currentProjectIdStorage = { + getItem: currentProjectIdJsonStorage.getItem, + setItem: currentProjectIdJsonStorage.setItem, + removeItem: currentProjectIdJsonStorage.removeItem, +}; +export const currentProjectIdAtom = atomWithStorage( + "rspack-playground-current-project-id", + null, + currentProjectIdStorage, + { getOnInit: true }, ); +const projectInitializedAtom = atom(false); export const bundleResultAtom = atom(null); export const enableFormatCode = atom(true); @@ -179,3 +167,56 @@ export const bundleActionAtom = atom( } }, ); + +export const initializeProjectAtom = atom(null, async (get, set) => { + if (get(projectInitializedAtom)) { + return; + } + set(projectInitializedAtom, true); + + let files = get(inputFilesAtom); + let versionOverride: string | undefined; + const projectId = get(currentProjectIdAtom); + const initialRequestId = get(latestBundleRequestIdAtom); + const hash = window.location.hash.slice(1); + const shareData = hash ? deserializeShareData(hash) : null; + + if (projectId !== null) { + try { + const restored = await restoreHistory(projectId); + if ( + get(latestBundleRequestIdAtom) !== initialRequestId || + get(currentProjectIdAtom) !== projectId + ) { + return; + } + + const shareDataMatchesProject = + !shareData || + (shareData.rspackVersion === restored.rspackVersion && + areSourceFilesEqual(shareData.inputFiles, restored.files)); + + if (shareDataMatchesProject) { + files = restored.files; + versionOverride = restored.rspackVersion; + set(activeInputFileAtom, 0); + set(inputFilesAtom, restored.files); + set(rspackVersionAtom, restored.rspackVersion); + } else { + set(currentProjectIdAtom, null); + } + } catch (error) { + if ( + get(latestBundleRequestIdAtom) !== initialRequestId || + get(currentProjectIdAtom) !== projectId + ) { + return; + } + + console.warn("Failed to restore the persisted project; starting a new project:", error); + set(currentProjectIdAtom, null); + } + } + + await set(bundleActionAtom, { files, versionOverride }); +});