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,561 @@
1
+ import { useState, useCallback, useMemo, useRef } from 'react';
2
+ import { getApiErrorMessage, readJsonResponse, type ApiErrorPayload } from '../lib/http';
3
+
4
+ export interface TabState {
5
+ filePath: string;
6
+ rawMarkdown: string;
7
+ isLoading: boolean;
8
+ error: string | null;
9
+ lastSaved: Date | null;
10
+ /** Server-reported mtime (ms since epoch) for conflict detection */
11
+ mtime?: number;
12
+ /** True when local content has not yet been successfully saved to disk */
13
+ dirty?: boolean;
14
+ }
15
+
16
+ interface PendingTabUpdate {
17
+ tabData: Map<string, TabState>;
18
+ tabOrder: string[];
19
+ activeFilePath: string | null;
20
+ }
21
+
22
+ function findTabKey(tabData: Map<string, TabState>, path: string): string | null {
23
+ if (tabData.has(path)) return path;
24
+ for (const [key, tab] of tabData) {
25
+ if (tab.filePath === path) return key;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ interface LoadedTabUpdate {
31
+ tabData: Map<string, TabState>;
32
+ tabOrder: string[];
33
+ activeFilePath: string | null;
34
+ }
35
+
36
+ type FileResponse = {
37
+ path: string;
38
+ content: string;
39
+ mtime?: number;
40
+ } & ApiErrorPayload;
41
+
42
+ type SaveFileResponse = {
43
+ success: boolean;
44
+ path: string;
45
+ mtime?: number;
46
+ } & ApiErrorPayload;
47
+
48
+ type ConflictResponse = {
49
+ error: string;
50
+ code: 'CONFLICT';
51
+ currentContent: string;
52
+ mtime: number;
53
+ } & ApiErrorPayload;
54
+
55
+ export function applyPendingTabState(
56
+ prevData: Map<string, TabState>,
57
+ prevOrder: string[],
58
+ prevActiveFilePath: string | null,
59
+ path: string,
60
+ activate: boolean,
61
+ ): PendingTabUpdate {
62
+ const nextData = new Map(prevData);
63
+ nextData.set(path, {
64
+ filePath: path,
65
+ rawMarkdown: '',
66
+ isLoading: true,
67
+ error: null,
68
+ lastSaved: null,
69
+ });
70
+ return {
71
+ tabData: nextData,
72
+ tabOrder: [...prevOrder, path],
73
+ activeFilePath: activate ? path : prevActiveFilePath,
74
+ };
75
+ }
76
+
77
+ export function applyLoadedTabState(
78
+ prevData: Map<string, TabState>,
79
+ prevOrder: string[],
80
+ prevActiveFilePath: string | null,
81
+ requestedPath: string,
82
+ loadedPath: string,
83
+ content: string,
84
+ savedAt: Date,
85
+ ): LoadedTabUpdate {
86
+ const nextData = new Map(prevData);
87
+ const loadedTabState: TabState = {
88
+ filePath: loadedPath,
89
+ rawMarkdown: content,
90
+ isLoading: false,
91
+ error: null,
92
+ lastSaved: savedAt,
93
+ };
94
+
95
+ if (requestedPath === loadedPath) {
96
+ nextData.set(loadedPath, loadedTabState);
97
+ return {
98
+ tabData: nextData,
99
+ tabOrder: prevOrder,
100
+ activeFilePath: prevActiveFilePath,
101
+ };
102
+ }
103
+
104
+ const nextOrder = prevOrder.filter((path) => path !== requestedPath);
105
+ const hasLoadedPath = nextData.has(loadedPath);
106
+
107
+ nextData.delete(requestedPath);
108
+ nextData.set(
109
+ loadedPath,
110
+ hasLoadedPath ? { ...nextData.get(loadedPath)!, ...loadedTabState } : loadedTabState,
111
+ );
112
+
113
+ if (!hasLoadedPath) {
114
+ const requestedIndex = prevOrder.indexOf(requestedPath);
115
+ nextOrder.splice(requestedIndex === -1 ? nextOrder.length : requestedIndex, 0, loadedPath);
116
+ }
117
+
118
+ return {
119
+ tabData: nextData,
120
+ tabOrder: nextOrder,
121
+ activeFilePath: prevActiveFilePath === requestedPath ? loadedPath : prevActiveFilePath,
122
+ };
123
+ }
124
+
125
+ export function useTabs(options?: { onSaveError?: (msg: string) => void }) {
126
+ const onSaveError = options?.onSaveError;
127
+ const [tabOrder, setTabOrder] = useState<string[]>([]);
128
+ const [tabData, setTabData] = useState<Map<string, TabState>>(new Map());
129
+ const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
130
+
131
+ // Use refs to avoid closures depending on state (which changes every render)
132
+ const tabDataRef = useRef(tabData);
133
+ tabDataRef.current = tabData;
134
+ const tabOrderRef = useRef(tabOrder);
135
+ tabOrderRef.current = tabOrder;
136
+ const activeFilePathRef = useRef(activeFilePath);
137
+ activeFilePathRef.current = activeFilePath;
138
+ const loadRequestIdsRef = useRef(new Map<string, number>());
139
+ const nextLoadRequestIdRef = useRef(1);
140
+ const abortControllersRef = useRef(new Map<string, AbortController>());
141
+ const saveQueueRef = useRef<Promise<void>>(Promise.resolve());
142
+
143
+ const setTabDataState = useCallback(
144
+ (updater: Map<string, TabState> | ((prev: Map<string, TabState>) => Map<string, TabState>)) => {
145
+ const next = typeof updater === 'function' ? updater(tabDataRef.current) : updater;
146
+ tabDataRef.current = next;
147
+ setTabData(next);
148
+ },
149
+ [],
150
+ );
151
+
152
+ const setTabOrderState = useCallback((updater: string[] | ((prev: string[]) => string[])) => {
153
+ const next = typeof updater === 'function' ? updater(tabOrderRef.current) : updater;
154
+ tabOrderRef.current = next;
155
+ setTabOrder(next);
156
+ }, []);
157
+
158
+ const setActiveFilePathState = useCallback(
159
+ (updater: string | null | ((prev: string | null) => string | null)) => {
160
+ const next = typeof updater === 'function' ? updater(activeFilePathRef.current) : updater;
161
+ activeFilePathRef.current = next;
162
+ setActiveFilePath(next);
163
+ },
164
+ [],
165
+ );
166
+
167
+ const startLoadRequest = useCallback((path: string) => {
168
+ const requestId = nextLoadRequestIdRef.current++;
169
+ loadRequestIdsRef.current.set(path, requestId);
170
+ return requestId;
171
+ }, []);
172
+
173
+ const isCurrentLoadRequest = useCallback((path: string, requestId: number) => {
174
+ return loadRequestIdsRef.current.get(path) === requestId;
175
+ }, []);
176
+
177
+ const finishLoadRequest = useCallback((path: string, requestId: number) => {
178
+ if (loadRequestIdsRef.current.get(path) === requestId) {
179
+ loadRequestIdsRef.current.delete(path);
180
+ }
181
+ }, []);
182
+
183
+ const cancelLoadRequest = useCallback((path: string) => {
184
+ loadRequestIdsRef.current.delete(path);
185
+ abortControllersRef.current.get(path)?.abort();
186
+ abortControllersRef.current.delete(path);
187
+ }, []);
188
+
189
+ const updateTab = useCallback(
190
+ (path: string, updates: Partial<TabState>) => {
191
+ setTabDataState((prev) => {
192
+ const next = new Map(prev);
193
+ const tabKey = findTabKey(next, path);
194
+ const existing = tabKey ? next.get(tabKey) : undefined;
195
+ if (existing) {
196
+ next.set(tabKey!, { ...existing, ...updates });
197
+ }
198
+ return next;
199
+ });
200
+ },
201
+ [setTabDataState],
202
+ );
203
+
204
+ const applyLoadedResponse = useCallback(
205
+ (requestedPath: string, loadedPath: string, content: string) => {
206
+ const next = applyLoadedTabState(
207
+ tabDataRef.current,
208
+ tabOrderRef.current,
209
+ activeFilePathRef.current,
210
+ requestedPath,
211
+ loadedPath,
212
+ content,
213
+ new Date(),
214
+ );
215
+ setTabDataState(next.tabData);
216
+ setTabOrderState(next.tabOrder);
217
+ setActiveFilePathState(next.activeFilePath);
218
+ },
219
+ [setActiveFilePathState, setTabDataState, setTabOrderState],
220
+ );
221
+
222
+ const openTab = useCallback(
223
+ async (path: string) => {
224
+ const existingPath = findTabKey(tabDataRef.current, path);
225
+ // If already open, just switch to it
226
+ if (existingPath) {
227
+ setActiveFilePathState(existingPath);
228
+ return;
229
+ }
230
+
231
+ const next = applyPendingTabState(
232
+ tabDataRef.current,
233
+ tabOrderRef.current,
234
+ activeFilePathRef.current,
235
+ path,
236
+ true,
237
+ );
238
+ setTabDataState(next.tabData);
239
+ setTabOrderState(next.tabOrder);
240
+ setActiveFilePathState(next.activeFilePath);
241
+ const requestId = startLoadRequest(path);
242
+ abortControllersRef.current.get(path)?.abort();
243
+ const controller = new AbortController();
244
+ abortControllersRef.current.set(path, controller);
245
+
246
+ // Fetch file content
247
+ try {
248
+ const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`, {
249
+ signal: controller.signal,
250
+ });
251
+ const data = await readJsonResponse<FileResponse>(res);
252
+ if (!res.ok || !data) {
253
+ throw new Error(getApiErrorMessage(res, data, 'Failed to load file'));
254
+ }
255
+ if (!isCurrentLoadRequest(path, requestId)) return;
256
+ applyLoadedResponse(path, data.path, data.content);
257
+ if (data.mtime != null) updateTab(data.path, { mtime: data.mtime });
258
+ finishLoadRequest(path, requestId);
259
+ } catch (err) {
260
+ if (err instanceof DOMException && err.name === 'AbortError') return;
261
+ if (!isCurrentLoadRequest(path, requestId)) return;
262
+ setTabDataState((prev) => {
263
+ const next = new Map(prev);
264
+ const existing = next.get(path);
265
+ if (existing) {
266
+ next.set(path, {
267
+ ...existing,
268
+ isLoading: false,
269
+ error: err instanceof Error ? err.message : 'Failed to load file',
270
+ });
271
+ }
272
+ return next;
273
+ });
274
+ finishLoadRequest(path, requestId);
275
+ } finally {
276
+ abortControllersRef.current.delete(path);
277
+ }
278
+ },
279
+ [
280
+ applyLoadedResponse,
281
+ finishLoadRequest,
282
+ isCurrentLoadRequest,
283
+ setActiveFilePathState,
284
+ setTabDataState,
285
+ setTabOrderState,
286
+ startLoadRequest,
287
+ updateTab,
288
+ ],
289
+ );
290
+
291
+ const openTabInBackground = useCallback(
292
+ async (path: string) => {
293
+ // If already open, do nothing (don't switch)
294
+ if (findTabKey(tabDataRef.current, path)) return;
295
+
296
+ const next = applyPendingTabState(
297
+ tabDataRef.current,
298
+ tabOrderRef.current,
299
+ activeFilePathRef.current,
300
+ path,
301
+ false,
302
+ );
303
+ setTabDataState(next.tabData);
304
+ setTabOrderState(next.tabOrder);
305
+ const requestId = startLoadRequest(path);
306
+ abortControllersRef.current.get(path)?.abort();
307
+ const controller = new AbortController();
308
+ abortControllersRef.current.set(path, controller);
309
+
310
+ try {
311
+ const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`, {
312
+ signal: controller.signal,
313
+ });
314
+ const data = await readJsonResponse<FileResponse>(res);
315
+ if (!res.ok || !data) {
316
+ throw new Error(getApiErrorMessage(res, data, 'Failed to load file'));
317
+ }
318
+ if (!isCurrentLoadRequest(path, requestId)) return;
319
+ applyLoadedResponse(path, data.path, data.content);
320
+ if (data.mtime != null) updateTab(data.path, { mtime: data.mtime });
321
+ finishLoadRequest(path, requestId);
322
+ } catch (err) {
323
+ if (err instanceof DOMException && err.name === 'AbortError') return;
324
+ if (!isCurrentLoadRequest(path, requestId)) return;
325
+ setTabDataState((prev) => {
326
+ const next = new Map(prev);
327
+ const existing = next.get(path);
328
+ if (existing) {
329
+ next.set(path, {
330
+ ...existing,
331
+ isLoading: false,
332
+ error: err instanceof Error ? err.message : 'Failed to load file',
333
+ });
334
+ }
335
+ return next;
336
+ });
337
+ finishLoadRequest(path, requestId);
338
+ } finally {
339
+ abortControllersRef.current.delete(path);
340
+ }
341
+ },
342
+ [
343
+ applyLoadedResponse,
344
+ finishLoadRequest,
345
+ isCurrentLoadRequest,
346
+ setTabDataState,
347
+ setTabOrderState,
348
+ startLoadRequest,
349
+ updateTab,
350
+ ],
351
+ );
352
+
353
+ const closeTab = useCallback(
354
+ (path: string) => {
355
+ const tabKey = findTabKey(tabDataRef.current, path) ?? path;
356
+ cancelLoadRequest(tabKey);
357
+ const currentOrder = tabOrderRef.current;
358
+ const idx = currentOrder.indexOf(tabKey);
359
+ const remaining = currentOrder.filter((p) => p !== tabKey);
360
+
361
+ setTabOrderState(remaining);
362
+ // If closing the active tab, switch to an adjacent one
363
+ setActiveFilePathState((currentActive) => {
364
+ if (tabKey !== currentActive) return currentActive;
365
+ if (remaining.length === 0) return null;
366
+ return remaining[Math.min(idx, remaining.length - 1)];
367
+ });
368
+ setTabDataState((prev) => {
369
+ const next = new Map(prev);
370
+ next.delete(tabKey);
371
+ return next;
372
+ });
373
+ },
374
+ [cancelLoadRequest, setActiveFilePathState, setTabDataState, setTabOrderState],
375
+ );
376
+
377
+ const closeOtherTabs = useCallback(
378
+ (keepPath: string) => {
379
+ const keepKey = findTabKey(tabDataRef.current, keepPath) ?? keepPath;
380
+ for (const path of tabDataRef.current.keys()) {
381
+ if (path !== keepKey) cancelLoadRequest(path);
382
+ }
383
+ setTabOrderState((prev) => prev.filter((p) => p === keepKey));
384
+ setActiveFilePathState(keepKey);
385
+ setTabDataState((prev) => {
386
+ const next = new Map<string, TabState>();
387
+ const kept = prev.get(keepKey);
388
+ if (kept) next.set(keepKey, kept);
389
+ return next;
390
+ });
391
+ },
392
+ [cancelLoadRequest, setActiveFilePathState, setTabDataState, setTabOrderState],
393
+ );
394
+
395
+ const closeAllTabs = useCallback(() => {
396
+ for (const controller of abortControllersRef.current.values()) {
397
+ controller.abort();
398
+ }
399
+ abortControllersRef.current.clear();
400
+ loadRequestIdsRef.current.clear();
401
+ setTabOrderState([]);
402
+ setTabDataState(new Map());
403
+ setActiveFilePathState(null);
404
+ }, [setActiveFilePathState, setTabDataState, setTabOrderState]);
405
+
406
+ const closeTabsToRight = useCallback(
407
+ (path: string) => {
408
+ const currentOrder = tabOrderRef.current;
409
+ const tabKey = findTabKey(tabDataRef.current, path) ?? path;
410
+ const idx = currentOrder.indexOf(tabKey);
411
+ if (idx === -1) return;
412
+
413
+ const kept = currentOrder.slice(0, idx + 1);
414
+ const removed = currentOrder.slice(idx + 1);
415
+
416
+ setTabOrderState(kept);
417
+ setActiveFilePathState((currentActive) => {
418
+ if (currentActive && kept.includes(currentActive)) return currentActive;
419
+ return tabKey;
420
+ });
421
+ if (removed.length > 0) {
422
+ for (const removedPath of removed) cancelLoadRequest(removedPath);
423
+ setTabDataState((prevData) => {
424
+ const nextData = new Map(prevData);
425
+ for (const p of removed) nextData.delete(p);
426
+ return nextData;
427
+ });
428
+ }
429
+ },
430
+ [cancelLoadRequest, setActiveFilePathState, setTabDataState, setTabOrderState],
431
+ );
432
+
433
+ const switchTab = useCallback(
434
+ (path: string) => {
435
+ const existingPath = findTabKey(tabDataRef.current, path);
436
+ if (existingPath) setActiveFilePathState(existingPath);
437
+ },
438
+ [setActiveFilePathState],
439
+ );
440
+
441
+ const activeTab = activeFilePath ? (tabData.get(activeFilePath) ?? null) : null;
442
+
443
+ const tabs = useMemo(
444
+ () => tabOrder.map((p) => tabData.get(p)).filter((t): t is TabState => !!t),
445
+ [tabOrder, tabData],
446
+ );
447
+
448
+ // Active tab delegates
449
+ const rawMarkdown = activeTab?.rawMarkdown ?? '';
450
+ const isLoading = activeTab?.isLoading ?? false;
451
+ const error = activeTab?.error ?? null;
452
+ const filePath = activeTab?.filePath ?? '';
453
+
454
+ const isTabDirty = useCallback(
455
+ (path: string): boolean => {
456
+ const key = findTabKey(tabDataRef.current, path) ?? path;
457
+ return tabDataRef.current.get(key)?.dirty === true;
458
+ },
459
+ [],
460
+ );
461
+
462
+ const setRawMarkdown = useCallback(
463
+ (content: string) => {
464
+ if (activeFilePath) updateTab(activeFilePath, { rawMarkdown: content, dirty: true });
465
+ },
466
+ [activeFilePath, updateTab],
467
+ );
468
+
469
+ const saveFile = useCallback(
470
+ (content: string) => {
471
+ if (!activeFilePath) return;
472
+ // Queue saves so rapid edits don't race: each save waits for the
473
+ // previous one to finish. Reading mtime from tabDataRef at execution time
474
+ // is correct because the queue serializes saves, so save1's returned mtime
475
+ // is already written to the ref before save2 runs.
476
+ const path = activeFilePath;
477
+ saveQueueRef.current = saveQueueRef.current.then(async () => {
478
+ const currentMtime = tabDataRef.current.get(path)?.mtime;
479
+ try {
480
+ const res = await fetch('/api/file', {
481
+ method: 'PUT',
482
+ headers: { 'Content-Type': 'application/json' },
483
+ body: JSON.stringify({
484
+ path,
485
+ content,
486
+ expectedMtime: currentMtime,
487
+ }),
488
+ });
489
+ if (res.status === 409) {
490
+ const conflict = await readJsonResponse<ConflictResponse>(res);
491
+ const msg = conflict?.error || 'File was modified externally. Reload to see changes.';
492
+ updateTab(path, { error: msg });
493
+ onSaveError?.(msg);
494
+ return;
495
+ }
496
+ const data = await readJsonResponse<SaveFileResponse>(res);
497
+ if (!res.ok || !data) {
498
+ throw new Error(getApiErrorMessage(res, data, 'Failed to save file'));
499
+ }
500
+ updateTab(path, {
501
+ lastSaved: new Date(),
502
+ error: null,
503
+ mtime: data.mtime,
504
+ dirty: false,
505
+ });
506
+ } catch (err) {
507
+ const msg = err instanceof Error ? err.message : 'Failed to save file';
508
+ updateTab(path, { error: msg });
509
+ onSaveError?.(msg);
510
+ }
511
+ });
512
+ },
513
+ [activeFilePath, updateTab, onSaveError],
514
+ );
515
+
516
+ const reloadFile = useCallback(async () => {
517
+ if (!activeFilePath) return;
518
+ updateTab(activeFilePath, { isLoading: true, error: null });
519
+ try {
520
+ const res = await fetch(`/api/file?path=${encodeURIComponent(activeFilePath)}`);
521
+ const data = await readJsonResponse<FileResponse>(res);
522
+ if (!res.ok || !data) {
523
+ throw new Error(getApiErrorMessage(res, data, 'Failed to reload file'));
524
+ }
525
+ updateTab(activeFilePath, {
526
+ rawMarkdown: data.content,
527
+ isLoading: false,
528
+ lastSaved: new Date(),
529
+ error: null,
530
+ mtime: data.mtime,
531
+ });
532
+ } catch (err) {
533
+ updateTab(activeFilePath, {
534
+ isLoading: false,
535
+ error: err instanceof Error ? err.message : 'Failed to reload file',
536
+ });
537
+ }
538
+ }, [activeFilePath, updateTab]);
539
+
540
+ return {
541
+ tabs,
542
+ activeTab,
543
+ activeFilePath,
544
+ filePath,
545
+ rawMarkdown,
546
+ setRawMarkdown,
547
+ updateTab,
548
+ isLoading,
549
+ error,
550
+ isTabDirty,
551
+ openTab,
552
+ openTabInBackground,
553
+ closeTab,
554
+ closeOtherTabs,
555
+ closeAllTabs,
556
+ closeTabsToRight,
557
+ switchTab,
558
+ saveFile,
559
+ reloadFile,
560
+ };
561
+ }
@@ -0,0 +1,41 @@
1
+ import { useEffect, useCallback, useRef } from 'react';
2
+ import { useTheme } from 'next-themes';
3
+ import { fetchPreferences, savePreferencesToDisk } from '../lib/preferences-client';
4
+
5
+ /**
6
+ * Syncs theme selection to disk. On mount, hydrates from disk preferences.
7
+ * Returns a wrapped setTheme that writes to both next-themes (localStorage) and disk.
8
+ */
9
+ export function useThemePersistence() {
10
+ const { theme, setTheme: setThemeNextThemes } = useTheme();
11
+ const hasLocalMutationRef = useRef(false);
12
+ const themeRef = useRef(theme);
13
+ themeRef.current = theme;
14
+
15
+ // Hydrate from disk on mount
16
+ useEffect(() => {
17
+ let cancelled = false;
18
+ fetchPreferences().then((prefs) => {
19
+ if (cancelled || hasLocalMutationRef.current) return;
20
+ if (prefs.theme && prefs.theme !== themeRef.current) {
21
+ setThemeNextThemes(prefs.theme);
22
+ }
23
+ });
24
+ return () => {
25
+ cancelled = true;
26
+ };
27
+ // eslint-disable-next-line react-hooks/exhaustive-deps
28
+ }, []);
29
+
30
+ // Wrapped setTheme that dual-writes
31
+ const setTheme = useCallback(
32
+ (newTheme: string) => {
33
+ hasLocalMutationRef.current = true;
34
+ setThemeNextThemes(newTheme);
35
+ savePreferencesToDisk({ theme: newTheme });
36
+ },
37
+ [setThemeNextThemes],
38
+ );
39
+
40
+ return { theme, setTheme };
41
+ }
@@ -0,0 +1,27 @@
1
+ import { useState, useCallback } from 'react';
2
+
3
+ export interface ToastAction {
4
+ label: string;
5
+ onClick: () => void;
6
+ }
7
+
8
+ export interface ToastState {
9
+ message: string;
10
+ visible: boolean;
11
+ action?: ToastAction;
12
+ }
13
+
14
+ export function useToast() {
15
+ const [toast, setToast] = useState<ToastState>({
16
+ message: '',
17
+ visible: false,
18
+ });
19
+ const showToast = useCallback((message: string, action?: ToastAction) => {
20
+ setToast({ message, visible: true, action });
21
+ }, []);
22
+ const dismissToast = useCallback(() => {
23
+ setToast((prev) => ({ ...prev, visible: false }));
24
+ }, []);
25
+
26
+ return { toast, showToast, dismissToast };
27
+ }