@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,506 @@
1
+ /**
2
+ * File-picker modal: a pi-desktop-style multi-select tree browser for '@'
3
+ * file/folder references.
4
+ *
5
+ * Trigger: typing `@` (with nothing after it) in the composer opens this
6
+ * modal directly — no inline trigger menu (the shell's trigger menu is a
7
+ * flat candidate list that cannot host a tree). The modal owns a recursive
8
+ * tree with lazy expansion, a workspace filename search, per-row checkboxes
9
+ * for multi-selection, and a chip row of the picked paths.
10
+ *
11
+ * Insertion replaces the `@` token the picker was opened from with the
12
+ * picked references joined by spaces (`@a.ts @src/ @b.md`); cancel drops
13
+ * the stray `@`.
14
+ * @module dsh-filemgr/client/picker/FilePickerModal
15
+ */
16
+
17
+ import { useEffect, useMemo, useRef, useState } from 'react'
18
+ import type { JSX } from 'react'
19
+ import { createPortal } from 'react-dom'
20
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
21
+ import type { FsEntry, SearchHit } from '../../core/types.ts'
22
+ import type { PanelApi } from '../api.ts'
23
+ import { filePicker, type PickState } from './file-picker.ts'
24
+ import { ChevronDownIcon, ChevronRightIcon, CloseIcon, FileIcon, FolderIcon, FolderOpenIcon, SearchIcon } from '../components/icons.tsx'
25
+ import { appendToDraft, insertFileReference, type RefSpan } from '../reference.ts'
26
+ import pickerCss from '../styles/picker.module.css'
27
+
28
+ /** Debounce for the workspace search box. */
29
+ const SEARCH_DEBOUNCE_MS = 200
30
+
31
+ /** Split a relative path into its ancestor chain ('' when empty). */
32
+ function ancestors(rel: string): string[] {
33
+ const out: string[] = []
34
+ let acc = ''
35
+ for (const part of rel.split('/')) {
36
+ if (part === '') continue
37
+ acc = acc === '' ? part : `${acc}/${part}`
38
+ out.push(acc)
39
+ }
40
+ return out
41
+ }
42
+
43
+ /** One picked path with its kind (drives the trailing slash on insert). */
44
+ export interface PickedPath {
45
+ path: string
46
+ isDir: boolean
47
+ }
48
+
49
+ /** The modal surface (portal target; renders only while open). */
50
+ export function FilePickerModal({
51
+ state,
52
+ api,
53
+ onInsert,
54
+ onClose,
55
+ }: {
56
+ state: PickState
57
+ api: PanelApi
58
+ onInsert: (paths: PickedPath[]) => void
59
+ onClose: () => void
60
+ }): JSX.Element | null {
61
+ const req = state.req
62
+ const root = req?.root ?? ''
63
+ const [expanded, setExpanded] = useState<Set<string>>(new Set())
64
+ const [dirs, setDirs] = useState<Record<string, FsEntry[]>>({})
65
+ const [loading, setLoading] = useState<Record<string, boolean>>({})
66
+ const [selected, setSelected] = useState<Map<string, boolean>>(new Map())
67
+ const [search, setSearch] = useState('')
68
+ const [searchResults, setSearchResults] = useState<SearchHit[] | null>(null)
69
+ const [searching, setSearching] = useState(false)
70
+ const searchSeq = useRef(0)
71
+
72
+ /** Load one directory listing into the tree cache (guarded). */
73
+ const loadDir = (dir: string): void => {
74
+ if (dirs[dir] !== undefined || loading[dir]) return
75
+ setLoading((prev) => ({ ...prev, [dir]: true }))
76
+ void api.list(root, dir).then((result) => {
77
+ setLoading((prev) => ({ ...prev, [dir]: false }))
78
+ if (result.ok) setDirs((prev) => ({ ...prev, [dir]: result.value.entries }))
79
+ })
80
+ }
81
+
82
+ /** Prime the tree: root + the initial directory's ancestor chain. */
83
+ useEffect(() => {
84
+ setExpanded(new Set())
85
+ setDirs({})
86
+ setLoading({})
87
+ setSelected(new Map())
88
+ setSearch('')
89
+ setSearchResults(null)
90
+ if (root === '') return
91
+ loadDir('')
92
+ if (req !== null && req.initialDir !== '') {
93
+ for (const dir of ancestors(req.initialDir)) {
94
+ setExpanded((prev) => new Set(prev).add(dir))
95
+ loadDir(dir)
96
+ }
97
+ }
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [root, req?.initialDir])
100
+
101
+ // Debounced, seq-guarded workspace filename search.
102
+ useEffect(() => {
103
+ const q = search.trim()
104
+ if (q === '') {
105
+ searchSeq.current += 1
106
+ setSearchResults(null)
107
+ setSearching(false)
108
+ return
109
+ }
110
+ const seq = ++searchSeq.current
111
+ setSearching(true)
112
+ const timer = setTimeout(() => {
113
+ void api.search(root, q).then((result) => {
114
+ if (searchSeq.current !== seq) return
115
+ setSearching(false)
116
+ setSearchResults(result.ok ? result.value.hits : [])
117
+ })
118
+ }, SEARCH_DEBOUNCE_MS)
119
+ return () => { clearTimeout(timer) }
120
+ }, [search, root, api])
121
+
122
+ const toggleDir = (dir: string): void => {
123
+ setExpanded((prev) => {
124
+ const next = new Set(prev)
125
+ if (next.has(dir)) next.delete(dir)
126
+ else {
127
+ next.add(dir)
128
+ loadDir(dir)
129
+ }
130
+ return next
131
+ })
132
+ }
133
+
134
+ /** Toggle one path in the multi-selection (checkbox semantics). */
135
+ const toggleSelect = (path: string, isDir: boolean): void => {
136
+ setSelected((prev) => {
137
+ const next = new Map(prev)
138
+ if (next.has(path)) next.delete(path)
139
+ else next.set(path, isDir)
140
+ return next
141
+ })
142
+ }
143
+
144
+ // Escape closes.
145
+ useEffect(() => {
146
+ if (!state.open) return
147
+ const onKey = (e: KeyboardEvent): void => {
148
+ if (e.key === 'Escape') onClose()
149
+ }
150
+ window.addEventListener('keydown', onKey)
151
+ return () => window.removeEventListener('keydown', onKey)
152
+ }, [state.open, onClose])
153
+
154
+ const tree = useMemo(() => {
155
+ const renderNode = (entry: FsEntry, depth: number): JSX.Element => {
156
+ const isDir = entry.isDir
157
+ const isOpen = expanded.has(entry.path)
158
+ const isSel = selected.has(entry.path)
159
+ const children = dirs[entry.path] ?? []
160
+ const isLoading = loading[entry.path] === true
161
+ return (
162
+ <div key={entry.path}>
163
+ <div
164
+ className={`${pickerCss.row}${isSel ? ` ${pickerCss.rowSelected}` : ''}`}
165
+ style={{ paddingLeft: 8 + depth * 16 }}
166
+ onClick={() => toggleSelect(entry.path, isDir)}
167
+ >
168
+ <span
169
+ className={pickerCss.chev}
170
+ role="button"
171
+ tabIndex={-1}
172
+ onClick={(e) => {
173
+ if (!isDir) return
174
+ e.stopPropagation()
175
+ toggleDir(entry.path)
176
+ }}
177
+ >
178
+ {isDir ? (isOpen ? <ChevronDownIcon size={14} /> : <ChevronRightIcon size={14} />) : <span className={pickerCss.chevSpacer} />}
179
+ </span>
180
+ <span className={pickerCss.icon}>
181
+ {isDir ? (isOpen ? <FolderOpenIcon size={15} /> : <FolderIcon size={15} />) : <FileIcon size={15} />}
182
+ </span>
183
+ <span className={pickerCss.name} title={entry.path}>{entry.name}</span>
184
+ <span
185
+ className={`${pickerCss.check}${isSel ? ` ${pickerCss.checkOn}` : ''}`}
186
+ onClick={(e) => {
187
+ e.stopPropagation()
188
+ toggleSelect(entry.path, isDir)
189
+ }}
190
+ >
191
+ {isSel ? <CheckIcon size={12} /> : null}
192
+ </span>
193
+ </div>
194
+ {isDir && isOpen && (
195
+ <div>
196
+ {isLoading ? (
197
+ <div className={pickerCss.note} style={{ paddingLeft: 8 + (depth + 1) * 16 }}>加载中…</div>
198
+ ) : children.length === 0 ? (
199
+ <div className={pickerCss.note} style={{ paddingLeft: 8 + (depth + 1) * 16 }}>(空目录)</div>
200
+ ) : (
201
+ children.map((child) => renderNode(child, depth + 1))
202
+ )}
203
+ </div>
204
+ )}
205
+ </div>
206
+ )
207
+ }
208
+ return renderNode
209
+ }, [expanded, dirs, loading, selected, toggleDir, toggleSelect])
210
+
211
+ if (!state.open || req === null) return null
212
+ const rootEntries = dirs[''] ?? []
213
+ const rootLoading = loading[''] === true
214
+ const selectedList: PickedPath[] = Array.from(selected.entries()).map(([path, isDir]) => ({ path, isDir }))
215
+
216
+ return createPortal(
217
+ <div className={pickerCss.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}>
218
+ <div className={pickerCss.modal} onClick={(e) => e.stopPropagation()}>
219
+ <div className={pickerCss.header}>
220
+ <div className={pickerCss.titleRow}>
221
+ <span className={pickerCss.title}>引用项目文件</span>
222
+ <button type="button" className={pickerCss.closeBtn} onClick={onClose} aria-label="关闭">
223
+ <CloseIcon size={16} />
224
+ </button>
225
+ </div>
226
+ <div className={pickerCss.searchWrap}>
227
+ <span className={pickerCss.searchIcon}><SearchIcon size={14} /></span>
228
+ <input
229
+ className={pickerCss.searchInput}
230
+ placeholder="搜索工作区文件…"
231
+ value={search}
232
+ onChange={(e) => setSearch(e.target.value)}
233
+ autoFocus
234
+ />
235
+ {search !== '' && (
236
+ <button type="button" className={pickerCss.searchClear} onClick={() => setSearch('')} aria-label="清空">
237
+ <CloseIcon size={13} />
238
+ </button>
239
+ )}
240
+ </div>
241
+ <div className={pickerCss.rootPath} title={root}>{root}</div>
242
+ </div>
243
+
244
+ <div className={pickerCss.body}>
245
+ {searchResults !== null ? (
246
+ searching ? (
247
+ <div className={pickerCss.note}>搜索中…</div>
248
+ ) : searchResults.length === 0 ? (
249
+ <div className={pickerCss.note}>没有匹配的文件</div>
250
+ ) : (
251
+ searchResults.map((hit) => {
252
+ const isSel = selected.has(hit.path)
253
+ return (
254
+ <div
255
+ key={hit.path}
256
+ className={`${pickerCss.row}${isSel ? ` ${pickerCss.rowSelected}` : ''}`}
257
+ onClick={() => toggleSelect(hit.path, false)}
258
+ >
259
+ <span className={pickerCss.chevSpacer} />
260
+ <span className={pickerCss.icon}><FileIcon size={15} /></span>
261
+ <span className={pickerCss.name} title={hit.path}>
262
+ <span>{hit.name}</span>
263
+ <span className={pickerCss.subpath}>{hit.path}</span>
264
+ </span>
265
+ <span
266
+ className={`${pickerCss.check}${isSel ? ` ${pickerCss.checkOn}` : ''}`}
267
+ onClick={(e) => {
268
+ e.stopPropagation()
269
+ toggleSelect(hit.path, false)
270
+ }}
271
+ >
272
+ {isSel ? <CheckIcon size={12} /> : null}
273
+ </span>
274
+ </div>
275
+ )
276
+ })
277
+ )
278
+ ) : rootLoading ? (
279
+ <div className={pickerCss.note}>加载中…</div>
280
+ ) : rootEntries.length === 0 ? (
281
+ <div className={pickerCss.note}>(空目录)</div>
282
+ ) : (
283
+ rootEntries.map((entry) => tree(entry, 0))
284
+ )}
285
+ </div>
286
+
287
+ <div className={pickerCss.footer}>
288
+ <div className={pickerCss.chips}>
289
+ {selectedList.length === 0 ? (
290
+ <span className={pickerCss.chipsEmpty}>选择文件或目录(可多选)</span>
291
+ ) : (
292
+ selectedList.map((pick) => (
293
+ <span key={pick.path} className={pickerCss.chip}>
294
+ <span>@{pick.path}{pick.isDir ? '/' : ''}</span>
295
+ <button
296
+ type="button"
297
+ className={pickerCss.chipX}
298
+ onClick={() => toggleSelect(pick.path, pick.isDir)}
299
+ aria-label="移除"
300
+ >
301
+ <CloseIcon size={11} />
302
+ </button>
303
+ </span>
304
+ ))
305
+ )}
306
+ </div>
307
+ <div className={pickerCss.footerActions}>
308
+ <button type="button" className={pickerCss.cancelBtn} onClick={onClose}>取消</button>
309
+ <button
310
+ type="button"
311
+ className={pickerCss.insertBtn}
312
+ disabled={selectedList.length === 0}
313
+ onClick={() => onInsert(selectedList)}
314
+ >
315
+ 插入{selectedList.length > 0 ? ` (${selectedList.length})` : ''}
316
+ </button>
317
+ </div>
318
+ </div>
319
+ </div>
320
+ </div>,
321
+ document.body,
322
+ )
323
+ }
324
+
325
+ /** Minimal inline check glyph (avoids an extra icon import cycle). */
326
+ function CheckIcon({ size }: { size: number }): JSX.Element {
327
+ return (
328
+ <svg width={size} height={size} viewBox="0 0 12 12" fill="none" aria-hidden="true">
329
+ <path d="M2.5 6.2 4.8 8.5 9.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
330
+ </svg>
331
+ )
332
+ }
333
+
334
+ /** Minimal structural view of the conversation input service (see reference.ts). */
335
+ interface DraftInput {
336
+ state: { getSnapshot(): { draft: string; draftRev: number } }
337
+ setDraft(draft: string): void
338
+ }
339
+ interface ConversationService {
340
+ input: { for(actx: ClientContext): DraftInput }
341
+ }
342
+
343
+ /** Active `@` mention candidate in the draft (mimics pi-desktop's getActiveMention). */
344
+ export function findAtMention(value: string, caret: number): { start: number; query: string } | null {
345
+ const before = value.slice(0, caret)
346
+ const at = before.lastIndexOf('@')
347
+ if (at < 0) return null
348
+ // The '@' must sit at a token boundary (start or after whitespace/punctuation).
349
+ if (at > 0 && !/[\s,;.!?()[\]{}"'/\\]/.test(before[at - 1])) return null
350
+ return { start: at, query: before.slice(at + 1) }
351
+ }
352
+
353
+ /**
354
+ * The picker host: watches the composer textarea for a bare `@` (opening the
355
+ * modal directly, pi-desktop style — no inline trigger menu), subscribes to
356
+ * the bridge, and renders the modal. Insertion replaces the `@` token span
357
+ * with the picked references joined by spaces; cancel drops the stray `@`.
358
+ */
359
+ export function FilePickerHost({ ctx, api }: { ctx: ClientContext; api: PanelApi }): JSX.Element {
360
+ const [state, setState] = useState<PickState>(filePicker.getSnapshot())
361
+ useEffect(() => filePicker.subscribe(setState), [])
362
+
363
+ // When the modal opens, close the shell's inline trigger menu: the `file`
364
+ // codec source registers under '@' (and any other '@' sources exist), so a
365
+ // bare `@` also pops the flat menu — the modal must be the only surface.
366
+ useEffect(() => {
367
+ if (!state.open || state.req === null) return
368
+ try {
369
+ const actx = ctx.sessions.scope(state.req.sessionId)
370
+ if (actx === undefined) return
371
+ const inputTriggers = (ctx as unknown as {
372
+ get(name: string): unknown
373
+ }).get('inputTriggers') as { sessionOf(a: ClientContext): { dismiss(): void } } | undefined
374
+ inputTriggers?.sessionOf(actx).dismiss()
375
+ } catch {
376
+ // Menu dismissal is best-effort; the modal stands alone regardless.
377
+ }
378
+ }, [state.open, state.req, ctx])
379
+
380
+ // Composer `@` watcher: open the picker the moment a bare `@` lands.
381
+ useEffect(() => {
382
+ const watched = new WeakSet<HTMLTextAreaElement>()
383
+ const onInput = (e: Event): void => {
384
+ if (filePicker.getSnapshot().open) return
385
+ const ta = e.target as HTMLTextAreaElement
386
+ const caret = ta.selectionStart ?? ta.value.length
387
+ const mention = findAtMention(ta.value, caret)
388
+ if (mention === null || mention.query !== '') return
389
+ const snapshot = ctx.sessions.list.getSnapshot()
390
+ const sessionId = snapshot.current
391
+ if (sessionId === undefined) return
392
+ const cwd = snapshot.byId[sessionId]?.cwd
393
+ const root = typeof cwd === 'string' && cwd !== '' ? cwd : ''
394
+ if (root === '') return
395
+ filePicker.open({
396
+ sessionId,
397
+ root,
398
+ initialDir: '',
399
+ span: { start: mention.start, end: mention.start + 1 },
400
+ })
401
+ }
402
+ const attach = (): void => {
403
+ for (const ta of document.querySelectorAll<HTMLTextAreaElement>('textarea')) {
404
+ if (watched.has(ta)) continue
405
+ watched.add(ta)
406
+ ta.addEventListener('input', onInput)
407
+ }
408
+ }
409
+ attach()
410
+ const observer = new MutationObserver(attach)
411
+ observer.observe(document.body, { childList: true, subtree: true })
412
+ return () => {
413
+ observer.disconnect()
414
+ for (const ta of document.querySelectorAll<HTMLTextAreaElement>('textarea')) {
415
+ ta.removeEventListener('input', onInput)
416
+ }
417
+ }
418
+ }, [ctx])
419
+
420
+ /**
421
+ * Insert the picked references into the composer draft as file-reference
422
+ * chips (one `slash/input-insert-reference` bail per file; the `file`
423
+ * source codec serializes each to `@path:lines` on submit). Folders fall
424
+ * back to plain `@path/ ` text since the chip has no line concept.
425
+ * Multiple selections land as adjacent chips.
426
+ */
427
+ const insert = async (paths: PickedPath[]): Promise<void> => {
428
+ const current = filePicker.getSnapshot()
429
+ if (!current.open || current.req === null || paths.length === 0) return
430
+ const { sessionId, root, span } = current.req
431
+ try {
432
+ const actx = ctx.sessions.scope(sessionId)
433
+ if (actx === undefined) return
434
+ const conversation = (ctx as unknown as { conversation?: ConversationService }).conversation
435
+ if (conversation === undefined) return
436
+ const shell = conversation.input.for(actx)
437
+ // Pre-fetch every file's line count in parallel (bails are serial so
438
+ // they share the same draft; reads are independent).
439
+ const lineCounts = await Promise.all(paths.map(async (pick) => {
440
+ if (pick.isDir) return { pick, lines: null }
441
+ const result = await api.read(root, pick.path, false)
442
+ if (!result.ok) return { pick, lines: null }
443
+ const text = result.value.content
444
+ const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text
445
+ const lines = trimmed === '' ? 1 : trimmed.split('\n').length
446
+ return { pick, lines }
447
+ }))
448
+ // Walk in original order. The first chip replaces the `@` token; the
449
+ // rest insert at the current cursor (0-length span) so the machine
450
+ // appends each one in turn.
451
+ let cursor = span.end
452
+ let firstSpan = true
453
+ for (const { pick, lines } of lineCounts) {
454
+ if (pick.isDir) {
455
+ appendToDraft(ctx, sessionId, `@${pick.path}/ `)
456
+ continue
457
+ }
458
+ if (lines === null) continue
459
+ const snap = shell.state.getSnapshot()
460
+ const currentSpan: RefSpan = firstSpan
461
+ ? { start: span.start, end: span.end, draftRev: snap.draftRev }
462
+ : { start: cursor, end: cursor, draftRev: snap.draftRev }
463
+ const ok = insertFileReference(
464
+ ctx,
465
+ sessionId,
466
+ { path: pick.path, startLine: 1, endLine: lines },
467
+ currentSpan,
468
+ )
469
+ if (ok) {
470
+ firstSpan = false
471
+ // The machine replaces [start,end) with U+FFFC and an optional
472
+ // trailing space; the cursor advances by 2 for the new characters.
473
+ cursor += 2
474
+ }
475
+ }
476
+ filePicker.close()
477
+ } catch (error) {
478
+ console.warn('[dsh-filemgr] picker insert failed:', error)
479
+ }
480
+ }
481
+
482
+ /** Cancel: drop the stray `@` the picker was opened from. */
483
+ const close = (): void => {
484
+ const current = filePicker.getSnapshot()
485
+ if (current.open && current.req !== null) {
486
+ const { sessionId, span } = current.req
487
+ try {
488
+ const actx = ctx.sessions.scope(sessionId)
489
+ const conversation = (ctx as unknown as { conversation?: ConversationService }).conversation
490
+ if (actx !== undefined && conversation !== undefined) {
491
+ const shell = conversation.input.for(actx)
492
+ const draft = shell.state.getSnapshot().draft
493
+ const token = draft.slice(span.start, span.end)
494
+ if (token.startsWith('@')) {
495
+ shell.setDraft(draft.slice(0, span.start) + draft.slice(span.end))
496
+ }
497
+ }
498
+ } catch {
499
+ // Best-effort cleanup; the picker still closes.
500
+ }
501
+ }
502
+ filePicker.close()
503
+ }
504
+
505
+ return <FilePickerModal state={state} api={api} onInsert={insert} onClose={close} />
506
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * File-picker bridge: the single channel between the '@' trigger source
3
+ * (which asks for a directory) and the tree-browser modal (which renders it).
4
+ *
5
+ * Why a modal and not the inline trigger menu: the shell's trigger menu is a
6
+ * flat candidate list with no tree affordance — picking a directory cannot
7
+ * expand it (the menu closes on pick, and the shell only re-runs trigger
8
+ * detection on keyboard input, not on programmatic draft writes). The modal
9
+ * is the pi-desktop-style answer: a real directory tree with lazy expansion,
10
+ * a search box, and one-click insertion.
11
+ *
12
+ * The bridge is framework-free (subscribe/getSnapshot/open/close) so the
13
+ * trigger source can open the picker without touching React; the modal host
14
+ * component subscribes and renders. Insertion happens through the
15
+ * conversation input shell, replacing the exact `@` token span the picker
16
+ * was opened from.
17
+ * @module dsh-filemgr/client/picker/file-picker
18
+ */
19
+
20
+ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
21
+
22
+ /** The `@` token span captured when the picker opened (replace-on-insert). */
23
+ export interface PickSpan {
24
+ /** Start offset of the `@` token in the draft. */
25
+ start: number
26
+ /** End offset (the caret). */
27
+ end: number
28
+ }
29
+
30
+ /** What the picker is currently anchored to. */
31
+ export interface PickRequest {
32
+ /** Session whose draft the picker will edit. */
33
+ sessionId: SessionId
34
+ /** Project root (canonical cwd) everything is relative to. */
35
+ root: string
36
+ /** Directory to start browsing at ('' = root). */
37
+ initialDir: string
38
+ /** The `@` token span to replace on insert. */
39
+ span: PickSpan
40
+ }
41
+
42
+ /** The bridge's public snapshot. */
43
+ export interface PickState {
44
+ open: boolean
45
+ req: PickRequest | null
46
+ }
47
+
48
+ /** Minimal listener shape. */
49
+ type Listener = (state: PickState) => void
50
+
51
+ /** Framework-free singleton store backing the modal. */
52
+ class FilePickerBridge {
53
+ private state: PickState = { open: false, req: null }
54
+ private readonly listeners = new Set<Listener>()
55
+
56
+ /** Current snapshot (stable reference until the next open/close). */
57
+ getSnapshot(): PickState {
58
+ return this.state
59
+ }
60
+
61
+ /** Subscribe to open/close transitions. */
62
+ subscribe(listener: Listener): () => void {
63
+ this.listeners.add(listener)
64
+ return () => { this.listeners.delete(listener) }
65
+ }
66
+
67
+ /** Open the picker anchored to one `@` token. */
68
+ open(req: PickRequest): void {
69
+ this.state = { open: true, req }
70
+ this.emit()
71
+ }
72
+
73
+ /** Close the picker without inserting anything. */
74
+ close(): void {
75
+ if (!this.state.open) return
76
+ this.state = { open: false, req: null }
77
+ this.emit()
78
+ }
79
+
80
+ private emit(): void {
81
+ for (const listener of this.listeners) listener(this.state)
82
+ }
83
+ }
84
+
85
+ /** The shared singleton instance. */
86
+ export const filePicker = new FilePickerBridge()