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/.gitignore b/.gitignore index d4958785..62945493 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ /playwright-report/ /blob-report/ /playwright/.cache/ +.playwright-mcp/ 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/Actions.svelte b/src/lib/components/Actions.svelte index 983e6618..ffcfc534 100644 --- a/src/lib/components/Actions.svelte +++ b/src/lib/components/Actions.svelte @@ -199,7 +199,10 @@ ${svgString}`); }; }; - const onCopyClipboard = async (event: Event) => { + const onCopyClipboard = async (event?: Event) => { + if (!event) { + return; + } await exportImage(event, clipboardCopy); logEvent('copyClipboard'); }; diff --git a/src/lib/components/Card/Tabs.svelte b/src/lib/components/Card/Tabs.svelte index b5527cb8..64a259dd 100644 --- a/src/lib/components/Card/Tabs.svelte +++ b/src/lib/components/Card/Tabs.svelte @@ -14,9 +14,8 @@ onselect?: (tab: Tab) => void; } = $props(); - if (!activeTabID && tabs.length > 0) { - activeTabID = tabs[0].id; - } + // Derive (don't mutate the prop) so the highlight tracks a bound activeTabID. + const effectiveTabID = $derived(activeTabID || tabs[0]?.id); const toggleTabs = (tab: Tab) => { return (event: Event) => { @@ -34,7 +33,7 @@ variant="ghost" class={[ 'px-2', - activeTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50' + effectiveTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50' ]} onclick={toggleTabs(tab)} onkeypress={toggleTabs(tab)}> diff --git a/src/lib/components/DesktopEditor.svelte b/src/lib/components/DesktopEditor.svelte index e945a1fa..df31818f 100644 --- a/src/lib/components/DesktopEditor.svelte +++ b/src/lib/components/DesktopEditor.svelte @@ -115,7 +115,7 @@ throw new Error('divEl is undefined'); } - monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + monaco.json.jsonDefaults.setDiagnosticsOptions({ validate: true, enableSchemaRequest: true, schemas: [ diff --git a/src/lib/components/DiagramDocumentationButton.svelte b/src/lib/components/DiagramDocumentationButton.svelte index 20081632..5e301e47 100644 --- a/src/lib/components/DiagramDocumentationButton.svelte +++ b/src/lib/components/DiagramDocumentationButton.svelte @@ -97,7 +97,9 @@ return { key: '', url: docURLBase }; } const key = standardizeDiagramType(diagramType); - const docConfig = docMap[key] ?? { code: '' }; + const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? { + code: '' + }; const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? ''); return { key, url }; }); diff --git a/src/lib/components/History/History.svelte b/src/lib/components/History/History.svelte index 9512d658..293fdbfe 100644 --- a/src/lib/components/History/History.svelte +++ b/src/lib/components/History/History.svelte @@ -2,12 +2,11 @@ import Card from '$lib/components/Card/Card.svelte'; import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types'; import { notify, prompt } from '$lib/util/notify'; - import { getStateString, inputStateStore } from '$lib/util/state'; + import { serializeState } from '$lib/util/serde'; + import { inputStateStore } from '$lib/util/state'; import { logEvent } from '$lib/util/stats'; import dayjs from 'dayjs'; import dayjsRelativeTime from 'dayjs/plugin/relativeTime'; - import { onMount } from 'svelte'; - 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'; @@ -16,41 +15,51 @@ import UploadIcon from '~icons/material-symbols/upload-rounded'; import HistoryIcon from '~icons/mdi/clock-outline'; import GitAltIcon from '~icons/mdi/git'; + import OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded'; import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; import { - addHistoryEntry, - clearHistoryData, - getPreviousState, - historyModeStore, - historyStore, - loaderHistoryStore, - restoreHistory - } from './history'; + addManualEntry, + clearActive, + historyState, + removeEntry, + restoreEntries, + setMode + } from './historyState.svelte'; dayjs.extend(dayjsRelativeTime); - const HISTORY_SAVE_INTERVAL = 60_000; + const baseTabs: Tab[] = [ + { id: 'manual', title: 'Saved', icon: BookmarkIcon }, + { id: 'auto', title: 'Timeline', icon: HistoryIcon } + ]; + const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon }; + + 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 (historyState.loaderEntries.length > 0 && !revisionsShown) { + revisionsShown = true; + setMode('loader'); + } + }); + + const emptyMessage = $derived( + 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.' + ); const tabSelectHandler = (tab: Tab) => { - historyModeStore.set(tab.id as HistoryType); + setMode(tab.id as HistoryType); }; - let tabs: Tab[] = $state([ - { - id: 'manual', - title: 'Saved', - icon: BookmarkIcon - }, - { - id: 'auto', - title: 'Timeline', - icon: HistoryIcon - } - ]); - 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'); @@ -58,9 +67,7 @@ a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`; a.click(); URL.revokeObjectURL(url); - logEvent('history', { - action: 'download' - }); + logEvent('history', { action: 'download' }); }; const uploadHistory = () => { @@ -73,59 +80,39 @@ return; } const data: HistoryEntry[] = JSON.parse(await file.text()); - restoreHistory(data); + const { restored, invalid, duplicates } = restoreEntries(data); + notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`); }); input.click(); }; - const saveHistory = (auto = false) => { - const currentState: string = getStateString(); - const previousState: string = getPreviousState(auto); - if (previousState !== currentState) { - addHistoryEntry({ - state: $inputStateStore, - time: Date.now(), - type: auto ? 'auto' : 'manual' - }); - } else if (!auto) { + const saveHistory = () => { + if (!addManualEntry($inputStateStore)) { notify('State already saved.'); } }; - const clearHistory = (id?: string): void => { - if (!id && !prompt('Clear all saved items?')) { - return; + const clearAll = () => { + if (prompt('Clear all saved items?')) { + clearActive(); } - clearHistoryData(id); }; const restoreHistoryItem = (state: State): void => { inputStateStore.set({ ...state, updateDiagram: true }); }; - onMount(() => { - historyModeStore.set('manual'); - setInterval(() => { - saveHistory(true); - }, HISTORY_SAVE_INTERVAL); - }); + // Absolute editor URL for an entry, so the link can be opened in a new tab or copied. + const entryUrl = (state: State): string => + `${window.location.origin}${window.location.pathname}#${serializeState(state)}`; - loaderHistoryStore.subscribe((entries) => { - if (entries.length > 0 && tabs.length === 2) { - tabs = [ - { - id: 'loader', - title: 'Revisions', - icon: GitAltIcon - }, - ...tabs - ]; - historyModeStore.set('loader'); - } - }); + // Serialize each entry's URL once per change rather than per row on every render. + const entriesWithUrl = $derived( + historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) })) + ); - + {#snippet actions()}
- {#if $historyStore.length > 0} + {#if historyState.entries.length > 0} - {#if $historyModeStore !== 'loader'} + {#if historyState.mode !== 'loader'} {/if}
{/snippet}
diff --git a/src/lib/components/History/history.test.ts b/src/lib/components/History/history.test.ts deleted file mode 100644 index f1c4a74c..00000000 --- a/src/lib/components/History/history.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { HistoryEntry } from '$lib/types'; -import { defaultState } from '$lib/util/state'; -import { get } from 'svelte/store'; -import { describe, expect, it } from 'vitest'; -import { - addHistoryEntry, - clearHistoryData, - historyModeStore, - historyStore, - injectHistoryIDs -} from './history'; - -describe('history', () => { - it('should handle saving individual history entry', () => { - expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]'); - expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]'); - - addHistoryEntry({ - state: defaultState, - time: 12_345, - type: 'manual' - }); - - const [manualEntry] = JSON.parse( - window.localStorage.getItem('manualHistoryStore') ?? '[]' - ) as HistoryEntry[]; - - expect(manualEntry.time).toBe(12_345); - expect(manualEntry.type).toBe('manual'); - expect(manualEntry.name).not.toBeNull(); - expect(manualEntry.state).not.toBeNull(); - - addHistoryEntry({ - state: defaultState, - time: 54_321, - type: 'auto' - }); - - const [autoEntry] = JSON.parse( - window.localStorage.getItem('autoHistoryStore') ?? '[]' - ) as HistoryEntry[]; - - expect(autoEntry.time).toBe(54_321); - expect(autoEntry.type).toBe('auto'); - expect(autoEntry.name).not.toBeNull(); - expect(autoEntry.state).not.toBeNull(); - - historyModeStore.set('manual'); - clearHistoryData(); - historyModeStore.set('auto'); - clearHistoryData(); - expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]'); - expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]'); - }); - - it('should clear history entries', () => { - addHistoryEntry({ - state: defaultState, - time: 12_345, - type: 'manual' - }); - addHistoryEntry({ - state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' }, - time: 123_456, - type: 'manual' - }); - - historyModeStore.set('manual'); - const store: HistoryEntry[] = get(historyStore); - expect(store.length).toBe(2); - clearHistoryData(store[1].id); - expect(get(historyStore).length).toBe(1); - clearHistoryData(); - expect(get(historyStore).length).toBe(0); - - historyModeStore.set('auto'); - addHistoryEntry({ - state: defaultState, - time: 54_321, - type: 'auto' - }); - addHistoryEntry({ - state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' }, - time: 654_321, - type: 'auto' - }); - expect(get(historyStore).length).toBe(2); - clearHistoryData(); - expect(get(historyStore).length).toBe(0); - // Test calling when history is empty - clearHistoryData(); - expect(get(historyStore).length).toBe(0); - }); -}); - -describe('history migration', () => { - it('should inject history IDs as migration', () => { - window.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","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","name":"helpful-ocean"}]' - ); - window.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","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","name":"needy-mosquito"}]' - ); - let manualHistoryStore = JSON.parse( - window.localStorage.getItem('manualHistoryStore') ?? '[]' - ) as HistoryEntry[], - autoHistoryStore = JSON.parse( - window.localStorage.getItem('autoHistoryStore') ?? '[]' - ) as HistoryEntry[]; - expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(false); - expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(false); - - injectHistoryIDs(); - - manualHistoryStore = JSON.parse( - window.localStorage.getItem('manualHistoryStore') ?? '[]' - ) as HistoryEntry[]; - autoHistoryStore = JSON.parse( - window.localStorage.getItem('autoHistoryStore') ?? '[]' - ) as HistoryEntry[]; - expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(true); - expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(true); - }); -}); diff --git a/src/lib/components/History/history.ts b/src/lib/components/History/history.ts deleted file mode 100644 index 838f2a48..00000000 --- a/src/lib/components/History/history.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { HistoryEntry, HistoryType, Optional } from '$lib/types'; -import { localStorage, persist } from '$lib/util/persist'; -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; - -export const historyModeStore: Writable = persist( - writable('manual'), - localStorage(), - 'autoHistoryMode' -); - -const autoHistoryStore: Writable = persist( - writable([]), - localStorage(), - 'autoHistoryStore' -); - -const manualHistoryStore: Writable = persist( - writable([]), - localStorage(), - 'manualHistoryStore' -); - -export const loaderHistoryStore: Writable = writable([]); - -export const historyStore: Readable = derived( - [historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore], - ([historyMode, autoHistories, manualHistories, loadedHistories], set) => { - switch (historyMode) { - case 'auto': { - set(autoHistories); - break; - } - case 'manual': { - set(manualHistories); - break; - } - case 'loader': { - set(loadedHistories); - break; - } - default: { - set(autoHistories); - } - } - } -); - -export const addHistoryEntry = (entryToAdd: Optional): void => { - const entry: HistoryEntry = { - ...entryToAdd, - id: uuidV4() - }; - - if (entry.type === 'loader') { - loaderHistoryStore.update((entries) => [entry, ...entries]); - return; - } - - if (!entry.name) { - entry.name = generateSlug(2); - } - - if (entry.type === 'auto') { - autoHistoryStore.update((entries) => { - if (entries.length >= MAX_AUTO_HISTORY_LENGTH) { - entries = entries.slice(0, MAX_AUTO_HISTORY_LENGTH - 1); - } - return [entry, ...entries]; - }); - } - - manualHistoryStore.update((entries) => [entry, ...entries]); - logEvent('history', { action: 'save' }); -}; - -export const clearHistoryData = (idToClear?: string): void => { - (get(historyModeStore) === 'auto' ? autoHistoryStore : manualHistoryStore).update((entries) => { - if (get(historyModeStore) !== 'loader') { - entries = entries.filter(({ id }) => idToClear && id != idToClear); - logEvent('history', { action: 'clear', type: idToClear ? 'single' : 'all' }); - } - return entries; - }); -}; - -export const getPreviousState = (auto: boolean): string => { - const entries = get(auto ? autoHistoryStore : manualHistoryStore); - if (entries.length > 0) { - return JSON.stringify(entries[0].state); - } - return ''; -}; - -export const restoreHistory = (data: HistoryEntry[]) => { - const entries = data.filter((element) => validateEntry(element)); - const invalidEntryCount = data.length - entries.length; - if (invalidEntryCount > 0) { - console.error(`${invalidEntryCount} invalid history entries were removed.`); - console.error(data); - } - if (entries.length > 0) { - let entryCount = 0; - (entries[0].type === 'auto' ? autoHistoryStore : manualHistoryStore).update((existing) => { - const existingIDs = new Set(existing.map(({ id }) => id)); - const newEntries = entries.filter(({ id }) => !existingIDs.has(id)); - entryCount = newEntries.length; - const combined = [...existing, ...newEntries]; - combined.sort((a, b) => b.time - a.time); - return combined; - }); - - alert( - `${entryCount} entries restored. ${invalidEntryCount} invalid, ${ - entries.length - entryCount - } duplicates.` - ); - logEvent('history', { - action: 'restore', - success: entryCount, - invalid: invalidEntryCount, - duplicates: entries.length - entryCount - }); - } else { - alert('No valid entries found.'); - } -}; -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); -}; - -const validateEntry = (entry: HistoryEntry): boolean => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - return entry.type && entry.state && entry.time && true; -}; diff --git a/src/lib/components/History/historyState.svelte.ts b/src/lib/components/History/historyState.svelte.ts new file mode 100644 index 00000000..72a35b45 --- /dev/null +++ b/src/lib/components/History/historyState.svelte.ts @@ -0,0 +1,212 @@ +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'; +} + +// The persisted slot backing a mode; loader is in-memory and has no slot. +const slotFor = (m: HistoryType): Persisted | null => { + switch (m) { + case 'auto': { + return auto; + } + case 'manual': { + return manual; + } + default: { + return null; + } + } +}; + +export const historyState = { + get entries(): HistoryEntry[] { + return slotFor(mode.value)?.value ?? loader; + }, + 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() } + ); +}; + +export const removeEntry = (id: string): void => { + const slot = slotFor(mode.value); + if (!slot) { + return; + } + slot.value = slot.value.filter((entry) => entry.id !== id); + logEvent('history', { action: 'clear', type: 'single' }); +}; + +export const clearActive = (): void => { + const slot = slotFor(mode.value); + if (!slot) { + return; + } + slot.value = []; + logEvent('history', { action: 'clear', type: 'all' }); +}; + +const validateEntry = (entry: HistoryEntry): boolean => + Boolean(entry && entry.type && entry.state) && typeof entry.time === 'number'; + +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/historyState.test.ts b/src/lib/components/History/historyState.test.ts new file mode 100644 index 00000000..e6790ca3 --- /dev/null +++ b/src/lib/components/History/historyState.test.ts @@ -0,0 +1,314 @@ +import type { HistoryEntry } from '$lib/types'; +import { defaultState, inputStateStore } from '$lib/util/state'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + addAutoEntry, + addManualEntry, + clearActive, + historyState, + injectHistoryIDs, + removeEntry, + restoreEntries, + setLoaderEntries, + setMode, + startAutoSave, + stateKey, + stopAutoSave +} 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 historyState.entries; +}; + +beforeEach(() => { + // Reset every store through the public API so tests don't leak into each other. + setMode('manual'); + clearActive(); + setMode('auto'); + clearActive(); + setLoaderEntries([]); + setMode('manual'); +}); + +describe('stateKey', () => { + it('ignores volatile and view-only fields, keying only on code + config', () => { + const a = { + ...defaultState, + code: 'graph TD\n A-->B', + panZoom: true, + renderCount: 1, + updateDiagram: true + }; + const b = { + ...defaultState, + code: 'graph TD\n A-->B', + pan: { x: 5, y: 5 }, + panZoom: false, + renderCount: 99, + updateDiagram: false + }; + expect(stateKey(a)).toBe(stateKey(b)); + }); + + it('differs when code differs', () => { + expect(stateKey(codeState('graph TD\n A-->B'))).not.toBe( + stateKey(codeState('graph TD\n A-->C')) + ); + }); + + it('differs when config differs', () => { + const a = { ...defaultState, mermaid: '{"theme":"dark"}' }; + const b = { ...defaultState, mermaid: '{"theme":"forest"}' }; + expect(stateKey(a)).not.toBe(stateKey(b)); + }); +}); + +describe('addManualEntry', () => { + it('adds to the manual store only, never the auto store', () => { + expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true); + expect(entriesFor('manual')).toHaveLength(1); + expect(entriesFor('auto')).toHaveLength(0); + }); + + it('returns false and does not add a duplicate of the latest entry', () => { + const state = codeState('graph TD\n A-->B'); + expect(addManualEntry(state)).toBe(true); + expect(addManualEntry(state)).toBe(false); + expect(entriesFor('manual')).toHaveLength(1); + }); + + it('treats states differing only in volatile/view fields as duplicates', () => { + expect(addManualEntry({ ...defaultState, code: 'graph TD\n A-->B', renderCount: 1 })).toBe( + true + ); + expect( + addManualEntry({ + ...defaultState, + code: 'graph TD\n A-->B', + panZoom: false, + renderCount: 2, + updateDiagram: true + }) + ).toBe(false); + expect(entriesFor('manual')).toHaveLength(1); + }); + + it('adds a new entry when the code changes', () => { + expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true); + expect(addManualEntry(codeState('graph TD\n A-->C'))).toBe(true); + expect(entriesFor('manual')).toHaveLength(2); + }); + + it('generates an id and a name for each entry', () => { + addManualEntry(codeState('graph TD\n A-->B')); + const [entry] = entriesFor('manual'); + expect(entry.id).toBeTruthy(); + expect(entry.name).toBeTruthy(); + expect(entry.type).toBe('manual'); + }); +}); + +describe('addAutoEntry', () => { + it('adds to the auto store only, never the manual store', () => { + expect(addAutoEntry(codeState('graph TD\n A-->B'))).toBe(true); + expect(entriesFor('auto')).toHaveLength(1); + expect(entriesFor('manual')).toHaveLength(0); + }); + + it('returns false and does not add a duplicate of the latest entry', () => { + const state = codeState('graph TD\n A-->B'); + expect(addAutoEntry(state)).toBe(true); + expect(addAutoEntry(state)).toBe(false); + expect(entriesFor('auto')).toHaveLength(1); + }); + + it('caps the auto store at 30 entries, dropping the oldest', () => { + for (let i = 0; i < 35; i++) { + addAutoEntry(codeState(`graph TD\n A-->B${i}`)); + } + const entries = entriesFor('auto'); + expect(entries).toHaveLength(30); + expect(entries[0].state.code).toBe('graph TD\n A-->B34'); + }); +}); + +describe('historyState.entries', () => { + it('reflects the active mode', () => { + addManualEntry(codeState('manual-code')); + addAutoEntry(codeState('auto-code')); + + setMode('manual'); + expect(historyState.entries).toHaveLength(1); + expect(historyState.entries[0].state.code).toBe('manual-code'); + + setMode('auto'); + expect(historyState.entries).toHaveLength(1); + expect(historyState.entries[0].state.code).toBe('auto-code'); + + setMode('loader'); + expect(historyState.entries).toHaveLength(0); + }); +}); + +describe('removeEntry / clearActive', () => { + it('removes a single entry from the active store by id', () => { + addManualEntry(codeState('graph TD\n A-->B')); + addManualEntry(codeState('graph TD\n A-->C')); + setMode('manual'); + const target = historyState.entries[1].id; + removeEntry(target); + expect(historyState.entries).toHaveLength(1); + expect(historyState.entries.some((e) => e.id === target)).toBe(false); + }); + + it('clears all entries in the active store only', () => { + addManualEntry(codeState('graph TD\n A-->B')); + addAutoEntry(codeState('graph TD\n A-->C')); + setMode('manual'); + clearActive(); + expect(entriesFor('manual')).toHaveLength(0); + expect(entriesFor('auto')).toHaveLength(1); + }); + + it('does nothing in loader mode', () => { + setLoaderEntries([ + { name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' } + ]); + setMode('loader'); + clearActive(); + expect(historyState.entries).toHaveLength(1); + }); +}); + +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'); + 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'); + }); +}); + +describe('restoreEntries', () => { + it('routes each entry to the store matching its own type', () => { + const result = restoreEntries([ + { id: 'a1', name: 'a', state: defaultState, time: 10, type: 'auto' }, + { id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' } + ]); + expect(result.restored).toBe(2); + expect(entriesFor('auto').map((e) => e.id)).toEqual(['a1']); + expect(entriesFor('manual').map((e) => e.id)).toEqual(['m1']); + }); + + it('skips duplicates by id and reports them', () => { + restoreEntries([{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }]); + const result = restoreEntries([ + { id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }, + { id: 'm2', name: 'm2', state: defaultState, time: 30, type: 'manual' } + ]); + expect(result.restored).toBe(1); + expect(result.duplicates).toBe(1); + expect(entriesFor('manual')).toHaveLength(2); + }); + + it('reports invalid entries and does not restore them', () => { + const result = restoreEntries([ + { id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }, + { foo: 'bar' } as unknown as HistoryEntry + ]); + expect(result.restored).toBe(1); + expect(result.invalid).toBe(1); + }); + + it('sorts restored entries newest first', () => { + restoreEntries([ + { id: 'm1', name: 'old', state: defaultState, time: 10, type: 'manual' }, + { id: 'm2', name: 'new', state: defaultState, time: 30, type: 'manual' }, + { id: 'm3', name: 'mid', state: defaultState, time: 20, type: 'manual' } + ]); + expect(entriesFor('manual').map((e) => e.time)).toEqual([30, 20, 10]); + }); + + it('restores entries whose time is 0 (epoch) instead of treating them as invalid', () => { + const result = restoreEntries([ + { id: 'm0', name: 'epoch', state: defaultState, time: 0, type: 'manual' } + ]); + expect(result.restored).toBe(1); + expect(result.invalid).toBe(0); + expect(entriesFor('manual')).toHaveLength(1); + }); +}); + +describe('injectHistoryIDs migration', () => { + it('adds ids to persisted entries that lack them', () => { + window.localStorage.setItem( + 'manualHistoryStore', + '[{"state":{"code":"a"},"time":1,"type":"manual","name":"x"}]' + ); + window.localStorage.setItem( + 'autoHistoryStore', + '[{"state":{"code":"b"},"time":2,"type":"auto","name":"y"}]' + ); + injectHistoryIDs(); + const manual = JSON.parse( + window.localStorage.getItem('manualHistoryStore') ?? '[]' + ) as HistoryEntry[]; + 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); + }); +}); + +describe('auto-save lifecycle', () => { + afterEach(() => { + stopAutoSave(); + vi.useRealTimers(); + }); + + it('records an auto entry on each interval from the current editor state', () => { + vi.useFakeTimers(); + inputStateStore.set(codeState('graph TD\n auto-saved')); + startAutoSave(); + vi.advanceTimersByTime(60_000); + const entries = entriesFor('auto'); + expect(entries).toHaveLength(1); + expect(entries[0].state.code).toBe('graph TD\n auto-saved'); + }); + + it('is idempotent: calling startAutoSave twice does not double-record', () => { + vi.useFakeTimers(); + inputStateStore.set(codeState('graph TD\n once')); + startAutoSave(); + startAutoSave(); + vi.advanceTimersByTime(60_000); + expect(entriesFor('auto')).toHaveLength(1); + }); + + it('stops recording after stopAutoSave', () => { + vi.useFakeTimers(); + inputStateStore.set(codeState('graph TD\n stoppable')); + startAutoSave(); + vi.advanceTimersByTime(60_000); + stopAutoSave(); + inputStateStore.set(codeState('graph TD\n after-stop')); + vi.advanceTimersByTime(60_000); + expect(entriesFor('auto')).toHaveLength(1); + }); +}); diff --git a/src/lib/components/MainMenu.svelte b/src/lib/components/MainMenu.svelte index 251ea2da..814f467f 100644 --- a/src/lib/components/MainMenu.svelte +++ b/src/lib/components/MainMenu.svelte @@ -28,7 +28,7 @@ sharesData?: boolean; checkDiagramType?: boolean; isSectionEnd?: boolean; - renderer: (item: Omit) => ReturnType; + renderer: Snippet<[Omit]>; } const menuItems: MenuItem[] = $derived([ @@ -89,7 +89,7 @@ ]); -{#snippet menuItem(options: MenuItem)} +{#snippet menuItem(options: Omit)} {/snippet} -{#snippet mcMenuItem(item: MenuItem)} +{#snippet mcMenuItem(item: Omit)} {/snippet} -{#snippet darkModeMenuItem(options: MenuItem)} +{#snippet darkModeMenuItem(options: Omit)}
(hidePromotion ? undefined : getActivePromotion()))); const trackBannerClick = () => { if (!activePromotion) { @@ -84,7 +84,7 @@
- + {#if !mobileToggle} Mermaid {/if} diff --git a/src/lib/components/Preset.svelte b/src/lib/components/Preset.svelte index 5ca72758..2b0d5a08 100644 --- a/src/lib/components/Preset.svelte +++ b/src/lib/components/Preset.svelte @@ -35,7 +35,7 @@ const samples = { ...getSampleDiagrams(), ...extras } as const; const loadSampleDiagram = (diagramType: string): void => { - updateCode(samples[diagramType], { + updateCode(samples[diagramType as keyof typeof samples], { resetPanZoom: true, updateDiagram: true }); diff --git a/src/lib/components/Privacy.svelte b/src/lib/components/Privacy.svelte index 07e5444a..b62c0d98 100644 --- a/src/lib/components/Privacy.svelte +++ b/src/lib/components/Privacy.svelte @@ -1,4 +1,4 @@ - +
diff --git a/src/lib/util/fileLoaders/gist.ts b/src/lib/util/fileLoaders/gist.ts index f2e69dc0..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 { addHistoryEntry } 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) { - addHistoryEntry({ - 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/lib/util/promos/promo.ts b/src/lib/util/promos/promo.ts index 6af7b2e6..48c82782 100644 --- a/src/lib/util/promos/promo.ts +++ b/src/lib/util/promos/promo.ts @@ -1,7 +1,7 @@ import { env } from '$lib/util/env'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; -import type { Component } from 'svelte'; +import type { Component, Snippet } from 'svelte'; import { get, writable, type Writable } from 'svelte/store'; import { localStorage, persist } from '../persist'; import April2025 from './April2025.svelte'; @@ -12,7 +12,7 @@ dayjs.extend(duration); interface Promotion { startDate: Date; endDate: Date; - component: Component; + component: Component<{ closeBanner: Snippet }>; hideDurationMs: number; } diff --git a/src/lib/util/state.ts b/src/lib/util/state.ts index 5707dbfa..811927aa 100644 --- a/src/lib/util/state.ts +++ b/src/lib/util/state.ts @@ -189,7 +189,7 @@ function getUnsafePaths(object: object, unsafeKeys: string[], path: string[] = [ } } Object.keys(object).forEach((key) => { - const value = object[key] as unknown; + const value = (object as Record)[key]; const currentPath = [...path, key]; // Prototype pollution check. if (key.startsWith('__')) { @@ -317,10 +317,6 @@ export const initURLSubscription = (): void => { }); }; -export const getStateString = (): string => { - return JSON.stringify(get(inputStateStore)); -}; - export const verifyState = (): void => { const state = get(inputStateStore); if (!state.panZoom) { diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte index edc06d73..0f240433 100644 --- a/src/routes/edit/+page.svelte +++ b/src/routes/edit/+page.svelte @@ -5,6 +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/historyState.svelte'; import McWrapper from '$/components/McWrapper.svelte'; import MermaidChartIcon from '$/components/MermaidChartIcon.svelte'; import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte'; @@ -63,6 +64,9 @@ }); }); + // Record the Timeline for the whole session, not just while the panel is open. + onMount(() => startAutoSave()); + let isHistoryOpen = $state(false); let editorPane: Resizable.Pane | undefined; @@ -87,7 +91,7 @@ {/snippet} - + diff --git a/tests/history.spec.ts b/tests/history.spec.ts index 0b81569e..842ff4f8 100644 --- a/tests/history.spec.ts +++ b/tests/history.spec.ts @@ -1,84 +1,147 @@ -import { expect, test } from '@playwright/test'; -import { typeInEditor } from './utils'; +import { expect, test, type Page } from '@playwright/test'; -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('each entry has a copyable link that opens it in a new tab', async ({ page }) => { + await page.evaluate( + (manual) => localStorage.setItem('manualHistoryStore', manual), + JSON.stringify(manualHistory) + ); + await page.reload(); + await openHistory(page); - await typeInEditor(page, ' C --> HistoryTest'); + // It is a real link (so it can be copied / opened in a new tab), not a button. + const link = page.getByRole('link', { name: 'Open in new tab' }).first(); + await expect(link).toHaveAttribute('target', '_blank'); + const href = await link.getAttribute('href'); + expect(href).toContain('/edit#pako:'); + + // Following it loads that entry's diagram. + await page.goto(href ?? ''); + await expect(page.locator('#view')).toContainText('Halloween'); + }); + + 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 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); + + // Loading a different sample changes the state, so it saves as a new entry. + await page.getByRole('button', { name: 'Sequence', exact: true }).click(); + await expect(page.locator('#view')).not.toContainText('Christmas'); 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 page.getByRole('button', { name: 'Sequence', exact: true }).click(); + await expect(page.locator('#view')).not.toContainText('Christmas'); 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"