diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 2391f5f9..69db426e 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -39,5 +39,8 @@ jobs: - name: Lint run: pnpm lint + - name: Type check + run: pnpm check + - name: Run unit tests run: pnpm test:unit diff --git a/package.json b/package.json index 06406120..178a5570 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "dev:test": "pnpm dev", "build": "vite build", "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check --cache . && eslint .", "lint:fix": "prettier --write --cache . && eslint --fix .", "format": "prettier --write --cache .", @@ -70,6 +72,7 @@ "prettier-plugin-svelte": "^4.1.0", "prettier-plugin-tailwindcss": "^0.8.0", "svelte": "^5.56.3", + "svelte-check": "^4.6.0", "svelte-preprocess": "^6.0.5", "svelte-sonner": "^1.1.1", "tailwind-merge": "^3.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dec521fd..2de0c6cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: svelte: specifier: ^5.56.3 version: 5.56.3(@typescript-eslint/types@8.60.1) + svelte-check: + specifier: ^4.6.0 + version: 4.6.0(picomatch@4.0.4)(svelte@5.56.3(@typescript-eslint/types@8.60.1))(typescript@6.0.3) svelte-preprocess: specifier: ^6.0.5 version: 6.0.5(postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0))(postcss@8.5.15)(svelte@5.56.3(@typescript-eslint/types@8.60.1))(typescript@6.0.3) @@ -687,30 +690,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.100': resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.100': resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.100': resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.100': resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} @@ -813,36 +821,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -899,6 +913,10 @@ packages: typescript: optional: true + '@sveltejs/load-config@0.1.1': + resolution: {integrity: sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==} + engines: {node: '>= 18.0.0'} + '@sveltejs/vite-plugin-svelte@7.1.2': resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==} engines: {node: ^20.19 || ^22.12 || >=24} @@ -947,24 +965,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -1463,6 +1485,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -2381,24 +2407,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2545,6 +2575,10 @@ packages: monaco-editor@0.55.1: resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -3074,6 +3108,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -3164,6 +3202,10 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3300,6 +3342,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte-check@4.6.0: + resolution: {integrity: sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + svelte-eslint-parser@1.8.0: resolution: {integrity: sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.1} @@ -4405,6 +4455,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + '@sveltejs/load-config@0.1.1': {} + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.60.1))(vite@8.0.16(@types/node@24.13.1)(jiti@1.21.7)(yaml@2.9.0))': dependencies: deepmerge: 4.3.1 @@ -5061,6 +5113,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + ci-info@4.4.0: {} class-variance-authority@0.7.1: @@ -6123,6 +6179,8 @@ snapshots: dompurify: 3.2.7 marked: 14.0.0 + mri@1.2.0: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -6564,6 +6622,8 @@ snapshots: dependencies: picomatch: 2.3.2 + readdirp@4.1.2: {} + real-require@0.2.0: {} redent@4.0.0: @@ -6664,6 +6724,10 @@ snapshots: rw@1.3.3: {} + sade@1.8.1: + dependencies: + mri: 1.2.0 + safe-buffer@5.2.1: {} safe-stable-stringify@2.5.0: {} @@ -6785,6 +6849,19 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte-check@4.6.0(picomatch@4.0.4)(svelte@5.56.3(@typescript-eslint/types@8.60.1))(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.1.1 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.56.3(@typescript-eslint/types@8.60.1) + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + svelte-eslint-parser@1.8.0(svelte@5.56.3(@typescript-eslint/types@8.60.1)): dependencies: eslint-scope: 8.4.0 diff --git a/src/lib/components/History/History.svelte b/src/lib/components/History/History.svelte index 0334fc15..e091e7b1 100644 --- a/src/lib/components/History/History.svelte +++ b/src/lib/components/History/History.svelte @@ -6,7 +6,6 @@ import { logEvent } from '$lib/util/stats'; import dayjs from 'dayjs'; import dayjsRelativeTime from 'dayjs/plugin/relativeTime'; - import { get } from 'svelte/store'; import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded'; import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded'; import DownloadIcon from '~icons/material-symbols/download-rounded'; @@ -20,13 +19,11 @@ import { addManualEntry, clearActive, - historyModeStore, - historyStore, - loaderHistoryStore, + historyState, removeEntry, restoreEntries, setMode - } from './history'; + } from './historyState.svelte'; dayjs.extend(dayjsRelativeTime); @@ -36,19 +33,21 @@ ]; const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon }; - const tabs = $derived($loaderHistoryStore.length > 0 ? [loaderTab, ...baseTabs] : baseTabs); + const tabs = $derived( + historyState.loaderEntries.length > 0 ? [loaderTab, ...baseTabs] : baseTabs + ); // Surface revisions once when they first appear; the user can switch away after. let revisionsShown = false; $effect(() => { - if ($loaderHistoryStore.length > 0 && !revisionsShown) { + if (historyState.loaderEntries.length > 0 && !revisionsShown) { revisionsShown = true; setMode('loader'); } }); const emptyMessage = $derived( - $historyModeStore === 'auto' + historyState.mode === 'auto' ? 'No timeline snapshots yet.\nThe Timeline is saved automatically every minute.' : 'No saved states yet.\nClick the Save button to bookmark the current diagram and restore it later.' ); @@ -58,7 +57,7 @@ }; const downloadHistory = () => { - const data = get(historyStore); + const data = historyState.entries; const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -102,7 +101,7 @@ }; - + {#snippet actions()}
- {#if $historyStore.length > 0} + {#if historyState.entries.length > 0} - {#if $historyModeStore !== 'loader'} + {#if historyState.mode !== 'loader'} {#if type !== 'loader'} @@ -169,6 +172,7 @@ size="icon" variant="ghost" class="hover:text-destructive" + title="Delete this version" onclick={() => removeEntry(id)}> diff --git a/src/lib/components/History/history.ts b/src/lib/components/History/history.ts deleted file mode 100644 index 7c159240..00000000 --- a/src/lib/components/History/history.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types'; -import { localStorage, persist } from '$lib/util/persist'; -import { inputStateStore } from '$lib/util/state'; -import { logEvent } from '$lib/util/stats'; -import { generateSlug } from 'random-word-slugs'; -import type { Readable, Writable } from 'svelte/store'; -import { derived, get, writable } from 'svelte/store'; -import { v4 as uuidV4 } from 'uuid'; - -const MAX_AUTO_HISTORY_LENGTH = 30; -const AUTO_SAVE_INTERVAL = 60_000; - -const autoHistoryStore: Writable = persist( - writable([]), - localStorage(), - 'autoHistoryStore' -); - -const manualHistoryStore: Writable = persist( - writable([]), - localStorage(), - 'manualHistoryStore' -); - -// Populated by file loaders (e.g. gist); in-memory only. -export const loaderHistoryStore: Writable = writable([]); - -export const historyModeStore: Writable = persist( - writable('manual'), - localStorage(), - 'autoHistoryMode' -); - -// Loader entries are in-memory, so a persisted 'loader' mode is empty after reload. -if (get(historyModeStore) === 'loader') { - historyModeStore.set('manual'); -} - -const storeForMode = (mode: HistoryType): Writable => { - switch (mode) { - case 'auto': { - return autoHistoryStore; - } - case 'loader': { - return loaderHistoryStore; - } - default: { - return manualHistoryStore; - } - } -}; - -export const historyStore: Readable = derived( - [historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore], - ([mode, auto, manual, loader]) => { - switch (mode) { - case 'auto': { - return auto; - } - case 'loader': { - return loader; - } - default: { - return manual; - } - } - } -); - -export const setMode = (mode: HistoryType): void => { - historyModeStore.set(mode); -}; - -// Dedup key: only the fields that define the diagram, so volatile/view-only -// fields (renderCount, pan/zoom, …) don't count as a change. -export const stateKey = (state: State): string => - JSON.stringify({ code: state.code, mermaid: state.mermaid }); - -const createEntry = (state: State, type: 'auto' | 'manual'): HistoryEntry => ({ - id: uuidV4(), - name: generateSlug(2), - state, - time: Date.now(), - type -}); - -// Returns true if added, false if it duplicated the most recent entry. -const addEntry = ( - store: Writable, - state: State, - type: 'auto' | 'manual', - maxLength?: number -): boolean => { - const entries = get(store); - if (entries.length > 0 && stateKey(entries[0].state) === stateKey(state)) { - return false; - } - store.update((existing) => { - const trimmed = - maxLength && existing.length >= maxLength ? existing.slice(0, maxLength - 1) : existing; - return [createEntry(state, type), ...trimmed]; - }); - logEvent('history', { action: 'save', type }); - return true; -}; - -export const addManualEntry = (state: State): boolean => - addEntry(manualHistoryStore, state, 'manual'); - -export const addAutoEntry = (state: State): boolean => - addEntry(autoHistoryStore, state, 'auto', MAX_AUTO_HISTORY_LENGTH); - -export const addLoaderEntry = (entry: Optional): void => { - loaderHistoryStore.update((entries) => [{ ...entry, id: uuidV4() } as HistoryEntry, ...entries]); -}; - -export const removeEntry = (id: string): void => { - const mode = get(historyModeStore); - if (mode === 'loader') { - return; - } - storeForMode(mode).update((entries) => entries.filter((entry) => entry.id !== id)); - logEvent('history', { action: 'clear', type: 'single' }); -}; - -export const clearActive = (): void => { - const mode = get(historyModeStore); - if (mode === 'loader') { - return; - } - storeForMode(mode).set([]); - logEvent('history', { action: 'clear', type: 'all' }); -}; - -const validateEntry = (entry: HistoryEntry): boolean => - Boolean(entry && entry.type && entry.state && entry.time); - -export interface RestoreResult { - restored: number; - invalid: number; - duplicates: number; -} - -// Routes each uploaded entry to the store matching its own type, skipping ids -// that already exist. -export const restoreEntries = (data: HistoryEntry[]): RestoreResult => { - const valid = data.filter((entry) => validateEntry(entry)); - const invalid = data.length - valid.length; - let restored = 0; - - for (const type of ['auto', 'manual'] as const) { - const incoming = valid.filter((entry) => entry.type === type); - if (incoming.length === 0) { - continue; - } - storeForMode(type).update((existing) => { - const existingIDs = new Set(existing.map(({ id }) => id)); - const fresh = incoming.filter(({ id }) => !existingIDs.has(id)); - restored += fresh.length; - return [...existing, ...fresh].sort((a, b) => b.time - a.time); - }); - } - - const duplicates = valid.length - restored; - logEvent('history', { action: 'restore', success: restored, invalid, duplicates }); - return { restored, invalid, duplicates }; -}; - -const setIDs = (entries: HistoryEntry[]) => { - for (const entry of entries) { - if (!entry.id) { - entry.id = uuidV4(); - } - } - return entries; -}; - -export const injectHistoryIDs = (): void => { - autoHistoryStore.update(setIDs); - manualHistoryStore.update(setIDs); -}; - -let autoSaveTimer: ReturnType | undefined; - -// Idempotent; returns the stop function for use as a lifecycle cleanup. -export const startAutoSave = (): (() => void) => { - if (autoSaveTimer === undefined) { - autoSaveTimer = setInterval(() => addAutoEntry(get(inputStateStore)), AUTO_SAVE_INTERVAL); - } - return stopAutoSave; -}; - -export const stopAutoSave = (): void => { - if (autoSaveTimer !== undefined) { - clearInterval(autoSaveTimer); - autoSaveTimer = undefined; - } -}; diff --git a/src/lib/components/History/historyState.svelte.ts b/src/lib/components/History/historyState.svelte.ts new file mode 100644 index 00000000..26277abe --- /dev/null +++ b/src/lib/components/History/historyState.svelte.ts @@ -0,0 +1,221 @@ +import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types'; +import { inputStateStore } from '$lib/util/state'; +import { logEvent } from '$lib/util/stats'; +import { generateSlug } from 'random-word-slugs'; +import { get } from 'svelte/store'; +import { v4 as uuidV4 } from 'uuid'; + +const MAX_AUTO_HISTORY_LENGTH = 30; +const AUTO_SAVE_INTERVAL = 60_000; + +const hasStorage = (): boolean => typeof window !== 'undefined' && !!window.localStorage; + +const readJSON = (key: string, fallback: T): T => { + if (!hasStorage()) { + return fallback; + } + try { + const raw = window.localStorage.getItem(key); + return raw === null ? fallback : (JSON.parse(raw) as T); + } catch { + return fallback; + } +}; + +const writeJSON = (key: string, value: unknown): void => { + if (hasStorage()) { + window.localStorage.setItem(key, JSON.stringify(value)); + } +}; + +interface Persisted { + value: T; +} + +// A localStorage-backed reactive value. Reads on init, writes on every set. +const persisted = (key: string, initial: T): Persisted => { + let value = $state(readJSON(key, initial)); + return { + get value() { + return value; + }, + set value(next: T) { + value = next; + writeJSON(key, next); + } + }; +}; + +const auto = persisted('autoHistoryStore', []); +const manual = persisted('manualHistoryStore', []); +const mode = persisted('autoHistoryMode', 'manual'); +let loader = $state([]); + +// Loader entries are in-memory, so a persisted 'loader' mode is empty after reload. +if (mode.value === 'loader') { + mode.value = 'manual'; +} + +export const historyState = { + get entries(): HistoryEntry[] { + switch (mode.value) { + case 'auto': { + return auto.value; + } + case 'loader': { + return loader; + } + default: { + return manual.value; + } + } + }, + get loaderEntries(): HistoryEntry[] { + return loader; + }, + get mode(): HistoryType { + return mode.value; + } +}; + +export const setMode = (next: HistoryType): void => { + mode.value = next; +}; + +// Dedup key: only the fields that define the diagram, so volatile/view-only +// fields (renderCount, pan/zoom, …) don't count as a change. +export const stateKey = (state: State): string => + JSON.stringify({ code: state.code, mermaid: state.mermaid }); + +const createEntry = (state: State, type: 'auto' | 'manual'): HistoryEntry => ({ + id: uuidV4(), + name: generateSlug(2), + state, + time: Date.now(), + type +}); + +// Returns true if added, false if it duplicated the most recent entry. +const addEntry = ( + slot: Persisted, + state: State, + type: 'auto' | 'manual', + maxLength?: number +): boolean => { + const entries = slot.value; + if (entries.length > 0 && stateKey(entries[0].state) === stateKey(state)) { + return false; + } + const trimmed = + maxLength && entries.length >= maxLength ? entries.slice(0, maxLength - 1) : entries; + slot.value = [createEntry(state, type), ...trimmed]; + logEvent('history', { action: 'save', type }); + return true; +}; + +export const addManualEntry = (state: State): boolean => addEntry(manual, state, 'manual'); + +export const addAutoEntry = (state: State): boolean => + addEntry(auto, state, 'auto', MAX_AUTO_HISTORY_LENGTH); + +// Replaces the in-memory revisions (e.g. when a gist is loaded), assigning ids. +export const setLoaderEntries = (entries: Optional[]): void => { + loader = entries.map((entry) => + entry.id ? (entry as HistoryEntry) : { ...entry, id: uuidV4() } + ); +}; + +const activeSlot = (): Persisted | null => { + switch (mode.value) { + case 'auto': { + return auto; + } + case 'manual': { + return manual; + } + default: { + return null; + } + } +}; + +export const removeEntry = (id: string): void => { + const slot = activeSlot(); + if (!slot) { + return; + } + slot.value = slot.value.filter((entry) => entry.id !== id); + logEvent('history', { action: 'clear', type: 'single' }); +}; + +export const clearActive = (): void => { + const slot = activeSlot(); + if (!slot) { + return; + } + slot.value = []; + logEvent('history', { action: 'clear', type: 'all' }); +}; + +const validateEntry = (entry: HistoryEntry): boolean => + Boolean(entry && entry.type && entry.state && entry.time); + +export interface RestoreResult { + restored: number; + invalid: number; + duplicates: number; +} + +// Routes each uploaded entry to the store matching its own type, skipping ids +// that already exist. +export const restoreEntries = (data: HistoryEntry[]): RestoreResult => { + const valid = data.filter((entry) => validateEntry(entry)); + const invalid = data.length - valid.length; + let restored = 0; + + const slots: [HistoryType, Persisted][] = [ + ['auto', auto], + ['manual', manual] + ]; + for (const [type, slot] of slots) { + const incoming = valid.filter((entry) => entry.type === type); + if (incoming.length === 0) { + continue; + } + const existingIDs = slot.value.map(({ id }) => id); + const fresh = incoming.filter(({ id }) => !existingIDs.includes(id)); + restored += fresh.length; + slot.value = [...slot.value, ...fresh].sort((a, b) => b.time - a.time); + } + + const duplicates = valid.length - restored; + logEvent('history', { action: 'restore', duplicates, invalid, success: restored }); + return { restored, invalid, duplicates }; +}; + +const setIDs = (entries: HistoryEntry[]): HistoryEntry[] => + entries.map((entry) => (entry.id ? entry : { ...entry, id: uuidV4() })); + +// One-time migration: re-reads localStorage so entries written by an older +// version get ids, then persists and updates the reactive state. +export const injectHistoryIDs = (): void => { + auto.value = setIDs(readJSON('autoHistoryStore', [])); + manual.value = setIDs(readJSON('manualHistoryStore', [])); +}; + +let autoSaveTimer: ReturnType | undefined; + +// Idempotent; returns the stop function for use as a lifecycle cleanup. +export const startAutoSave = (): (() => void) => { + if (autoSaveTimer === undefined) { + autoSaveTimer = setInterval(() => addAutoEntry(get(inputStateStore)), AUTO_SAVE_INTERVAL); + } + return stopAutoSave; +}; + +export const stopAutoSave = (): void => { + if (autoSaveTimer !== undefined) { + clearInterval(autoSaveTimer); + autoSaveTimer = undefined; + } +}; diff --git a/src/lib/components/History/history.test.ts b/src/lib/components/History/historyState.test.ts similarity index 85% rename from src/lib/components/History/history.test.ts rename to src/lib/components/History/historyState.test.ts index cf168e74..85e5f28e 100644 --- a/src/lib/components/History/history.test.ts +++ b/src/lib/components/History/historyState.test.ts @@ -1,29 +1,27 @@ import type { HistoryEntry } from '$lib/types'; import { defaultState, inputStateStore } from '$lib/util/state'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { get } from 'svelte/store'; import { addAutoEntry, - addLoaderEntry, addManualEntry, clearActive, - historyStore, + historyState, injectHistoryIDs, - loaderHistoryStore, removeEntry, restoreEntries, + setLoaderEntries, setMode, startAutoSave, stateKey, stopAutoSave -} from './history'; +} from './historyState.svelte'; const codeState = (code: string) => ({ ...defaultState, code }); /** Read the entries currently shown for a given mode. */ const entriesFor = (mode: 'auto' | 'manual' | 'loader'): HistoryEntry[] => { setMode(mode); - return get(historyStore); + return historyState.entries; }; beforeEach(() => { @@ -32,7 +30,7 @@ beforeEach(() => { clearActive(); setMode('auto'); clearActive(); - loaderHistoryStore.set([]); + setLoaderEntries([]); setMode('manual'); }); @@ -138,21 +136,21 @@ describe('addAutoEntry', () => { }); }); -describe('historyStore', () => { +describe('historyState.entries', () => { it('reflects the active mode', () => { addManualEntry(codeState('manual-code')); addAutoEntry(codeState('auto-code')); setMode('manual'); - expect(get(historyStore)).toHaveLength(1); - expect(get(historyStore)[0].state.code).toBe('manual-code'); + expect(historyState.entries).toHaveLength(1); + expect(historyState.entries[0].state.code).toBe('manual-code'); setMode('auto'); - expect(get(historyStore)).toHaveLength(1); - expect(get(historyStore)[0].state.code).toBe('auto-code'); + expect(historyState.entries).toHaveLength(1); + expect(historyState.entries[0].state.code).toBe('auto-code'); setMode('loader'); - expect(get(historyStore)).toHaveLength(0); + expect(historyState.entries).toHaveLength(0); }); }); @@ -161,10 +159,10 @@ describe('removeEntry / clearActive', () => { addManualEntry(codeState('graph TD\n A-->B')); addManualEntry(codeState('graph TD\n A-->C')); setMode('manual'); - const target = get(historyStore)[1].id; + const target = historyState.entries[1].id; removeEntry(target); - expect(get(historyStore)).toHaveLength(1); - expect(get(historyStore).some((e) => e.id === target)).toBe(false); + expect(historyState.entries).toHaveLength(1); + expect(historyState.entries.some((e) => e.id === target)).toBe(false); }); it('clears all entries in the active store only', () => { @@ -177,22 +175,30 @@ describe('removeEntry / clearActive', () => { }); it('does nothing in loader mode', () => { - addLoaderEntry({ name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' }); + setLoaderEntries([ + { name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' } + ]); setMode('loader'); clearActive(); - expect(get(historyStore)).toHaveLength(1); + expect(historyState.entries).toHaveLength(1); }); }); -describe('addLoaderEntry', () => { - it('prepends entries to the in-memory loader store', () => { - addLoaderEntry({ name: 'v1', state: defaultState, time: 1, type: 'loader', url: 'http://x/1' }); - addLoaderEntry({ name: 'v2', state: defaultState, time: 2, type: 'loader', url: 'http://x/2' }); +describe('setLoaderEntries', () => { + it('replaces the in-memory revisions and assigns ids', () => { + setLoaderEntries([ + { name: 'v1', state: defaultState, time: 1, type: 'loader', url: 'http://x/1' }, + { name: 'v2', state: defaultState, time: 2, type: 'loader', url: 'http://x/2' } + ]); setMode('loader'); - const entries = get(historyStore); - expect(entries).toHaveLength(2); - expect(entries[0].name).toBe('v2'); - expect(entries.every((e) => e.id)).toBe(true); + expect(historyState.entries).toHaveLength(2); + expect(historyState.entries.every((e) => e.id)).toBe(true); + + setLoaderEntries([ + { name: 'only', state: defaultState, time: 3, type: 'loader', url: 'http://x/3' } + ]); + expect(historyState.entries).toHaveLength(1); + expect(historyState.entries[0].name).toBe('only'); }); }); @@ -254,6 +260,8 @@ describe('injectHistoryIDs migration', () => { const auto = JSON.parse( window.localStorage.getItem('autoHistoryStore') ?? '[]' ) as HistoryEntry[]; + expect(manual).toHaveLength(1); + expect(auto).toHaveLength(1); expect(manual.every(({ id }) => id !== undefined)).toBe(true); expect(auto.every(({ id }) => id !== undefined)).toBe(true); }); diff --git a/src/lib/util/fileLoaders/gist.ts b/src/lib/util/fileLoaders/gist.ts index 61032fd3..34d81a0a 100644 --- a/src/lib/util/fileLoaders/gist.ts +++ b/src/lib/util/fileLoaders/gist.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { addLoaderEntry } from '$lib/components/History/history'; +import { setLoaderEntries } from '$lib/components/History/historyState.svelte'; import type { State } from '$lib/types'; import { defaultState } from '$lib/util/state'; import { fetchJSON, fetchText } from '$lib/util/util'; @@ -117,14 +117,16 @@ export const loadGistData = async (gistURL: string): Promise => { throw new Error('Invalid gist provided'); } const state = getStateFromGist(entry, gistURL); - for (const gist of gistHistory) { - addLoaderEntry({ - name: `${gist.author} v${gist.version}`, - state: getStateFromGist(gist), - time: gist.time, - type: 'loader', - url: gist.url - }); - } + setLoaderEntries( + gistHistory + .map((gist) => ({ + name: `${gist.author} v${gist.version}`, + state: getStateFromGist(gist), + time: gist.time, + type: 'loader' as const, + url: gist.url + })) + .reverse() + ); return state; }; diff --git a/src/lib/util/migrations.ts b/src/lib/util/migrations.ts index 7851270b..bc86f832 100644 --- a/src/lib/util/migrations.ts +++ b/src/lib/util/migrations.ts @@ -1,6 +1,6 @@ import { writable, get, type Writable } from 'svelte/store'; import { persist, localStorage } from '$lib/util/persist'; -import { injectHistoryIDs } from '$lib/components/History/history'; +import { injectHistoryIDs } from '$lib/components/History/historyState.svelte'; import { logEvent } from './stats'; interface MigrationState { diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte index 02db59e3..0f240433 100644 --- a/src/routes/edit/+page.svelte +++ b/src/routes/edit/+page.svelte @@ -5,7 +5,7 @@ import Editor from '$/components/Editor.svelte'; import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte'; import History from '$/components/History/History.svelte'; - import { startAutoSave } from '$/components/History/history'; + import { startAutoSave } from '$/components/History/historyState.svelte'; import McWrapper from '$/components/McWrapper.svelte'; import MermaidChartIcon from '$/components/MermaidChartIcon.svelte'; import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte'; @@ -91,7 +91,7 @@ {/snippet} - + diff --git a/tests/history.spec.ts b/tests/history.spec.ts index 0b81569e..452b6dbc 100644 --- a/tests/history.spec.ts +++ b/tests/history.spec.ts @@ -1,84 +1,127 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; import { typeInEditor } from './utils'; -test.describe.skip('Save History', () => { +const config = '{\n "theme": "default"\n}'; + +const entry = (id: string, name: string, type: 'manual' | 'auto', label: string) => ({ + id, + name, + type, + time: Number(id.slice(2)), + state: { + code: `flowchart TD\n A[${label}]`, + mermaid: config, + autoSync: true, + updateDiagram: false + } +}); + +const manualHistory = [ + entry('m-2', 'hollow-art', 'manual', 'Halloween'), + entry('m-1', 'helpful-ocean', 'manual', 'Pumpkin') +]; +const autoHistory = [ + entry('a-2', 'barking-dog', 'auto', 'NewYear'), + entry('a-1', 'needy-mosquito', 'auto', 'Fireworks') +]; + +const openHistory = (page: Page) => page.getByRole('button', { name: 'History' }).click(); + +test.describe('History', () => { test.beforeEach(async ({ page }) => { + // Freeze time so auto-save snapshots are deterministic. await page.addInitScript(() => { - Object.defineProperty(Date, 'now', { - value: () => new Date(2022, 0, 1).getTime() - }); + Object.defineProperty(Date, 'now', { value: () => new Date(2022, 0, 1).getTime() }); }); await page.goto('/edit'); - await page.getByText('History').click(); }); - test('should load history from localstorage', async ({ page }) => { - await page.evaluate(() => { - localStorage.setItem( - 'manualHistoryStore', - '[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","id":"d7ea820e-21dd-418a-b984-fd58acde09df","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"b749ffc6-522b-4a44-86cf-7c1ffc3146b3","name":"helpful-ocean"}]' - ); - localStorage.setItem( - 'autoHistoryStore', - '[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","id":"69ea820e-522b-4a44-86cf-fd58acde09df","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"x749ffc6-21dd-418a-b984-7c1ffc3146b3","name":"needy-mosquito"}]' - ); - }); + test('loads Saved and Timeline history from localStorage and restores entries', async ({ + page + }) => { + await page.evaluate( + ([manual, auto]) => { + localStorage.setItem('manualHistoryStore', manual); + localStorage.setItem('autoHistoryStore', auto); + }, + [JSON.stringify(manualHistory), JSON.stringify(autoHistory)] + ); await page.reload(); - await page.getByText('History').click(); + await openHistory(page); + + // Saved tab is active by default. await expect(page.locator('#historyList li')).toHaveCount(2); - await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible(); - await expect(page.locator('#historyList')).toContainText('helpful-ocean'); await expect(page.locator('#historyList')).toContainText('hollow-art'); - await page.getByText('Restore').first().click(); - await expect(page.locator('#view').getByText('Halloween')).toBeVisible(); - await page.getByText('Timeline').click(); + await expect(page.locator('#historyList')).toContainText('helpful-ocean'); + await page.getByRole('button', { name: 'Restore this version' }).first().click(); + await expect(page.locator('#view')).toContainText('Halloween'); + + // Switching to the Timeline tab shows the auto entries only. + await page.getByRole('tab', { name: 'Timeline' }).click(); await expect(page.locator('#historyList li')).toHaveCount(2); - await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible(); - await expect(page.locator('#historyList')).toContainText('needy-mosquito'); await expect(page.locator('#historyList')).toContainText('barking-dog'); - await page.getByText('Restore').first().click(); - await expect(page.locator('#view').getByText('New Year')).toBeVisible(); + await expect(page.locator('#historyList')).toContainText('needy-mosquito'); + await expect(page.locator('#historyList')).not.toContainText('hollow-art'); + + await page.getByRole('button', { name: 'Restore this version' }).first().click(); + await expect(page.locator('#view')).toContainText('NewYear'); }); - test.skip('should save when clicked', async ({ page }) => { - await expect(page.locator('#historyList li')).toHaveCount(0); - await expect(page.locator('#historyList')).toContainText('No items in History'); - await page.locator('#saveHistory').click(); - await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible(); - await expect(page.locator('#historyList li')).toHaveCount(1); - const dialogPromise = page.waitForEvent('dialog'); - await page.locator('#saveHistory').click(); - const dialog = await dialogPromise; - expect(dialog.message()).toBe('State already saved.'); - await dialog.accept(); + test('keeps the active tab highlighted when switching modes', async ({ page }) => { + await openHistory(page); + const saved = page.getByRole('tab', { name: 'Saved' }); + const timeline = page.getByRole('tab', { name: 'Timeline' }); - await typeInEditor(page, ' C --> HistoryTest'); + await expect(saved).toHaveClass(/border-b-2/); + await expect(timeline).not.toHaveClass(/border-b-2/); + + await timeline.click(); + await expect(timeline).toHaveClass(/border-b-2/); + await expect(saved).not.toHaveClass(/border-b-2/); + }); + + test('saves the current state and reports duplicates', async ({ page }) => { + await openHistory(page); + await expect(page.locator('#historyList li')).toHaveCount(0); + + await page.locator('#saveHistory').click(); + await expect(page.locator('#historyList li')).toHaveCount(1); + + // Saving again without changes does not add a duplicate and notifies the user. + await page.locator('#saveHistory').click(); + await expect(page.getByText('State already saved.')).toBeVisible(); + await expect(page.locator('#historyList li')).toHaveCount(1); + + // A real edit produces a new entry. + await typeInEditor(page, ' Z[Extra]', { newline: true }); await page.locator('#saveHistory').click(); await expect(page.locator('#historyList li')).toHaveCount(2); }); - test.skip('should be able to restore and delete', async ({ page }) => { + test('auto-saves to the Timeline only, never the Saved list', async ({ page }) => { + await openHistory(page); await page.locator('#saveHistory').click(); - await typeInEditor(page, ' C --> HistoryTest'); - await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible(); await expect(page.locator('#historyList li')).toHaveCount(1); - await expect(page.locator('#view').getByText('HistoryTest')).toBeVisible(); - await page.getByText('Restore').click(); - await expect(page.locator('#view').getByText('HistoryTest')).not.toBeVisible(); - await page.getByText('Delete').click(); - await expect(page.locator('#historyList li')).toHaveCount(0); - await expect(page.locator('#historyList')).toContainText('No items in History'); + + await page.getByRole('tab', { name: 'Timeline' }).click(); + // A manual save must not appear under Timeline. + await expect(page.locator('#historyList')).toContainText('No timeline snapshots yet.'); + }); + + test('deletes a single entry and clears all after confirmation', async ({ page }) => { + await openHistory(page); await page.locator('#saveHistory').click(); - await typeInEditor(page, ' C --> HistoryTest'); + await typeInEditor(page, ' Z[Another]', { newline: true }); await page.locator('#saveHistory').click(); - await page.locator('#editor').type('ing'); + await expect(page.locator('#historyList li')).toHaveCount(2); + + await page.getByRole('button', { name: 'Delete this version' }).first().click(); + await expect(page.locator('#historyList li')).toHaveCount(1); + + page.on('dialog', (dialog) => dialog.accept()); await page.locator('#clearHistory').click(); - - const dialog = await page.waitForEvent('dialog'); - expect(dialog.message()).toBe('Clear all saved items?'); - await dialog.accept(); - - await expect(page.locator('#historyList')).toContainText('No items in History'); + await expect(page.locator('#historyList li')).toHaveCount(0); + await expect(page.locator('#historyList')).toContainText('No saved states yet.'); }); }); diff --git a/tsconfig.json b/tsconfig.json index b0da0d50..f387371e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "strictNullChecks": true, + "skipLibCheck": true, "types": ["vitest/importMeta", "@playwright/test"] }, "extends": "./.svelte-kit/tsconfig.json"