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:
co-authored by
Claude Opus 4.8
parent
ebced635c9
commit
3628acec02
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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<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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user