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