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,392 @@
1
+ import { useState, useCallback, type RefObject, type Dispatch, type SetStateAction } from 'react';
2
+ import type { ContextMenuEntry, ContextMenuItem } from '../components/ContextMenu';
3
+ import type { ViewerContextMenuInfo, MarkdownViewerHandle } from '../components/MarkdownViewer';
4
+ import type { ExplorerContextMenuInfo } from '../components/FileExplorer';
5
+ import type { TabContextMenuInfo } from '../components/TabBar';
6
+ import type { SidebarContextMenuInfo } from '../components/CommentSidebar';
7
+ import type { MdComment, SelectionInfo } from '../types';
8
+ import { getEffectiveStatus } from '../types';
9
+ import { getPathBasename } from '../lib/path-utils';
10
+
11
+ type ContextMenuInstance = {
12
+ open: (x: number, y: number) => void;
13
+ close: () => void;
14
+ };
15
+
16
+ interface CommentTemplate {
17
+ label: string;
18
+ text: string;
19
+ }
20
+
21
+ export interface UseContextMenuItemsParams {
22
+ comments: MdComment[];
23
+ enableResolve: boolean;
24
+ templates: CommentTemplate[];
25
+ handleResolve: (id: string) => void;
26
+ handleUnresolve: (id: string) => void;
27
+ handleDelete: (id: string) => void;
28
+ handleAddComment: (
29
+ anchor: string,
30
+ text: string,
31
+ contextBefore?: string,
32
+ contextAfter?: string,
33
+ hintOffset?: number,
34
+ ) => void;
35
+ setActiveCommentId: Dispatch<SetStateAction<string | null>>;
36
+ setSidebarVisible: (v: boolean | ((prev: boolean) => boolean)) => void;
37
+ selectionRef: RefObject<SelectionInfo | null>;
38
+ lockSelection: () => void;
39
+ setAutoExpandForm: Dispatch<SetStateAction<boolean>>;
40
+ triggerEdit: (id: string) => void;
41
+ triggerReply: (id: string) => void;
42
+ viewerRef: RefObject<MarkdownViewerHandle | null>;
43
+ handleExplorerOpenFile: (path: string) => void;
44
+ openTabInBackground: (path: string) => void;
45
+ addRecentFile: (path: string) => void;
46
+ revealInFinder: (path: string) => void;
47
+ revealLabel: string;
48
+ setExplorerDir: Dispatch<SetStateAction<string | undefined>>;
49
+ setExplorerVisible: (v: boolean | ((prev: boolean) => boolean)) => void;
50
+ tabs: Array<{ filePath: string }>;
51
+ closeTab: (path: string) => void;
52
+ closeOtherTabs: (path: string) => void;
53
+ closeAllTabs: () => void;
54
+ closeTabsToRight: (path: string) => void;
55
+ viewerCtxMenu: ContextMenuInstance;
56
+ explorerCtxMenu: ContextMenuInstance;
57
+ tabCtxMenu: ContextMenuInstance;
58
+ sidebarCtxMenu: ContextMenuInstance;
59
+ }
60
+
61
+ export function useContextMenuItems(params: UseContextMenuItemsParams) {
62
+ const {
63
+ comments,
64
+ enableResolve,
65
+ templates,
66
+ handleResolve,
67
+ handleUnresolve,
68
+ handleDelete,
69
+ handleAddComment,
70
+ setActiveCommentId,
71
+ setSidebarVisible,
72
+ selectionRef,
73
+ lockSelection,
74
+ setAutoExpandForm,
75
+ triggerEdit,
76
+ triggerReply,
77
+ viewerRef,
78
+ handleExplorerOpenFile,
79
+ openTabInBackground,
80
+ addRecentFile,
81
+ revealInFinder,
82
+ revealLabel,
83
+ setExplorerDir,
84
+ setExplorerVisible,
85
+ tabs,
86
+ closeTab,
87
+ closeOtherTabs,
88
+ closeAllTabs,
89
+ closeTabsToRight,
90
+ viewerCtxMenu,
91
+ explorerCtxMenu,
92
+ tabCtxMenu,
93
+ sidebarCtxMenu,
94
+ } = params;
95
+
96
+ const [ctxMenuItems, setCtxMenuItems] = useState<ContextMenuEntry[]>([]);
97
+ const [explorerCtxMenuItems, setExplorerCtxMenuItems] = useState<ContextMenuEntry[]>([]);
98
+ const [tabCtxMenuItems, setTabCtxMenuItems] = useState<ContextMenuEntry[]>([]);
99
+ const [sidebarCtxMenuItems, setSidebarCtxMenuItems] = useState<ContextMenuEntry[]>([]);
100
+
101
+ const handleViewerContextMenu = useCallback(
102
+ (info: ViewerContextMenuInfo) => {
103
+ explorerCtxMenu.close();
104
+ tabCtxMenu.close();
105
+ sidebarCtxMenu.close();
106
+
107
+ if (info.type === 'highlight' && info.commentIds?.length) {
108
+ const commentId = info.commentIds[0];
109
+ const comment = comments.find((c) => c.id === commentId);
110
+ if (!comment) return;
111
+
112
+ const resolveItems: ContextMenuEntry[] = enableResolve
113
+ ? [
114
+ { type: 'divider' as const },
115
+ getEffectiveStatus(comment) === 'resolved'
116
+ ? { label: 'Reopen', onClick: () => handleUnresolve(commentId) }
117
+ : { label: 'Resolve', onClick: () => handleResolve(commentId) },
118
+ ]
119
+ : [];
120
+
121
+ const items: ContextMenuEntry[] = [
122
+ {
123
+ label: 'Edit',
124
+ onClick: () => {
125
+ setActiveCommentId(commentId);
126
+ setSidebarVisible(true);
127
+ triggerEdit(commentId);
128
+ },
129
+ },
130
+ {
131
+ label: 'Reply',
132
+ onClick: () => {
133
+ setActiveCommentId(commentId);
134
+ setSidebarVisible(true);
135
+ triggerReply(commentId);
136
+ },
137
+ },
138
+ ...resolveItems,
139
+ { type: 'divider' as const },
140
+ {
141
+ label: 'Delete',
142
+ danger: true,
143
+ onClick: () => handleDelete(commentId),
144
+ },
145
+ { type: 'divider' as const },
146
+ {
147
+ label: 'Copy Anchor Text',
148
+ onClick: () => navigator.clipboard.writeText(comment.anchor),
149
+ },
150
+ {
151
+ label: 'Copy Comment Text',
152
+ onClick: () => navigator.clipboard.writeText(comment.text),
153
+ },
154
+ {
155
+ label: 'Jump to Sidebar',
156
+ onClick: () => {
157
+ setActiveCommentId(commentId);
158
+ setSidebarVisible(true);
159
+ },
160
+ },
161
+ ];
162
+
163
+ setCtxMenuItems(items);
164
+ viewerCtxMenu.open(info.x, info.y);
165
+ } else if (info.type === 'selection') {
166
+ const sel = selectionRef.current;
167
+ if (!sel) return;
168
+
169
+ const templateItems: ContextMenuItem[] = templates.map((t) => ({
170
+ label: t.label,
171
+ onClick: () => {
172
+ handleAddComment(sel.text, t.text, sel.contextBefore, sel.contextAfter, sel.offset);
173
+ },
174
+ }));
175
+
176
+ const items: ContextMenuEntry[] = [
177
+ {
178
+ label: 'Comment',
179
+ onClick: () => {
180
+ lockSelection();
181
+ setAutoExpandForm(true);
182
+ },
183
+ },
184
+ {
185
+ label: 'Templates',
186
+ items: templateItems,
187
+ },
188
+ { type: 'divider' as const },
189
+ {
190
+ label: 'Copy',
191
+ onClick: () => {
192
+ navigator.clipboard.writeText(sel.text);
193
+ },
194
+ },
195
+ ];
196
+
197
+ setCtxMenuItems(items);
198
+ viewerCtxMenu.open(info.x, info.y);
199
+ }
200
+ },
201
+ [
202
+ comments,
203
+ enableResolve,
204
+ templates,
205
+ handleResolve,
206
+ handleUnresolve,
207
+ handleDelete,
208
+ handleAddComment,
209
+ lockSelection,
210
+ setSidebarVisible,
211
+ triggerEdit,
212
+ triggerReply,
213
+ selectionRef,
214
+ setActiveCommentId,
215
+ setAutoExpandForm,
216
+ viewerCtxMenu,
217
+ explorerCtxMenu,
218
+ tabCtxMenu,
219
+ sidebarCtxMenu,
220
+ ],
221
+ );
222
+
223
+ const handleExplorerContextMenu = useCallback(
224
+ (info: ExplorerContextMenuInfo) => {
225
+ viewerCtxMenu.close();
226
+ tabCtxMenu.close();
227
+ sidebarCtxMenu.close();
228
+
229
+ if (info.type === 'file') {
230
+ const items: ContextMenuEntry[] = [
231
+ { label: 'Open', onClick: () => handleExplorerOpenFile(info.path) },
232
+ {
233
+ label: 'Open in Background Tab',
234
+ onClick: () => {
235
+ openTabInBackground(info.path);
236
+ addRecentFile(info.path);
237
+ },
238
+ },
239
+ { type: 'divider' as const },
240
+ { label: revealLabel, onClick: () => revealInFinder(info.path) },
241
+ { label: 'Copy Path', onClick: () => navigator.clipboard.writeText(info.path) },
242
+ { label: 'Copy File Name', onClick: () => navigator.clipboard.writeText(info.name) },
243
+ ];
244
+ setExplorerCtxMenuItems(items);
245
+ explorerCtxMenu.open(info.x, info.y);
246
+ } else if (info.type === 'directory') {
247
+ const items: ContextMenuEntry[] = [
248
+ {
249
+ label: 'Open in Explorer',
250
+ onClick: () => {
251
+ setExplorerDir(info.path);
252
+ setExplorerVisible(true);
253
+ },
254
+ },
255
+ { type: 'divider' as const },
256
+ { label: revealLabel, onClick: () => revealInFinder(info.path) },
257
+ { label: 'Copy Path', onClick: () => navigator.clipboard.writeText(info.path) },
258
+ ];
259
+ setExplorerCtxMenuItems(items);
260
+ explorerCtxMenu.open(info.x, info.y);
261
+ } else {
262
+ const items: ContextMenuEntry[] = [
263
+ { label: revealLabel, onClick: () => revealInFinder(info.path) },
264
+ { label: 'Copy Path', onClick: () => navigator.clipboard.writeText(info.path) },
265
+ ];
266
+ setExplorerCtxMenuItems(items);
267
+ explorerCtxMenu.open(info.x, info.y);
268
+ }
269
+ },
270
+ [
271
+ handleExplorerOpenFile,
272
+ openTabInBackground,
273
+ addRecentFile,
274
+ revealInFinder,
275
+ revealLabel,
276
+ setExplorerVisible,
277
+ setExplorerDir,
278
+ viewerCtxMenu,
279
+ explorerCtxMenu,
280
+ tabCtxMenu,
281
+ sidebarCtxMenu,
282
+ ],
283
+ );
284
+
285
+ const handleTabContextMenu = useCallback(
286
+ (info: TabContextMenuInfo) => {
287
+ viewerCtxMenu.close();
288
+ explorerCtxMenu.close();
289
+ sidebarCtxMenu.close();
290
+
291
+ const tabIndex = tabs.findIndex((t) => t.filePath === info.filePath);
292
+ const hasTabsToRight = tabIndex >= 0 && tabIndex < tabs.length - 1;
293
+ const hasOtherTabs = tabs.length > 1;
294
+ const fileName = getPathBasename(info.filePath) || info.filePath;
295
+
296
+ const items: ContextMenuEntry[] = [
297
+ { label: 'Close', onClick: () => closeTab(info.filePath) },
298
+ {
299
+ label: 'Close Others',
300
+ onClick: () => closeOtherTabs(info.filePath),
301
+ disabled: !hasOtherTabs,
302
+ },
303
+ {
304
+ label: 'Close Tabs to the Right',
305
+ onClick: () => closeTabsToRight(info.filePath),
306
+ disabled: !hasTabsToRight,
307
+ },
308
+ { label: 'Close All', onClick: () => closeAllTabs() },
309
+ { type: 'divider' as const },
310
+ { label: revealLabel, onClick: () => revealInFinder(info.filePath) },
311
+ { label: 'Copy Path', onClick: () => navigator.clipboard.writeText(info.filePath) },
312
+ { label: 'Copy File Name', onClick: () => navigator.clipboard.writeText(fileName) },
313
+ ];
314
+ setTabCtxMenuItems(items);
315
+ tabCtxMenu.open(info.x, info.y);
316
+ },
317
+ [
318
+ tabs,
319
+ closeTab,
320
+ closeOtherTabs,
321
+ closeAllTabs,
322
+ closeTabsToRight,
323
+ revealInFinder,
324
+ revealLabel,
325
+ viewerCtxMenu,
326
+ explorerCtxMenu,
327
+ tabCtxMenu,
328
+ sidebarCtxMenu,
329
+ ],
330
+ );
331
+
332
+ const handleSidebarContextMenu = useCallback(
333
+ (info: SidebarContextMenuInfo) => {
334
+ viewerCtxMenu.close();
335
+ explorerCtxMenu.close();
336
+ tabCtxMenu.close();
337
+
338
+ const comment = comments.find((c) => c.id === info.commentId);
339
+ if (!comment) return;
340
+
341
+ const resolveItems: ContextMenuEntry[] = enableResolve
342
+ ? [
343
+ getEffectiveStatus(comment) === 'resolved'
344
+ ? { label: 'Reopen', onClick: () => handleUnresolve(info.commentId) }
345
+ : { label: 'Resolve', onClick: () => handleResolve(info.commentId) },
346
+ ]
347
+ : [];
348
+
349
+ const items: ContextMenuEntry[] = [
350
+ ...resolveItems,
351
+ { label: 'Delete', danger: true, onClick: () => handleDelete(info.commentId) },
352
+ { type: 'divider' as const },
353
+ { label: 'Copy Anchor Text', onClick: () => navigator.clipboard.writeText(comment.anchor) },
354
+ { label: 'Copy Comment Text', onClick: () => navigator.clipboard.writeText(comment.text) },
355
+ { type: 'divider' as const },
356
+ {
357
+ label: 'Scroll to Highlight',
358
+ onClick: () => {
359
+ setActiveCommentId(info.commentId);
360
+ viewerRef.current?.scrollToComment(info.commentId);
361
+ },
362
+ },
363
+ ];
364
+ setSidebarCtxMenuItems(items);
365
+ sidebarCtxMenu.open(info.x, info.y);
366
+ },
367
+ [
368
+ comments,
369
+ enableResolve,
370
+ handleResolve,
371
+ handleUnresolve,
372
+ handleDelete,
373
+ setActiveCommentId,
374
+ viewerRef,
375
+ viewerCtxMenu,
376
+ explorerCtxMenu,
377
+ tabCtxMenu,
378
+ sidebarCtxMenu,
379
+ ],
380
+ );
381
+
382
+ return {
383
+ ctxMenuItems,
384
+ explorerCtxMenuItems,
385
+ tabCtxMenuItems,
386
+ sidebarCtxMenuItems,
387
+ handleViewerContextMenu,
388
+ handleExplorerContextMenu,
389
+ handleTabContextMenu,
390
+ handleSidebarContextMenu,
391
+ };
392
+ }
@@ -0,0 +1,130 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { renderHook, act } from '@testing-library/react';
5
+ import { useDiffSnapshot } from './useDiffSnapshot';
6
+
7
+ const STORAGE_KEY = 'md-redline-snapshots';
8
+
9
+ function setup(activeFilePath: string | null = '/test.md', initialContent: string = 'hello world') {
10
+ const rawMarkdownRef = { current: initialContent };
11
+ const showToast = vi.fn();
12
+ const setDiffEnabled = vi.fn();
13
+ return {
14
+ rawMarkdownRef,
15
+ showToast,
16
+ setDiffEnabled,
17
+ hookArgs: [activeFilePath, rawMarkdownRef, showToast, setDiffEnabled] as const,
18
+ };
19
+ }
20
+
21
+ describe('useDiffSnapshot', () => {
22
+ beforeEach(() => {
23
+ localStorage.clear();
24
+ });
25
+
26
+ it('currentSnapshot is null when no snapshot exists for active file', () => {
27
+ const { hookArgs } = setup();
28
+ const { result } = renderHook(() => useDiffSnapshot(...hookArgs));
29
+ expect(result.current.currentSnapshot).toBeNull();
30
+ });
31
+
32
+ it('handleSnapshot saves rawMarkdownRef.current to snapshots', () => {
33
+ const { hookArgs, rawMarkdownRef } = setup('/test.md', 'file content');
34
+ const { result } = renderHook(() => useDiffSnapshot(...hookArgs));
35
+
36
+ act(() => result.current.handleSnapshot());
37
+
38
+ expect(result.current.currentSnapshot).toBe('file content');
39
+
40
+ // Confirm it tracks the ref's value at call time
41
+ rawMarkdownRef.current = 'changed';
42
+ act(() => result.current.handleSnapshot());
43
+ expect(result.current.currentSnapshot).toBe('changed');
44
+ });
45
+
46
+ it('after handleSnapshot, currentSnapshot returns the saved content', () => {
47
+ const { hookArgs } = setup('/doc.md', 'saved text');
48
+ const { result } = renderHook(() => useDiffSnapshot(...hookArgs));
49
+
50
+ act(() => result.current.handleSnapshot());
51
+ expect(result.current.currentSnapshot).toBe('saved text');
52
+ });
53
+
54
+ it('handleSnapshot with extraEntries saves additional file snapshots', () => {
55
+ const { hookArgs } = setup('/a.md', 'content a');
56
+ const { result, rerender } = renderHook(({ args }) => useDiffSnapshot(...args), {
57
+ initialProps: { args: hookArgs },
58
+ });
59
+
60
+ const extras = new Map([
61
+ ['/b.md', 'content b'],
62
+ ['/c.md', 'content c'],
63
+ ]);
64
+ act(() => result.current.handleSnapshot(extras));
65
+
66
+ // Current file has its snapshot
67
+ expect(result.current.currentSnapshot).toBe('content a');
68
+
69
+ // Switch to /b.md to verify extra entry was saved
70
+ const { hookArgs: bArgs } = setup('/b.md', '');
71
+ rerender({ args: bArgs });
72
+ expect(result.current.currentSnapshot).toBe('content b');
73
+ });
74
+
75
+ it('handleClearSnapshot removes the snapshot and calls setDiffEnabled(false)', () => {
76
+ const { hookArgs, setDiffEnabled, showToast } = setup('/test.md', 'content');
77
+ const { result } = renderHook(() => useDiffSnapshot(...hookArgs));
78
+
79
+ act(() => result.current.handleSnapshot());
80
+ expect(result.current.currentSnapshot).toBe('content');
81
+
82
+ act(() => result.current.handleClearSnapshot());
83
+ expect(result.current.currentSnapshot).toBeNull();
84
+ expect(setDiffEnabled).toHaveBeenCalledWith(false);
85
+ expect(showToast).toHaveBeenCalledWith('Snapshot cleared');
86
+ });
87
+
88
+ it('first save shows "Snapshot saved", second shows "Snapshot updated"', () => {
89
+ const rawMarkdownRef = { current: 'v1' };
90
+ const showToast = vi.fn();
91
+ const setDiffEnabled = vi.fn();
92
+
93
+ const { result, rerender } = renderHook(
94
+ ({ path }) => useDiffSnapshot(path, rawMarkdownRef, showToast, setDiffEnabled),
95
+ { initialProps: { path: '/test.md' } },
96
+ );
97
+
98
+ // First snapshot
99
+ act(() => result.current.handleSnapshot());
100
+ expect(showToast).toHaveBeenLastCalledWith('Snapshot saved — diff view will show changes');
101
+
102
+ // Force a re-render so the hook picks up committed state and recreates handleSnapshot
103
+ rerender({ path: '/test.md' });
104
+
105
+ // Second snapshot — should detect the existing entry
106
+ act(() => result.current.handleSnapshot());
107
+ expect(showToast).toHaveBeenLastCalledWith('Snapshot updated');
108
+ });
109
+
110
+ it('snapshots persist to localStorage', () => {
111
+ const { hookArgs } = setup('/test.md', 'persisted');
112
+ const { result } = renderHook(() => useDiffSnapshot(...hookArgs));
113
+
114
+ act(() => result.current.handleSnapshot());
115
+
116
+ const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
117
+ expect(stored['/test.md']).toBe('persisted');
118
+ });
119
+
120
+ it('initializes from localStorage on mount', () => {
121
+ // Pre-populate localStorage
122
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ '/existing.md': 'pre-existing content' }));
123
+
124
+ const { hookArgs } = setup('/existing.md', 'new content');
125
+ const { result } = renderHook(() => useDiffSnapshot(...hookArgs));
126
+
127
+ // Should read the pre-existing snapshot, not null
128
+ expect(result.current.currentSnapshot).toBe('pre-existing content');
129
+ });
130
+ });
@@ -0,0 +1,67 @@
1
+ import { useState, useCallback, useEffect, type RefObject } from 'react';
2
+
3
+ const STORAGE_KEY = 'md-redline-snapshots';
4
+
5
+ export function useDiffSnapshot(
6
+ activeFilePath: string | null,
7
+ rawMarkdownRef: RefObject<string>,
8
+ showToast: (msg: string) => void,
9
+ setDiffEnabled: (v: boolean) => void,
10
+ ) {
11
+ const [snapshots, setSnapshots] = useState<Map<string, string>>(() => {
12
+ try {
13
+ const raw = localStorage.getItem(STORAGE_KEY);
14
+ if (!raw) return new Map();
15
+ return new Map(Object.entries(JSON.parse(raw)));
16
+ } catch {
17
+ return new Map();
18
+ }
19
+ });
20
+
21
+ useEffect(() => {
22
+ try {
23
+ if (snapshots.size === 0) {
24
+ localStorage.removeItem(STORAGE_KEY);
25
+ } else {
26
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(snapshots)));
27
+ }
28
+ } catch {
29
+ /* ignore quota errors */
30
+ }
31
+ }, [snapshots]);
32
+
33
+ const currentSnapshot = activeFilePath ? (snapshots.get(activeFilePath) ?? null) : null;
34
+
35
+ const handleSnapshot = useCallback(
36
+ (extraEntries?: Map<string, string>) => {
37
+ if (!activeFilePath) return;
38
+ let isUpdate = false;
39
+ setSnapshots((prev) => {
40
+ isUpdate = prev.has(activeFilePath);
41
+ const next = new Map(prev);
42
+ next.set(activeFilePath, rawMarkdownRef.current);
43
+ if (extraEntries) {
44
+ for (const [path, content] of extraEntries) {
45
+ next.set(path, content);
46
+ }
47
+ }
48
+ return next;
49
+ });
50
+ showToast(isUpdate ? 'Snapshot updated' : 'Snapshot saved — diff view will show changes');
51
+ },
52
+ [activeFilePath, showToast, rawMarkdownRef],
53
+ );
54
+
55
+ const handleClearSnapshot = useCallback(() => {
56
+ if (!activeFilePath) return;
57
+ setSnapshots((prev) => {
58
+ const next = new Map(prev);
59
+ next.delete(activeFilePath);
60
+ return next;
61
+ });
62
+ setDiffEnabled(false);
63
+ showToast('Snapshot cleared');
64
+ }, [activeFilePath, setDiffEnabled, showToast]);
65
+
66
+ return { currentSnapshot, handleSnapshot, handleClearSnapshot };
67
+ }