@lijian-ui/dsh-file-manager 0.1.20

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 (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. package/src/mount-once.ts +48 -0
@@ -0,0 +1,546 @@
1
+ /**
2
+ * The DOM layout controller: extends the web shell's three-column frame
3
+ * (`[data-dsh-frame]`, a grid) with two trailing grid tracks — the preview
4
+ * region and the explorer column — by mirroring the shell's own inline
5
+ * grid-template-columns string and re-appending the two panel tracks on every
6
+ * shell update (MutationObserver, same frame before paint). Also owns the
7
+ * absolute drag handles (12px explorer / 20px preview hit zones), the
8
+ * floating expand button (docked at the top-right corner, just below the
9
+ * shell header's divider — issues #374 / #292), the collapse-as-width-0
10
+ * keep-mounted behavior, and the transient
11
+ * maximize mode (issue #315): while a panel is maximized the target column
12
+ * takes over the whole frame row (or renders as a fixed full-screen overlay
13
+ * on narrow viewports), and Esc / the header button restore the layout.
14
+ *
15
+ * The shell's inline style is the source of truth for the sidebar and details
16
+ * tracks; this controller never guesses their widths. Handles are out-of-flow
17
+ * (absolute), so appending tracks never disturbs the shell's own children.
18
+ *
19
+ * FileManager Layout architecture (Apache-2.0, re-implemented): the explorer
20
+ * column collapses to width 0 while staying mounted; the preview region keeps
21
+ * a 1px left border only (no outer margins — gaps would expose the window
22
+ * background, jarring in dark mode).
23
+ * @module dsh-filemgr/client/layout
24
+ */
25
+
26
+ import { handlePointerDragStart } from './drag.ts'
27
+ import {
28
+ DEFAULT_PREVIEW_REGION_PX, DEFAULT_WORKSPACE_PANEL_PX,
29
+ MAX_PREVIEW_REGION_PX, MAX_WORKSPACE_PANEL_PX,
30
+ MIN_PREVIEW_PANEL_PX, MIN_WORKSPACE_PANEL_PX,
31
+ KEY_EXPLORER_WIDTH, KEY_PREVIEW_WIDTH,
32
+ clampExplorerWidth, clampPreviewWidth,
33
+ type MaximizeTarget,
34
+ } from './store.ts'
35
+ import { writeStoredNumber } from './persist.ts'
36
+ import { maximizedGridTracks, maximizedOverlay } from './maximize.ts'
37
+ import {
38
+ FLOATING_BUTTON_HEIGHT_PX, FLOATING_HEADER_GAP_PX,
39
+ clampFloatingTop, titlebarAreaHeight, topAlignedFloatingTop,
40
+ } from './floating.ts'
41
+ import type { LayoutStore } from './store.ts'
42
+
43
+ /** The frame grid element (portals target it). */
44
+ let frameElement: HTMLElement | null = null
45
+
46
+ /** Read the current frame element (undefined while the shell is not mounted). */
47
+ export function getFrameElement(): HTMLElement | null {
48
+ return frameElement
49
+ }
50
+
51
+ /**
52
+ * Locate the frame grid element the two panel columns append into. The web-ui
53
+ * aggregate's compat shim stamps `data-dsh-frame` onto the grid, but a
54
+ * STANDALONE install of this package has no shim (the attribute never
55
+ * appears), so the panel would wait forever and never mount (issue #56). Fall
56
+ * back to the rc.6-native structure: the frame grid is the parent of the
57
+ * sidebar column, exactly the element the shim would stamp.
58
+ */
59
+ function findFrame(): HTMLElement | null {
60
+ const stamped = document.querySelector<HTMLElement>('[data-dsh-frame]')
61
+ if (stamped !== null) return stamped
62
+ return document.querySelector<HTMLElement>('[class*="sidebarCol"]')?.parentElement ?? null
63
+ }
64
+
65
+ /**
66
+ * Parse an inline grid-template-columns string into its tracks. Handles
67
+ * "minmax(0, 1fr)" (spaces inside parens must not split). Empty on failure.
68
+ */
69
+ export function parseGridTracks(input: string): string[] {
70
+ const tracks: string[] = []
71
+ let depth = 0
72
+ let current = ''
73
+ for (const char of input) {
74
+ if (char === '(') depth += 1
75
+ if (char === ')') depth = Math.max(0, depth - 1)
76
+ if (char === ' ' && depth === 0) {
77
+ if (current !== '') {
78
+ tracks.push(current)
79
+ current = ''
80
+ }
81
+ continue
82
+ }
83
+ current += char
84
+ }
85
+ if (current !== '') tracks.push(current)
86
+ return tracks
87
+ }
88
+
89
+ /** Extract a px width from one track (0 for fr/minmax/non-px tracks). */
90
+ export function trackPx(track: string): number {
91
+ const match = /^(-?[\d.]+)px$/.exec(track.trim())
92
+ return match === null ? 0 : Number(match[1])
93
+ }
94
+
95
+ /** One drag handle's geometry (hit zone + visual line) — pure CSS in the module. */
96
+ export const EXPLORER_HANDLE_WIDTH = 12
97
+ export const PREVIEW_HANDLE_WIDTH = 20
98
+
99
+ /**
100
+ * Drag target width: apply the hard px bounds (the same min/max the handle
101
+ * always enforced), then the store's ordered container-aware clamp so the
102
+ * grid never re-clamps a width the drag showed.
103
+ */
104
+ export function dragTargetWidth(
105
+ kind: 'explorer' | 'preview',
106
+ startWidth: number,
107
+ deltaX: number,
108
+ snapshot: { availableWidth: number; previewOpen: boolean; explorerWidth: number },
109
+ ): number {
110
+ const requested = startWidth + deltaX
111
+ if (kind === 'explorer') {
112
+ const bounded = Math.min(MAX_WORKSPACE_PANEL_PX, Math.max(MIN_WORKSPACE_PANEL_PX, requested))
113
+ return clampExplorerWidth(bounded, snapshot.availableWidth, snapshot.previewOpen)
114
+ }
115
+ const bounded = Math.min(MAX_PREVIEW_REGION_PX, Math.max(MIN_PREVIEW_PANEL_PX, requested))
116
+ return clampPreviewWidth(bounded, snapshot.availableWidth, snapshot.explorerWidth)
117
+ }
118
+
119
+ /** The layout controller: frame sync, handles, floating button, width math. */
120
+ export class PanelLayoutController {
121
+ private frame: HTMLElement | null = null
122
+ private previewCol: HTMLDivElement | null = null
123
+ private explorerCol: HTMLDivElement | null = null
124
+ private explorerHandle: HTMLDivElement | null = null
125
+ private previewHandle: HTMLDivElement | null = null
126
+ private floatingButton: HTMLButtonElement | null = null
127
+ private styleObserver: MutationObserver | null = null
128
+ private sizeObserver: ResizeObserver | null = null
129
+ private waitObserver: MutationObserver | null = null
130
+ private frameWidth = 0
131
+ /** Cached shell details handle (re-resolved when the shell rebuilds it). */
132
+ private detailsHandle: HTMLElement | null = null
133
+ /** The shell's own 3 tracks (sidebar, center, details) — mirror of its inline style. */
134
+ private shellTracks: string[] = []
135
+ private instantTimer: ReturnType<typeof setTimeout> | undefined
136
+ private disposers: Array<() => void> = []
137
+ constructor(private readonly layout: LayoutStore) {}
138
+
139
+ /** Start watching for the frame and attach once it appears. */
140
+ mount(): void {
141
+ const tryAttach = (): void => {
142
+ if (this.frame !== null) return
143
+ const frame = findFrame()
144
+ if (frame === null) return
145
+ this.attach(frame)
146
+ }
147
+ this.waitObserver = new MutationObserver(() => { tryAttach() })
148
+ this.waitObserver.observe(document.body, { childList: true, subtree: true })
149
+ tryAttach()
150
+ }
151
+
152
+ /** Attach to the frame: columns, handles, observers, store subscription. */
153
+ private attach(frame: HTMLElement): void {
154
+ this.frame = frame
155
+ frameElement = frame
156
+ // The wait observer's only job was finding the frame; a document-wide
157
+ // MutationObserver left running would fire on every chat render for the
158
+ // rest of the session.
159
+ this.waitObserver?.disconnect()
160
+ this.waitObserver = null
161
+ this.detailsHandle = null
162
+
163
+ // The two panel columns: trailing grid items (tracks 4 and 5).
164
+ const previewCol = document.createElement('div')
165
+ previewCol.dataset.filemgrPreviewCol = ''
166
+ previewCol.className = 'filemgr-preview-col'
167
+ previewCol.style.minWidth = '0'
168
+ previewCol.style.overflow = 'hidden'
169
+ previewCol.style.display = 'flex'
170
+ previewCol.style.flexDirection = 'column'
171
+ previewCol.style.borderLeft = '1px solid var(--aion-bg-3, #e5e6eb)'
172
+
173
+ const explorerCol = document.createElement('div')
174
+ explorerCol.dataset.filemgrExplorerCol = ''
175
+ explorerCol.className = 'filemgr-explorer-col'
176
+ explorerCol.style.minWidth = '0'
177
+ explorerCol.style.overflow = 'hidden'
178
+ explorerCol.style.display = 'flex'
179
+ explorerCol.style.flexDirection = 'column'
180
+ explorerCol.style.borderLeft = '1px solid var(--aion-bg-3, #e5e6eb)'
181
+
182
+ frame.appendChild(previewCol)
183
+ frame.appendChild(explorerCol)
184
+ this.previewCol = previewCol
185
+ this.explorerCol = explorerCol
186
+
187
+ // The absolute drag handles (out of the grid flow). Both sit on the left
188
+ // edge of their panel: dragging left widens (reverse).
189
+ this.explorerHandle = this.createHandle('filemgr-explorer-handle', EXPLORER_HANDLE_WIDTH, true, 'explorer')
190
+ this.previewHandle = this.createHandle('filemgr-preview-handle', PREVIEW_HANDLE_WIDTH, true, 'preview')
191
+ frame.appendChild(this.explorerHandle)
192
+ frame.appendChild(this.previewHandle)
193
+
194
+ // The floating expand button (fixed, top-right corner) — DOM-level,
195
+ // no React. Docked exactly where the explorer's collapse chevron sits,
196
+ // so collapsing and re-expanding toggle in place (issue #374 follow-up);
197
+ // a click toggles the explorer.
198
+ this.floatingButton = document.createElement('button')
199
+ this.floatingButton.type = 'button'
200
+ this.floatingButton.className = 'filemgr-floating-expand'
201
+ this.floatingButton.setAttribute('aria-label', 'Expand explorer')
202
+ this.floatingButton.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>'
203
+ this.floatingButton.addEventListener('click', () => { this.toggleExplorer() })
204
+ document.body.appendChild(this.floatingButton)
205
+
206
+ // Window Controls Overlay (dsh-desktop, issue #292): re-position when
207
+ // the titlebar area changes (button must stay below the window buttons).
208
+ const overlay = (navigator as Navigator & { windowControlsOverlay?: EventTarget }).windowControlsOverlay
209
+ if (overlay !== undefined) {
210
+ const onGeometryChange = (): void => { this.positionFloatingButton() }
211
+ overlay.addEventListener('geometrychange', onGeometryChange)
212
+ this.disposers.push(() => overlay.removeEventListener('geometrychange', onGeometryChange))
213
+ }
214
+
215
+ // Esc restores a maximized panel (issue #315). Editing surfaces own Esc:
216
+ // while an input/textarea/contenteditable is focused, leave it alone.
217
+ const onKeyDown = (event: KeyboardEvent): void => {
218
+ if (event.key !== 'Escape') return
219
+ const target = event.target instanceof Element ? event.target : null
220
+ if (target !== null && target.closest('input, textarea, [contenteditable="true"]') !== null) return
221
+ this.layout.update((prev) => (prev.maximized === null ? prev : { ...prev, maximized: null }))
222
+ }
223
+ window.addEventListener('keydown', onKeyDown)
224
+ this.disposers.push(() => window.removeEventListener('keydown', onKeyDown))
225
+
226
+ // Sync the shell's inline grid: any shell write re-appends our tracks.
227
+ const syncGrid = (): void => {
228
+ const el = this.frame
229
+ if (el === null) return
230
+ const inline = el.style.gridTemplateColumns
231
+ if (inline === '') return
232
+ const tracks = parseGridTracks(inline)
233
+ if (tracks.length >= 2 && tracks.length <= 3) {
234
+ // The shell's own write (3 tracks) — remember it and re-append ours.
235
+ this.shellTracks = tracks
236
+ this.applyGrid()
237
+ return
238
+ }
239
+ if (tracks.length === 5 && this.shellTracks.length === 3) {
240
+ // Our own write — keep it (the shell tracks are already mirrored).
241
+ return
242
+ }
243
+ }
244
+ this.styleObserver = new MutationObserver(syncGrid)
245
+ this.styleObserver.observe(frame, { attributes: true, attributeFilter: ['style'] })
246
+
247
+ // Measure the row width: frame minus the shell's sidebar + details tracks.
248
+ const measure = (): void => {
249
+ if (this.frame === null) return
250
+ this.frameWidth = this.frame.getBoundingClientRect().width
251
+ const sidebar = this.shellTracks.length >= 1 ? trackPx(this.shellTracks[0]) : 0
252
+ const details = this.shellTracks.length >= 3 ? trackPx(this.shellTracks[2]) : 0
253
+ const available = Math.max(0, this.frameWidth - sidebar - details)
254
+ const state = this.layout.getSnapshot()
255
+ if (Math.abs(state.availableWidth - available) > 0.5) {
256
+ this.layout.update((prev) => ({ ...prev, availableWidth: available }))
257
+ }
258
+ this.layout.shrinkToFit(this.layout.getSnapshot())
259
+ }
260
+ this.sizeObserver = new ResizeObserver(() => {
261
+ measure()
262
+ this.applyGrid()
263
+ })
264
+ this.sizeObserver.observe(frame)
265
+
266
+ // Store -> DOM: grid, handles, floating button.
267
+ this.disposers.push(this.layout.subscribe(() => this.applyGrid()))
268
+
269
+ // Initial sync: read the shell's inline style (it is already applied).
270
+ const initial = frame.style.gridTemplateColumns
271
+ if (initial !== '') {
272
+ const tracks = parseGridTracks(initial)
273
+ if (tracks.length >= 2 && tracks.length <= 3) {
274
+ this.shellTracks = tracks
275
+ } else if (tracks.length === 5 && trackPx(tracks[0]) > 0) {
276
+ // The frame may already carry our own previous 5-track write: the
277
+ // plugin hot-reloads in place (client HMR re-materializes the fiber),
278
+ // and the shell's grid is then the first three tracks. A zero-width
279
+ // first track is never a shell write (the shell collapses to the 56px
280
+ // rail, drags clamp at 280px) — treat it as untrusted and wait for
281
+ // the next shell write instead of mirroring the damage.
282
+ this.shellTracks = tracks.slice(0, 3)
283
+ }
284
+ }
285
+ measure()
286
+ this.applyGrid()
287
+ }
288
+
289
+ /** Create one drag handle element with its pointer wiring. */
290
+ private createHandle(
291
+ className: string,
292
+ hitWidth: number,
293
+ reverse: boolean,
294
+ kind: 'explorer' | 'preview',
295
+ ): HTMLDivElement {
296
+ const el = document.createElement('div')
297
+ el.className = className
298
+ el.style.position = 'absolute'
299
+ el.style.top = '0'
300
+ el.style.bottom = '0'
301
+ // Same layer as the columns (z 30): the handle strips overlap the
302
+ // column tracks, so anything lower would be painted under the opaque
303
+ // columns and stop receiving pointer events (issue #234 follow-up).
304
+ // Full-screen overlay drawers must render at the ROOT stacking context
305
+ // (z 100~1000) to cover both the columns and the handles — see the
306
+ // columns' stacking-contract note in tokens.module.css.
307
+ el.style.zIndex = '30'
308
+ el.style.cursor = 'col-resize'
309
+ el.style.width = `${hitWidth}px`
310
+ if (reverse) {
311
+ // The preview handle extends LEFT of the preview region's left edge.
312
+ el.style.marginLeft = `-${hitWidth}px`
313
+ }
314
+ el.addEventListener('pointerdown', (event: PointerEvent) => {
315
+ const isExplorer = kind === 'explorer'
316
+ handlePointerDragStart(event, el, {
317
+ reverse,
318
+ getStartWidth: () => {
319
+ const state = this.layout.getSnapshot()
320
+ return isExplorer ? state.explorerWidth : state.previewWidth
321
+ },
322
+ compute: (startWidth, deltaX) => dragTargetWidth(
323
+ isExplorer ? 'explorer' : 'preview',
324
+ startWidth,
325
+ deltaX,
326
+ this.layout.getSnapshot(),
327
+ ),
328
+ onFrame: (width) => {
329
+ // layout.update notifies the subscribers (subscribe -> applyGrid),
330
+ // so no explicit applyGrid here — double-writing every frame.
331
+ this.layout.update((prev) => (
332
+ isExplorer ? { ...prev, explorerWidth: width } : { ...prev, previewWidth: width }
333
+ ))
334
+ },
335
+ onEnd: (width) => {
336
+ writeStoredNumber(isExplorer ? KEY_EXPLORER_WIDTH : KEY_PREVIEW_WIDTH, width)
337
+ },
338
+ })
339
+ })
340
+ // Double-click resets THIS panel to its default width (instant, like
341
+ // every panel width change — the shell's grid transition must not lag).
342
+ el.addEventListener('dblclick', () => {
343
+ this.instant(() => {
344
+ const width = kind === 'explorer' ? DEFAULT_WORKSPACE_PANEL_PX : DEFAULT_PREVIEW_REGION_PX
345
+ this.layout.update((prev) => (
346
+ kind === 'explorer' ? { ...prev, explorerWidth: width } : { ...prev, previewWidth: width }
347
+ ))
348
+ writeStoredNumber(kind === 'explorer' ? KEY_EXPLORER_WIDTH : KEY_PREVIEW_WIDTH, width)
349
+ this.applyGrid()
350
+ })
351
+ })
352
+ // The visual line is drawn by CSS (::after) — the hit zone is transparent.
353
+ return el
354
+ }
355
+
356
+ /** Toggle explorer collapse (width 0, kept mounted; no transition). */
357
+ toggleExplorer(): void {
358
+ const state = this.layout.getSnapshot()
359
+ const next = !state.explorerCollapsed
360
+ this.instant(() => {
361
+ this.layout.update((prev) => ({ ...prev, explorerCollapsed: next }))
362
+ try {
363
+ localStorage.setItem(`project-panel-collapse:${state.root}`, next ? 'collapsed' : 'expanded')
364
+ } catch {
365
+ // best-effort
366
+ }
367
+ this.applyGrid()
368
+ })
369
+ }
370
+
371
+ /** Toggle the preview region (open = tabs exist; close keeps tabs). */
372
+ setPreviewOpen(open: boolean): void {
373
+ this.instant(() => {
374
+ this.layout.update((prev) => ({ ...prev, previewOpen: open }))
375
+ this.applyGrid()
376
+ })
377
+ }
378
+
379
+ /**
380
+ * Locate the shell conversation header: its bottom border is the
381
+ * horizontal divider under the "Session log" row the button should
382
+ * sit below. Resolved per call (the shell may mount it late); null when
383
+ * the shell has no header (standalone installs, desktop variants).
384
+ */
385
+ private findHeaderBottom(): number | null {
386
+ const frame = this.frame
387
+ if (frame === null) return null
388
+ const header = frame.querySelector<HTMLElement>(
389
+ '[data-pane="conversation"] header, [class*="centerCol"] header',
390
+ )
391
+ if (header === null) return null
392
+ const bottom = header.getBoundingClientRect().bottom
393
+ return Number.isFinite(bottom) ? bottom : null
394
+ }
395
+
396
+ /** Position the floating button: docked at the top-right corner, just
397
+ * below the shell header's bottom divider (fallback: the chevron row). */
398
+ private positionFloatingButton(): void {
399
+ const el = this.floatingButton
400
+ if (el === null) return
401
+ const height = window.innerHeight
402
+ const titlebar = titlebarAreaHeight()
403
+ const headerBottom = this.findHeaderBottom()
404
+ const top = headerBottom !== null
405
+ ? clampFloatingTop(headerBottom + FLOATING_HEADER_GAP_PX, height, FLOATING_BUTTON_HEIGHT_PX, titlebar)
406
+ : topAlignedFloatingTop(height, FLOATING_BUTTON_HEIGHT_PX, titlebar)
407
+ el.style.top = `${Math.round(top)}px`
408
+ el.style.transform = 'none'
409
+ }
410
+
411
+ /** Apply one store update with transitions disabled for exactly one frame. */
412
+ private instant(fn: () => void): void {
413
+ const frame = this.frame
414
+ if (frame === null) {
415
+ fn()
416
+ return
417
+ }
418
+ frame.setAttribute('data-filemgr-instant', '')
419
+ if (this.instantTimer !== undefined) clearTimeout(this.instantTimer)
420
+ this.instantTimer = setTimeout(() => {
421
+ this.instantTimer = undefined
422
+ frame.removeAttribute('data-filemgr-instant')
423
+ }, 0)
424
+ fn()
425
+ }
426
+
427
+ /** Re-write the frame grid and reposition handles + floating button. */
428
+ private applyGrid(): void {
429
+ const frame = this.frame
430
+ if (frame === null) return
431
+ // Never guess the shell tracks: without a mirrored shell write, the old
432
+ // fallback zeroed the sidebar track (the bug where the left sidebar
433
+ // vanished after a hot reload). Skip the write until syncGrid observes
434
+ // the shell's own 3-track grid.
435
+ if (this.shellTracks.length !== 3) return
436
+ const state = this.layout.getSnapshot()
437
+ const width = this.frameWidth > 0 ? this.frameWidth : frame.getBoundingClientRect().width
438
+
439
+ if (state.maximized !== null) {
440
+ this.applyMaximized(frame, state.maximized, width)
441
+ return
442
+ }
443
+ this.clearMaximizedChrome()
444
+
445
+ const explorer = this.layout.explorerWidthPx(state)
446
+ const preview = this.layout.previewWidthPx(state)
447
+
448
+ // Five tracks: shell sidebar, center, shell details, preview, explorer.
449
+ frame.style.gridTemplateColumns =
450
+ `${this.shellTracks[0]} minmax(0, 1fr) ${this.shellTracks[2]} ${Math.round(preview)}px ${Math.round(explorer)}px`
451
+
452
+ // Column contents follow the tracks (both columns always mounted).
453
+ if (this.explorerCol !== null) {
454
+ this.explorerCol.style.visibility = explorer > 0 ? 'visible' : 'hidden'
455
+ }
456
+ if (this.previewCol !== null) {
457
+ this.previewCol.style.visibility = preview > 0 ? 'visible' : 'hidden'
458
+ }
459
+
460
+ // Handles: at the left edge of each panel.
461
+ if (this.explorerHandle !== null) {
462
+ const left = Math.round(width - explorer)
463
+ this.explorerHandle.style.left = `${left}px`
464
+ this.explorerHandle.style.marginLeft = `${-EXPLORER_HANDLE_WIDTH / 2}px`
465
+ this.explorerHandle.style.display = explorer > 0 && state.root !== '' ? 'block' : 'none'
466
+ }
467
+ if (this.previewHandle !== null) {
468
+ const left = Math.round(width - explorer - preview)
469
+ this.previewHandle.style.left = `${left}px`
470
+ this.previewHandle.style.display = preview > 0 && state.root !== '' ? 'block' : 'none'
471
+ }
472
+
473
+ // The official shell renders its own details drag handle at
474
+ // `viewport - details` (details treated as the last track). Once this
475
+ // panel extends the grid with preview/explorer tracks, the real
476
+ // center/details boundary shifts left by preview + explorer — re-derive
477
+ // the handle position from the actual tracks so it stays on the details
478
+ // column's left edge (degenerates to the official value when both panels
479
+ // are closed).
480
+ const detailsTrack = trackPx(this.shellTracks[2])
481
+ // applyGrid runs on every drag frame: resolve the shell handle once and
482
+ // cache it instead of scanning the whole frame subtree each call. The
483
+ // cache resets on attach (the shell may rebuild its chrome on HMR).
484
+ if (this.detailsHandle === null || !this.detailsHandle.isConnected) {
485
+ this.detailsHandle = frame.querySelector<HTMLElement>('[data-side="details"]')
486
+ }
487
+ if (this.detailsHandle !== null) {
488
+ this.detailsHandle.style.left = `${Math.round(width - detailsTrack - preview - explorer)}px`
489
+ }
490
+
491
+ // Floating expand button: visible only when the explorer is collapsed.
492
+ if (this.floatingButton !== null) {
493
+ const show = state.root !== '' && state.explorerCollapsed
494
+ this.floatingButton.style.display = show ? 'flex' : 'none'
495
+ this.positionFloatingButton()
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Maximize layout: the target column takes over the whole frame row (the
501
+ * other tracks collapse to 0px). On narrow viewports the takeover grid is
502
+ * skipped and the column renders as a fixed full-screen overlay instead
503
+ * (issue #315). Everything stays mounted — only geometry changes.
504
+ */
505
+ private applyMaximized(frame: HTMLElement, target: MaximizeTarget, width: number): void {
506
+ const overlay = maximizedOverlay(this.layout.getSnapshot().availableWidth)
507
+ if (!overlay) {
508
+ frame.style.gridTemplateColumns = maximizedGridTracks(target, width)
509
+ }
510
+ if (this.explorerCol !== null) {
511
+ this.explorerCol.style.visibility = target === 'explorer' ? 'visible' : 'hidden'
512
+ this.explorerCol.classList.toggle('filemgr-maximized', target === 'explorer' && overlay)
513
+ }
514
+ if (this.previewCol !== null) {
515
+ this.previewCol.style.visibility = target === 'preview' ? 'visible' : 'hidden'
516
+ this.previewCol.classList.toggle('filemgr-maximized', target === 'preview' && overlay)
517
+ }
518
+ // No drag chrome while maximized: nothing to resize, and the floating
519
+ // button only makes sense for the collapsed explorer.
520
+ if (this.explorerHandle !== null) this.explorerHandle.style.display = 'none'
521
+ if (this.previewHandle !== null) this.previewHandle.style.display = 'none'
522
+ if (this.floatingButton !== null) this.floatingButton.style.display = 'none'
523
+ }
524
+
525
+ /** Remove the narrow-screen overlay class from both columns. */
526
+ private clearMaximizedChrome(): void {
527
+ this.explorerCol?.classList.remove('filemgr-maximized')
528
+ this.previewCol?.classList.remove('filemgr-maximized')
529
+ }
530
+
531
+ /** Detach everything (plugin unload). */
532
+ dispose(): void {
533
+ this.waitObserver?.disconnect()
534
+ this.styleObserver?.disconnect()
535
+ this.sizeObserver?.disconnect()
536
+ for (const dispose of this.disposers) dispose()
537
+ this.previewCol?.remove()
538
+ this.explorerCol?.remove()
539
+ this.explorerHandle?.remove()
540
+ this.previewHandle?.remove()
541
+ this.floatingButton?.remove()
542
+ if (this.instantTimer !== undefined) clearTimeout(this.instantTimer)
543
+ if (frameElement === this.frame) frameElement = null
544
+ this.frame = null
545
+ }
546
+ }