md-redline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/md-redline +255 -0
- package/bin/test-windows.ps1 +70 -0
- package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
- package/dist/assets/arc-DI2g9LXK.js +1 -0
- package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
- package/dist/assets/array-DOVTz2Mq.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
- package/dist/assets/channel-DPCihw7y.js +1 -0
- package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
- package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
- package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
- package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
- package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
- package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
- package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
- package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
- package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
- package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
- package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
- package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
- package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
- package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
- package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
- package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
- package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
- package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
- package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
- package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
- package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
- package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
- package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
- package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
- package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
- package/dist/assets/clone-CI9aUwHe.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
- package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
- package/dist/assets/dagre-CilMRazv.js +1 -0
- package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
- package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
- package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
- package/dist/assets/dist-C_eddq6m.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
- package/dist/assets/flatten-B8XIuT0x.js +1 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
- package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
- package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
- package/dist/assets/index-BoggyWS0.css +2 -0
- package/dist/assets/index-aLvjHQW4.js +104 -0
- package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
- package/dist/assets/init-Bft5Ffpj.js +1 -0
- package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
- package/dist/assets/katex-Uj9wLT16.js +265 -0
- package/dist/assets/line-CRxEwpOv.js +1 -0
- package/dist/assets/linear-PDPfFByd.js +1 -0
- package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
- package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
- package/dist/assets/ordinal-DIg8h6NI.js +1 -0
- package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
- package/dist/assets/path-DfRbCp9y.js +1 -0
- package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
- package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
- package/dist/assets/reduce-CV2X8n1a.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
- package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
- package/dist/assets/src-PKe5NtkK.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
- package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
- package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/screenshot.png +0 -0
- package/index.html +13 -0
- package/package.json +105 -0
- package/public/favicon.svg +15 -0
- package/public/screenshot.png +0 -0
- package/server/index.test.ts +814 -0
- package/server/index.ts +736 -0
- package/server/preferences.test.ts +126 -0
- package/server/preferences.ts +76 -0
- package/src/App.tsx +1620 -0
- package/src/components/ActionButton.tsx +41 -0
- package/src/components/CommandPalette.tsx +191 -0
- package/src/components/CommentCard.tsx +556 -0
- package/src/components/CommentForm.tsx +285 -0
- package/src/components/CommentSidebar.tsx +428 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/ContextMenu.tsx +220 -0
- package/src/components/DragHandles.tsx +48 -0
- package/src/components/FileExplorer.tsx +251 -0
- package/src/components/FileOpener.tsx +304 -0
- package/src/components/IconButton.tsx +32 -0
- package/src/components/KeyboardShortcutsPanel.tsx +136 -0
- package/src/components/MarkdownViewer.tsx +682 -0
- package/src/components/RawView.tsx +798 -0
- package/src/components/SearchBar.tsx +129 -0
- package/src/components/Separator.tsx +7 -0
- package/src/components/SettingsPanel.tsx +813 -0
- package/src/components/SplitIconButton.tsx +133 -0
- package/src/components/TabBar.tsx +594 -0
- package/src/components/TableOfContents.tsx +70 -0
- package/src/components/ThemeSelector.tsx +159 -0
- package/src/components/Toast.tsx +99 -0
- package/src/components/Toolbar.tsx +161 -0
- package/src/components/iconButtonVariants.ts +19 -0
- package/src/components/rawView.test.ts +291 -0
- package/src/contexts/SettingsContext.tsx +120 -0
- package/src/hooks/useAuthor.test.ts +58 -0
- package/src/hooks/useAuthor.ts +69 -0
- package/src/hooks/useAutoResize.ts +20 -0
- package/src/hooks/useCommentCardTriggers.ts +20 -0
- package/src/hooks/useComments.test.ts +773 -0
- package/src/hooks/useComments.ts +332 -0
- package/src/hooks/useContextMenu.ts +48 -0
- package/src/hooks/useContextMenuItems.ts +392 -0
- package/src/hooks/useDiffSnapshot.test.ts +130 -0
- package/src/hooks/useDiffSnapshot.ts +67 -0
- package/src/hooks/useDragHandles.ts +417 -0
- package/src/hooks/useFileWatcher.ts +45 -0
- package/src/hooks/useHeadingTracking.ts +84 -0
- package/src/hooks/useMermaidRenderer.ts +75 -0
- package/src/hooks/useModalState.ts +22 -0
- package/src/hooks/usePageVisible.test.ts +69 -0
- package/src/hooks/usePageVisible.ts +19 -0
- package/src/hooks/usePaneLayout.test.ts +108 -0
- package/src/hooks/usePaneLayout.ts +102 -0
- package/src/hooks/useRecentFiles.test.ts +103 -0
- package/src/hooks/useRecentFiles.ts +99 -0
- package/src/hooks/useResizablePanel.test.ts +84 -0
- package/src/hooks/useResizablePanel.ts +118 -0
- package/src/hooks/useSearch.test.ts +72 -0
- package/src/hooks/useSearch.ts +53 -0
- package/src/hooks/useSelection.ts +48 -0
- package/src/hooks/useSessionPersistence.test.ts +59 -0
- package/src/hooks/useSessionPersistence.ts +43 -0
- package/src/hooks/useTabs.test.ts +127 -0
- package/src/hooks/useTabs.ts +561 -0
- package/src/hooks/useThemePersistence.ts +41 -0
- package/src/hooks/useToast.ts +27 -0
- package/src/index.css +1047 -0
- package/src/lib/agent-prompts.test.ts +34 -0
- package/src/lib/agent-prompts.ts +68 -0
- package/src/lib/comment-editor-state.ts +6 -0
- package/src/lib/comment-parser.test.ts +1959 -0
- package/src/lib/comment-parser.ts +1021 -0
- package/src/lib/diff.test.ts +164 -0
- package/src/lib/diff.ts +139 -0
- package/src/lib/heading-slugs.test.ts +85 -0
- package/src/lib/heading-slugs.ts +44 -0
- package/src/lib/http.test.ts +43 -0
- package/src/lib/http.ts +29 -0
- package/src/lib/mermaid-highlights.test.ts +517 -0
- package/src/lib/mermaid-highlights.ts +936 -0
- package/src/lib/mermaid-renderer.test.ts +114 -0
- package/src/lib/mermaid-renderer.ts +89 -0
- package/src/lib/path-utils.test.ts +17 -0
- package/src/lib/path-utils.ts +7 -0
- package/src/lib/platform.test.ts +58 -0
- package/src/lib/platform.ts +14 -0
- package/src/lib/preferences-client.test.ts +177 -0
- package/src/lib/preferences-client.ts +94 -0
- package/src/lib/selection-resolver.test.ts +118 -0
- package/src/lib/selection-resolver.ts +37 -0
- package/src/lib/settings.test.ts +152 -0
- package/src/lib/settings.ts +78 -0
- package/src/lib/shortcut-label.tsx +18 -0
- package/src/lib/themes.ts +21 -0
- package/src/lib/visible-text.test.ts +86 -0
- package/src/lib/visible-text.ts +77 -0
- package/src/main.tsx +22 -0
- package/src/markdown/pipeline.test.ts +82 -0
- package/src/markdown/pipeline.ts +33 -0
- package/src/types.test.ts +43 -0
- package/src/types.ts +46 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +50 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import { renderHook, act } from '@testing-library/react';
|
|
5
|
+
import { useSearch } from './useSearch';
|
|
6
|
+
|
|
7
|
+
describe('useSearch', () => {
|
|
8
|
+
it('handleSearchCount clamps activeSearchIndex when count decreases', () => {
|
|
9
|
+
const onClose = vi.fn();
|
|
10
|
+
const { result } = renderHook(() => useSearch(onClose));
|
|
11
|
+
|
|
12
|
+
// Set count to 5, then navigate to index 4
|
|
13
|
+
act(() => result.current.handleSearchCount(5));
|
|
14
|
+
act(() => result.current.handleSearchNext()); // 1
|
|
15
|
+
act(() => result.current.handleSearchNext()); // 2
|
|
16
|
+
act(() => result.current.handleSearchNext()); // 3
|
|
17
|
+
act(() => result.current.handleSearchNext()); // 4
|
|
18
|
+
expect(result.current.activeSearchIndex).toBe(4);
|
|
19
|
+
|
|
20
|
+
// Now reduce count to 3 — index should clamp to 2 (count - 1)
|
|
21
|
+
act(() => result.current.handleSearchCount(3));
|
|
22
|
+
expect(result.current.activeSearchIndex).toBe(2);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('handleSearchCount resets index to 0 when count becomes 0', () => {
|
|
26
|
+
const onClose = vi.fn();
|
|
27
|
+
const { result } = renderHook(() => useSearch(onClose));
|
|
28
|
+
|
|
29
|
+
act(() => result.current.handleSearchCount(5));
|
|
30
|
+
act(() => result.current.handleSearchNext()); // 1
|
|
31
|
+
expect(result.current.activeSearchIndex).toBe(1);
|
|
32
|
+
|
|
33
|
+
act(() => result.current.handleSearchCount(0));
|
|
34
|
+
expect(result.current.activeSearchIndex).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('handleSearchNext wraps from last to first', () => {
|
|
38
|
+
const onClose = vi.fn();
|
|
39
|
+
const { result } = renderHook(() => useSearch(onClose));
|
|
40
|
+
|
|
41
|
+
act(() => result.current.handleSearchCount(3));
|
|
42
|
+
act(() => result.current.handleSearchNext()); // 1
|
|
43
|
+
act(() => result.current.handleSearchNext()); // 2
|
|
44
|
+
act(() => result.current.handleSearchNext()); // wraps to 0
|
|
45
|
+
expect(result.current.activeSearchIndex).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('handleSearchPrev wraps from first to last', () => {
|
|
49
|
+
const onClose = vi.fn();
|
|
50
|
+
const { result } = renderHook(() => useSearch(onClose));
|
|
51
|
+
|
|
52
|
+
act(() => result.current.handleSearchCount(3));
|
|
53
|
+
expect(result.current.activeSearchIndex).toBe(0);
|
|
54
|
+
|
|
55
|
+
act(() => result.current.handleSearchPrev()); // wraps to 2
|
|
56
|
+
expect(result.current.activeSearchIndex).toBe(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handleSearchQueryChange resets index to 0', () => {
|
|
60
|
+
const onClose = vi.fn();
|
|
61
|
+
const { result } = renderHook(() => useSearch(onClose));
|
|
62
|
+
|
|
63
|
+
act(() => result.current.handleSearchCount(5));
|
|
64
|
+
act(() => result.current.handleSearchNext()); // 1
|
|
65
|
+
act(() => result.current.handleSearchNext()); // 2
|
|
66
|
+
expect(result.current.activeSearchIndex).toBe(2);
|
|
67
|
+
|
|
68
|
+
act(() => result.current.handleSearchQueryChange('new query'));
|
|
69
|
+
expect(result.current.activeSearchIndex).toBe(0);
|
|
70
|
+
expect(result.current.searchQuery).toBe('new query');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useSearch(onClose: () => void) {
|
|
4
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
5
|
+
const [activeSearchIndex, setActiveSearchIndex] = useState(0);
|
|
6
|
+
const [searchMatchCount, setSearchMatchCount] = useState(0);
|
|
7
|
+
const [searchFocusTrigger, setSearchFocusTrigger] = useState(0);
|
|
8
|
+
|
|
9
|
+
const searchMatchCountRef = useRef(searchMatchCount);
|
|
10
|
+
searchMatchCountRef.current = searchMatchCount;
|
|
11
|
+
|
|
12
|
+
const handleSearchCount = useCallback((count: number) => {
|
|
13
|
+
setSearchMatchCount(count);
|
|
14
|
+
setActiveSearchIndex((prev) => (count === 0 ? 0 : Math.min(prev, count - 1)));
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
const handleSearchNext = useCallback(() => {
|
|
18
|
+
setActiveSearchIndex((prev) =>
|
|
19
|
+
prev < searchMatchCountRef.current - 1 ? prev + 1 : 0,
|
|
20
|
+
);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
const handleSearchPrev = useCallback(() => {
|
|
24
|
+
setActiveSearchIndex((prev) =>
|
|
25
|
+
prev > 0 ? prev - 1 : Math.max(0, searchMatchCountRef.current - 1),
|
|
26
|
+
);
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const handleSearchClose = useCallback(() => {
|
|
30
|
+
onClose();
|
|
31
|
+
setSearchQuery('');
|
|
32
|
+
setActiveSearchIndex(0);
|
|
33
|
+
setSearchMatchCount(0);
|
|
34
|
+
}, [onClose]);
|
|
35
|
+
|
|
36
|
+
const handleSearchQueryChange = useCallback((query: string) => {
|
|
37
|
+
setSearchQuery(query);
|
|
38
|
+
setActiveSearchIndex(0);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
searchQuery,
|
|
43
|
+
activeSearchIndex,
|
|
44
|
+
searchMatchCount,
|
|
45
|
+
searchFocusTrigger,
|
|
46
|
+
setSearchFocusTrigger,
|
|
47
|
+
handleSearchCount,
|
|
48
|
+
handleSearchNext,
|
|
49
|
+
handleSearchPrev,
|
|
50
|
+
handleSearchClose,
|
|
51
|
+
handleSearchQueryChange,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { resolveSelection } from '../lib/selection-resolver';
|
|
3
|
+
import type { SelectionInfo } from '../types';
|
|
4
|
+
|
|
5
|
+
export function useSelection(containerRef: React.RefObject<HTMLElement | null>) {
|
|
6
|
+
const [selection, setSelection] = useState<SelectionInfo | null>(null);
|
|
7
|
+
const lockedRef = useRef(false);
|
|
8
|
+
|
|
9
|
+
const lockSelection = useCallback(() => {
|
|
10
|
+
lockedRef.current = true;
|
|
11
|
+
}, []);
|
|
12
|
+
|
|
13
|
+
const clearSelection = useCallback(() => {
|
|
14
|
+
lockedRef.current = false;
|
|
15
|
+
setSelection(null);
|
|
16
|
+
window.getSelection()?.removeAllRanges();
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const handleMouseUp = (e: MouseEvent) => {
|
|
21
|
+
if (lockedRef.current) return;
|
|
22
|
+
if ((e.target as Element)?.closest?.('[data-comment-form]')) return;
|
|
23
|
+
if ((e.target as Element)?.closest?.('[data-drag-handle]')) return;
|
|
24
|
+
if (document.body.classList.contains('anchor-dragging')) return;
|
|
25
|
+
if (!containerRef.current) return;
|
|
26
|
+
|
|
27
|
+
const info = resolveSelection(containerRef.current);
|
|
28
|
+
setSelection(info);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const handleKeyUp = (e: KeyboardEvent) => {
|
|
32
|
+
if (e.key === 'Escape') {
|
|
33
|
+
lockedRef.current = false;
|
|
34
|
+
setSelection(null);
|
|
35
|
+
window.getSelection()?.removeAllRanges();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
40
|
+
document.addEventListener('keyup', handleKeyUp);
|
|
41
|
+
return () => {
|
|
42
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
43
|
+
document.removeEventListener('keyup', handleKeyUp);
|
|
44
|
+
};
|
|
45
|
+
}, [containerRef]);
|
|
46
|
+
|
|
47
|
+
return { selection, clearSelection, lockSelection };
|
|
48
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { loadSession } from './useSessionPersistence';
|
|
3
|
+
|
|
4
|
+
const store: Record<string, string> = {};
|
|
5
|
+
const localStorageMock = {
|
|
6
|
+
getItem: vi.fn((key: string) => store[key] ?? null),
|
|
7
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
8
|
+
store[key] = value;
|
|
9
|
+
}),
|
|
10
|
+
removeItem: vi.fn((key: string) => {
|
|
11
|
+
delete store[key];
|
|
12
|
+
}),
|
|
13
|
+
clear: vi.fn(() => {
|
|
14
|
+
for (const key in store) delete store[key];
|
|
15
|
+
}),
|
|
16
|
+
get length() {
|
|
17
|
+
return Object.keys(store).length;
|
|
18
|
+
},
|
|
19
|
+
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
localStorageMock.clear();
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('loadSession', () => {
|
|
29
|
+
it('returns null when localStorage is empty', () => {
|
|
30
|
+
expect(loadSession()).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns null for invalid JSON', () => {
|
|
34
|
+
store['md-redline-session'] = 'not-json!!!';
|
|
35
|
+
expect(loadSession()).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns null when openTabs is not an array', () => {
|
|
39
|
+
store['md-redline-session'] = JSON.stringify({ openTabs: 'not-array' });
|
|
40
|
+
expect(loadSession()).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns null when openTabs is missing', () => {
|
|
44
|
+
store['md-redline-session'] = JSON.stringify({ activeFilePath: '/test.md' });
|
|
45
|
+
expect(loadSession()).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns parsed session when valid', () => {
|
|
49
|
+
const session = { openTabs: ['/a.md', '/b.md'], activeFilePath: '/a.md' };
|
|
50
|
+
store['md-redline-session'] = JSON.stringify(session);
|
|
51
|
+
expect(loadSession()).toEqual(session);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns session with empty openTabs array', () => {
|
|
55
|
+
const session = { openTabs: [], activeFilePath: null };
|
|
56
|
+
store['md-redline-session'] = JSON.stringify(session);
|
|
57
|
+
expect(loadSession()).toEqual(session);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SessionState {
|
|
4
|
+
openTabs: string[];
|
|
5
|
+
activeFilePath: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'md-redline-session';
|
|
9
|
+
const DEBOUNCE_MS = 500;
|
|
10
|
+
|
|
11
|
+
export function loadSession(): SessionState | null {
|
|
12
|
+
try {
|
|
13
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
14
|
+
if (!raw) return null;
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
// Basic shape validation
|
|
17
|
+
if (!Array.isArray(parsed.openTabs)) return null;
|
|
18
|
+
return parsed as SessionState;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useSessionPersistence() {
|
|
25
|
+
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
26
|
+
|
|
27
|
+
const persist = useCallback((state: SessionState) => {
|
|
28
|
+
clearTimeout(timerRef.current);
|
|
29
|
+
timerRef.current = setTimeout(() => {
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
32
|
+
} catch {
|
|
33
|
+
// Storage full or unavailable — ignore
|
|
34
|
+
}
|
|
35
|
+
}, DEBOUNCE_MS);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
return () => clearTimeout(timerRef.current);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
return { persist };
|
|
43
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { applyLoadedTabState, applyPendingTabState, type TabState } from './useTabs';
|
|
3
|
+
|
|
4
|
+
function makeTab(path: string, overrides: Partial<TabState> = {}): TabState {
|
|
5
|
+
return {
|
|
6
|
+
filePath: path,
|
|
7
|
+
rawMarkdown: '',
|
|
8
|
+
isLoading: true,
|
|
9
|
+
error: null,
|
|
10
|
+
lastSaved: null,
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('applyLoadedTabState', () => {
|
|
16
|
+
it('migrates a requested tab to the canonical loaded path', () => {
|
|
17
|
+
const requestedPath = '/tmp/link.md';
|
|
18
|
+
const loadedPath = '/tmp/real.md';
|
|
19
|
+
const prevData = new Map([[requestedPath, makeTab(requestedPath)]]);
|
|
20
|
+
const result = applyLoadedTabState(
|
|
21
|
+
prevData,
|
|
22
|
+
[requestedPath],
|
|
23
|
+
requestedPath,
|
|
24
|
+
requestedPath,
|
|
25
|
+
loadedPath,
|
|
26
|
+
'# Loaded\n',
|
|
27
|
+
new Date('2026-03-30T00:00:00.000Z'),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(result.tabOrder).toEqual([loadedPath]);
|
|
31
|
+
expect(result.activeFilePath).toBe(loadedPath);
|
|
32
|
+
expect(result.tabData.has(requestedPath)).toBe(false);
|
|
33
|
+
expect(result.tabData.get(loadedPath)).toMatchObject({
|
|
34
|
+
filePath: loadedPath,
|
|
35
|
+
rawMarkdown: '# Loaded\n',
|
|
36
|
+
isLoading: false,
|
|
37
|
+
error: null,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('deduplicates when the canonical path is already open', () => {
|
|
42
|
+
const requestedPath = '/tmp/link.md';
|
|
43
|
+
const loadedPath = '/tmp/real.md';
|
|
44
|
+
const prevData = new Map<string, TabState>([
|
|
45
|
+
[loadedPath, makeTab(loadedPath, { rawMarkdown: '# Existing\n', isLoading: false })],
|
|
46
|
+
[requestedPath, makeTab(requestedPath)],
|
|
47
|
+
]);
|
|
48
|
+
const result = applyLoadedTabState(
|
|
49
|
+
prevData,
|
|
50
|
+
[loadedPath, requestedPath],
|
|
51
|
+
requestedPath,
|
|
52
|
+
requestedPath,
|
|
53
|
+
loadedPath,
|
|
54
|
+
'# Updated\n',
|
|
55
|
+
new Date('2026-03-30T00:00:00.000Z'),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(result.tabOrder).toEqual([loadedPath]);
|
|
59
|
+
expect(result.activeFilePath).toBe(loadedPath);
|
|
60
|
+
expect(result.tabData.size).toBe(1);
|
|
61
|
+
expect(result.tabData.get(loadedPath)?.rawMarkdown).toBe('# Updated\n');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('applyPendingTabState', () => {
|
|
66
|
+
it('preserves session-restore order and active tab for a fast load response', () => {
|
|
67
|
+
const firstPath = '/tmp/first.md';
|
|
68
|
+
const secondPath = '/tmp/second.md';
|
|
69
|
+
const withBackgroundTab = applyPendingTabState(new Map(), [], null, firstPath, false);
|
|
70
|
+
const withActiveTab = applyPendingTabState(
|
|
71
|
+
withBackgroundTab.tabData,
|
|
72
|
+
withBackgroundTab.tabOrder,
|
|
73
|
+
withBackgroundTab.activeFilePath,
|
|
74
|
+
secondPath,
|
|
75
|
+
true,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const loaded = applyLoadedTabState(
|
|
79
|
+
withActiveTab.tabData,
|
|
80
|
+
withActiveTab.tabOrder,
|
|
81
|
+
withActiveTab.activeFilePath,
|
|
82
|
+
secondPath,
|
|
83
|
+
secondPath,
|
|
84
|
+
'# Second\n',
|
|
85
|
+
new Date('2026-03-30T00:00:00.000Z'),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(loaded.tabOrder).toEqual([firstPath, secondPath]);
|
|
89
|
+
expect(loaded.activeFilePath).toBe(secondPath);
|
|
90
|
+
expect(loaded.tabData.get(secondPath)).toMatchObject({
|
|
91
|
+
filePath: secondPath,
|
|
92
|
+
rawMarkdown: '# Second\n',
|
|
93
|
+
isLoading: false,
|
|
94
|
+
error: null,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('TabState dirty flag', () => {
|
|
100
|
+
it('defaults to undefined (not dirty) on new tabs', () => {
|
|
101
|
+
const path = '/tmp/test.md';
|
|
102
|
+
const result = applyPendingTabState(new Map(), [], null, path, true);
|
|
103
|
+
expect(result.tabData.get(path)?.dirty).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('is preserved through applyLoadedTabState', () => {
|
|
107
|
+
const path = '/tmp/test.md';
|
|
108
|
+
const prevData = new Map([
|
|
109
|
+
[path, makeTab(path, { dirty: true })],
|
|
110
|
+
]);
|
|
111
|
+
const result = applyLoadedTabState(
|
|
112
|
+
prevData, [path], path, path, path, '# content', new Date(),
|
|
113
|
+
);
|
|
114
|
+
// applyLoadedTabState creates a new TabState without dirty, so it should be undefined
|
|
115
|
+
expect(result.tabData.get(path)?.dirty).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('can be cleared by spreading dirty:false over a dirty tab', () => {
|
|
119
|
+
// This pattern is used by onExternalChange: external SSE content should
|
|
120
|
+
// NOT mark the tab dirty since the content already matches disk.
|
|
121
|
+
const path = '/tmp/test.md';
|
|
122
|
+
const tab = makeTab(path, { dirty: true, rawMarkdown: '# old' });
|
|
123
|
+
const updated = { ...tab, rawMarkdown: '# new', dirty: false };
|
|
124
|
+
expect(updated.dirty).toBe(false);
|
|
125
|
+
expect(updated.rawMarkdown).toBe('# new');
|
|
126
|
+
});
|
|
127
|
+
});
|