md-redline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/md-redline +255 -0
- package/bin/test-windows.ps1 +70 -0
- package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
- package/dist/assets/arc-DI2g9LXK.js +1 -0
- package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
- package/dist/assets/array-DOVTz2Mq.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
- package/dist/assets/channel-DPCihw7y.js +1 -0
- package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
- package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
- package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
- package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
- package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
- package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
- package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
- package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
- package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
- package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
- package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
- package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
- package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
- package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
- package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
- package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
- package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
- package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
- package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
- package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
- package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
- package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
- package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
- package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
- package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
- package/dist/assets/clone-CI9aUwHe.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
- package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
- package/dist/assets/dagre-CilMRazv.js +1 -0
- package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
- package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
- package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
- package/dist/assets/dist-C_eddq6m.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
- package/dist/assets/flatten-B8XIuT0x.js +1 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
- package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
- package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
- package/dist/assets/index-BoggyWS0.css +2 -0
- package/dist/assets/index-aLvjHQW4.js +104 -0
- package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
- package/dist/assets/init-Bft5Ffpj.js +1 -0
- package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
- package/dist/assets/katex-Uj9wLT16.js +265 -0
- package/dist/assets/line-CRxEwpOv.js +1 -0
- package/dist/assets/linear-PDPfFByd.js +1 -0
- package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
- package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
- package/dist/assets/ordinal-DIg8h6NI.js +1 -0
- package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
- package/dist/assets/path-DfRbCp9y.js +1 -0
- package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
- package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
- package/dist/assets/reduce-CV2X8n1a.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
- package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
- package/dist/assets/src-PKe5NtkK.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
- package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
- package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/screenshot.png +0 -0
- package/index.html +13 -0
- package/package.json +105 -0
- package/public/favicon.svg +15 -0
- package/public/screenshot.png +0 -0
- package/server/index.test.ts +814 -0
- package/server/index.ts +736 -0
- package/server/preferences.test.ts +126 -0
- package/server/preferences.ts +76 -0
- package/src/App.tsx +1620 -0
- package/src/components/ActionButton.tsx +41 -0
- package/src/components/CommandPalette.tsx +191 -0
- package/src/components/CommentCard.tsx +556 -0
- package/src/components/CommentForm.tsx +285 -0
- package/src/components/CommentSidebar.tsx +428 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/ContextMenu.tsx +220 -0
- package/src/components/DragHandles.tsx +48 -0
- package/src/components/FileExplorer.tsx +251 -0
- package/src/components/FileOpener.tsx +304 -0
- package/src/components/IconButton.tsx +32 -0
- package/src/components/KeyboardShortcutsPanel.tsx +136 -0
- package/src/components/MarkdownViewer.tsx +682 -0
- package/src/components/RawView.tsx +798 -0
- package/src/components/SearchBar.tsx +129 -0
- package/src/components/Separator.tsx +7 -0
- package/src/components/SettingsPanel.tsx +813 -0
- package/src/components/SplitIconButton.tsx +133 -0
- package/src/components/TabBar.tsx +594 -0
- package/src/components/TableOfContents.tsx +70 -0
- package/src/components/ThemeSelector.tsx +159 -0
- package/src/components/Toast.tsx +99 -0
- package/src/components/Toolbar.tsx +161 -0
- package/src/components/iconButtonVariants.ts +19 -0
- package/src/components/rawView.test.ts +291 -0
- package/src/contexts/SettingsContext.tsx +120 -0
- package/src/hooks/useAuthor.test.ts +58 -0
- package/src/hooks/useAuthor.ts +69 -0
- package/src/hooks/useAutoResize.ts +20 -0
- package/src/hooks/useCommentCardTriggers.ts +20 -0
- package/src/hooks/useComments.test.ts +773 -0
- package/src/hooks/useComments.ts +332 -0
- package/src/hooks/useContextMenu.ts +48 -0
- package/src/hooks/useContextMenuItems.ts +392 -0
- package/src/hooks/useDiffSnapshot.test.ts +130 -0
- package/src/hooks/useDiffSnapshot.ts +67 -0
- package/src/hooks/useDragHandles.ts +417 -0
- package/src/hooks/useFileWatcher.ts +45 -0
- package/src/hooks/useHeadingTracking.ts +84 -0
- package/src/hooks/useMermaidRenderer.ts +75 -0
- package/src/hooks/useModalState.ts +22 -0
- package/src/hooks/usePageVisible.test.ts +69 -0
- package/src/hooks/usePageVisible.ts +19 -0
- package/src/hooks/usePaneLayout.test.ts +108 -0
- package/src/hooks/usePaneLayout.ts +102 -0
- package/src/hooks/useRecentFiles.test.ts +103 -0
- package/src/hooks/useRecentFiles.ts +99 -0
- package/src/hooks/useResizablePanel.test.ts +84 -0
- package/src/hooks/useResizablePanel.ts +118 -0
- package/src/hooks/useSearch.test.ts +72 -0
- package/src/hooks/useSearch.ts +53 -0
- package/src/hooks/useSelection.ts +48 -0
- package/src/hooks/useSessionPersistence.test.ts +59 -0
- package/src/hooks/useSessionPersistence.ts +43 -0
- package/src/hooks/useTabs.test.ts +127 -0
- package/src/hooks/useTabs.ts +561 -0
- package/src/hooks/useThemePersistence.ts +41 -0
- package/src/hooks/useToast.ts +27 -0
- package/src/index.css +1047 -0
- package/src/lib/agent-prompts.test.ts +34 -0
- package/src/lib/agent-prompts.ts +68 -0
- package/src/lib/comment-editor-state.ts +6 -0
- package/src/lib/comment-parser.test.ts +1959 -0
- package/src/lib/comment-parser.ts +1021 -0
- package/src/lib/diff.test.ts +164 -0
- package/src/lib/diff.ts +139 -0
- package/src/lib/heading-slugs.test.ts +85 -0
- package/src/lib/heading-slugs.ts +44 -0
- package/src/lib/http.test.ts +43 -0
- package/src/lib/http.ts +29 -0
- package/src/lib/mermaid-highlights.test.ts +517 -0
- package/src/lib/mermaid-highlights.ts +936 -0
- package/src/lib/mermaid-renderer.test.ts +114 -0
- package/src/lib/mermaid-renderer.ts +89 -0
- package/src/lib/path-utils.test.ts +17 -0
- package/src/lib/path-utils.ts +7 -0
- package/src/lib/platform.test.ts +58 -0
- package/src/lib/platform.ts +14 -0
- package/src/lib/preferences-client.test.ts +177 -0
- package/src/lib/preferences-client.ts +94 -0
- package/src/lib/selection-resolver.test.ts +118 -0
- package/src/lib/selection-resolver.ts +37 -0
- package/src/lib/settings.test.ts +152 -0
- package/src/lib/settings.ts +78 -0
- package/src/lib/shortcut-label.tsx +18 -0
- package/src/lib/themes.ts +21 -0
- package/src/lib/visible-text.test.ts +86 -0
- package/src/lib/visible-text.ts +77 -0
- package/src/main.tsx +22 -0
- package/src/markdown/pipeline.test.ts +82 -0
- package/src/markdown/pipeline.ts +33 -0
- package/src/types.test.ts +43 -0
- package/src/types.ts +46 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +50 -0
|
@@ -0,0 +1,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
|
+
}
|