@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,389 @@
1
+ /**
2
+ * FileManager right-panel system — browser half: mounts the explorer and preview
3
+ * columns into the web shell's frame grid (through the layout controller),
4
+ * binds the four stores to the live client runtime (the active session's cwd
5
+ * is the project root), subscribes to the host change stream (fs + git), and
6
+ * follows the shell's dark marker (body[data-ds-dark-theme]) via CSS only.
7
+ *
8
+ * Failure policy: every DOM/runtime wiring failure is logged, never thrown —
9
+ * the web shell fails the whole boot when a plugin apply throws.
10
+ *
11
+ * FileManager right-panel design (Apache-2.0, iOfficeAI/FileManager) — re-implemented
12
+ * from measured behavior and architecture, not copied code.
13
+ * @module dsh-filemgr/client
14
+ */
15
+
16
+ import { createElement } from 'react'
17
+ import { createRoot } from 'react-dom/client'
18
+ import type { ClientContext, SessionId, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
19
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
20
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
21
+ // Type-only: pulls the official settings-scope service onto the client Context.
22
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
23
+ // Type-only: pulls the ui-conversation SlotMap merge (the input dock entry).
24
+ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
25
+ import { PanelApi, subscribePanelEvents } from './api.ts'
26
+ import { FileManagerSettingsCard, FileManagerSettingsCardController, type FileManagerPanelSettings } from './FileManagerSettingsCard.tsx'
27
+ import { PanelLayoutController } from './layout.ts'
28
+ import { createPanelStores, layoutSetRoot } from './store.ts'
29
+ import { mountPanels } from './mount.tsx'
30
+ import { NS, dictionaries, setLanguage, type FileManagerPanelKey } from './locales.ts'
31
+ import { DragFileInlay, type DragFileInjected } from './drag/DragFileInlay.tsx'
32
+ import { insertPathIntoDraft } from './drag/file-drag.ts'
33
+ import { MermaidChatEnhancer } from './chat/mermaid-chat.tsx'
34
+ import { PlaceholderHint } from './chat/placeholder-hint.tsx'
35
+ import { FilePickerHost } from './picker/FilePickerModal.tsx'
36
+ import { handleFileRefClick } from './chat/file-ref.ts'
37
+ import { appendToDraft, appendFileReferenceChip } from './reference.ts'
38
+ import { registerFileReferenceSource } from './file-source.ts'
39
+ import type { FileReferenceSelection } from './preview/selection.ts'
40
+ import './styles/chip.module.css'
41
+
42
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
43
+ interface LocaleNamespaceMap {
44
+ /** Panel surface copy. */
45
+ 'filemgr': FileManagerPanelKey
46
+ }
47
+
48
+ interface SlotMap {
49
+ /**
50
+ * One family plugin card inside the Web UI Plugins group. Spelled here
51
+ * with the same shape so this package can register without depending on
52
+ * the sibling web-ui-settings package.
53
+ */
54
+ 'web-ui.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
55
+ }
56
+ }
57
+
58
+ /** Owner share of a plugin card (the section supplies nothing). */
59
+ export interface SettingsPluginItemOwnerProps {
60
+ /** Marker field: card owner props are intentionally empty. */
61
+ children?: never
62
+ }
63
+
64
+ declare module '@deepseek-ai/cordis' {
65
+ interface Context {
66
+ /**
67
+ * Optional rc.6 compatibility binder provided by dsh-web-ui-settings;
68
+ * absent when that group plugin is not installed, so callers fall back to
69
+ * the official settings scope.
70
+ */
71
+ webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
72
+ }
73
+ }
74
+
75
+ /** Required services: sessions for the project root, locale for the copy, the settings scope for the master switch, and conversation for the composer draft edits (picker insert / @ button). */
76
+ export const inject = ['sessions', 'locale', 'settingsScope', 'conversation']
77
+
78
+ /** Shared, stateless fs client (the PanelApi only wraps fetch calls). */
79
+ const panelApi = new PanelApi()
80
+
81
+ /** Apply the browser half. */
82
+ export function apply(ctx: ClientContext): void {
83
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-filemgr: dictionaries')
84
+
85
+ // The `file` reference source: registers the codec that serializes file
86
+ // chips on submit (`@path:lines`), which the shell requires before any
87
+ // `slash/input-insert-reference` chip can send. Inert as a menu
88
+ // participant — the '@' tree modal is our own picker, not the shell menu.
89
+ ctx.effect(() => registerFileReferenceSource(ctx), 'dsh-filemgr: file reference source')
90
+
91
+ // The '@' picker is our own tree modal (pi-desktop style), NOT the shell's
92
+ // flat trigger menu: a bare `@` in the composer opens FilePickerHost
93
+ // directly. The one inputTriggers registration above is codec-only and
94
+ // inert in the menu; the modal covers any residual menu the moment it
95
+ // opens.
96
+
97
+ // The composer drop target for explorer file drags: mounted in the
98
+ // official `conversation.input.dock` band (declared by the shipped
99
+ // ui-conversation rc.6 shell), session-routed through the conversation
100
+ // input facade. A missing session scope or conversation service degrades
101
+ // to no-op — the panels themselves never depend on the dock entry.
102
+ ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
103
+ const sessions = scope.sessions
104
+ const conversation = scope.conversation
105
+ scope.slots.inject('conversation.input.dock', () =>
106
+ scope.slots.register({
107
+ name: 'conversation.input.dock',
108
+ id: 'filemgr-drag-file',
109
+ order: 90,
110
+ locale: NS,
111
+ inject: (sessionId: SessionId | undefined): DragFileInjected => ({
112
+ insertPath: (path: string): boolean => {
113
+ if (sessionId === undefined) return false
114
+ const actx = sessions.scope(sessionId)
115
+ if (actx === undefined) return false
116
+ const input = conversation.input
117
+ if (input === undefined) return false
118
+ const shell = input.for(actx)
119
+ const draft = shell.state.getSnapshot().draft
120
+ shell.setDraft(insertPathIntoDraft(draft, path))
121
+ return true
122
+ },
123
+ }),
124
+ }, DragFileInlay))
125
+ })
126
+
127
+ // Transcript mermaid enhancement rides the same dock as a zero-render
128
+ // sentinel: the shell has no message-body slot, so the sentinel observes
129
+ // the document for the chat renderer's `code.language-mermaid` blocks.
130
+ ctx.inject(['slots'], (scope: ClientContext) => {
131
+ scope.slots.inject('conversation.input.dock', () =>
132
+ scope.slots.register({
133
+ name: 'conversation.input.dock',
134
+ id: 'filemgr-mermaid-chat',
135
+ order: 91,
136
+ }, MermaidChatEnhancer))
137
+ })
138
+
139
+ // Composer placeholder hint rides the same band: the shell's input bar
140
+ // placeholder ("给智能体发消息") has no plugin override seam, so a sentinel
141
+ // rewrites the DOM attribute to advertise the '@' file reference and the
142
+ // '/' command trigger (see placeholder-hint.tsx).
143
+ ctx.inject(['slots'], (scope: ClientContext) => {
144
+ scope.slots.inject('conversation.input.dock', () =>
145
+ scope.slots.register({
146
+ name: 'conversation.input.dock',
147
+ id: 'filemgr-placeholder-hint',
148
+ order: 92,
149
+ }, PlaceholderHint))
150
+ })
151
+
152
+ // The settings card: one master switch (issue #307) in the Web UI Plugins
153
+ // group, bound to the 'filemgr' namespace through the family bridge
154
+ // (or the official settings scope when the deployment exposes it).
155
+ ctx.inject(['slots', 'settingsScope'], (settingsCtx: ClientContext) => {
156
+ const binder = settingsCtx.get('webUiSettings') ?? settingsCtx.settingsScope
157
+ const panelScope = binder.bind<FileManagerPanelSettings>({ namespace: NS })
158
+ const settingsCard = new FileManagerSettingsCardController(panelScope)
159
+ settingsCtx.slots.inject('web-ui.plugin.item', () => {
160
+ const unregister = settingsCtx.slots.register({
161
+ name: 'web-ui.plugin.item',
162
+ id: 'filemgr',
163
+ order: 110,
164
+ locale: NS,
165
+ inject: () => settingsCard.inject(),
166
+ }, FileManagerSettingsCard)
167
+ return () => {
168
+ settingsCard.dispose()
169
+ unregister()
170
+ }
171
+ })
172
+ })
173
+
174
+ ctx.effect(() => {
175
+ // Master switch (issue #307): the settings card edits the 'filemgr'
176
+ // namespace through the family settings bridge (or the official scope).
177
+ // While off, the panels, the floating button and the change stream stay
178
+ // unmounted; toggling the switch re-mounts them live (the pet's model).
179
+ let panelScope: SettingsScope<FileManagerPanelSettings> | undefined
180
+ try {
181
+ const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
182
+ if (binder !== undefined) panelScope = binder.bind<FileManagerPanelSettings>({ namespace: NS })
183
+ } catch (error) {
184
+ // A missing settings seam must not break the panel boot: default on.
185
+ panelScope = undefined
186
+ }
187
+ const enabled = (): boolean => panelScope?.getSnapshot().value?.enabled ?? true
188
+ let disposeUi: (() => void) | undefined
189
+
190
+ /**
191
+ * Mount the whole panel UI (columns, handles, floating button, change
192
+ * stream, persists) and return its teardown. A fresh lifecycle per
193
+ * enable keeps toggling idempotent (the layout controller cannot be
194
+ * reused after dispose).
195
+ */
196
+ const mountUi = (): (() => void) => {
197
+ const stores = createPanelStores(panelApi)
198
+ const layout = new PanelLayoutController(stores.layout)
199
+ const disposers: Array<() => void> = []
200
+ let disposeGitEvents: (() => void) | undefined
201
+ let currentRoot = ''
202
+ let lastPreviewOpen = false
203
+
204
+ // The Explorer row '@' button: insert a file-reference chip for files
205
+ // (path + 1..N) or plain `@<rel-path>/` text for folders. File lines
206
+ // are fetched from the host so the chip can carry an accurate line
207
+ // range; failures fall back to plain text.
208
+ const onReference = (path: string, isDir: boolean): void => {
209
+ const sessionId = ctx.sessions.list.getSnapshot().current as SessionId | undefined
210
+ if (sessionId === undefined) {
211
+ console.warn('[dsh-filemgr] @ 引用插入失败:没有活动会话')
212
+ return
213
+ }
214
+ if (isDir) {
215
+ appendToDraft(ctx, sessionId, `@${path}/ `)
216
+ return
217
+ }
218
+ void panelApi.read(currentRoot, path, false).then((result) => {
219
+ if (!result.ok) {
220
+ appendToDraft(ctx, sessionId, `@${path} `)
221
+ return
222
+ }
223
+ const text = result.value.content
224
+ const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text
225
+ const lines = trimmed === '' ? 1 : trimmed.split('\n').length
226
+ appendFileReferenceChip(ctx, sessionId, { path, startLine: 1, endLine: lines })
227
+ })
228
+ }
229
+
230
+ // The preview "添加到对话" action: insert a file-reference chip
231
+ // carrying the selected line range, appended at the draft end.
232
+ const onAddFileReference = (selection: FileReferenceSelection): boolean => {
233
+ const sessionId = ctx.sessions.list.getSnapshot().current as SessionId | undefined
234
+ return appendFileReferenceChip(ctx, sessionId, selection)
235
+ }
236
+
237
+ // The project root follows the active session's cwd; switching sessions
238
+ // re-binds every store (widths, collapse, tree, tabs persist per root).
239
+ const bindRoot = (): void => {
240
+ const snapshot = ctx.sessions.list.getSnapshot()
241
+ const sessionId = snapshot.current as SessionId | undefined
242
+ const cwd = sessionId === undefined ? undefined : snapshot.byId[sessionId]?.cwd
243
+ const root = typeof cwd === 'string' && cwd !== '' ? cwd : ''
244
+ if (root === currentRoot) return
245
+ currentRoot = root
246
+
247
+ // The change stream is git-only and lives exactly as long as the SCM
248
+ // tab is visible (no fs watch — the explorer/preview refresh on user
249
+ // actions, see the host route layer). Drop the old stream on root
250
+ // switches; syncGitSubscription re-evaluates it below.
251
+ disposeGitEvents?.()
252
+ disposeGitEvents = undefined
253
+ const previewOpen = stores.preview.getSnapshot().open
254
+ lastPreviewOpen = previewOpen
255
+ layoutSetRoot(stores.layout, root, previewOpen)
256
+ stores.explorer.setRoot(root)
257
+ stores.scm.setRoot(root)
258
+ stores.preview.setRoot(root)
259
+
260
+ syncGitSubscription()
261
+ }
262
+
263
+ /**
264
+ * Keep the git change stream in lockstep with SCM-tab visibility:
265
+ * opening the changes tab starts the subscription (and fetches status
266
+ * once immediately — the host poll only ticks on its own cadence);
267
+ * switching away closes it, so git polling happens only while the SCM
268
+ * panel is actually on screen. Idempotent — explorer state changes
269
+ * (expansion, selection) call it and it no-ops unless the tab flipped.
270
+ */
271
+ const syncGitSubscription = (): void => {
272
+ const root = currentRoot
273
+ const want = root !== '' && stores.explorer.getSnapshot().activeTab === 'changes'
274
+ if (want && disposeGitEvents === undefined) {
275
+ void stores.scm.refresh()
276
+ disposeGitEvents = subscribePanelEvents(root, (event) => {
277
+ if (event.kind === 'git') {
278
+ // The host status is the only truth; land it directly.
279
+ stores.scm.update((prev) => (prev.root !== root ? prev : { ...prev, status: event.status, loading: false }))
280
+ // The index/worktree moved: every open diff tab is stale by now.
281
+ void stores.preview.handleGitChange(root)
282
+ }
283
+ if (event.kind === 'gitUnavailable') {
284
+ // The host could not run git at all: land the friendly
285
+ // unavailable state once instead of a "not a repository" view.
286
+ stores.scm.update((prev) => (prev.root !== root ? prev : { ...prev, status: null, loading: false, gitMissing: true }))
287
+ }
288
+ })
289
+ } else if (!want && disposeGitEvents !== undefined) {
290
+ disposeGitEvents()
291
+ disposeGitEvents = undefined
292
+ }
293
+ }
294
+ disposers.push(ctx.sessions.list.subscribe(bindRoot))
295
+ disposers.push(stores.explorer.subscribe(syncGitSubscription))
296
+ bindRoot()
297
+
298
+ // Mirror the preview open state into the layout store (single source: the
299
+ // preview store), and play the enter animation when the region opens.
300
+ const mirrorPreviewOpen = (): void => {
301
+ const open = stores.preview.getSnapshot().open
302
+ if (open === lastPreviewOpen) return
303
+ lastPreviewOpen = open
304
+ stores.layout.update((prev) => ({ ...prev, previewOpen: open }))
305
+ if (open) {
306
+ const col = document.querySelector<HTMLElement>('[data-filemgr-preview-col]')
307
+ col?.classList.add('filemgr-preview-enter')
308
+ setTimeout(() => col?.classList.remove('filemgr-preview-enter'), 300)
309
+ }
310
+ }
311
+ disposers.push(stores.preview.subscribe(mirrorPreviewOpen))
312
+
313
+ // Language mirroring (the shell owns <html lang>; the dictionary follows).
314
+ let langObserver: MutationObserver | undefined
315
+ const syncLanguage = (): void => {
316
+ setLanguage(document.documentElement.lang?.startsWith('zh') ? 'zh' : 'en')
317
+ }
318
+ langObserver = new MutationObserver(syncLanguage)
319
+ langObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] })
320
+ syncLanguage()
321
+
322
+ // Mount everything. DOM failures degrade the panels, never the GUI.
323
+ try {
324
+ layout.mount()
325
+ mountPanels(stores, () => layout.toggleExplorer(), onReference, onAddFileReference)
326
+ } catch (error) {
327
+ console.error('[dsh-filemgr] mount failed:', error)
328
+ }
329
+
330
+ // The '@' tree-browser modal host: a dedicated root appended to body so
331
+ // the modal portals above the shell chrome regardless of panel state.
332
+ const pickerHostEl = document.createElement('div')
333
+ document.body.appendChild(pickerHostEl)
334
+ const pickerRoot = createRoot(pickerHostEl)
335
+ pickerRoot.render(createElement(FilePickerHost, { ctx, api: panelApi }))
336
+
337
+ // Chat file-reference clicks (issue #314): recognize workspace paths in
338
+ // transcript code spans and locate them in the Explorer / Preview.
339
+ const onFileRefClick = (event: MouseEvent): void => {
340
+ try {
341
+ handleFileRefClick(stores, panelApi, event)
342
+ } catch (error) {
343
+ // A broken locate must never break the transcript's own clicks.
344
+ console.error('[dsh-filemgr] file ref locate failed:', error)
345
+ }
346
+ }
347
+ document.addEventListener('click', onFileRefClick)
348
+
349
+ // Debounced persists (explorer/scm/preview) may be pending when the page
350
+ // hides; flush them so a close/background never drops the last 150ms.
351
+ const flushOnHide = (): void => stores.flushNow()
352
+ const onVisibilityChange = (): void => {
353
+ if (document.visibilityState === 'hidden') flushOnHide()
354
+ }
355
+ window.addEventListener('pagehide', flushOnHide)
356
+ document.addEventListener('visibilitychange', onVisibilityChange)
357
+
358
+ return () => {
359
+ flushOnHide()
360
+ window.removeEventListener('pagehide', flushOnHide)
361
+ document.removeEventListener('visibilitychange', onVisibilityChange)
362
+ document.removeEventListener('click', onFileRefClick)
363
+ disposeGitEvents?.()
364
+ langObserver?.disconnect()
365
+ pickerRoot.unmount()
366
+ pickerHostEl.remove()
367
+ for (const dispose of disposers) dispose()
368
+ layout.dispose()
369
+ }
370
+ }
371
+ const syncUi = (): void => {
372
+ if (enabled() && disposeUi === undefined) {
373
+ disposeUi = mountUi()
374
+ } else if (!enabled() && disposeUi !== undefined) {
375
+ disposeUi()
376
+ disposeUi = undefined
377
+ }
378
+ }
379
+ syncUi()
380
+ const unsubscribeSettings = panelScope?.subscribe(syncUi)
381
+ return () => {
382
+ unsubscribeSettings?.()
383
+ if (disposeUi !== undefined) {
384
+ disposeUi()
385
+ disposeUi = undefined
386
+ }
387
+ }
388
+ }, 'dsh-filemgr: wiring')
389
+ }