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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/md-redline +255 -0
  4. package/bin/test-windows.ps1 +70 -0
  5. package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
  6. package/dist/assets/arc-DI2g9LXK.js +1 -0
  7. package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
  8. package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
  9. package/dist/assets/array-DOVTz2Mq.js +1 -0
  10. package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
  11. package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
  12. package/dist/assets/channel-DPCihw7y.js +1 -0
  13. package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
  14. package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
  15. package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
  16. package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
  17. package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
  18. package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
  19. package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
  20. package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
  21. package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
  22. package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
  23. package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
  24. package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
  25. package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
  26. package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
  27. package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
  28. package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
  29. package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
  30. package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
  31. package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
  32. package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
  33. package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
  34. package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
  35. package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
  36. package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
  37. package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
  38. package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
  39. package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
  40. package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
  41. package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
  42. package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
  43. package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
  44. package/dist/assets/clone-CI9aUwHe.js +1 -0
  45. package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
  46. package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
  47. package/dist/assets/dagre-CilMRazv.js +1 -0
  48. package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
  49. package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
  50. package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
  51. package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
  52. package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
  53. package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
  54. package/dist/assets/dist-C_eddq6m.js +1 -0
  55. package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
  56. package/dist/assets/flatten-B8XIuT0x.js +1 -0
  57. package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
  58. package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
  59. package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
  60. package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
  61. package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
  62. package/dist/assets/index-BoggyWS0.css +2 -0
  63. package/dist/assets/index-aLvjHQW4.js +104 -0
  64. package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
  65. package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
  66. package/dist/assets/init-Bft5Ffpj.js +1 -0
  67. package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
  68. package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
  69. package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
  70. package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
  71. package/dist/assets/katex-Uj9wLT16.js +265 -0
  72. package/dist/assets/line-CRxEwpOv.js +1 -0
  73. package/dist/assets/linear-PDPfFByd.js +1 -0
  74. package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
  75. package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
  76. package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
  77. package/dist/assets/ordinal-DIg8h6NI.js +1 -0
  78. package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
  79. package/dist/assets/path-DfRbCp9y.js +1 -0
  80. package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
  81. package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
  82. package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
  83. package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
  84. package/dist/assets/reduce-CV2X8n1a.js +1 -0
  85. package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
  86. package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
  87. package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
  88. package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
  89. package/dist/assets/src-PKe5NtkK.js +1 -0
  90. package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
  91. package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
  92. package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
  93. package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
  94. package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
  95. package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
  96. package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
  97. package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
  98. package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
  99. package/dist/favicon.svg +15 -0
  100. package/dist/index.html +14 -0
  101. package/dist/screenshot.png +0 -0
  102. package/index.html +13 -0
  103. package/package.json +105 -0
  104. package/public/favicon.svg +15 -0
  105. package/public/screenshot.png +0 -0
  106. package/server/index.test.ts +814 -0
  107. package/server/index.ts +736 -0
  108. package/server/preferences.test.ts +126 -0
  109. package/server/preferences.ts +76 -0
  110. package/src/App.tsx +1620 -0
  111. package/src/components/ActionButton.tsx +41 -0
  112. package/src/components/CommandPalette.tsx +191 -0
  113. package/src/components/CommentCard.tsx +556 -0
  114. package/src/components/CommentForm.tsx +285 -0
  115. package/src/components/CommentSidebar.tsx +428 -0
  116. package/src/components/ConfirmDialog.tsx +64 -0
  117. package/src/components/ContextMenu.tsx +220 -0
  118. package/src/components/DragHandles.tsx +48 -0
  119. package/src/components/FileExplorer.tsx +251 -0
  120. package/src/components/FileOpener.tsx +304 -0
  121. package/src/components/IconButton.tsx +32 -0
  122. package/src/components/KeyboardShortcutsPanel.tsx +136 -0
  123. package/src/components/MarkdownViewer.tsx +682 -0
  124. package/src/components/RawView.tsx +798 -0
  125. package/src/components/SearchBar.tsx +129 -0
  126. package/src/components/Separator.tsx +7 -0
  127. package/src/components/SettingsPanel.tsx +813 -0
  128. package/src/components/SplitIconButton.tsx +133 -0
  129. package/src/components/TabBar.tsx +594 -0
  130. package/src/components/TableOfContents.tsx +70 -0
  131. package/src/components/ThemeSelector.tsx +159 -0
  132. package/src/components/Toast.tsx +99 -0
  133. package/src/components/Toolbar.tsx +161 -0
  134. package/src/components/iconButtonVariants.ts +19 -0
  135. package/src/components/rawView.test.ts +291 -0
  136. package/src/contexts/SettingsContext.tsx +120 -0
  137. package/src/hooks/useAuthor.test.ts +58 -0
  138. package/src/hooks/useAuthor.ts +69 -0
  139. package/src/hooks/useAutoResize.ts +20 -0
  140. package/src/hooks/useCommentCardTriggers.ts +20 -0
  141. package/src/hooks/useComments.test.ts +773 -0
  142. package/src/hooks/useComments.ts +332 -0
  143. package/src/hooks/useContextMenu.ts +48 -0
  144. package/src/hooks/useContextMenuItems.ts +392 -0
  145. package/src/hooks/useDiffSnapshot.test.ts +130 -0
  146. package/src/hooks/useDiffSnapshot.ts +67 -0
  147. package/src/hooks/useDragHandles.ts +417 -0
  148. package/src/hooks/useFileWatcher.ts +45 -0
  149. package/src/hooks/useHeadingTracking.ts +84 -0
  150. package/src/hooks/useMermaidRenderer.ts +75 -0
  151. package/src/hooks/useModalState.ts +22 -0
  152. package/src/hooks/usePageVisible.test.ts +69 -0
  153. package/src/hooks/usePageVisible.ts +19 -0
  154. package/src/hooks/usePaneLayout.test.ts +108 -0
  155. package/src/hooks/usePaneLayout.ts +102 -0
  156. package/src/hooks/useRecentFiles.test.ts +103 -0
  157. package/src/hooks/useRecentFiles.ts +99 -0
  158. package/src/hooks/useResizablePanel.test.ts +84 -0
  159. package/src/hooks/useResizablePanel.ts +118 -0
  160. package/src/hooks/useSearch.test.ts +72 -0
  161. package/src/hooks/useSearch.ts +53 -0
  162. package/src/hooks/useSelection.ts +48 -0
  163. package/src/hooks/useSessionPersistence.test.ts +59 -0
  164. package/src/hooks/useSessionPersistence.ts +43 -0
  165. package/src/hooks/useTabs.test.ts +127 -0
  166. package/src/hooks/useTabs.ts +561 -0
  167. package/src/hooks/useThemePersistence.ts +41 -0
  168. package/src/hooks/useToast.ts +27 -0
  169. package/src/index.css +1047 -0
  170. package/src/lib/agent-prompts.test.ts +34 -0
  171. package/src/lib/agent-prompts.ts +68 -0
  172. package/src/lib/comment-editor-state.ts +6 -0
  173. package/src/lib/comment-parser.test.ts +1959 -0
  174. package/src/lib/comment-parser.ts +1021 -0
  175. package/src/lib/diff.test.ts +164 -0
  176. package/src/lib/diff.ts +139 -0
  177. package/src/lib/heading-slugs.test.ts +85 -0
  178. package/src/lib/heading-slugs.ts +44 -0
  179. package/src/lib/http.test.ts +43 -0
  180. package/src/lib/http.ts +29 -0
  181. package/src/lib/mermaid-highlights.test.ts +517 -0
  182. package/src/lib/mermaid-highlights.ts +936 -0
  183. package/src/lib/mermaid-renderer.test.ts +114 -0
  184. package/src/lib/mermaid-renderer.ts +89 -0
  185. package/src/lib/path-utils.test.ts +17 -0
  186. package/src/lib/path-utils.ts +7 -0
  187. package/src/lib/platform.test.ts +58 -0
  188. package/src/lib/platform.ts +14 -0
  189. package/src/lib/preferences-client.test.ts +177 -0
  190. package/src/lib/preferences-client.ts +94 -0
  191. package/src/lib/selection-resolver.test.ts +118 -0
  192. package/src/lib/selection-resolver.ts +37 -0
  193. package/src/lib/settings.test.ts +152 -0
  194. package/src/lib/settings.ts +78 -0
  195. package/src/lib/shortcut-label.tsx +18 -0
  196. package/src/lib/themes.ts +21 -0
  197. package/src/lib/visible-text.test.ts +86 -0
  198. package/src/lib/visible-text.ts +77 -0
  199. package/src/main.tsx +22 -0
  200. package/src/markdown/pipeline.test.ts +82 -0
  201. package/src/markdown/pipeline.ts +33 -0
  202. package/src/types.test.ts +43 -0
  203. package/src/types.ts +46 -0
  204. package/tsconfig.app.json +28 -0
  205. package/tsconfig.json +7 -0
  206. package/tsconfig.node.json +26 -0
  207. package/vite.config.ts +50 -0
