@phenx-inc/ctlsurf 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/bin/ctlsurf-worker.js +173 -0
- package/electron-vite.config.ts +34 -0
- package/out/headless/index.mjs +1364 -0
- package/out/headless/index.mjs.map +7 -0
- package/out/main/index.js +1131 -0
- package/out/preload/index.js +67 -0
- package/out/renderer/assets/abap-D5KwWAsZ.js +1399 -0
- package/out/renderer/assets/apex-DVGUZ64i.js +331 -0
- package/out/renderer/assets/azcli-BEAhqcuE.js +69 -0
- package/out/renderer/assets/bat-Bqkp9Cfu.js +101 -0
- package/out/renderer/assets/bicep-DIlfshcM.js +110 -0
- package/out/renderer/assets/cameligo-CLaaYNMV.js +175 -0
- package/out/renderer/assets/clojure-fcgFaMHx.js +762 -0
- package/out/renderer/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/out/renderer/assets/coffee-CzJ5oEdj.js +233 -0
- package/out/renderer/assets/cpp-CcN6f0ik.js +390 -0
- package/out/renderer/assets/csharp-BJeIuvde.js +327 -0
- package/out/renderer/assets/csp-D_3BK2Wp.js +54 -0
- package/out/renderer/assets/css-i3rI3_64.js +186 -0
- package/out/renderer/assets/css.worker-umuuUiIb.js +53567 -0
- package/out/renderer/assets/cssMode-DL0XItGB.js +208 -0
- package/out/renderer/assets/cypher-D0--_GAN.js +264 -0
- package/out/renderer/assets/dart-vLMHv35g.js +282 -0
- package/out/renderer/assets/dockerfile--oxj0cAH.js +131 -0
- package/out/renderer/assets/ecl-CeuUgzaZ.js +457 -0
- package/out/renderer/assets/editor.worker-CNgWLVu7.js +13695 -0
- package/out/renderer/assets/elixir-eLfY1jWH.js +570 -0
- package/out/renderer/assets/flow9-ZSTChSMd.js +143 -0
- package/out/renderer/assets/freemarker2-CrOEuDcF.js +995 -0
- package/out/renderer/assets/fsharp-D2uoxuLH.js +218 -0
- package/out/renderer/assets/go-brnMpFrj.js +219 -0
- package/out/renderer/assets/graphql-BeiGgjIU.js +152 -0
- package/out/renderer/assets/handlebars-D4QYaBof.js +414 -0
- package/out/renderer/assets/hcl-CrX1Es2W.js +184 -0
- package/out/renderer/assets/html-B2Dqk2ai.js +303 -0
- package/out/renderer/assets/html.worker-BT47iy49.js +29777 -0
- package/out/renderer/assets/htmlMode-CdZ0Prhd.js +224 -0
- package/out/renderer/assets/index-CJ6RsQWP.css +8108 -0
- package/out/renderer/assets/index-pZmE1QXB.js +211777 -0
- package/out/renderer/assets/ini-BcQysCTb.js +72 -0
- package/out/renderer/assets/java-Dt3iMn2o.js +233 -0
- package/out/renderer/assets/javascript-CK8zNQXj.js +72 -0
- package/out/renderer/assets/json.worker-D4JVmXIe.js +21424 -0
- package/out/renderer/assets/jsonMode-Cewaellc.js +931 -0
- package/out/renderer/assets/julia-Cm3ItYL_.js +512 -0
- package/out/renderer/assets/kotlin-Ddo1SjA5.js +253 -0
- package/out/renderer/assets/less-B7Qaxw-O.js +162 -0
- package/out/renderer/assets/lexon-C1U0m2n9.js +158 -0
- package/out/renderer/assets/liquid-Bd3GPNs2.js +235 -0
- package/out/renderer/assets/lspLanguageFeatures-DSDH7BnA.js +1841 -0
- package/out/renderer/assets/lua-hNsuGJkO.js +163 -0
- package/out/renderer/assets/m3-6ko6q9-_.js +211 -0
- package/out/renderer/assets/markdown-B0YTnTxW.js +230 -0
- package/out/renderer/assets/mdx-CCPVCrXC.js +159 -0
- package/out/renderer/assets/mips-CJm71dS3.js +199 -0
- package/out/renderer/assets/msdax-BBeIktCY.js +376 -0
- package/out/renderer/assets/mysql-BWiizXSn.js +879 -0
- package/out/renderer/assets/objective-c-B1L1C5EC.js +184 -0
- package/out/renderer/assets/pascal-DMQyD4Xk.js +252 -0
- package/out/renderer/assets/pascaligo-VA_LQ1oU.js +165 -0
- package/out/renderer/assets/perl-DC0Z0tlO.js +627 -0
- package/out/renderer/assets/pgsql-DaSGFTLp.js +852 -0
- package/out/renderer/assets/php-Bkx1qpkQ.js +501 -0
- package/out/renderer/assets/pla-DEV89yYj.js +138 -0
- package/out/renderer/assets/postiats-CVVurEnu.js +908 -0
- package/out/renderer/assets/powerquery-BQ_t1ZiQ.js +891 -0
- package/out/renderer/assets/powershell-BXiKvz7Z.js +240 -0
- package/out/renderer/assets/protobuf-CndvAUGu.js +421 -0
- package/out/renderer/assets/pug-BxCXwerb.js +403 -0
- package/out/renderer/assets/python-34jOtlcC.js +295 -0
- package/out/renderer/assets/qsharp-BWK6YLKm.js +302 -0
- package/out/renderer/assets/r-CtqYUQ6l.js +244 -0
- package/out/renderer/assets/razor-DXRw694z.js +545 -0
- package/out/renderer/assets/redis-O7gSt3oh.js +303 -0
- package/out/renderer/assets/redshift-CvYMMYZY.js +810 -0
- package/out/renderer/assets/restructuredtext-B-KQCVu_.js +175 -0
- package/out/renderer/assets/ruby-DCd4DmAr.js +512 -0
- package/out/renderer/assets/rust-B1c0VCeq.js +344 -0
- package/out/renderer/assets/sb-Chfc_wZF.js +116 -0
- package/out/renderer/assets/scala-DbVzH-3O.js +371 -0
- package/out/renderer/assets/scheme-D7PxodDG.js +109 -0
- package/out/renderer/assets/scss-B42qMyAu.js +261 -0
- package/out/renderer/assets/shell-vZEubQ82.js +222 -0
- package/out/renderer/assets/solidity-yHOxYChb.js +1368 -0
- package/out/renderer/assets/sophia-D7pU0Y1d.js +200 -0
- package/out/renderer/assets/sparql-DxuVdnRl.js +202 -0
- package/out/renderer/assets/sql-BAGepFCR.js +854 -0
- package/out/renderer/assets/st-C-b0Dh53.js +417 -0
- package/out/renderer/assets/swift-BmOZGynf.js +313 -0
- package/out/renderer/assets/systemverilog-BOC0OOdC.js +577 -0
- package/out/renderer/assets/tcl-Bb4GCwBr.js +233 -0
- package/out/renderer/assets/ts.worker-C7hW3aY-.js +225330 -0
- package/out/renderer/assets/tsMode-CmND5_wB.js +1265 -0
- package/out/renderer/assets/twig-DvgEGWAV.js +393 -0
- package/out/renderer/assets/typescript-BNNI0Euv.js +337 -0
- package/out/renderer/assets/typespec-R77Ln7Jb.js +128 -0
- package/out/renderer/assets/vb-Bm6ESA0Q.js +373 -0
- package/out/renderer/assets/wgsl-_KPae5vw.js +454 -0
- package/out/renderer/assets/xml-CgdndrNB.js +89 -0
- package/out/renderer/assets/yaml-DNWPIf1s.js +200 -0
- package/out/renderer/index.html +13 -0
- package/package.json +67 -0
- package/resources/icon.icns +0 -0
- package/resources/icon.ico +0 -0
- package/resources/icon.png +0 -0
- package/src/main/agents.ts +46 -0
- package/src/main/bridge.ts +180 -0
- package/src/main/ctlsurfApi.ts +142 -0
- package/src/main/detectMode.ts +17 -0
- package/src/main/headless.ts +182 -0
- package/src/main/index.ts +300 -0
- package/src/main/orchestrator.ts +404 -0
- package/src/main/pty.ts +65 -0
- package/src/main/settingsDir.ts +17 -0
- package/src/main/tui.ts +366 -0
- package/src/main/workerWs.ts +312 -0
- package/src/preload/index.ts +114 -0
- package/src/renderer/App.tsx +275 -0
- package/src/renderer/components/CtlsurfPanel.tsx +49 -0
- package/src/renderer/components/EditorPanel.tsx +232 -0
- package/src/renderer/components/MultiSplitPane.tsx +251 -0
- package/src/renderer/components/PaneLayout.tsx +419 -0
- package/src/renderer/components/SettingsDialog.tsx +204 -0
- package/src/renderer/components/SplitPane.tsx +82 -0
- package/src/renderer/components/StatusBar.tsx +73 -0
- package/src/renderer/components/TerminalPanel.tsx +140 -0
- package/src/renderer/index.html +12 -0
- package/src/renderer/main.tsx +10 -0
- package/src/renderer/styles.css +722 -0
- package/tsconfig.json +8 -0
- package/tsconfig.main.json +15 -0
- package/tsconfig.preload.json +14 -0
- package/tsconfig.renderer.json +15 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface PaneConfig {
|
|
4
|
+
id: string
|
|
5
|
+
label: string
|
|
6
|
+
content: React.ReactNode
|
|
7
|
+
visible: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface MultiSplitPaneProps {
|
|
11
|
+
panes: PaneConfig[]
|
|
12
|
+
onReorder: (orderedIds: string[]) => void
|
|
13
|
+
onToggle: (id: string) => void
|
|
14
|
+
onResize?: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function MultiSplitPane({ panes, onReorder, onToggle, onResize }: MultiSplitPaneProps) {
|
|
18
|
+
const visiblePanes = panes.filter(p => p.visible)
|
|
19
|
+
const count = visiblePanes.length
|
|
20
|
+
|
|
21
|
+
// Equal splits by default
|
|
22
|
+
const [splits, setSplits] = useState<number[]>(() =>
|
|
23
|
+
Array(count).fill(100 / count)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
// Reset splits when visible pane count changes
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setSplits(Array(count).fill(100 / count))
|
|
29
|
+
}, [count])
|
|
30
|
+
|
|
31
|
+
// --- Divider resize ---
|
|
32
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
33
|
+
const resizingIndex = useRef<number | null>(null)
|
|
34
|
+
|
|
35
|
+
const handleDividerDown = useCallback((index: number, e: React.MouseEvent) => {
|
|
36
|
+
e.preventDefault()
|
|
37
|
+
resizingIndex.current = index
|
|
38
|
+
document.body.style.cursor = 'col-resize'
|
|
39
|
+
document.body.style.userSelect = 'none'
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
44
|
+
if (resizingIndex.current === null || !containerRef.current) return
|
|
45
|
+
const idx = resizingIndex.current
|
|
46
|
+
const rect = containerRef.current.getBoundingClientRect()
|
|
47
|
+
const mousePct = ((e.clientX - rect.left) / rect.width) * 100
|
|
48
|
+
const sumBefore = splits.slice(0, idx).reduce((a, b) => a + b, 0)
|
|
49
|
+
const pairTotal = splits[idx] + splits[idx + 1]
|
|
50
|
+
|
|
51
|
+
let newLeft = mousePct - sumBefore
|
|
52
|
+
let newRight = pairTotal - newLeft
|
|
53
|
+
|
|
54
|
+
const minPct = 10
|
|
55
|
+
if (newLeft < minPct) { newLeft = minPct; newRight = pairTotal - minPct }
|
|
56
|
+
if (newRight < minPct) { newRight = minPct; newLeft = pairTotal - minPct }
|
|
57
|
+
|
|
58
|
+
setSplits(prev => {
|
|
59
|
+
const next = [...prev]
|
|
60
|
+
next[idx] = newLeft
|
|
61
|
+
next[idx + 1] = newRight
|
|
62
|
+
return next
|
|
63
|
+
})
|
|
64
|
+
onResize?.()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const handleMouseUp = () => {
|
|
68
|
+
if (resizingIndex.current !== null) {
|
|
69
|
+
resizingIndex.current = null
|
|
70
|
+
document.body.style.cursor = ''
|
|
71
|
+
document.body.style.userSelect = ''
|
|
72
|
+
onResize?.()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
window.addEventListener('mousemove', handleMouseMove)
|
|
77
|
+
window.addEventListener('mouseup', handleMouseUp)
|
|
78
|
+
return () => {
|
|
79
|
+
window.removeEventListener('mousemove', handleMouseMove)
|
|
80
|
+
window.removeEventListener('mouseup', handleMouseUp)
|
|
81
|
+
}
|
|
82
|
+
}, [splits, onResize])
|
|
83
|
+
|
|
84
|
+
// --- Drag-to-reorder ---
|
|
85
|
+
const [dragId, setDragId] = useState<string | null>(null)
|
|
86
|
+
const [dropTarget, setDropTarget] = useState<{ id: string; side: 'left' | 'right' } | null>(null)
|
|
87
|
+
|
|
88
|
+
const handleDragStart = useCallback((id: string, e: React.DragEvent) => {
|
|
89
|
+
setDragId(id)
|
|
90
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
91
|
+
e.dataTransfer.setData('text/plain', id)
|
|
92
|
+
// Use a ghost image from the pane header
|
|
93
|
+
const el = (e.target as HTMLElement).closest('.pane-header') as HTMLElement
|
|
94
|
+
if (el) {
|
|
95
|
+
e.dataTransfer.setDragImage(el, 40, 14)
|
|
96
|
+
}
|
|
97
|
+
}, [])
|
|
98
|
+
|
|
99
|
+
const handleDragOver = useCallback((targetId: string, e: React.DragEvent) => {
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
e.dataTransfer.dropEffect = 'move'
|
|
102
|
+
if (!dragId || targetId === dragId) {
|
|
103
|
+
setDropTarget(null)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
// Determine left vs right side of the target pane header
|
|
107
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
108
|
+
const midX = rect.left + rect.width / 2
|
|
109
|
+
const side = e.clientX < midX ? 'left' : 'right'
|
|
110
|
+
setDropTarget({ id: targetId, side })
|
|
111
|
+
}, [dragId])
|
|
112
|
+
|
|
113
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
if (!dragId || !dropTarget) {
|
|
116
|
+
setDragId(null)
|
|
117
|
+
setDropTarget(null)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build the full ordering (including hidden panes in their relative positions)
|
|
122
|
+
const allIds = panes.map(p => p.id)
|
|
123
|
+
const visibleIds = visiblePanes.map(p => p.id)
|
|
124
|
+
|
|
125
|
+
// Remove dragged from visible order
|
|
126
|
+
const filtered = visibleIds.filter(id => id !== dragId)
|
|
127
|
+
|
|
128
|
+
// Find insert position
|
|
129
|
+
const targetIdx = filtered.indexOf(dropTarget.id)
|
|
130
|
+
const insertIdx = dropTarget.side === 'left' ? targetIdx : targetIdx + 1
|
|
131
|
+
filtered.splice(insertIdx, 0, dragId)
|
|
132
|
+
|
|
133
|
+
// Rebuild full order: keep hidden panes in place relative to visible ones
|
|
134
|
+
const newOrder: string[] = []
|
|
135
|
+
let visIdx = 0
|
|
136
|
+
for (const id of allIds) {
|
|
137
|
+
if (panes.find(p => p.id === id)?.visible) {
|
|
138
|
+
newOrder.push(filtered[visIdx++])
|
|
139
|
+
} else {
|
|
140
|
+
newOrder.push(id)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onReorder(newOrder)
|
|
145
|
+
setDragId(null)
|
|
146
|
+
setDropTarget(null)
|
|
147
|
+
}, [dragId, dropTarget, panes, visiblePanes, onReorder])
|
|
148
|
+
|
|
149
|
+
const handleDragEnd = useCallback(() => {
|
|
150
|
+
setDragId(null)
|
|
151
|
+
setDropTarget(null)
|
|
152
|
+
}, [])
|
|
153
|
+
|
|
154
|
+
if (count === 0) {
|
|
155
|
+
return (
|
|
156
|
+
<div className="multi-split-empty">
|
|
157
|
+
<span>All panes hidden. Use the titlebar buttons to show a pane.</span>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (count === 1) {
|
|
163
|
+
const pane = visiblePanes[0]
|
|
164
|
+
return (
|
|
165
|
+
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
166
|
+
<PaneHeader
|
|
167
|
+
pane={pane}
|
|
168
|
+
isDragging={false}
|
|
169
|
+
dropSide={null}
|
|
170
|
+
onDragStart={handleDragStart}
|
|
171
|
+
onDragOver={handleDragOver}
|
|
172
|
+
onDrop={handleDrop}
|
|
173
|
+
onDragEnd={handleDragEnd}
|
|
174
|
+
onToggle={onToggle}
|
|
175
|
+
/>
|
|
176
|
+
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>{pane.content}</div>
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className="multi-split-pane" ref={containerRef}>
|
|
183
|
+
{visiblePanes.map((pane, i) => (
|
|
184
|
+
<div key={pane.id} style={{ display: 'contents' }}>
|
|
185
|
+
<div
|
|
186
|
+
className={`multi-split-panel ${dragId === pane.id ? 'dragging' : ''}`}
|
|
187
|
+
style={{ width: `${splits[i]}%` }}
|
|
188
|
+
>
|
|
189
|
+
<PaneHeader
|
|
190
|
+
pane={pane}
|
|
191
|
+
isDragging={dragId === pane.id}
|
|
192
|
+
dropSide={dropTarget?.id === pane.id ? dropTarget.side : null}
|
|
193
|
+
onDragStart={handleDragStart}
|
|
194
|
+
onDragOver={handleDragOver}
|
|
195
|
+
onDrop={handleDrop}
|
|
196
|
+
onDragEnd={handleDragEnd}
|
|
197
|
+
onToggle={onToggle}
|
|
198
|
+
/>
|
|
199
|
+
<div className="multi-split-panel-content">{pane.content}</div>
|
|
200
|
+
</div>
|
|
201
|
+
{i < count - 1 && (
|
|
202
|
+
<div
|
|
203
|
+
className="split-pane-divider"
|
|
204
|
+
onMouseDown={(e) => handleDividerDown(i, e)}
|
|
205
|
+
onDoubleClick={() => {
|
|
206
|
+
setSplits(Array(count).fill(100 / count))
|
|
207
|
+
onResize?.()
|
|
208
|
+
}}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Pane header with drag handle, label, and hide button ---
|
|
218
|
+
|
|
219
|
+
interface PaneHeaderProps {
|
|
220
|
+
pane: PaneConfig
|
|
221
|
+
isDragging: boolean
|
|
222
|
+
dropSide: 'left' | 'right' | null
|
|
223
|
+
onDragStart: (id: string, e: React.DragEvent) => void
|
|
224
|
+
onDragOver: (id: string, e: React.DragEvent) => void
|
|
225
|
+
onDrop: (e: React.DragEvent) => void
|
|
226
|
+
onDragEnd: () => void
|
|
227
|
+
onToggle: (id: string) => void
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function PaneHeader({ pane, isDragging, dropSide, onDragStart, onDragOver, onDrop, onDragEnd, onToggle }: PaneHeaderProps) {
|
|
231
|
+
return (
|
|
232
|
+
<div
|
|
233
|
+
className={`pane-header ${isDragging ? 'pane-header-dragging' : ''} ${dropSide ? `pane-drop-${dropSide}` : ''}`}
|
|
234
|
+
draggable
|
|
235
|
+
onDragStart={(e) => onDragStart(pane.id, e)}
|
|
236
|
+
onDragOver={(e) => onDragOver(pane.id, e)}
|
|
237
|
+
onDrop={onDrop}
|
|
238
|
+
onDragEnd={onDragEnd}
|
|
239
|
+
>
|
|
240
|
+
<span className="pane-drag-grip" title="Drag to reorder">☰</span>
|
|
241
|
+
<span className="pane-header-label">{pane.label}</span>
|
|
242
|
+
<button
|
|
243
|
+
className="pane-hide-btn"
|
|
244
|
+
onClick={() => onToggle(pane.id)}
|
|
245
|
+
title={`Hide ${pane.label}`}
|
|
246
|
+
>
|
|
247
|
+
×
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect, createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
// ─── Layout tree types ────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type SplitDirection = 'horizontal' | 'vertical'
|
|
6
|
+
|
|
7
|
+
export interface LeafNode {
|
|
8
|
+
type: 'leaf'
|
|
9
|
+
paneId: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SplitNode {
|
|
13
|
+
type: 'split'
|
|
14
|
+
direction: SplitDirection
|
|
15
|
+
children: LayoutNode[]
|
|
16
|
+
sizes: number[] // percentages, must sum to 100
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type LayoutNode = LeafNode | SplitNode
|
|
20
|
+
|
|
21
|
+
export type DropZone = 'left' | 'right' | 'top' | 'bottom' | 'center'
|
|
22
|
+
|
|
23
|
+
// ─── Layout tree helpers ──────────────────────────
|
|
24
|
+
|
|
25
|
+
export function removePane(node: LayoutNode, paneId: string): LayoutNode | null {
|
|
26
|
+
if (node.type === 'leaf') {
|
|
27
|
+
return node.paneId === paneId ? null : node
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const newChildren: LayoutNode[] = []
|
|
31
|
+
const newSizes: number[] = []
|
|
32
|
+
let removedSize = 0
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
35
|
+
const result = removePane(node.children[i], paneId)
|
|
36
|
+
if (result) {
|
|
37
|
+
newChildren.push(result)
|
|
38
|
+
newSizes.push(node.sizes[i])
|
|
39
|
+
} else {
|
|
40
|
+
removedSize += node.sizes[i]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (newChildren.length === 0) return null
|
|
45
|
+
if (newChildren.length === 1) return newChildren[0]
|
|
46
|
+
|
|
47
|
+
// Redistribute removed size proportionally
|
|
48
|
+
if (removedSize > 0) {
|
|
49
|
+
const total = newSizes.reduce((a, b) => a + b, 0)
|
|
50
|
+
for (let i = 0; i < newSizes.length; i++) {
|
|
51
|
+
newSizes[i] = (newSizes[i] / total) * 100
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ...node, children: newChildren, sizes: newSizes }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function insertPane(
|
|
59
|
+
tree: LayoutNode,
|
|
60
|
+
targetPaneId: string,
|
|
61
|
+
newPaneId: string,
|
|
62
|
+
zone: DropZone
|
|
63
|
+
): LayoutNode {
|
|
64
|
+
if (tree.type === 'leaf') {
|
|
65
|
+
if (tree.paneId !== targetPaneId) return tree
|
|
66
|
+
if (zone === 'center') return { type: 'leaf', paneId: newPaneId }
|
|
67
|
+
|
|
68
|
+
const direction: SplitDirection = (zone === 'left' || zone === 'right') ? 'horizontal' : 'vertical'
|
|
69
|
+
const first = zone === 'left' || zone === 'top' ? newPaneId : tree.paneId
|
|
70
|
+
const second = zone === 'left' || zone === 'top' ? tree.paneId : newPaneId
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
type: 'split',
|
|
74
|
+
direction,
|
|
75
|
+
children: [
|
|
76
|
+
{ type: 'leaf', paneId: first },
|
|
77
|
+
{ type: 'leaf', paneId: second },
|
|
78
|
+
],
|
|
79
|
+
sizes: [50, 50],
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
...tree,
|
|
85
|
+
children: tree.children.map(child => insertPane(child, targetPaneId, newPaneId, zone)),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function findPaneIds(node: LayoutNode): string[] {
|
|
90
|
+
if (node.type === 'leaf') return [node.paneId]
|
|
91
|
+
return node.children.flatMap(findPaneIds)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Drag context ─────────────────────────────────
|
|
95
|
+
|
|
96
|
+
interface DragState {
|
|
97
|
+
dragPaneId: string | null
|
|
98
|
+
setDragPaneId: (id: string | null) => void
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const DragContext = createContext<DragState>({
|
|
102
|
+
dragPaneId: null,
|
|
103
|
+
setDragPaneId: () => {},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ─── PaneLayout (root component) ──────────────────
|
|
107
|
+
|
|
108
|
+
export interface PaneContent {
|
|
109
|
+
id: string
|
|
110
|
+
label: string
|
|
111
|
+
content: React.ReactNode
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface PaneLayoutProps {
|
|
115
|
+
layout: LayoutNode
|
|
116
|
+
panes: PaneContent[]
|
|
117
|
+
onLayoutChange: (layout: LayoutNode) => void
|
|
118
|
+
onToggle: (id: string) => void
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function PaneLayout({ layout, panes, onLayoutChange, onToggle }: PaneLayoutProps) {
|
|
122
|
+
const [dragPaneId, setDragPaneId] = useState<string | null>(null)
|
|
123
|
+
|
|
124
|
+
const handleDrop = useCallback((targetPaneId: string, zone: DropZone) => {
|
|
125
|
+
if (!dragPaneId || dragPaneId === targetPaneId) return
|
|
126
|
+
|
|
127
|
+
// Remove from current position
|
|
128
|
+
let newLayout = removePane(layout, dragPaneId)
|
|
129
|
+
if (!newLayout) {
|
|
130
|
+
// Tree is now empty — just place the pane
|
|
131
|
+
newLayout = { type: 'leaf', paneId: dragPaneId }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Insert at new position
|
|
135
|
+
newLayout = insertPane(newLayout, targetPaneId, dragPaneId, zone)
|
|
136
|
+
onLayoutChange(newLayout)
|
|
137
|
+
setDragPaneId(null)
|
|
138
|
+
}, [dragPaneId, layout, onLayoutChange])
|
|
139
|
+
|
|
140
|
+
const paneMap = new Map(panes.map(p => [p.id, p]))
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<DragContext.Provider value={{ dragPaneId, setDragPaneId }}>
|
|
144
|
+
<div className="pane-layout-root">
|
|
145
|
+
<LayoutRenderer
|
|
146
|
+
node={layout}
|
|
147
|
+
paneMap={paneMap}
|
|
148
|
+
onDrop={handleDrop}
|
|
149
|
+
onToggle={onToggle}
|
|
150
|
+
onLayoutChange={onLayoutChange}
|
|
151
|
+
parentDirection={null}
|
|
152
|
+
path={[]}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</DragContext.Provider>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Recursive layout renderer ────────────────────
|
|
160
|
+
|
|
161
|
+
interface LayoutRendererProps {
|
|
162
|
+
node: LayoutNode
|
|
163
|
+
paneMap: Map<string, PaneContent>
|
|
164
|
+
onDrop: (targetPaneId: string, zone: DropZone) => void
|
|
165
|
+
onToggle: (id: string) => void
|
|
166
|
+
onLayoutChange: (layout: LayoutNode) => void
|
|
167
|
+
parentDirection: SplitDirection | null
|
|
168
|
+
path: number[]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function LayoutRenderer({ node, paneMap, onDrop, onToggle, onLayoutChange, parentDirection, path }: LayoutRendererProps) {
|
|
172
|
+
if (node.type === 'leaf') {
|
|
173
|
+
const pane = paneMap.get(node.paneId)
|
|
174
|
+
if (!pane) return null
|
|
175
|
+
return (
|
|
176
|
+
<LeafPane
|
|
177
|
+
pane={pane}
|
|
178
|
+
onDrop={onDrop}
|
|
179
|
+
onToggle={onToggle}
|
|
180
|
+
/>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<SplitContainer
|
|
186
|
+
node={node}
|
|
187
|
+
paneMap={paneMap}
|
|
188
|
+
onDrop={onDrop}
|
|
189
|
+
onToggle={onToggle}
|
|
190
|
+
onLayoutChange={onLayoutChange}
|
|
191
|
+
path={path}
|
|
192
|
+
/>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Split container with resizable dividers ──────
|
|
197
|
+
|
|
198
|
+
interface SplitContainerProps {
|
|
199
|
+
node: SplitNode
|
|
200
|
+
paneMap: Map<string, PaneContent>
|
|
201
|
+
onDrop: (targetPaneId: string, zone: DropZone) => void
|
|
202
|
+
onToggle: (id: string) => void
|
|
203
|
+
onLayoutChange: (layout: LayoutNode) => void
|
|
204
|
+
path: number[]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function SplitContainer({ node, paneMap, onDrop, onToggle, onLayoutChange, path }: SplitContainerProps) {
|
|
208
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
209
|
+
const draggingDivider = useRef<number | null>(null)
|
|
210
|
+
const sizesRef = useRef(node.sizes)
|
|
211
|
+
const [sizes, setSizes] = useState(node.sizes)
|
|
212
|
+
|
|
213
|
+
// Sync with incoming prop
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
setSizes(node.sizes)
|
|
216
|
+
sizesRef.current = node.sizes
|
|
217
|
+
}, [node.sizes])
|
|
218
|
+
|
|
219
|
+
const isHorizontal = node.direction === 'horizontal'
|
|
220
|
+
|
|
221
|
+
const handleDividerDown = useCallback((index: number, e: React.MouseEvent) => {
|
|
222
|
+
e.preventDefault()
|
|
223
|
+
e.stopPropagation()
|
|
224
|
+
draggingDivider.current = index
|
|
225
|
+
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize'
|
|
226
|
+
document.body.style.userSelect = 'none'
|
|
227
|
+
}, [isHorizontal])
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
231
|
+
if (draggingDivider.current === null || !containerRef.current) return
|
|
232
|
+
const idx = draggingDivider.current
|
|
233
|
+
const rect = containerRef.current.getBoundingClientRect()
|
|
234
|
+
|
|
235
|
+
const pos = isHorizontal
|
|
236
|
+
? ((e.clientX - rect.left) / rect.width) * 100
|
|
237
|
+
: ((e.clientY - rect.top) / rect.height) * 100
|
|
238
|
+
|
|
239
|
+
const currentSizes = sizesRef.current
|
|
240
|
+
const sumBefore = currentSizes.slice(0, idx).reduce((a, b) => a + b, 0)
|
|
241
|
+
const pairTotal = currentSizes[idx] + currentSizes[idx + 1]
|
|
242
|
+
|
|
243
|
+
let newFirst = pos - sumBefore
|
|
244
|
+
let newSecond = pairTotal - newFirst
|
|
245
|
+
|
|
246
|
+
const minPct = 8
|
|
247
|
+
if (newFirst < minPct) { newFirst = minPct; newSecond = pairTotal - minPct }
|
|
248
|
+
if (newSecond < minPct) { newSecond = minPct; newFirst = pairTotal - minPct }
|
|
249
|
+
|
|
250
|
+
const next = [...currentSizes]
|
|
251
|
+
next[idx] = newFirst
|
|
252
|
+
next[idx + 1] = newSecond
|
|
253
|
+
sizesRef.current = next
|
|
254
|
+
setSizes(next)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const handleMouseUp = () => {
|
|
258
|
+
if (draggingDivider.current !== null) {
|
|
259
|
+
draggingDivider.current = null
|
|
260
|
+
document.body.style.cursor = ''
|
|
261
|
+
document.body.style.userSelect = ''
|
|
262
|
+
|
|
263
|
+
// Commit sizes to layout
|
|
264
|
+
onLayoutChange(updateSizesAtPath(path))
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Build a new tree with updated sizes at the current path
|
|
269
|
+
function updateSizesAtPath(_path: number[]): LayoutNode {
|
|
270
|
+
// We'll let the parent handle full tree updates via onLayoutChange
|
|
271
|
+
// For now just update this node's sizes
|
|
272
|
+
return { ...node, sizes: sizesRef.current }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
window.addEventListener('mousemove', handleMouseMove)
|
|
276
|
+
window.addEventListener('mouseup', handleMouseUp)
|
|
277
|
+
return () => {
|
|
278
|
+
window.removeEventListener('mousemove', handleMouseMove)
|
|
279
|
+
window.removeEventListener('mouseup', handleMouseUp)
|
|
280
|
+
}
|
|
281
|
+
}, [isHorizontal, node, onLayoutChange, path])
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<div
|
|
285
|
+
ref={containerRef}
|
|
286
|
+
className={`split-container ${isHorizontal ? 'split-horizontal' : 'split-vertical'}`}
|
|
287
|
+
>
|
|
288
|
+
{node.children.map((child, i) => (
|
|
289
|
+
<div key={i} style={{ display: 'contents' }}>
|
|
290
|
+
<div
|
|
291
|
+
className="split-child"
|
|
292
|
+
style={isHorizontal
|
|
293
|
+
? { width: `${sizes[i]}%`, height: '100%' }
|
|
294
|
+
: { height: `${sizes[i]}%`, width: '100%' }
|
|
295
|
+
}
|
|
296
|
+
>
|
|
297
|
+
<LayoutRenderer
|
|
298
|
+
node={child}
|
|
299
|
+
paneMap={paneMap}
|
|
300
|
+
onDrop={onDrop}
|
|
301
|
+
onToggle={onToggle}
|
|
302
|
+
onLayoutChange={(newChild) => {
|
|
303
|
+
const newChildren = [...node.children]
|
|
304
|
+
newChildren[i] = newChild
|
|
305
|
+
onLayoutChange({ ...node, children: newChildren })
|
|
306
|
+
}}
|
|
307
|
+
parentDirection={node.direction}
|
|
308
|
+
path={[...path, i]}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
{i < node.children.length - 1 && (
|
|
312
|
+
<div
|
|
313
|
+
className={`split-divider ${isHorizontal ? 'split-divider-h' : 'split-divider-v'}`}
|
|
314
|
+
onMouseDown={(e) => handleDividerDown(i, e)}
|
|
315
|
+
onDoubleClick={() => {
|
|
316
|
+
const equal = Array(node.children.length).fill(100 / node.children.length)
|
|
317
|
+
sizesRef.current = equal
|
|
318
|
+
setSizes(equal)
|
|
319
|
+
onLayoutChange({ ...node, sizes: equal })
|
|
320
|
+
}}
|
|
321
|
+
/>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
))}
|
|
325
|
+
</div>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Leaf pane with drop zone overlays ────────────
|
|
330
|
+
|
|
331
|
+
interface LeafPaneProps {
|
|
332
|
+
pane: PaneContent
|
|
333
|
+
onDrop: (targetPaneId: string, zone: DropZone) => void
|
|
334
|
+
onToggle: (id: string) => void
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function LeafPane({ pane, onDrop, onToggle }: LeafPaneProps) {
|
|
338
|
+
const { dragPaneId, setDragPaneId } = useContext(DragContext)
|
|
339
|
+
const [hoverZone, setHoverZone] = useState<DropZone | null>(null)
|
|
340
|
+
const paneRef = useRef<HTMLDivElement>(null)
|
|
341
|
+
|
|
342
|
+
const isDragging = dragPaneId === pane.id
|
|
343
|
+
const isDropTarget = dragPaneId !== null && dragPaneId !== pane.id
|
|
344
|
+
|
|
345
|
+
const computeZone = useCallback((e: React.DragEvent): DropZone => {
|
|
346
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
347
|
+
const x = (e.clientX - rect.left) / rect.width
|
|
348
|
+
const y = (e.clientY - rect.top) / rect.height
|
|
349
|
+
|
|
350
|
+
// Edge zones: 25% from each edge
|
|
351
|
+
const edgeThreshold = 0.25
|
|
352
|
+
|
|
353
|
+
if (y < edgeThreshold) return 'top'
|
|
354
|
+
if (y > 1 - edgeThreshold) return 'bottom'
|
|
355
|
+
if (x < edgeThreshold) return 'left'
|
|
356
|
+
if (x > 1 - edgeThreshold) return 'right'
|
|
357
|
+
return 'center'
|
|
358
|
+
}, [])
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<div
|
|
362
|
+
className={`leaf-pane ${isDragging ? 'leaf-pane-dragging' : ''}`}
|
|
363
|
+
ref={paneRef}
|
|
364
|
+
>
|
|
365
|
+
{/* Header */}
|
|
366
|
+
<div
|
|
367
|
+
className="pane-header"
|
|
368
|
+
draggable
|
|
369
|
+
onDragStart={(e) => {
|
|
370
|
+
setDragPaneId(pane.id)
|
|
371
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
372
|
+
e.dataTransfer.setData('text/plain', pane.id)
|
|
373
|
+
}}
|
|
374
|
+
onDragEnd={() => setDragPaneId(null)}
|
|
375
|
+
>
|
|
376
|
+
<span className="pane-drag-grip" title="Drag to any edge">☰</span>
|
|
377
|
+
<span className="pane-header-label">{pane.label}</span>
|
|
378
|
+
<button
|
|
379
|
+
className="pane-hide-btn"
|
|
380
|
+
onClick={() => onToggle(pane.id)}
|
|
381
|
+
title={`Hide ${pane.label}`}
|
|
382
|
+
>
|
|
383
|
+
×
|
|
384
|
+
</button>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
{/* Content */}
|
|
388
|
+
<div className="leaf-pane-content">
|
|
389
|
+
{pane.content}
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{/* Drop zone overlay (only visible when another pane is being dragged) */}
|
|
393
|
+
{isDropTarget && (
|
|
394
|
+
<div
|
|
395
|
+
className="drop-overlay"
|
|
396
|
+
onDragOver={(e) => {
|
|
397
|
+
e.preventDefault()
|
|
398
|
+
e.dataTransfer.dropEffect = 'move'
|
|
399
|
+
setHoverZone(computeZone(e))
|
|
400
|
+
}}
|
|
401
|
+
onDragLeave={() => setHoverZone(null)}
|
|
402
|
+
onDrop={(e) => {
|
|
403
|
+
e.preventDefault()
|
|
404
|
+
const zone = computeZone(e)
|
|
405
|
+
setHoverZone(null)
|
|
406
|
+
onDrop(pane.id, zone)
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
{/* Visual zone indicators */}
|
|
410
|
+
<div className={`drop-zone drop-zone-top ${hoverZone === 'top' ? 'active' : ''}`} />
|
|
411
|
+
<div className={`drop-zone drop-zone-bottom ${hoverZone === 'bottom' ? 'active' : ''}`} />
|
|
412
|
+
<div className={`drop-zone drop-zone-left ${hoverZone === 'left' ? 'active' : ''}`} />
|
|
413
|
+
<div className={`drop-zone drop-zone-right ${hoverZone === 'right' ? 'active' : ''}`} />
|
|
414
|
+
<div className={`drop-zone drop-zone-center ${hoverZone === 'center' ? 'active' : ''}`} />
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</div>
|
|
418
|
+
)
|
|
419
|
+
}
|