From 3628acec02b8a7d5403cb73c1a1f0186022cbd57 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Mon, 8 Jun 2026 18:57:02 +0530 Subject: [PATCH 1/7] fix: Rewrite Timeline/Saved history state and UI handling The auto (Timeline) and manual (Saved) history handling had several bugs. This rewrites the state module into a small, tested source-of-truth API and turns History.svelte into a thin view. Bugs fixed: - Auto-saves no longer leak into the Saved list. addEntry now writes only to the store for its own type (previously every entry was also pushed to the manual store). - Dedup is keyed on code + config via stateKey() instead of the whole input state, so volatile/view-only fields (renderCount, updateDiagram, pan/zoom) no longer cause spurious saves or hide real edits. - The active-tab highlight tracks the history mode. History binds activeTabID={$historyModeStore} and Tabs derives the highlight reactively instead of mutating its prop once, which had frozen it on the first tab. - Auto-save runs for the whole edit session via startAutoSave() in +page, with proper cleanup. Previously the interval lived in History.svelte, only ran while the panel was open, and was never cleared (leaking a new interval on every open). - restoreEntries() routes each uploaded entry to the store matching its own type instead of dumping everything into one store by the first entry's type. The persisted localStorage keys (autoHistoryStore, manualHistoryStore, autoHistoryMode) are unchanged, so existing user data is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/Card/Tabs.svelte | 7 +- src/lib/components/History/History.svelte | 111 +++---- src/lib/components/History/history.test.ts | 367 +++++++++++++++------ src/lib/components/History/history.ts | 229 ++++++++----- src/lib/util/fileLoaders/gist.ts | 4 +- src/routes/edit/+page.svelte | 4 + 6 files changed, 458 insertions(+), 264 deletions(-) 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/History/History.svelte b/src/lib/components/History/History.svelte index 9512d658..0334fc15 100644 --- a/src/lib/components/History/History.svelte +++ b/src/lib/components/History/History.svelte @@ -2,11 +2,10 @@ 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 { 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'; @@ -19,36 +18,45 @@ import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; import { - addHistoryEntry, - clearHistoryData, - getPreviousState, + addManualEntry, + clearActive, historyModeStore, historyStore, loaderHistoryStore, - restoreHistory + removeEntry, + restoreEntries, + setMode } from './history'; 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($loaderHistoryStore.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) { + revisionsShown = true; + setMode('loader'); + } + }); + + const emptyMessage = $derived( + $historyModeStore === '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 blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); @@ -58,9 +66,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 +79,30 @@ 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); - }); - - loaderHistoryStore.subscribe((entries) => { - if (entries.length > 0 && tabs.length === 2) { - tabs = [ - { - id: 'loader', - title: 'Revisions', - icon: GitAltIcon - }, - ...tabs - ]; - historyModeStore.set('loader'); - } - }); - + {#snippet actions()}
{#if $historyModeStore !== 'loader'} {/if}
@@ -192,7 +169,7 @@ size="icon" variant="ghost" class="hover:text-destructive" - onclick={() => clearHistory(id)}> + onclick={() => removeEntry(id)}> {/if} @@ -202,11 +179,7 @@ {/each} {:else} -
- No items in History
- Click the Save button to save current state and restore it later.
- Timeline will automatically be saved every minute. -
+
{emptyMessage}
{/if}
diff --git a/src/lib/components/History/history.test.ts b/src/lib/components/History/history.test.ts index f1c4a74c..cf168e74 100644 --- a/src/lib/components/History/history.test.ts +++ b/src/lib/components/History/history.test.ts @@ -1,126 +1,297 @@ import type { HistoryEntry } from '$lib/types'; -import { defaultState } from '$lib/util/state'; +import { defaultState, inputStateStore } from '$lib/util/state'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { get } from 'svelte/store'; -import { describe, expect, it } from 'vitest'; import { - addHistoryEntry, - clearHistoryData, - historyModeStore, + addAutoEntry, + addLoaderEntry, + addManualEntry, + clearActive, historyStore, - injectHistoryIDs + injectHistoryIDs, + loaderHistoryStore, + removeEntry, + restoreEntries, + setMode, + startAutoSave, + stateKey, + stopAutoSave } from './history'; -describe('history', () => { - it('should handle saving individual history entry', () => { - expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]'); - expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]'); +const codeState = (code: string) => ({ ...defaultState, code }); - addHistoryEntry({ - state: defaultState, - time: 12_345, - type: 'manual' - }); +/** Read the entries currently shown for a given mode. */ +const entriesFor = (mode: 'auto' | 'manual' | 'loader'): HistoryEntry[] => { + setMode(mode); + return get(historyStore); +}; - const [manualEntry] = JSON.parse( - window.localStorage.getItem('manualHistoryStore') ?? '[]' - ) as HistoryEntry[]; +beforeEach(() => { + // Reset every store through the public API so tests don't leak into each other. + setMode('manual'); + clearActive(); + setMode('auto'); + clearActive(); + loaderHistoryStore.set([]); + setMode('manual'); +}); - 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('[]'); +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('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' - }); + it('differs when code differs', () => { + expect(stateKey(codeState('graph TD\n A-->B'))).not.toBe( + stateKey(codeState('graph TD\n A-->C')) + ); + }); - 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); + 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('history migration', () => { - it('should inject history IDs as migration', () => { +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('historyStore', () => { + 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'); + + setMode('auto'); + expect(get(historyStore)).toHaveLength(1); + expect(get(historyStore)[0].state.code).toBe('auto-code'); + + setMode('loader'); + expect(get(historyStore)).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 = get(historyStore)[1].id; + removeEntry(target); + expect(get(historyStore)).toHaveLength(1); + expect(get(historyStore).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', () => { + addLoaderEntry({ name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' }); + setMode('loader'); + clearActive(); + expect(get(historyStore)).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' }); + setMode('loader'); + const entries = get(historyStore); + expect(entries).toHaveLength(2); + expect(entries[0].name).toBe('v2'); + expect(entries.every((e) => e.id)).toBe(true); + }); +}); + +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]); + }); +}); + +describe('injectHistoryIDs migration', () => { + it('adds ids to persisted entries that lack them', () => { 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"}]' + '[{"state":{"code":"a"},"time":1,"type":"manual","name":"x"}]' ); 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"}]' + '[{"state":{"code":"b"},"time":2,"type":"auto","name":"y"}]' ); - 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( + const manual = JSON.parse( window.localStorage.getItem('manualHistoryStore') ?? '[]' ) as HistoryEntry[]; - autoHistoryStore = JSON.parse( + const auto = JSON.parse( window.localStorage.getItem('autoHistoryStore') ?? '[]' ) as HistoryEntry[]; - expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(true); - expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(true); + 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/History/history.ts b/src/lib/components/History/history.ts index 838f2a48..7c159240 100644 --- a/src/lib/components/History/history.ts +++ b/src/lib/components/History/history.ts @@ -1,5 +1,6 @@ -import type { HistoryEntry, HistoryType, Optional } from '$lib/types'; +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'; @@ -7,12 +8,7 @@ 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 AUTO_SAVE_INTERVAL = 60_000; const autoHistoryStore: Writable = persist( writable([]), @@ -26,110 +22,150 @@ const manualHistoryStore: Writable = persist( '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], - ([historyMode, autoHistories, manualHistories, loadedHistories], set) => { - switch (historyMode) { + ([mode, auto, manual, loader]) => { + switch (mode) { case 'auto': { - set(autoHistories); - break; - } - case 'manual': { - set(manualHistories); - break; + return auto; } case 'loader': { - set(loadedHistories); - break; + return loader; } default: { - set(autoHistories); + return manual; } } } ); -export const addHistoryEntry = (entryToAdd: Optional): void => { - const entry: HistoryEntry = { - ...entryToAdd, - id: uuidV4() - }; +export const setMode = (mode: HistoryType): void => { + historyModeStore.set(mode); +}; - if (entry.type === 'loader') { - loaderHistoryStore.update((entries) => [entry, ...entries]); +// 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; } - - 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' }); + storeForMode(mode).update((entries) => entries.filter((entry) => entry.id !== id)); + logEvent('history', { action: 'clear', type: 'single' }); }; -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' }); +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; } - 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) => { + storeForMode(type).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; + const fresh = incoming.filter(({ id }) => !existingIDs.has(id)); + restored += fresh.length; + return [...existing, ...fresh].sort((a, b) => b.time - a.time); }); - - 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 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) { @@ -144,8 +180,19 @@ export const injectHistoryIDs = (): void => { 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; +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/util/fileLoaders/gist.ts b/src/lib/util/fileLoaders/gist.ts index f2e69dc0..61032fd3 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 { addLoaderEntry } from '$lib/components/History/history'; import type { State } from '$lib/types'; import { defaultState } from '$lib/util/state'; import { fetchJSON, fetchText } from '$lib/util/util'; @@ -118,7 +118,7 @@ export const loadGistData = async (gistURL: string): Promise => { } const state = getStateFromGist(entry, gistURL); for (const gist of gistHistory) { - addHistoryEntry({ + addLoaderEntry({ name: `${gist.author} v${gist.version}`, state: getStateFromGist(gist), time: gist.time, diff --git a/src/routes/edit/+page.svelte b/src/routes/edit/+page.svelte index edc06d73..02db59e3 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/history'; 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; From e6333e9d7faa97812e35de38b1b483ce7703bb52 Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Mon, 8 Jun 2026 19:24:05 +0530 Subject: [PATCH 2/7] refactor: Convert history state to Svelte 5 runes + add svelte-check Migrate the Timeline/Saved history state module to idiomatic Svelte 5 runes and wire up type-checking. - Rename history.ts -> historyState.svelte.ts and replace the Svelte stores with $state-backed reactive values, removing the get() calls (the only remaining one reads the external inputStateStore). A small localStorage-backed `persisted()` helper keeps the same keys, so user data is preserved. Consumers read via the reactive `historyState` getter object instead of store auto-subscriptions. - The gist loader now replaces the in-memory revisions in one call (setLoaderEntries) instead of appending, so loading a new gist no longer accumulates stale revisions. - Add svelte-check: new `check` script, a "Type check" step in the unit-tests CI workflow, and skipLibCheck so the check passes on the dependency .d.ts files. svelte-check reports 0 errors / 0 warnings. - Add accessible labels to the History toggle and the per-item Restore/Delete buttons (a11y + testability). - Unskip tests/history.spec.ts and rewrite it against the new UI: load-from-localStorage + restore, tab-highlight follows the mode, save/dedupe, auto-vs-manual isolation, and delete/clear. All pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/unit-tests.yml | 3 + package.json | 3 + pnpm-lock.yaml | 77 ++++++ src/lib/components/History/History.svelte | 34 +-- src/lib/components/History/history.ts | 198 ---------------- .../components/History/historyState.svelte.ts | 221 ++++++++++++++++++ .../{history.test.ts => historyState.test.ts} | 60 ++--- src/lib/util/fileLoaders/gist.ts | 22 +- src/lib/util/migrations.ts | 2 +- src/routes/edit/+page.svelte | 4 +- tests/history.spec.ts | 155 +++++++----- tsconfig.json | 1 + 12 files changed, 472 insertions(+), 308 deletions(-) delete mode 100644 src/lib/components/History/history.ts create mode 100644 src/lib/components/History/historyState.svelte.ts rename src/lib/components/History/{history.test.ts => historyState.test.ts} (85%) 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" From a3a3f8ffbe5403a1265918c5d8db16afa694990e Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Mon, 8 Jun 2026 19:24:52 +0530 Subject: [PATCH 3/7] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 64e8822e32acf367855d39d01500f5f3cf85788c Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Mon, 8 Jun 2026 19:53:25 +0530 Subject: [PATCH 4/7] fix: Make svelte-check pass and resolve TS6/Svelte 5.56 type errors Adding the `svelte-check` CI gate surfaced pre-existing type errors and warnings under develop's TypeScript 6 / Svelte 5.56 bump. Fixes them so `pnpm check` reports 0 errors / 0 warnings: - Add `lang="ts"` to Share/Privacy (and a script to PrivacyPolicyLink) so importers get real declarations instead of implicit `any`. - Replace the deprecated `monaco.languages.json` with the new top-level `monaco.json` namespace (proper API, no casts). - Navbar: use `resolve('/', {})` instead of the deprecated `base`. - Type the promo `component` as `Component<{ closeBanner: Snippet }>` and MainMenu's `renderer` as `Snippet<[Omit]>`. - Index-by-string casts in state.ts / Preset / DiagramDocumentationButton. - Make Actions' clipboard handler accept an optional event. - toggle-group: expose variant/size via getters to fix the state_referenced_locally warning. - Make the History e2e save/delete cases change state via a sample diagram (deterministic) instead of editor typing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/Actions.svelte | 5 ++++- src/lib/components/DesktopEditor.svelte | 2 +- src/lib/components/DiagramDocumentationButton.svelte | 4 +++- src/lib/components/MainMenu.svelte | 8 ++++---- src/lib/components/Navbar.svelte | 6 +++--- src/lib/components/Preset.svelte | 2 +- src/lib/components/Privacy.svelte | 2 +- src/lib/components/Share.svelte | 2 +- src/lib/components/migration/PrivacyPolicyLink.svelte | 2 ++ src/lib/components/ui/toggle-group/toggle-group.svelte | 8 ++++++-- src/lib/util/promos/promo.ts | 4 ++-- src/lib/util/state.ts | 2 +- tests/history.spec.ts | 9 +++++---- 13 files changed, 34 insertions(+), 22 deletions(-) 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/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/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/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..e87c9677 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('__')) { diff --git a/tests/history.spec.ts b/tests/history.spec.ts index 452b6dbc..17a8ac0a 100644 --- a/tests/history.spec.ts +++ b/tests/history.spec.ts @@ -1,5 +1,4 @@ import { expect, test, type Page } from '@playwright/test'; -import { typeInEditor } from './utils'; const config = '{\n "theme": "default"\n}'; @@ -93,8 +92,9 @@ test.describe('History', () => { 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 }); + // 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); }); @@ -112,7 +112,8 @@ test.describe('History', () => { test('deletes a single entry and clears all after confirmation', async ({ page }) => { await openHistory(page); await page.locator('#saveHistory').click(); - await typeInEditor(page, ' Z[Another]', { newline: true }); + 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); From 8dd9cb49a01fa2b7c0d45897f3d819587622dcac Mon Sep 17 00:00:00 2001 From: Sidharth Vinod Date: Mon, 8 Jun 2026 20:03:31 +0530 Subject: [PATCH 5/7] feat: Add "open in new tab" link to history entries Each history entry now has a third action: a real anchor (rendered via the Button's href, target="_blank") linking to the editor with that entry's serialized state. Being a normal link, users can also copy it or open it in a new tab via the context menu. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/components/History/History.svelte | 15 +++++++++++++++ tests/history.spec.ts | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/lib/components/History/History.svelte b/src/lib/components/History/History.svelte index e091e7b1..444cb294 100644 --- a/src/lib/components/History/History.svelte +++ b/src/lib/components/History/History.svelte @@ -2,6 +2,7 @@ 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 { serializeState } from '$lib/util/serde'; import { inputStateStore } from '$lib/util/state'; import { logEvent } from '$lib/util/stats'; import dayjs from 'dayjs'; @@ -14,6 +15,7 @@ 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 { @@ -99,6 +101,10 @@ const restoreHistoryItem = (state: State): void => { inputStateStore.set({ ...state, updateDiagram: true }); }; + + // 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)}`; @@ -160,6 +166,15 @@ {dayjs(time).fromNow()} +
{/snippet}
    - {#if historyState.entries.length > 0} - {#each historyState.entries as { id, state, time, name, url, type } (id)} + {#if entriesWithUrl.length > 0} + {#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)}
  • @@ -167,7 +172,7 @@ {dayjs(time).fromNow()}