@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.
Files changed (133) hide show
  1. package/bin/ctlsurf-worker.js +173 -0
  2. package/electron-vite.config.ts +34 -0
  3. package/out/headless/index.mjs +1364 -0
  4. package/out/headless/index.mjs.map +7 -0
  5. package/out/main/index.js +1131 -0
  6. package/out/preload/index.js +67 -0
  7. package/out/renderer/assets/abap-D5KwWAsZ.js +1399 -0
  8. package/out/renderer/assets/apex-DVGUZ64i.js +331 -0
  9. package/out/renderer/assets/azcli-BEAhqcuE.js +69 -0
  10. package/out/renderer/assets/bat-Bqkp9Cfu.js +101 -0
  11. package/out/renderer/assets/bicep-DIlfshcM.js +110 -0
  12. package/out/renderer/assets/cameligo-CLaaYNMV.js +175 -0
  13. package/out/renderer/assets/clojure-fcgFaMHx.js +762 -0
  14. package/out/renderer/assets/codicon-ngg6Pgfi.ttf +0 -0
  15. package/out/renderer/assets/coffee-CzJ5oEdj.js +233 -0
  16. package/out/renderer/assets/cpp-CcN6f0ik.js +390 -0
  17. package/out/renderer/assets/csharp-BJeIuvde.js +327 -0
  18. package/out/renderer/assets/csp-D_3BK2Wp.js +54 -0
  19. package/out/renderer/assets/css-i3rI3_64.js +186 -0
  20. package/out/renderer/assets/css.worker-umuuUiIb.js +53567 -0
  21. package/out/renderer/assets/cssMode-DL0XItGB.js +208 -0
  22. package/out/renderer/assets/cypher-D0--_GAN.js +264 -0
  23. package/out/renderer/assets/dart-vLMHv35g.js +282 -0
  24. package/out/renderer/assets/dockerfile--oxj0cAH.js +131 -0
  25. package/out/renderer/assets/ecl-CeuUgzaZ.js +457 -0
  26. package/out/renderer/assets/editor.worker-CNgWLVu7.js +13695 -0
  27. package/out/renderer/assets/elixir-eLfY1jWH.js +570 -0
  28. package/out/renderer/assets/flow9-ZSTChSMd.js +143 -0
  29. package/out/renderer/assets/freemarker2-CrOEuDcF.js +995 -0
  30. package/out/renderer/assets/fsharp-D2uoxuLH.js +218 -0
  31. package/out/renderer/assets/go-brnMpFrj.js +219 -0
  32. package/out/renderer/assets/graphql-BeiGgjIU.js +152 -0
  33. package/out/renderer/assets/handlebars-D4QYaBof.js +414 -0
  34. package/out/renderer/assets/hcl-CrX1Es2W.js +184 -0
  35. package/out/renderer/assets/html-B2Dqk2ai.js +303 -0
  36. package/out/renderer/assets/html.worker-BT47iy49.js +29777 -0
  37. package/out/renderer/assets/htmlMode-CdZ0Prhd.js +224 -0
  38. package/out/renderer/assets/index-CJ6RsQWP.css +8108 -0
  39. package/out/renderer/assets/index-pZmE1QXB.js +211777 -0
  40. package/out/renderer/assets/ini-BcQysCTb.js +72 -0
  41. package/out/renderer/assets/java-Dt3iMn2o.js +233 -0
  42. package/out/renderer/assets/javascript-CK8zNQXj.js +72 -0
  43. package/out/renderer/assets/json.worker-D4JVmXIe.js +21424 -0
  44. package/out/renderer/assets/jsonMode-Cewaellc.js +931 -0
  45. package/out/renderer/assets/julia-Cm3ItYL_.js +512 -0
  46. package/out/renderer/assets/kotlin-Ddo1SjA5.js +253 -0
  47. package/out/renderer/assets/less-B7Qaxw-O.js +162 -0
  48. package/out/renderer/assets/lexon-C1U0m2n9.js +158 -0
  49. package/out/renderer/assets/liquid-Bd3GPNs2.js +235 -0
  50. package/out/renderer/assets/lspLanguageFeatures-DSDH7BnA.js +1841 -0
  51. package/out/renderer/assets/lua-hNsuGJkO.js +163 -0
  52. package/out/renderer/assets/m3-6ko6q9-_.js +211 -0
  53. package/out/renderer/assets/markdown-B0YTnTxW.js +230 -0
  54. package/out/renderer/assets/mdx-CCPVCrXC.js +159 -0
  55. package/out/renderer/assets/mips-CJm71dS3.js +199 -0
  56. package/out/renderer/assets/msdax-BBeIktCY.js +376 -0
  57. package/out/renderer/assets/mysql-BWiizXSn.js +879 -0
  58. package/out/renderer/assets/objective-c-B1L1C5EC.js +184 -0
  59. package/out/renderer/assets/pascal-DMQyD4Xk.js +252 -0
  60. package/out/renderer/assets/pascaligo-VA_LQ1oU.js +165 -0
  61. package/out/renderer/assets/perl-DC0Z0tlO.js +627 -0
  62. package/out/renderer/assets/pgsql-DaSGFTLp.js +852 -0
  63. package/out/renderer/assets/php-Bkx1qpkQ.js +501 -0
  64. package/out/renderer/assets/pla-DEV89yYj.js +138 -0
  65. package/out/renderer/assets/postiats-CVVurEnu.js +908 -0
  66. package/out/renderer/assets/powerquery-BQ_t1ZiQ.js +891 -0
  67. package/out/renderer/assets/powershell-BXiKvz7Z.js +240 -0
  68. package/out/renderer/assets/protobuf-CndvAUGu.js +421 -0
  69. package/out/renderer/assets/pug-BxCXwerb.js +403 -0
  70. package/out/renderer/assets/python-34jOtlcC.js +295 -0
  71. package/out/renderer/assets/qsharp-BWK6YLKm.js +302 -0
  72. package/out/renderer/assets/r-CtqYUQ6l.js +244 -0
  73. package/out/renderer/assets/razor-DXRw694z.js +545 -0
  74. package/out/renderer/assets/redis-O7gSt3oh.js +303 -0
  75. package/out/renderer/assets/redshift-CvYMMYZY.js +810 -0
  76. package/out/renderer/assets/restructuredtext-B-KQCVu_.js +175 -0
  77. package/out/renderer/assets/ruby-DCd4DmAr.js +512 -0
  78. package/out/renderer/assets/rust-B1c0VCeq.js +344 -0
  79. package/out/renderer/assets/sb-Chfc_wZF.js +116 -0
  80. package/out/renderer/assets/scala-DbVzH-3O.js +371 -0
  81. package/out/renderer/assets/scheme-D7PxodDG.js +109 -0
  82. package/out/renderer/assets/scss-B42qMyAu.js +261 -0
  83. package/out/renderer/assets/shell-vZEubQ82.js +222 -0
  84. package/out/renderer/assets/solidity-yHOxYChb.js +1368 -0
  85. package/out/renderer/assets/sophia-D7pU0Y1d.js +200 -0
  86. package/out/renderer/assets/sparql-DxuVdnRl.js +202 -0
  87. package/out/renderer/assets/sql-BAGepFCR.js +854 -0
  88. package/out/renderer/assets/st-C-b0Dh53.js +417 -0
  89. package/out/renderer/assets/swift-BmOZGynf.js +313 -0
  90. package/out/renderer/assets/systemverilog-BOC0OOdC.js +577 -0
  91. package/out/renderer/assets/tcl-Bb4GCwBr.js +233 -0
  92. package/out/renderer/assets/ts.worker-C7hW3aY-.js +225330 -0
  93. package/out/renderer/assets/tsMode-CmND5_wB.js +1265 -0
  94. package/out/renderer/assets/twig-DvgEGWAV.js +393 -0
  95. package/out/renderer/assets/typescript-BNNI0Euv.js +337 -0
  96. package/out/renderer/assets/typespec-R77Ln7Jb.js +128 -0
  97. package/out/renderer/assets/vb-Bm6ESA0Q.js +373 -0
  98. package/out/renderer/assets/wgsl-_KPae5vw.js +454 -0
  99. package/out/renderer/assets/xml-CgdndrNB.js +89 -0
  100. package/out/renderer/assets/yaml-DNWPIf1s.js +200 -0
  101. package/out/renderer/index.html +13 -0
  102. package/package.json +67 -0
  103. package/resources/icon.icns +0 -0
  104. package/resources/icon.ico +0 -0
  105. package/resources/icon.png +0 -0
  106. package/src/main/agents.ts +46 -0
  107. package/src/main/bridge.ts +180 -0
  108. package/src/main/ctlsurfApi.ts +142 -0
  109. package/src/main/detectMode.ts +17 -0
  110. package/src/main/headless.ts +182 -0
  111. package/src/main/index.ts +300 -0
  112. package/src/main/orchestrator.ts +404 -0
  113. package/src/main/pty.ts +65 -0
  114. package/src/main/settingsDir.ts +17 -0
  115. package/src/main/tui.ts +366 -0
  116. package/src/main/workerWs.ts +312 -0
  117. package/src/preload/index.ts +114 -0
  118. package/src/renderer/App.tsx +275 -0
  119. package/src/renderer/components/CtlsurfPanel.tsx +49 -0
  120. package/src/renderer/components/EditorPanel.tsx +232 -0
  121. package/src/renderer/components/MultiSplitPane.tsx +251 -0
  122. package/src/renderer/components/PaneLayout.tsx +419 -0
  123. package/src/renderer/components/SettingsDialog.tsx +204 -0
  124. package/src/renderer/components/SplitPane.tsx +82 -0
  125. package/src/renderer/components/StatusBar.tsx +73 -0
  126. package/src/renderer/components/TerminalPanel.tsx +140 -0
  127. package/src/renderer/index.html +12 -0
  128. package/src/renderer/main.tsx +10 -0
  129. package/src/renderer/styles.css +722 -0
  130. package/tsconfig.json +8 -0
  131. package/tsconfig.main.json +15 -0
  132. package/tsconfig.preload.json +14 -0
  133. 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">&#x2630;</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
+ &times;
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">&#x2630;</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
+ &times;
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
+ }