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,220 @@
1
+ import { useRef, useEffect, useLayoutEffect, useState } from 'react';
2
+
3
+ export interface ContextMenuItem {
4
+ label: string;
5
+ onClick: () => void;
6
+ /** Optional danger styling (red text) */
7
+ danger?: boolean;
8
+ /** Optional disabled state */
9
+ disabled?: boolean;
10
+ }
11
+
12
+ export interface ContextMenuDivider {
13
+ type: 'divider';
14
+ }
15
+
16
+ export interface ContextMenuSubmenu {
17
+ label: string;
18
+ items: ContextMenuItem[];
19
+ }
20
+
21
+ export type ContextMenuEntry = ContextMenuItem | ContextMenuDivider | ContextMenuSubmenu;
22
+
23
+ function isDivider(entry: ContextMenuEntry): entry is ContextMenuDivider {
24
+ return 'type' in entry && entry.type === 'divider';
25
+ }
26
+
27
+ function isSubmenu(entry: ContextMenuEntry): entry is ContextMenuSubmenu {
28
+ return 'items' in entry;
29
+ }
30
+
31
+ interface Props {
32
+ items: ContextMenuEntry[];
33
+ position: { x: number; y: number };
34
+ onClose: () => void;
35
+ }
36
+
37
+ export function ContextMenu({ items, position, onClose }: Props) {
38
+ const menuRef = useRef<HTMLDivElement>(null);
39
+ const [adjustedPos, setAdjustedPos] = useState(position);
40
+ const [openSubmenuIdx, setOpenSubmenuIdx] = useState<number | null>(null);
41
+ const [submenuPos, setSubmenuPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
42
+ const submenuRef = useRef<HTMLDivElement>(null);
43
+
44
+ // Adjust position to keep menu within viewport (useLayoutEffect to avoid flash)
45
+ useLayoutEffect(() => {
46
+ const menu = menuRef.current;
47
+ if (!menu) return;
48
+
49
+ const rect = menu.getBoundingClientRect();
50
+ const vw = window.innerWidth;
51
+ const vh = window.innerHeight;
52
+ let x = position.x;
53
+ let y = position.y;
54
+
55
+ if (x + rect.width > vw - 8) {
56
+ x = vw - rect.width - 8;
57
+ }
58
+ if (y + rect.height > vh - 8) {
59
+ y = vh - rect.height - 8;
60
+ }
61
+ if (x < 8) x = 8;
62
+ if (y < 8) y = 8;
63
+
64
+ setAdjustedPos({ x, y });
65
+ }, [position]);
66
+
67
+ // Close on click outside
68
+ useEffect(() => {
69
+ const handleMouseDown = (e: MouseEvent) => {
70
+ if (
71
+ menuRef.current &&
72
+ !menuRef.current.contains(e.target as Node) &&
73
+ (!submenuRef.current || !submenuRef.current.contains(e.target as Node))
74
+ ) {
75
+ onClose();
76
+ }
77
+ };
78
+
79
+ // Use mousedown so the menu closes before the click is processed
80
+ document.addEventListener('mousedown', handleMouseDown);
81
+ return () => document.removeEventListener('mousedown', handleMouseDown);
82
+ }, [onClose]);
83
+
84
+ // Close on window blur
85
+ useEffect(() => {
86
+ const handleBlur = () => onClose();
87
+ window.addEventListener('blur', handleBlur);
88
+ return () => window.removeEventListener('blur', handleBlur);
89
+ }, [onClose]);
90
+
91
+ const handleItemClick = (item: ContextMenuItem) => {
92
+ if (item.disabled) return;
93
+ onClose();
94
+ item.onClick();
95
+ };
96
+
97
+ const handleSubmenuHover = (idx: number, e: React.MouseEvent) => {
98
+ const target = e.currentTarget as HTMLElement;
99
+ const rect = target.getBoundingClientRect();
100
+ const menuRect = menuRef.current?.getBoundingClientRect();
101
+ const vw = window.innerWidth;
102
+ const vh = window.innerHeight;
103
+
104
+ // Default: open to the right
105
+ let x = rect.right;
106
+ let y = rect.top;
107
+
108
+ // If not enough space on the right, open to the left
109
+ if (menuRect && x + 180 > vw - 8) {
110
+ x = menuRect.left - 180;
111
+ }
112
+
113
+ // Clamp vertically so submenu doesn't overflow below viewport
114
+ // Estimate submenu height as ~32px per item (max 10 items visible)
115
+ const entry = items[idx];
116
+ const estimatedHeight = isSubmenu(entry) ? Math.min(entry.items.length, 10) * 32 : 200;
117
+ if (y + estimatedHeight > vh - 8) {
118
+ y = vh - estimatedHeight - 8;
119
+ }
120
+ if (y < 8) y = 8;
121
+
122
+ setSubmenuPos({ x, y });
123
+ setOpenSubmenuIdx(idx);
124
+ };
125
+
126
+ return (
127
+ <>
128
+ <div
129
+ ref={menuRef}
130
+ className="fixed z-[200] min-w-[160px] max-w-[240px] py-1 bg-surface-raised rounded-lg shadow-xl border border-border context-menu-enter"
131
+ style={{ left: adjustedPos.x, top: adjustedPos.y }}
132
+ onContextMenu={(e) => e.preventDefault()}
133
+ >
134
+ {items.map((entry, idx) => {
135
+ if (isDivider(entry)) {
136
+ return (
137
+ <div
138
+ key={`divider-${idx}`} /* dividers have no identity — index key is acceptable */
139
+ className="my-1 mx-2 h-px bg-border"
140
+ />
141
+ );
142
+ }
143
+
144
+ if (isSubmenu(entry)) {
145
+ return (
146
+ <div
147
+ key={`submenu-${entry.label}`}
148
+ className="relative"
149
+ onMouseEnter={(e) => handleSubmenuHover(idx, e)}
150
+ onMouseLeave={() => setOpenSubmenuIdx(null)}
151
+ >
152
+ <div className="flex items-center justify-between px-3 py-1.5 text-xs text-content hover:bg-tint cursor-default transition-colors">
153
+ <span>{entry.label}</span>
154
+ <svg
155
+ className="w-3 h-3 text-content-muted ml-4"
156
+ fill="none"
157
+ viewBox="0 0 24 24"
158
+ stroke="currentColor"
159
+ strokeWidth={2}
160
+ >
161
+ <path
162
+ strokeLinecap="round"
163
+ strokeLinejoin="round"
164
+ d="M8.25 4.5l7.5 7.5-7.5 7.5"
165
+ />
166
+ </svg>
167
+ </div>
168
+
169
+ {openSubmenuIdx === idx && (
170
+ <div
171
+ ref={submenuRef}
172
+ className="fixed z-[201] min-w-[160px] max-w-[240px] py-1 bg-surface-raised rounded-lg shadow-xl border border-border"
173
+ style={{ left: submenuPos.x, top: submenuPos.y }}
174
+ onMouseEnter={() => setOpenSubmenuIdx(idx)}
175
+ onMouseLeave={() => setOpenSubmenuIdx(null)}
176
+ >
177
+ {entry.items.map((subItem) => (
178
+ <button
179
+ key={subItem.label}
180
+ onClick={() => handleItemClick(subItem)}
181
+ disabled={subItem.disabled}
182
+ className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
183
+ subItem.disabled
184
+ ? 'text-content-muted cursor-not-allowed'
185
+ : subItem.danger
186
+ ? 'text-danger hover:bg-tint-danger cursor-default'
187
+ : 'text-content hover:bg-tint cursor-default'
188
+ }`}
189
+ >
190
+ {subItem.label}
191
+ </button>
192
+ ))}
193
+ </div>
194
+ )}
195
+ </div>
196
+ );
197
+ }
198
+
199
+ // Regular menu item
200
+ return (
201
+ <button
202
+ key={entry.label}
203
+ onClick={() => handleItemClick(entry)}
204
+ disabled={entry.disabled}
205
+ className={`w-full text-left px-3 py-1.5 text-xs transition-colors ${
206
+ entry.disabled
207
+ ? 'text-content-muted cursor-not-allowed'
208
+ : entry.danger
209
+ ? 'text-danger hover:bg-tint-danger cursor-default'
210
+ : 'text-content hover:bg-tint cursor-default'
211
+ }`}
212
+ >
213
+ {entry.label}
214
+ </button>
215
+ );
216
+ })}
217
+ </div>
218
+ </>
219
+ );
220
+ }
@@ -0,0 +1,48 @@
1
+ interface Position {
2
+ top: number;
3
+ left: number;
4
+ height: number;
5
+ }
6
+
7
+ interface DragHandlesProps {
8
+ startPos: Position | null;
9
+ endPos: Position | null;
10
+ onMouseDown: (handle: 'start' | 'end') => void;
11
+ }
12
+
13
+ export function DragHandles({ startPos, endPos, onMouseDown }: DragHandlesProps) {
14
+ if (!startPos || !endPos) return null;
15
+
16
+ return (
17
+ <>
18
+ <div
19
+ className="drag-handle"
20
+ style={{
21
+ top: startPos.top,
22
+ left: startPos.left - 2,
23
+ height: startPos.height,
24
+ }}
25
+ onMouseDown={(e) => {
26
+ e.preventDefault();
27
+ e.stopPropagation();
28
+ onMouseDown('start');
29
+ }}
30
+ data-drag-handle
31
+ />
32
+ <div
33
+ className="drag-handle"
34
+ style={{
35
+ top: endPos.top,
36
+ left: endPos.left - 2,
37
+ height: endPos.height,
38
+ }}
39
+ onMouseDown={(e) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ onMouseDown('end');
43
+ }}
44
+ data-drag-handle
45
+ />
46
+ </>
47
+ );
48
+ }
@@ -0,0 +1,251 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { getApiErrorMessage, readJsonResponse, type ApiErrorPayload } from '../lib/http';
3
+ import { getPathBasename } from '../lib/path-utils';
4
+
5
+ interface BrowseResult {
6
+ dir: string;
7
+ parent: string | null;
8
+ directories: { name: string; path: string }[];
9
+ files: { name: string; path: string }[];
10
+ }
11
+
12
+ type BrowseResponse = BrowseResult & ApiErrorPayload;
13
+
14
+ export interface ExplorerContextMenuInfo {
15
+ type: 'file' | 'directory' | 'blank';
16
+ path: string;
17
+ name: string;
18
+ x: number;
19
+ y: number;
20
+ }
21
+
22
+ interface Props {
23
+ initialDir?: string;
24
+ activeFilePath: string | null;
25
+ onOpenFile: (path: string) => void;
26
+ onClose: () => void;
27
+ onContextMenu?: (info: ExplorerContextMenuInfo) => void;
28
+ hideHeader?: boolean;
29
+ }
30
+
31
+ export function FileExplorer({
32
+ initialDir,
33
+ activeFilePath,
34
+ onOpenFile,
35
+ onClose,
36
+ onContextMenu: onCtxMenu,
37
+ hideHeader,
38
+ }: Props) {
39
+ const [data, setData] = useState<BrowseResult | null>(null);
40
+ const [loading, setLoading] = useState(false);
41
+ const [error, setError] = useState<string | null>(null);
42
+
43
+ const abortRef = useRef<AbortController | null>(null);
44
+
45
+ const browse = useCallback(async (dir?: string) => {
46
+ abortRef.current?.abort();
47
+ const controller = new AbortController();
48
+ abortRef.current = controller;
49
+
50
+ setLoading(true);
51
+ setError(null);
52
+ try {
53
+ const params = dir ? `?dir=${encodeURIComponent(dir)}` : '';
54
+ const res = await fetch(`/api/browse${params}`, { signal: controller.signal });
55
+ const result = await readJsonResponse<BrowseResponse>(res);
56
+ if (!res.ok || !result) {
57
+ throw new Error(getApiErrorMessage(res, result, 'Failed to browse'));
58
+ }
59
+ setData(result);
60
+ } catch (err) {
61
+ if (controller.signal.aborted) return;
62
+ setError(err instanceof Error ? err.message : 'Failed to browse');
63
+ } finally {
64
+ if (!controller.signal.aborted) setLoading(false);
65
+ }
66
+ }, []);
67
+
68
+ useEffect(() => {
69
+ browse(initialDir);
70
+ return () => {
71
+ abortRef.current?.abort();
72
+ };
73
+ }, [browse, initialDir]);
74
+
75
+ const dirName = getPathBasename(data?.dir || '') || data?.dir || 'Files';
76
+
77
+ return (
78
+ <div className="flex flex-col h-full">
79
+ {/* Header */}
80
+ {!hideHeader && (
81
+ <div className="h-10 border-b border-border flex items-center justify-between pl-1 pr-2 shrink-0">
82
+ <span className="px-2.5 py-1.5 rounded text-xs font-medium text-content truncate">
83
+ Explorer
84
+ </span>
85
+ <button
86
+ onClick={onClose}
87
+ className="shrink-0 p-1 rounded-md text-content-muted hover:text-content-secondary hover:bg-tint transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
88
+ title="Close panel"
89
+ aria-label="Close panel"
90
+ >
91
+ <svg
92
+ className="w-3.5 h-3.5"
93
+ viewBox="0 0 24 24"
94
+ fill="none"
95
+ stroke="currentColor"
96
+ strokeWidth={2}
97
+ >
98
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
99
+ </svg>
100
+ </button>
101
+ </div>
102
+ )}
103
+
104
+ {/* Breadcrumb navigation */}
105
+ {data && (
106
+ <div className="px-1.5 py-1 border-b border-border-subtle flex items-center gap-0.5">
107
+ {data.parent && (
108
+ <button
109
+ onClick={() => browse(data.parent!)}
110
+ className="p-1 rounded text-content-muted hover:text-content-secondary hover:bg-tint transition-colors shrink-0"
111
+ title="Go up"
112
+ >
113
+ <svg
114
+ className="w-3.5 h-3.5"
115
+ fill="none"
116
+ viewBox="0 0 24 24"
117
+ stroke="currentColor"
118
+ strokeWidth={2}
119
+ >
120
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
121
+ </svg>
122
+ </button>
123
+ )}
124
+ <span className="text-xs text-content-secondary truncate px-1" title={data.dir}>
125
+ {dirName}
126
+ </span>
127
+ </div>
128
+ )}
129
+
130
+ {/* Loading */}
131
+ {loading && !data && (
132
+ <div className="flex-1 flex items-center justify-center text-xs text-content-muted">
133
+ Loading...
134
+ </div>
135
+ )}
136
+
137
+ {/* Error */}
138
+ {error && !data && (
139
+ <div className="flex-1 flex items-center justify-center text-xs text-danger px-3 text-center">
140
+ {error}
141
+ </div>
142
+ )}
143
+
144
+ {/* File listing */}
145
+ {data && (
146
+ <div
147
+ className="flex-1 overflow-y-auto py-1"
148
+ onContextMenu={(e) => {
149
+ // Fire on blank space — skip if a file/dir button already handled it
150
+ if (!onCtxMenu || !data) return;
151
+ // If the target is inside a button (file/dir item), let that handler take over
152
+ if ((e.target as HTMLElement).closest('button')) return;
153
+ e.preventDefault();
154
+ onCtxMenu({
155
+ type: 'blank',
156
+ path: data.dir,
157
+ name: getPathBasename(data.dir) || data.dir,
158
+ x: e.clientX,
159
+ y: e.clientY,
160
+ });
161
+ }}
162
+ >
163
+ {/* Directories */}
164
+ {data.directories.map((dir) => (
165
+ <button
166
+ key={dir.path}
167
+ onClick={() => browse(dir.path)}
168
+ onContextMenu={(e) => {
169
+ if (!onCtxMenu) return;
170
+ e.preventDefault();
171
+ onCtxMenu({
172
+ type: 'directory',
173
+ path: dir.path,
174
+ name: dir.name,
175
+ x: e.clientX,
176
+ y: e.clientY,
177
+ });
178
+ }}
179
+ className="w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-tint transition-colors"
180
+ >
181
+ <svg
182
+ className="w-3.5 h-3.5 text-warning shrink-0"
183
+ fill="none"
184
+ viewBox="0 0 24 24"
185
+ stroke="currentColor"
186
+ strokeWidth={2}
187
+ >
188
+ <path
189
+ strokeLinecap="round"
190
+ strokeLinejoin="round"
191
+ d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
192
+ />
193
+ </svg>
194
+ <span className="text-content truncate">{dir.name}</span>
195
+ </button>
196
+ ))}
197
+
198
+ {/* Files */}
199
+ {data.files.map((file) => {
200
+ const isActive = file.path === activeFilePath;
201
+ return (
202
+ <button
203
+ key={file.path}
204
+ onClick={() => onOpenFile(file.path)}
205
+ onContextMenu={(e) => {
206
+ if (!onCtxMenu) return;
207
+ e.preventDefault();
208
+ onCtxMenu({
209
+ type: 'file',
210
+ path: file.path,
211
+ name: file.name,
212
+ x: e.clientX,
213
+ y: e.clientY,
214
+ });
215
+ }}
216
+ className={`w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 transition-colors ${
217
+ isActive
218
+ ? 'bg-primary-bg text-primary-text font-medium'
219
+ : 'text-content hover:bg-tint'
220
+ }`}
221
+ title={file.path}
222
+ >
223
+ <svg
224
+ className={`w-3.5 h-3.5 shrink-0 ${isActive ? 'text-primary-text' : 'text-content-muted'}`}
225
+ fill="none"
226
+ viewBox="0 0 24 24"
227
+ stroke="currentColor"
228
+ strokeWidth={2}
229
+ >
230
+ <path
231
+ strokeLinecap="round"
232
+ strokeLinejoin="round"
233
+ d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
234
+ />
235
+ </svg>
236
+ <span className="truncate">{file.name}</span>
237
+ </button>
238
+ );
239
+ })}
240
+
241
+ {/* Empty state */}
242
+ {data.directories.length === 0 && data.files.length === 0 && (
243
+ <div className="px-3 py-6 text-center text-[10px] text-content-muted">
244
+ No markdown files
245
+ </div>
246
+ )}
247
+ </div>
248
+ )}
249
+ </div>
250
+ );
251
+ }