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;