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,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
|
+
}
|