@@ -0,0 +1,69 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import { createElement, act } from 'react';
4
+ import { createRoot, type Root } from 'react-dom/client';
5
+
6
+ import { usePageVisible } from './usePageVisible';
7
+
8
+ let root: Root;
9
+ let container: HTMLDivElement;
10
+
11
+ function TestComponent() {
12
+ return createElement('div', undefined, usePageVisible() ? 'visible' : 'hidden');
13
+ }
14
+
15
+ function setVisibility(state: DocumentVisibilityState) {
16
+ Object.defineProperty(document, 'visibilityState', {
17
+ value: state,
18
+ writable: true,
19
+ configurable: true,
20
+ });
21
+ act(() => {
22
+ document.dispatchEvent(new Event('visibilitychange'));
23
+ });
24
+ }
25
+
26
+ beforeEach(() => {
27
+ Object.defineProperty(document, 'visibilityState', {
28
+ value: 'visible',
29
+ writable: true,
30
+ configurable: true,
31
+ });
32
+ container = document.createElement('div');
33
+ document.body.appendChild(container);
34
+ root = createRoot(container);
35
+ });
36
+
37
+ afterEach(() => {
38
+ root?.unmount();
39
+ container?.remove();
40
+ });
41
+
42
+ describe('usePageVisible', () => {
43
+ it('returns true when the page is visible', () => {
44
+ act(() => root.render(createElement(TestComponent)));
45
+ expect(container.textContent).toBe('visible');
46
+ });
47
+
48
+ it('returns false after the page becomes hidden', () => {
49
+ act(() => root.render(createElement(TestComponent)));
50
+ setVisibility('hidden');
51
+ expect(container.textContent).toBe('hidden');
52
+ });
53
+
54
+ it('returns true again when the page becomes visible', () => {
55
+ act(() => root.render(createElement(TestComponent)));
56
+ setVisibility('hidden');
57
+ expect(container.textContent).toBe('hidden');
58
+ setVisibility('visible');
59
+ expect(container.textContent).toBe('visible');
60
+ });
61
+
62
+ it('cleans up the event listener on unmount', () => {
63
+ act(() => root.render(createElement(TestComponent)));
64
+ const spy = vi.spyOn(document, 'removeEventListener');
65
+ act(() => root.unmount());
66
+ expect(spy).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
67
+ spy.mockRestore();
68
+ });
69
+ });
@@ -0,0 +1,19 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ /**
4
+ * Returns true when the browser tab is visible.
5
+ * SSE connections should be gated on this to avoid exhausting
6
+ * the browser's per-origin HTTP/1.1 connection limit (6) when
7
+ * multiple browser tabs point at the same server.
8
+ */
9
+ export function usePageVisible(): boolean {
10
+ const [visible, setVisible] = useState(() => document.visibilityState === 'visible');
11
+
12
+ useEffect(() => {
13
+ const handler = () => setVisible(document.visibilityState === 'visible');
14
+ document.addEventListener('visibilitychange', handler);
15
+ return () => document.removeEventListener('visibilitychange', handler);
16
+ }, []);
17
+
18
+ return visible;
19
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { load, save } from './usePaneLayout';
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
+ const DEFAULTS = {
24
+ explorerVisible: true,
25
+ sidebarVisible: true,
26
+ leftPanelView: 'explorer' as const,
27
+ viewMode: 'rendered' as const,
28
+ };
29
+
30
+ beforeEach(() => {
31
+ localStorageMock.clear();
32
+ vi.clearAllMocks();
33
+ });
34
+
35
+ describe('load', () => {
36
+ it('returns defaults when localStorage is empty', () => {
37
+ expect(load()).toEqual(DEFAULTS);
38
+ });
39
+
40
+ it('returns defaults on invalid JSON', () => {
41
+ store['md-redline-pane-layout'] = 'not-json!!!';
42
+ expect(load()).toEqual(DEFAULTS);
43
+ });
44
+
45
+ it('preserves valid boolean explorerVisible', () => {
46
+ store['md-redline-pane-layout'] = JSON.stringify({ explorerVisible: false });
47
+ expect(load().explorerVisible).toBe(false);
48
+ });
49
+
50
+ it('falls back to default for non-boolean explorerVisible', () => {
51
+ store['md-redline-pane-layout'] = JSON.stringify({ explorerVisible: 'yes' });
52
+ expect(load().explorerVisible).toBe(true);
53
+ });
54
+
55
+ it('preserves valid boolean sidebarVisible', () => {
56
+ store['md-redline-pane-layout'] = JSON.stringify({ sidebarVisible: false });
57
+ expect(load().sidebarVisible).toBe(false);
58
+ });
59
+
60
+ it('only accepts "outline" or "explorer" for leftPanelView', () => {
61
+ store['md-redline-pane-layout'] = JSON.stringify({ leftPanelView: 'outline' });
62
+ expect(load().leftPanelView).toBe('outline');
63
+
64
+ store['md-redline-pane-layout'] = JSON.stringify({ leftPanelView: 'invalid' });
65
+ expect(load().leftPanelView).toBe('explorer');
66
+ });
67
+
68
+ it('only accepts valid viewMode values', () => {
69
+ for (const mode of ['rendered', 'raw']) {
70
+ store['md-redline-pane-layout'] = JSON.stringify({ viewMode: mode });
71
+ expect(load().viewMode).toBe(mode);
72
+ }
73
+
74
+ // Legacy 'diff' migrates to 'raw'
75
+ store['md-redline-pane-layout'] = JSON.stringify({ viewMode: 'diff' });
76
+ expect(load().viewMode).toBe('raw');
77
+
78
+ store['md-redline-pane-layout'] = JSON.stringify({ viewMode: 'invalid' });
79
+ expect(load().viewMode).toBe('rendered');
80
+ });
81
+ });
82
+
83
+ describe('save', () => {
84
+ it('persists layout to localStorage', () => {
85
+ const layout = {
86
+ explorerVisible: false,
87
+ sidebarVisible: true,
88
+ leftPanelView: 'outline' as const,
89
+ viewMode: 'raw' as const,
90
+ };
91
+ save(layout);
92
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
93
+ 'md-redline-pane-layout',
94
+ JSON.stringify(layout),
95
+ );
96
+ });
97
+
98
+ it('round-trips through load', () => {
99
+ const layout = {
100
+ explorerVisible: false,
101
+ sidebarVisible: false,
102
+ leftPanelView: 'outline' as const,
103
+ viewMode: 'raw' as const,
104
+ };
105
+ save(layout);
106
+ expect(load()).toEqual(layout);
107
+ });
108
+ });
@@ -0,0 +1,102 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import type { ViewMode } from '../components/Toolbar';
3
+
4
+ export type LeftPanelView = 'explorer' | 'outline';
5
+
6
+ interface PaneLayout {
7
+ explorerVisible: boolean;
8
+ sidebarVisible: boolean;
9
+ leftPanelView: LeftPanelView;
10
+ viewMode: ViewMode;
11
+ }
12
+
13
+ const STORAGE_KEY = 'md-redline-pane-layout';
14
+
15
+ const DEFAULTS: PaneLayout = {
16
+ explorerVisible: true,
17
+ sidebarVisible: true,
18
+ leftPanelView: 'explorer',
19
+ viewMode: 'rendered',
20
+ };
21
+
22
+ export function load(): PaneLayout {
23
+ try {
24
+ const raw = localStorage.getItem(STORAGE_KEY);
25
+ if (!raw) return DEFAULTS;
26
+ const parsed = JSON.parse(raw);
27
+ return {
28
+ explorerVisible:
29
+ typeof parsed.explorerVisible === 'boolean'
30
+ ? parsed.explorerVisible
31
+ : DEFAULTS.explorerVisible,
32
+ sidebarVisible:
33
+ typeof parsed.sidebarVisible === 'boolean'
34
+ ? parsed.sidebarVisible
35
+ : DEFAULTS.sidebarVisible,
36
+ leftPanelView: parsed.leftPanelView === 'outline' ? 'outline' : 'explorer',
37
+ viewMode: parsed.viewMode === 'raw' || parsed.viewMode === 'diff' ? 'raw' : 'rendered',
38
+ };
39
+ } catch {
40
+ return DEFAULTS;
41
+ }
42
+ }
43
+
44
+ export function save(layout: PaneLayout) {
45
+ try {
46
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
47
+ } catch {
48
+ /* ignore */
49
+ }
50
+ }
51
+
52
+ export function usePaneLayout() {
53
+ const [layout, setLayout] = useState<PaneLayout>(load);
54
+ const layoutRef = useRef(layout);
55
+ layoutRef.current = layout;
56
+
57
+ // Persist on every change
58
+ useEffect(() => {
59
+ save(layout);
60
+ }, [layout]);
61
+
62
+ const setExplorerVisible = useCallback((v: boolean | ((prev: boolean) => boolean)) => {
63
+ setLayout((prev) => {
64
+ const next = typeof v === 'function' ? v(prev.explorerVisible) : v;
65
+ return { ...prev, explorerVisible: next };
66
+ });
67
+ }, []);
68
+
69
+ const setSidebarVisible = useCallback((v: boolean | ((prev: boolean) => boolean)) => {
70
+ setLayout((prev) => {
71
+ const next = typeof v === 'function' ? v(prev.sidebarVisible) : v;
72
+ return { ...prev, sidebarVisible: next };
73
+ });
74
+ }, []);
75
+
76
+ const setLeftPanelView = useCallback((v: LeftPanelView) => {
77
+ setLayout((prev) => ({ ...prev, leftPanelView: v }));
78
+ }, []);
79
+
80
+ const setViewMode = useCallback((v: ViewMode | ((prev: ViewMode) => ViewMode)) => {
81
+ setLayout((prev) => {
82
+ const next = typeof v === 'function' ? v(prev.viewMode) : v;
83
+ return { ...prev, viewMode: next };
84
+ });
85
+ }, []);
86
+
87
+ // Diff overlay is transient — not persisted to localStorage
88
+ const [diffEnabled, setDiffEnabled] = useState(false);
89
+
90
+ return {
91
+ explorerVisible: layout.explorerVisible,
92
+ sidebarVisible: layout.sidebarVisible,
93
+ leftPanelView: layout.leftPanelView,
94
+ viewMode: layout.viewMode,
95
+ diffEnabled,
96
+ setExplorerVisible,
97
+ setSidebarVisible,
98
+ setLeftPanelView,
99
+ setViewMode,
100
+ setDiffEnabled,
101
+ };
102
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+
3
+ // Mock preferences-client before importing module under test
4
+ vi.mock('../lib/preferences-client', () => ({
5
+ fetchPreferences: vi.fn(() => Promise.resolve({})),
6
+ savePreferencesToDisk: vi.fn(() => Promise.resolve()),
7
+ }));
8
+
9
+ import { loadFromStorage, mergeRecentFiles, saveToStorage } from './useRecentFiles';
10
+ import { savePreferencesToDisk } from '../lib/preferences-client';
11
+
12
+ const store: Record<string, string> = {};
13
+ const localStorageMock = {
14
+ getItem: vi.fn((key: string) => store[key] ?? null),
15
+ setItem: vi.fn((key: string, value: string) => {
16
+ store[key] = value;
17
+ }),
18
+ removeItem: vi.fn((key: string) => {
19
+ delete store[key];
20
+ }),
21
+ clear: vi.fn(() => {
22
+ for (const key in store) delete store[key];
23
+ }),
24
+ get length() {
25
+ return Object.keys(store).length;
26
+ },
27
+ key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
28
+ };
29
+ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
30
+
31
+ beforeEach(() => {
32
+ localStorageMock.clear();
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ describe('loadFromStorage', () => {
37
+ it('returns empty array when localStorage is empty', () => {
38
+ expect(loadFromStorage()).toEqual([]);
39
+ });
40
+
41
+ it('returns empty array on invalid JSON', () => {
42
+ store['md-redline-recent-files'] = 'bad-json';
43
+ expect(loadFromStorage()).toEqual([]);
44
+ });
45
+
46
+ it('returns parsed array when valid', () => {
47
+ const files = [{ path: '/a.md', name: 'a.md', openedAt: '2026-01-01T00:00:00.000Z' }];
48
+ store['md-redline-recent-files'] = JSON.stringify(files);
49
+ expect(loadFromStorage()).toEqual(files);
50
+ });
51
+ });
52
+
53
+ describe('saveToStorage', () => {
54
+ it('persists files to localStorage as JSON', () => {
55
+ const files = [{ path: '/b.md', name: 'b.md', openedAt: '2026-01-01T00:00:00.000Z' }];
56
+ saveToStorage(files);
57
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
58
+ 'md-redline-recent-files',
59
+ JSON.stringify(files),
60
+ );
61
+ });
62
+
63
+ it('calls savePreferencesToDisk with the files', () => {
64
+ const files = [{ path: '/c.md', name: 'c.md', openedAt: '2026-01-01T00:00:00.000Z' }];
65
+ saveToStorage(files);
66
+ expect(savePreferencesToDisk).toHaveBeenCalledWith({ recentFiles: files });
67
+ });
68
+
69
+ it('round-trips through loadFromStorage', () => {
70
+ const files = [
71
+ { path: '/d.md', name: 'd.md', openedAt: '2026-01-01T00:00:00.000Z' },
72
+ { path: '/e.md', name: 'e.md', openedAt: '2026-01-02T00:00:00.000Z' },
73
+ ];
74
+ saveToStorage(files);
75
+ expect(loadFromStorage()).toEqual(files);
76
+ });
77
+ });
78
+
79
+ describe('mergeRecentFiles', () => {
80
+ it('merges and sorts recents by most recent openedAt', () => {
81
+ const merged = mergeRecentFiles(
82
+ [{ path: '/a.md', name: 'a.md', openedAt: '2026-01-01T00:00:00.000Z' }],
83
+ [{ path: '/b.md', name: 'b.md', openedAt: '2026-01-02T00:00:00.000Z' }],
84
+ );
85
+
86
+ expect(merged.map((file) => file.path)).toEqual(['/b.md', '/a.md']);
87
+ });
88
+
89
+ it('keeps the newest timestamp when the same file exists in both sources', () => {
90
+ const merged = mergeRecentFiles(
91
+ [{ path: '/a.md', name: 'a.md', openedAt: '2026-01-03T00:00:00.000Z' }],
92
+ [
93
+ { path: '/a.md', name: 'a.md', openedAt: '2026-01-01T00:00:00.000Z' },
94
+ { path: '/b.md', name: 'b.md', openedAt: '2026-01-02T00:00:00.000Z' },
95
+ ],
96
+ );
97
+
98
+ expect(merged).toEqual([
99
+ { path: '/a.md', name: 'a.md', openedAt: '2026-01-03T00:00:00.000Z' },
100
+ { path: '/b.md', name: 'b.md', openedAt: '2026-01-02T00:00:00.000Z' },
101
+ ]);
102
+ });
103
+ });
@@ -0,0 +1,99 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import { getPathBasename } from '../lib/path-utils';
3
+ import { fetchPreferences, savePreferencesToDisk } from '../lib/preferences-client';
4
+
5
+ const STORAGE_KEY = 'md-redline-recent-files';
6
+ const MAX_RECENT = 10;
7
+
8
+ export interface RecentFile {
9
+ path: string;
10
+ name: string;
11
+ openedAt: string; // ISO-8601
12
+ }
13
+
14
+ export function mergeRecentFiles(primary: RecentFile[], secondary: RecentFile[]): RecentFile[] {
15
+ const byPath = new Map<string, RecentFile>();
16
+
17
+ for (const file of [...primary, ...secondary]) {
18
+ const existing = byPath.get(file.path);
19
+ if (!existing || file.openedAt > existing.openedAt) {
20
+ byPath.set(file.path, file);
21
+ }
22
+ }
23
+
24
+ return Array.from(byPath.values())
25
+ .sort((a, b) => b.openedAt.localeCompare(a.openedAt))
26
+ .slice(0, MAX_RECENT);
27
+ }
28
+
29
+ export function loadFromStorage(): RecentFile[] {
30
+ try {
31
+ const raw = localStorage.getItem(STORAGE_KEY);
32
+ return raw ? JSON.parse(raw) : [];
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ export function saveToStorage(files: RecentFile[]) {
39
+ try {
40
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(files));
41
+ } catch {
42
+ /* Storage unavailable */
43
+ }
44
+ savePreferencesToDisk({ recentFiles: files });
45
+ }
46
+
47
+ export function useRecentFiles() {
48
+ const [recentFiles, setRecentFiles] = useState<RecentFile[]>(loadFromStorage);
49
+ const hasLocalMutationRef = useRef(false);
50
+
51
+ // Hydrate from disk on mount
52
+ useEffect(() => {
53
+ let cancelled = false;
54
+ fetchPreferences().then((prefs) => {
55
+ if (cancelled || hasLocalMutationRef.current) return;
56
+ if (prefs.recentFiles && Array.isArray(prefs.recentFiles) && prefs.recentFiles.length > 0) {
57
+ setRecentFiles((prev) => {
58
+ const next = mergeRecentFiles(prev, prefs.recentFiles as RecentFile[]);
59
+ try {
60
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
61
+ } catch {
62
+ /* storage unavailable */
63
+ }
64
+ return next;
65
+ });
66
+ }
67
+ });
68
+ return () => {
69
+ cancelled = true;
70
+ };
71
+ }, []);
72
+
73
+ const addRecentFile = useCallback((path: string) => {
74
+ hasLocalMutationRef.current = true;
75
+ setRecentFiles((prev) => {
76
+ const name = getPathBasename(path) || path;
77
+ const filtered = prev.filter((f) => f.path !== path);
78
+ const next = [{ path, name, openedAt: new Date().toISOString() }, ...filtered].slice(
79
+ 0,
80
+ MAX_RECENT,
81
+ );
82
+ saveToStorage(next);
83
+ return next;
84
+ });
85
+ }, []);
86
+
87
+ const clearRecentFiles = useCallback(() => {
88
+ hasLocalMutationRef.current = true;
89
+ setRecentFiles([]);
90
+ try {
91
+ localStorage.removeItem(STORAGE_KEY);
92
+ } catch {
93
+ /* storage unavailable */
94
+ }
95
+ savePreferencesToDisk({ recentFiles: [] });
96
+ }, []);
97
+
98
+ return { recentFiles, addRecentFile, clearRecentFiles };
99
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { clamp, loadWidths } from './useResizablePanel';
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('clamp', () => {
29
+ it('returns value when within range', () => {
30
+ expect(clamp(5, 0, 10)).toBe(5);
31
+ });
32
+
33
+ it('returns min when value is below range', () => {
34
+ expect(clamp(-1, 0, 10)).toBe(0);
35
+ });
36
+
37
+ it('returns max when value is above range', () => {
38
+ expect(clamp(15, 0, 10)).toBe(10);
39
+ });
40
+
41
+ it('handles equal min and max', () => {
42
+ expect(clamp(5, 3, 3)).toBe(3);
43
+ });
44
+
45
+ it('returns boundary values exactly', () => {
46
+ expect(clamp(0, 0, 10)).toBe(0);
47
+ expect(clamp(10, 0, 10)).toBe(10);
48
+ });
49
+ });
50
+
51
+ describe('loadWidths', () => {
52
+ const DEFAULTS = { explorer: 224, sidebar: 320 };
53
+
54
+ it('returns defaults when localStorage is empty', () => {
55
+ expect(loadWidths()).toEqual(DEFAULTS);
56
+ });
57
+
58
+ it('returns defaults on invalid JSON', () => {
59
+ store['md-redline-panel-widths'] = 'bad-json';
60
+ expect(loadWidths()).toEqual(DEFAULTS);
61
+ });
62
+
63
+ it('clamps explorer width to bounds', () => {
64
+ store['md-redline-panel-widths'] = JSON.stringify({ explorer: 50, sidebar: 320 });
65
+ const result = loadWidths();
66
+ expect(result.explorer).toBe(160); // MIN_WIDTHS.explorer
67
+ });
68
+
69
+ it('clamps sidebar width to bounds', () => {
70
+ store['md-redline-panel-widths'] = JSON.stringify({ explorer: 224, sidebar: 999 });
71
+ const result = loadWidths();
72
+ expect(result.sidebar).toBe(560); // MAX_WIDTHS.sidebar
73
+ });
74
+
75
+ it('preserves valid widths', () => {
76
+ store['md-redline-panel-widths'] = JSON.stringify({ explorer: 300, sidebar: 400 });
77
+ expect(loadWidths()).toEqual({ explorer: 300, sidebar: 400 });
78
+ });
79
+
80
+ it('handles missing fields gracefully', () => {
81
+ store['md-redline-panel-widths'] = JSON.stringify({});
82
+ expect(loadWidths()).toEqual(DEFAULTS);
83
+ });
84
+ });
@@ -0,0 +1,118 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react';
2
+
3
+ const STORAGE_KEY = 'md-redline-panel-widths';
4
+
5
+ interface PanelWidths {
6
+ explorer: number;
7
+ sidebar: number;
8
+ }
9
+
10
+ const DEFAULTS: PanelWidths = {
11
+ explorer: 224, // w-56
12
+ sidebar: 320, // w-80
13
+ };
14
+
15
+ const MIN_WIDTHS: PanelWidths = {
16
+ explorer: 160,
17
+ sidebar: 240,
18
+ };
19
+
20
+ const MAX_WIDTHS: PanelWidths = {
21
+ explorer: 480,
22
+ sidebar: 560,
23
+ };
24
+
25
+ export function loadWidths(): PanelWidths {
26
+ try {
27
+ const raw = localStorage.getItem(STORAGE_KEY);
28
+ if (!raw) return DEFAULTS;
29
+ const parsed = JSON.parse(raw);
30
+ return {
31
+ explorer: clamp(
32
+ parsed.explorer ?? DEFAULTS.explorer,
33
+ MIN_WIDTHS.explorer,
34
+ MAX_WIDTHS.explorer,
35
+ ),
36
+ sidebar: clamp(parsed.sidebar ?? DEFAULTS.sidebar, MIN_WIDTHS.sidebar, MAX_WIDTHS.sidebar),
37
+ };
38
+ } catch {
39
+ return DEFAULTS;
40
+ }
41
+ }
42
+
43
+ export function clamp(value: number, min: number, max: number) {
44
+ return Math.min(max, Math.max(min, value));
45
+ }
46
+
47
+ export function useResizablePanel() {
48
+ const [widths, setWidths] = useState<PanelWidths>(loadWidths);
49
+ const [isDragging, setIsDragging] = useState(false);
50
+ const dragging = useRef<'explorer' | 'sidebar' | null>(null);
51
+ const startX = useRef(0);
52
+ const startWidth = useRef(0);
53
+
54
+ const persist = useCallback((w: PanelWidths) => {
55
+ try {
56
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(w));
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ }, []);
61
+
62
+ const onMouseDown = useCallback(
63
+ (panel: 'explorer' | 'sidebar', e: React.MouseEvent) => {
64
+ e.preventDefault();
65
+ dragging.current = panel;
66
+ setIsDragging(true);
67
+ startX.current = e.clientX;
68
+ startWidth.current = widths[panel];
69
+ document.body.style.cursor = 'col-resize';
70
+ document.body.style.userSelect = 'none';
71
+ },
72
+ [widths],
73
+ );
74
+
75
+ useEffect(() => {
76
+ const onMouseMove = (e: MouseEvent) => {
77
+ const panel = dragging.current;
78
+ if (!panel) return;
79
+
80
+ const delta = panel === 'explorer' ? e.clientX - startX.current : startX.current - e.clientX; // sidebar drags leftward to grow
81
+
82
+ const newWidth = clamp(startWidth.current + delta, MIN_WIDTHS[panel], MAX_WIDTHS[panel]);
83
+
84
+ setWidths((prev) => ({ ...prev, [panel]: newWidth }));
85
+ };
86
+
87
+ const onMouseUp = () => {
88
+ if (!dragging.current) return;
89
+ dragging.current = null;
90
+ setIsDragging(false);
91
+ document.body.style.cursor = '';
92
+ document.body.style.userSelect = '';
93
+ setWidths((prev) => {
94
+ persist(prev);
95
+ return prev;
96
+ });
97
+ };
98
+
99
+ window.addEventListener('mousemove', onMouseMove);
100
+ window.addEventListener('mouseup', onMouseUp);
101
+ return () => {
102
+ window.removeEventListener('mousemove', onMouseMove);
103
+ window.removeEventListener('mouseup', onMouseUp);
104
+ // Clean up body styles if component unmounts mid-drag
105
+ if (dragging.current) {
106
+ document.body.style.cursor = '';
107
+ document.body.style.userSelect = '';
108
+ }
109
+ };
110
+ }, [persist]);
111
+
112
+ return {
113
+ explorerWidth: widths.explorer,
114
+ sidebarWidth: widths.sidebar,
115
+ onResizeStart: onMouseDown,
116
+ isDragging,
117
+ };
118
+ }