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) <noreply@anthropic.com>
This commit is contained in:
Sidharth Vinod
2026-06-08 19:27:23 +05:30
co-authored by Claude Opus 4.8
parent ebced635c9
commit 3628acec02
6 changed files with 458 additions and 264 deletions
+3 -4
View File
@@ -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)}>
+42 -69
View File
@@ -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');
}
});
</script>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={$historyModeStore}>
{#snippet actions()}
<div class="flex items-center gap-2">
<Button
@@ -147,7 +124,7 @@
id="saveHistory"
size="icon"
variant="ghost"
onclick={() => saveHistory()}
onclick={saveHistory}
title="Save current state"><SaveIcon /></Button>
{#if $historyModeStore !== 'loader'}
<Button
@@ -155,7 +132,7 @@
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory()}
onclick={clearAll}
title="Delete all saved states"><TrashAltIcon /></Button>
{/if}
</div>
@@ -192,7 +169,7 @@
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory(id)}>
onclick={() => removeEntry(id)}>
<TrashAltIcon />
</Button>
{/if}
@@ -202,11 +179,7 @@
</li>
{/each}
{:else}
<div class="m-2 text-center">
No items in History<br />
Click the Save button to save current state and restore it later.<br />
Timeline will automatically be saved every minute.
</div>
<div class="m-2 text-center whitespace-pre-line">{emptyMessage}</div>
{/if}
</ul>
</Card>
+270 -99
View File
@@ -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);
};
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');
});
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));
});
const [manualEntry] = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manualEntry.time).toBe(12_345);
expect(manualEntry.type).toBe('manual');
expect(manualEntry.name).not.toBeNull();
expect(manualEntry.state).not.toBeNull();
addHistoryEntry({
state: defaultState,
time: 54_321,
type: 'auto'
it('differs when code differs', () => {
expect(stateKey(codeState('graph TD\n A-->B'))).not.toBe(
stateKey(codeState('graph TD\n A-->C'))
);
});
const [autoEntry] = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(autoEntry.time).toBe(54_321);
expect(autoEntry.type).toBe('auto');
expect(autoEntry.name).not.toBeNull();
expect(autoEntry.state).not.toBeNull();
historyModeStore.set('manual');
clearHistoryData();
historyModeStore.set('auto');
clearHistoryData();
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
});
it('should clear history entries', () => {
addHistoryEntry({
state: defaultState,
time: 12_345,
type: 'manual'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 123_456,
type: 'manual'
});
historyModeStore.set('manual');
const store: HistoryEntry[] = get(historyStore);
expect(store.length).toBe(2);
clearHistoryData(store[1].id);
expect(get(historyStore).length).toBe(1);
clearHistoryData();
expect(get(historyStore).length).toBe(0);
historyModeStore.set('auto');
addHistoryEntry({
state: defaultState,
time: 54_321,
type: 'auto'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 654_321,
type: 'auto'
});
expect(get(historyStore).length).toBe(2);
clearHistoryData();
expect(get(historyStore).length).toBe(0);
// Test calling when history is empty
clearHistoryData();
expect(get(historyStore).length).toBe(0);
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);
});
});
+134 -87
View File
@@ -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<HistoryType> = persist(
writable('manual'),
localStorage(),
'autoHistoryMode'
);
const AUTO_SAVE_INTERVAL = 60_000;
const autoHistoryStore: Writable<HistoryEntry[]> = persist(
writable([]),
@@ -26,110 +22,150 @@ const manualHistoryStore: Writable<HistoryEntry[]> = persist(
'manualHistoryStore'
);
// Populated by file loaders (e.g. gist); in-memory only.
export const loaderHistoryStore: Writable<HistoryEntry[]> = writable([]);
export const historyModeStore: Writable<HistoryType> = 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<HistoryEntry[]> => {
switch (mode) {
case 'auto': {
return autoHistoryStore;
}
case 'loader': {
return loaderHistoryStore;
}
default: {
return manualHistoryStore;
}
}
};
export const historyStore: Readable<HistoryEntry[]> = 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<HistoryEntry, 'id'>): 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<HistoryEntry[]>,
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<HistoryEntry, 'id'>): 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;
}
return entries;
});
storeForMode(mode).set([]);
logEvent('history', { action: 'clear', type: 'all' });
};
export const getPreviousState = (auto: boolean): string => {
const entries = get(auto ? autoHistoryStore : manualHistoryStore);
if (entries.length > 0) {
return JSON.stringify(entries[0].state);
}
return '';
};
const validateEntry = (entry: HistoryEntry): boolean =>
Boolean(entry && entry.type && entry.state && entry.time);
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);
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;
}
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<typeof setInterval> | 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;
}
};
+2 -2
View File
@@ -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<State> => {
}
const state = getStateFromGist(entry, gistURL);
for (const gist of gistHistory) {
addHistoryEntry({
addLoaderEntry({
name: `${gist.author} v${gist.version}`,
state: getStateFromGist(gist),
time: gist.time,
+4
View File
@@ -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;