diff --git a/packages/ui/src/components/diff/diff-line.tsx b/packages/ui/src/components/diff/diff-line.tsx index e647b79..d0dbc7b 100644 --- a/packages/ui/src/components/diff/diff-line.tsx +++ b/packages/ui/src/components/diff/diff-line.tsx @@ -70,7 +70,7 @@ export function DiffLine(props: DiffLineProps) { {getPrefix(line.type)} - + {renderContent(line, syntaxTokens)} diff --git a/packages/ui/src/components/diff/diff-page.tsx b/packages/ui/src/components/diff/diff-page.tsx index 9027ff9..5ab7de8 100644 --- a/packages/ui/src/components/diff/diff-page.tsx +++ b/packages/ui/src/components/diff/diff-page.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useDiff } from '../../hooks/use-diff'; import { useInfo } from '../../hooks/use-info'; import { useTheme } from '../../hooks/use-theme'; +import { useWrapLines } from '../../hooks/use-wrap-lines'; import { useKeyboard } from '../../hooks/use-keyboard'; import { useReviewThreads } from '../../hooks/use-review-threads'; import { useCommentActions } from '../../hooks/use-comment-actions'; @@ -18,6 +19,13 @@ import { useDiffStaleness } from '../../hooks/use-diff-staleness'; import { type ViewMode, getFilePath, getAutoCollapsedPaths } from '../../lib/diff-utils'; import { buildFirstOpenThreadByFile, buildThreadCountsByFile } from '../../lib/comment-navigation'; import { getHunkHeaders, scrollToElement } from '../../lib/dom-utils'; +import { + fingerprintFiles, + loadViewedFiles, + pickFingerprints, + reconcileViewed, + saveViewedFiles, +} from '../../lib/viewed-storage'; import { fetchGitHubDetails, type GitHubDetails } from '../../lib/api'; import type { LineSelection } from '../comments/types'; import { isThreadResolved } from '../comments/types'; @@ -33,6 +41,7 @@ export function DiffPage() { const [hideWhitespace, setHideWhitespace] = useState(false); const [showHelp, setShowHelp] = useState(false); const { theme, toggleTheme } = useTheme(initialTheme); + const { wrapLines, toggleWrapLines } = useWrapLines(); const { data: diff, error } = useDiff(hideWhitespace, refParam); const { data: info } = useInfo(refParam); const [activeFile, setActiveFile] = useState(null); @@ -79,16 +88,27 @@ export function DiffPage() { setPendingSelection(null); }, [commentActions]); + const repoRoot = info?.root ?? null; + const fileFingerprints = useMemo(() => (diff ? fingerprintFiles(diff.files) : {}), [diff]); + useEffect(() => { if (!diff || diff === initializedDiffRef.current) { return; } initializedDiffRef.current = diff; + const restoredViewed = repoRoot + ? reconcileViewed(loadViewedFiles(repoRoot, refParam), fileFingerprints) + : new Set(); + setReviewedFiles(restoredViewed); + const autoCollapsed = getAutoCollapsedPaths(diff.files); for (const path of filesWithComments) { autoCollapsed.delete(path); } + for (const path of restoredViewed) { + autoCollapsed.add(path); + } for (const path of manuallyToggledRef.current) { if (autoCollapsed.has(path)) { autoCollapsed.delete(path); @@ -97,7 +117,14 @@ export function DiffPage() { } } setCollapsedFiles(autoCollapsed); - }, [diff]); + }, [diff, fileFingerprints, repoRoot, refParam]); + + useEffect(() => { + if (!repoRoot || !initializedDiffRef.current) { + return; + } + saveViewedFiles(repoRoot, refParam, pickFingerprints(fileFingerprints, reviewedFiles)); + }, [reviewedFiles, fileFingerprints, repoRoot, refParam]); useEffect(() => { if (filesWithComments.size === 0) { @@ -107,6 +134,9 @@ export function DiffPage() { let changed = false; const next = new Set(prev); for (const path of filesWithComments) { + if (reviewedFiles.has(path)) { + continue; + } if (next.has(path)) { next.delete(path); changed = true; @@ -114,7 +144,7 @@ export function DiffPage() { } return changed ? next : prev; }); - }, [filesWithComments]); + }, [filesWithComments, reviewedFiles]); const handleToggleCollapse = useCallback((path: string) => { const toggled = manuallyToggledRef.current; @@ -334,6 +364,8 @@ export function DiffPage() { onHideWhitespaceChange={setHideWhitespace} theme={theme} onToggleTheme={toggleTheme} + wrapLines={wrapLines} + onToggleWrapLines={toggleWrapLines} onShowHelp={() => setShowHelp(true)} diff={diff || undefined} diffRef={refParam} diff --git a/packages/ui/src/components/diff/file-block.tsx b/packages/ui/src/components/diff/file-block.tsx index 8446ee9..4070d14 100644 --- a/packages/ui/src/components/diff/file-block.tsx +++ b/packages/ui/src/components/diff/file-block.tsx @@ -484,7 +484,8 @@ export function FileBlock(props: FileBlockProps) { onDeleteComment={deleteComment} onDeleteThread={deleteThread} /> - +
+
{viewMode === 'split' ? ( @@ -568,6 +569,7 @@ export function FileBlock(props: FileBlockProps) { ); })()}
+ )} diff --git a/packages/ui/src/components/diff/hunk-block-split.tsx b/packages/ui/src/components/diff/hunk-block-split.tsx index 54f7f20..1aa6f89 100644 --- a/packages/ui/src/components/diff/hunk-block-split.tsx +++ b/packages/ui/src/components/diff/hunk-block-split.tsx @@ -144,7 +144,7 @@ function SplitCell(props: { onCommentClick={onCommentClick} /> setContentHovered(true)} onMouseLeave={() => setContentHovered(false)} > diff --git a/packages/ui/src/components/icons/wrap-text-icon.tsx b/packages/ui/src/components/icons/wrap-text-icon.tsx new file mode 100644 index 0000000..dfa8162 --- /dev/null +++ b/packages/ui/src/components/icons/wrap-text-icon.tsx @@ -0,0 +1,18 @@ +export function WrapTextIcon(props: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/packages/ui/src/components/layout/options-menu.tsx b/packages/ui/src/components/layout/options-menu.tsx index c113496..72819e9 100644 --- a/packages/ui/src/components/layout/options-menu.tsx +++ b/packages/ui/src/components/layout/options-menu.tsx @@ -3,17 +3,20 @@ import { SunIcon } from '../icons/sun-icon'; import { MoonIcon } from '../icons/moon-icon'; import { EllipsisIcon } from '../icons/ellipsis-icon'; import { GitHubIcon } from '../icons/github-icon'; +import { WrapTextIcon } from '../icons/wrap-text-icon'; export const menuItemClass = 'flex items-center gap-2.5 w-full px-3 py-1.5 text-xs text-text-secondary hover:bg-hover hover:text-text transition-colors cursor-pointer text-left'; interface OptionsMenuProps { theme: 'light' | 'dark'; onToggleTheme: () => void; + wrapLines: boolean; + onToggleWrapLines: () => void; renderExtraItems?: (close: () => void) => ReactNode; } export function OptionsMenu(props: OptionsMenuProps) { - const { theme, onToggleTheme, renderExtraItems } = props; + const { theme, onToggleTheme, wrapLines, onToggleWrapLines, renderExtraItems } = props; const [showMenu, setShowMenu] = useState(false); const menuRef = useRef(null); @@ -44,6 +47,19 @@ export function OptionsMenu(props: OptionsMenuProps) { {showMenu && (
{renderExtraItems && renderExtraItems(close)} + +
+
+ + + {rows} + +
+
); } diff --git a/packages/ui/src/components/tree/tree-page.tsx b/packages/ui/src/components/tree/tree-page.tsx index 72ecebb..978eb81 100644 --- a/packages/ui/src/components/tree/tree-page.tsx +++ b/packages/ui/src/components/tree/tree-page.tsx @@ -18,6 +18,7 @@ import { tourOptions, } from '../../queries/tree'; import { useTheme } from '../../hooks/use-theme'; +import { useWrapLines } from '../../hooks/use-wrap-lines'; import { useReviewThreads } from '../../hooks/use-review-threads'; import { useCommentActions } from '../../hooks/use-comment-actions'; import { isThreadResolved, GENERAL_THREAD_FILE_PATH } from '../comments/types'; @@ -104,6 +105,7 @@ export function TreePage(props: TreePageProps) { const { theme, toggleTheme } = useTheme( initialTheme ?? loaderData?.theme ?? null, ); + const { wrapLines, toggleWrapLines } = useWrapLines(); const queryClient = useQueryClient(); const { isStale, resetStaleness } = useTreeStaleness(); @@ -459,7 +461,12 @@ export function TreePage(props: TreePageProps) { onDeleteAllComments={commentActions.deleteAllThreads} formatForCopy={formatForCopy} /> - + diff --git a/packages/ui/src/hooks/use-wrap-lines.ts b/packages/ui/src/hooks/use-wrap-lines.ts new file mode 100644 index 0000000..b988b0b --- /dev/null +++ b/packages/ui/src/hooks/use-wrap-lines.ts @@ -0,0 +1,34 @@ +import { useState, useLayoutEffect, useCallback } from 'react'; + +const STORAGE_KEY = 'diffity-wrap-lines'; + +function getStoredWrapLines(): boolean | null { + if (typeof window === 'undefined') { + return null; + } + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === null) { + return null; + } + return stored === 'true'; +} + +export function useWrapLines() { + const [wrapLines, setWrapLines] = useState( + () => getStoredWrapLines() ?? true + ); + + useLayoutEffect(() => { + document.documentElement.setAttribute('data-wrap-lines', String(wrapLines)); + }, [wrapLines]); + + const toggleWrapLines = useCallback(() => { + setWrapLines(prev => { + const next = !prev; + localStorage.setItem(STORAGE_KEY, String(next)); + return next; + }); + }, []); + + return { wrapLines, toggleWrapLines }; +} diff --git a/packages/ui/src/lib/viewed-storage.ts b/packages/ui/src/lib/viewed-storage.ts new file mode 100644 index 0000000..9de43c2 --- /dev/null +++ b/packages/ui/src/lib/viewed-storage.ts @@ -0,0 +1,180 @@ +import type { DiffFile } from '@diffity/parser'; +import { getFilePath } from './diff-utils'; + +const STORAGE_KEY = 'diffity-viewed'; +const STORAGE_VERSION = 1; +const MAX_ENTRIES = 10; + +/** Map of file path -> fingerprint of the file's diff at the time it was marked viewed. */ +export type ViewedFiles = Record; + +interface ViewedEntry { + updatedAt: number; + /** + * Monotonic write counter used to order entries for eviction. Date.now() is too coarse — + * several writes can land in the same millisecond and tie, which makes eviction arbitrary. + */ + seq: number; + files: ViewedFiles; +} + +interface ViewedStore { + version: number; + seq: number; + entries: Record; +} + +function emptyStore(): ViewedStore { + return { version: STORAGE_VERSION, seq: 0, entries: {} }; +} + +export function buildEntryKey(repoRoot: string, ref: string): string { + return `${repoRoot}|${ref}`; +} + +/** + * Fingerprints a file's diff so we can tell whether it changed since it was marked viewed. + * + * Two files with the same fingerprint are treated as "the same change", and a viewed mark + * survives. A different fingerprint drops the mark and the file expands again. + * + * Deliberately excluded: context lines, hunk headers, and line numbers, so an unrelated edit + * elsewhere in the file doesn't unmark a hunk you already reviewed. Content is trimmed so + * toggling the hide-whitespace filter doesn't churn every mark. + */ +export function fingerprintFile(file: DiffFile): string { + const parts: string[] = [file.status, file.oldPath, file.newPath]; + + for (const hunk of file.hunks) { + for (const line of hunk.lines) { + if (line.type === 'context') { + continue; + } + parts.push(`${line.type}:${line.content.trim()}`); + } + } + + return hashString(parts.join('\n')); +} + +/** FNV-1a. Non-cryptographic and synchronous, so restore happens in a single pass. */ +export function hashString(input: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + +export function fingerprintFiles(files: DiffFile[]): ViewedFiles { + const fingerprints: ViewedFiles = {}; + + for (const file of files) { + fingerprints[getFilePath(file)] = fingerprintFile(file); + } + + return fingerprints; +} + +/** Keeps a stored viewed mark only when the file's fingerprint still matches. */ +export function reconcileViewed( + stored: ViewedFiles, + current: ViewedFiles, +): Set { + const viewed = new Set(); + + for (const [path, fingerprint] of Object.entries(stored)) { + if (current[path] === fingerprint) { + viewed.add(path); + } + } + + return viewed; +} + +/** Narrows a full fingerprint map down to the paths that are currently marked viewed. */ +export function pickFingerprints( + fingerprints: ViewedFiles, + paths: Set, +): ViewedFiles { + const picked: ViewedFiles = {}; + + for (const path of paths) { + if (fingerprints[path] !== undefined) { + picked[path] = fingerprints[path]; + } + } + + return picked; +} + +function readStore(): ViewedStore { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return emptyStore(); + } + + const parsed = JSON.parse(raw) as ViewedStore; + if ( + parsed?.version !== STORAGE_VERSION || + typeof parsed.entries !== 'object' + ) { + return emptyStore(); + } + + return { ...parsed, seq: parsed.seq ?? 0 }; + } catch { + return emptyStore(); + } +} + +/** Drops the least recently used entries so the store can't grow without bound. */ +export function evictOldest( + entries: Record, + max = MAX_ENTRIES, +): Record { + const keys = Object.keys(entries); + if (keys.length <= max) { + return entries; + } + + const kept = keys + .sort((a, b) => entries[b].seq - entries[a].seq) + .slice(0, max); + + const next: Record = {}; + for (const key of kept) { + next[key] = entries[key]; + } + + return next; +} + +export function loadViewedFiles(repoRoot: string, ref: string): ViewedFiles { + return readStore().entries[buildEntryKey(repoRoot, ref)]?.files ?? {}; +} + +export function saveViewedFiles( + repoRoot: string, + ref: string, + files: ViewedFiles, +): void { + try { + const store = readStore(); + const key = buildEntryKey(repoRoot, ref); + + if (Object.keys(files).length === 0) { + delete store.entries[key]; + } else { + store.seq += 1; + store.entries[key] = { updatedAt: Date.now(), seq: store.seq, files }; + } + + store.entries = evictOldest(store.entries); + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); + } catch { + // Storage full, disabled, or unavailable — viewed state just won't persist. + } +} diff --git a/packages/ui/src/styles/app.css b/packages/ui/src/styles/app.css index 7edc322..aca252b 100644 --- a/packages/ui/src/styles/app.css +++ b/packages/ui/src/styles/app.css @@ -365,6 +365,36 @@ dialog { border-left: 1px solid var(--color-border); } +/* + * Code cells wrap by default (long lines stay readable without horizontal + * scrolling). `data-wrap-lines="false"` on opts out, letting the + * surrounding .code-scroll container scroll horizontally instead. + */ +.code-cell { + white-space: pre-wrap; + word-break: break-all; +} +:root[data-wrap-lines='false'] .code-cell { + white-space: pre; + word-break: normal; +} + +.code-scroll { + overflow-x: clip; +} +:root[data-wrap-lines='false'] .code-scroll { + overflow-x: auto; +} + +/* + * Fixed layout keeps gutter/pane widths stable while wrapping. With wrapping + * off, columns must grow to the longest line so .code-scroll has something to + * scroll (and so split panes don't overlap each other). + */ +:root[data-wrap-lines='false'] .code-table { + table-layout: auto; +} + .diff-empty-cell { background: repeating-linear-gradient( diff --git a/packages/ui/tests/viewed-storage.test.ts b/packages/ui/tests/viewed-storage.test.ts new file mode 100644 index 0000000..89a6162 --- /dev/null +++ b/packages/ui/tests/viewed-storage.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { DiffFile, DiffLine } from '@diffity/parser'; +import { + buildEntryKey, + evictOldest, + fingerprintFile, + fingerprintFiles, + hashString, + loadViewedFiles, + reconcileViewed, + saveViewedFiles, +} from '../src/lib/viewed-storage'; + +function makeLine(type: DiffLine['type'], content: string, oldLineNumber: number | null, newLineNumber: number | null): DiffLine { + return { type, content, oldLineNumber, newLineNumber }; +} + +function makeFile(path: string, lines: DiffLine[], status: DiffFile['status'] = 'modified'): DiffFile { + return { + oldPath: status === 'added' ? '' : path, + newPath: status === 'deleted' ? '' : path, + status, + additions: lines.filter(line => line.type === 'add').length, + deletions: lines.filter(line => line.type === 'delete').length, + isBinary: false, + hunks: [ + { + header: '@@ -1,3 +1,3 @@', + oldStart: 1, + oldCount: 3, + newStart: 1, + newCount: 3, + lines, + }, + ], + }; +} + +// The baseline file every fingerprint case is compared against. +function baseFile(path = 'src/app.ts'): DiffFile { + return makeFile(path, [ + makeLine('context', 'const a = 1;', 1, 1), + makeLine('delete', 'const b = 2;', 2, null), + makeLine('add', 'const b = 3;', null, 2), + makeLine('context', 'export { a, b };', 3, 3), + ]); +} + +describe('hashString', () => { + it('is stable for the same input', () => { + expect(hashString('hello')).toBe(hashString('hello')); + }); + + it('differs for different input', () => { + expect(hashString('hello')).not.toBe(hashString('world')); + }); + + it('handles the empty string', () => { + expect(typeof hashString('')).toBe('string'); + }); +}); + +describe('fingerprintFile', () => { + it('is stable across calls for an identical file', () => { + expect(fingerprintFile(baseFile())).toBe(fingerprintFile(baseFile())); + }); + + it('changes when a changed line is edited', () => { + const edited = makeFile('src/app.ts', [ + makeLine('context', 'const a = 1;', 1, 1), + makeLine('delete', 'const b = 2;', 2, null), + makeLine('add', 'const b = 4;', null, 2), + makeLine('context', 'export { a, b };', 3, 3), + ]); + + expect(fingerprintFile(edited)).not.toBe(fingerprintFile(baseFile())); + }); + + it('ignores indentation-only differences so the whitespace filter does not churn marks', () => { + const reindented = makeFile('src/app.ts', [ + makeLine('context', ' const a = 1;', 1, 1), + makeLine('delete', ' const b = 2;', 2, null), + makeLine('add', ' const b = 3;', null, 2), + makeLine('context', ' export { a, b };', 3, 3), + ]); + + expect(fingerprintFile(reindented)).toBe(fingerprintFile(baseFile())); + }); + + it('survives an unrelated edit elsewhere in the file shifting context and line numbers', () => { + const shifted = makeFile('src/app.ts', [ + makeLine('context', 'const zero = 0;', 40, 41), + makeLine('delete', 'const b = 2;', 41, null), + makeLine('add', 'const b = 3;', null, 42), + makeLine('context', 'const later = 9;', 42, 43), + ]); + + expect(fingerprintFile(shifted)).toBe(fingerprintFile(baseFile())); + }); + + it('changes when the file is renamed', () => { + const renamed = baseFile('src/renamed.ts'); + + expect(fingerprintFile(renamed)).not.toBe(fingerprintFile(baseFile())); + }); + + it('changes when the status changes', () => { + const deleted = makeFile('src/app.ts', baseFile().hunks[0].lines, 'deleted'); + + expect(fingerprintFile(deleted)).not.toBe(fingerprintFile(baseFile())); + }); +}); + +describe('fingerprintFiles', () => { + it('keys fingerprints by display path', () => { + const fingerprints = fingerprintFiles([baseFile('src/a.ts'), baseFile('src/b.ts')]); + + expect(Object.keys(fingerprints).sort()).toEqual(['src/a.ts', 'src/b.ts']); + }); +}); + +describe('reconcileViewed', () => { + it('keeps paths whose fingerprint is unchanged', () => { + const viewed = reconcileViewed({ 'src/a.ts': 'abc' }, { 'src/a.ts': 'abc' }); + + expect(viewed.has('src/a.ts')).toBe(true); + }); + + it('drops paths whose fingerprint changed', () => { + const viewed = reconcileViewed({ 'src/a.ts': 'abc' }, { 'src/a.ts': 'xyz' }); + + expect(viewed.has('src/a.ts')).toBe(false); + }); + + it('drops paths that are no longer in the diff', () => { + const viewed = reconcileViewed({ 'src/gone.ts': 'abc' }, { 'src/a.ts': 'abc' }); + + expect(viewed.has('src/gone.ts')).toBe(false); + }); + + it('keeps unchanged files while dropping the one that was edited', () => { + const viewed = reconcileViewed( + { 'src/a.ts': 'abc', 'src/b.ts': 'def' }, + { 'src/a.ts': 'abc', 'src/b.ts': 'CHANGED' }, + ); + + expect([...viewed]).toEqual(['src/a.ts']); + }); +}); + +describe('evictOldest', () => { + it('leaves the store alone when under the cap', () => { + const entries = { + a: { updatedAt: 1, seq: 1, files: {} }, + b: { updatedAt: 2, seq: 2, files: {} }, + }; + + expect(Object.keys(evictOldest(entries, 10))).toHaveLength(2); + }); + + it('drops the least recently used entries past the cap', () => { + const entries = { + oldest: { updatedAt: 1, seq: 1, files: {} }, + middle: { updatedAt: 2, seq: 2, files: {} }, + newest: { updatedAt: 3, seq: 3, files: {} }, + }; + + expect(Object.keys(evictOldest(entries, 2)).sort()).toEqual(['middle', 'newest']); + }); + + it('orders by write sequence, not wall clock, so same-millisecond writes still evict correctly', () => { + const entries = { + oldest: { updatedAt: 1000, seq: 1, files: {} }, + middle: { updatedAt: 1000, seq: 2, files: {} }, + newest: { updatedAt: 1000, seq: 3, files: {} }, + }; + + expect(Object.keys(evictOldest(entries, 2)).sort()).toEqual(['middle', 'newest']); + }); +}); + +describe('localStorage round-trip', () => { + beforeEach(() => { + const store = new Map(); + // vitest runs these in the node environment, so stand up a minimal localStorage. + (globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + clear: () => store.clear(), + }; + }); + + it('returns an empty map when nothing is stored', () => { + expect(loadViewedFiles('/repo', 'work')).toEqual({}); + }); + + it('round-trips viewed files for a repo and ref', () => { + saveViewedFiles('/repo', 'work', { 'src/a.ts': 'abc' }); + + expect(loadViewedFiles('/repo', 'work')).toEqual({ 'src/a.ts': 'abc' }); + }); + + it('keeps refs in the same repo isolated', () => { + saveViewedFiles('/repo', 'work', { 'src/a.ts': 'abc' }); + saveViewedFiles('/repo', 'main', { 'src/b.ts': 'def' }); + + expect(loadViewedFiles('/repo', 'work')).toEqual({ 'src/a.ts': 'abc' }); + expect(loadViewedFiles('/repo', 'main')).toEqual({ 'src/b.ts': 'def' }); + }); + + it('keeps repos isolated so a recycled port cannot leak state', () => { + saveViewedFiles('/repo-one', 'work', { 'src/a.ts': 'abc' }); + + expect(loadViewedFiles('/repo-two', 'work')).toEqual({}); + }); + + it('clears the entry when the last file is unmarked', () => { + saveViewedFiles('/repo', 'work', { 'src/a.ts': 'abc' }); + saveViewedFiles('/repo', 'work', {}); + + expect(loadViewedFiles('/repo', 'work')).toEqual({}); + }); + + it('evicts the oldest ref once more than ten are stored', () => { + for (let i = 0; i < 11; i++) { + saveViewedFiles('/repo', `ref-${i}`, { 'src/a.ts': `fp-${i}` }); + } + + expect(loadViewedFiles('/repo', 'ref-0')).toEqual({}); + expect(loadViewedFiles('/repo', 'ref-10')).toEqual({ 'src/a.ts': 'fp-10' }); + }); + + it('recovers from corrupt stored JSON instead of throwing', () => { + localStorage.setItem('diffity-viewed', '{not json'); + + expect(loadViewedFiles('/repo', 'work')).toEqual({}); + }); + + it('ignores a store written by a future version', () => { + localStorage.setItem('diffity-viewed', JSON.stringify({ + version: 99, + entries: { [buildEntryKey('/repo', 'work')]: { updatedAt: 1, files: { 'src/a.ts': 'abc' } } }, + })); + + expect(loadViewedFiles('/repo', 'work')).toEqual({}); + }); +});