@lijian-ui/dsh-file-manager 0.2.3 → 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 (96) 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 +27305 -270
  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.map +1 -1
  16. package/lib/types/client/locales.d.ts +3 -0
  17. package/lib/types/client/locales.d.ts.map +1 -1
  18. package/lib/types/client/mount.d.ts +1 -1
  19. package/lib/types/client/mount.d.ts.map +1 -1
  20. package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
  21. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
  22. package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
  23. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
  24. package/lib/types/client/preview/content.d.ts +3 -1
  25. package/lib/types/client/preview/content.d.ts.map +1 -1
  26. package/lib/types/client/preview/office.d.ts +23 -0
  27. package/lib/types/client/preview/office.d.ts.map +1 -0
  28. package/lib/types/client/reference.d.ts +17 -0
  29. package/lib/types/client/reference.d.ts.map +1 -1
  30. package/lib/types/client/store.d.ts.map +1 -1
  31. package/lib/types/host/fs-service.d.ts.map +1 -1
  32. package/lib/types/host/office-preview.d.ts +13 -0
  33. package/lib/types/host/office-preview.d.ts.map +1 -0
  34. package/lib/types/host/routes.d.ts.map +1 -1
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/package.json +19 -4
  37. package/src/client/FileManagerSettingsCard.tsx +117 -117
  38. package/src/client/PluginSettingsCard.tsx +337 -337
  39. package/src/client/chat/file-ref.ts +144 -144
  40. package/src/client/chat/mermaid-chat.tsx +102 -102
  41. package/src/client/chat/placeholder-hint.tsx +46 -46
  42. package/src/client/components/ExplorerPanel.tsx +534 -534
  43. package/src/client/components/FileIcon.tsx +46 -46
  44. package/src/client/components/ScmPanel.tsx +480 -480
  45. package/src/client/components/a11y.ts +20 -20
  46. package/src/client/components/icons.tsx +274 -274
  47. package/src/client/components/overlay.tsx +225 -225
  48. package/src/client/drag/DragFileInlay.tsx +89 -89
  49. package/src/client/drag/file-drag.ts +67 -67
  50. package/src/client/drag.ts +154 -154
  51. package/src/client/file-source.ts +34 -0
  52. package/src/client/fileType.ts +32 -5
  53. package/src/client/hooks/useResizableSplit.ts +91 -91
  54. package/src/client/hooks/useStore.ts +15 -15
  55. package/src/client/index.ts +42 -3
  56. package/src/client/layout.ts +523 -523
  57. package/src/client/locales.ts +6 -0
  58. package/src/client/maximize.ts +34 -34
  59. package/src/client/mount.tsx +2 -1
  60. package/src/client/persist.ts +194 -194
  61. package/src/client/picker/FilePickerModal.tsx +526 -526
  62. package/src/client/picker/file-picker.ts +86 -86
  63. package/src/client/preview/PreviewPanel.tsx +7 -1
  64. package/src/client/preview/PreviewTabs.tsx +159 -159
  65. package/src/client/preview/PreviewToolbar.tsx +3 -2
  66. package/src/client/preview/content.tsx +7 -2
  67. package/src/client/preview/markdown.ts +351 -351
  68. package/src/client/preview/mermaid.ts +274 -274
  69. package/src/client/preview/office.tsx +321 -0
  70. package/src/client/reference.ts +49 -0
  71. package/src/client/settings-card.module.css +316 -316
  72. package/src/client/settings-form.ts +451 -451
  73. package/src/client/store.ts +17 -12
  74. package/src/client/styles/drag.module.css +30 -30
  75. package/src/client/styles/explorer.module.css +371 -371
  76. package/src/client/styles/picker.module.css +344 -344
  77. package/src/client/styles/preview.module.css +3 -1
  78. package/src/client/styles/scm.module.css +380 -380
  79. package/src/client/styles/tokens.module.css +376 -371
  80. package/src/core/types.ts +162 -162
  81. package/src/host/fs-service.ts +7 -1
  82. package/src/host/gate.ts +73 -73
  83. package/src/host/git-runner.ts +116 -116
  84. package/src/host/git-service.ts +395 -395
  85. package/src/host/loopback.ts +63 -63
  86. package/src/host/office-preview.ts +399 -0
  87. package/src/host/poll-guard.ts +109 -109
  88. package/src/host/routes.ts +72 -1
  89. package/src/index.ts +3 -1
  90. package/src/mount-once.ts +48 -48
  91. package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
  92. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
  93. package/lib/types/client/floating.d.ts +0 -27
  94. package/lib/types/client/floating.d.ts.map +0 -1
  95. package/lib/types/client/mention.d.ts +0 -44
  96. package/lib/types/client/mention.d.ts.map +0 -1
@@ -1,526 +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). 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
- }
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
+ }