@lijian-ui/dsh-file-manager 0.2.2 → 0.2.4

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