@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.
- package/LICENSE +38 -38
- package/README.i18n.yaml +6 -6
- package/README.md +66 -66
- package/README.zh.md +66 -66
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27305 -270
- package/lib/client.js.map +1 -1
- package/lib/index.js +474 -42
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/file-source.d.ts +10 -0
- package/lib/types/client/file-source.d.ts.map +1 -1
- package/lib/types/client/fileType.d.ts +19 -4
- package/lib/types/client/fileType.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/mount.d.ts +1 -1
- package/lib/types/client/mount.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
- package/lib/types/client/preview/content.d.ts +3 -1
- package/lib/types/client/preview/content.d.ts.map +1 -1
- package/lib/types/client/preview/office.d.ts +23 -0
- package/lib/types/client/preview/office.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +17 -0
- package/lib/types/client/reference.d.ts.map +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/host/fs-service.d.ts.map +1 -1
- package/lib/types/host/office-preview.d.ts +13 -0
- package/lib/types/host/office-preview.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -4
- package/src/client/FileManagerSettingsCard.tsx +117 -117
- package/src/client/PluginSettingsCard.tsx +337 -337
- package/src/client/chat/file-ref.ts +144 -144
- package/src/client/chat/mermaid-chat.tsx +102 -102
- package/src/client/chat/placeholder-hint.tsx +46 -46
- package/src/client/components/ExplorerPanel.tsx +534 -534
- package/src/client/components/FileIcon.tsx +46 -46
- package/src/client/components/ScmPanel.tsx +480 -480
- package/src/client/components/a11y.ts +20 -20
- package/src/client/components/icons.tsx +274 -274
- package/src/client/components/overlay.tsx +225 -225
- package/src/client/drag/DragFileInlay.tsx +89 -89
- package/src/client/drag/file-drag.ts +67 -67
- package/src/client/drag.ts +154 -154
- package/src/client/file-source.ts +34 -0
- package/src/client/fileType.ts +32 -5
- package/src/client/hooks/useResizableSplit.ts +91 -91
- package/src/client/hooks/useStore.ts +15 -15
- package/src/client/index.ts +42 -3
- package/src/client/layout.ts +523 -523
- package/src/client/locales.ts +6 -0
- package/src/client/maximize.ts +34 -34
- package/src/client/mount.tsx +2 -1
- package/src/client/persist.ts +194 -194
- package/src/client/picker/FilePickerModal.tsx +526 -526
- package/src/client/picker/file-picker.ts +86 -86
- package/src/client/preview/PreviewPanel.tsx +7 -1
- package/src/client/preview/PreviewTabs.tsx +159 -159
- package/src/client/preview/PreviewToolbar.tsx +3 -2
- package/src/client/preview/content.tsx +7 -2
- package/src/client/preview/markdown.ts +351 -351
- package/src/client/preview/mermaid.ts +274 -274
- package/src/client/preview/office.tsx +321 -0
- package/src/client/reference.ts +49 -0
- package/src/client/settings-card.module.css +316 -316
- package/src/client/settings-form.ts +451 -451
- package/src/client/store.ts +17 -12
- package/src/client/styles/drag.module.css +30 -30
- package/src/client/styles/explorer.module.css +371 -371
- package/src/client/styles/picker.module.css +344 -344
- package/src/client/styles/preview.module.css +3 -1
- package/src/client/styles/scm.module.css +380 -380
- package/src/client/styles/tokens.module.css +376 -371
- package/src/core/types.ts +162 -162
- package/src/host/fs-service.ts +7 -1
- package/src/host/gate.ts +73 -73
- package/src/host/git-runner.ts +116 -116
- package/src/host/git-service.ts +395 -395
- package/src/host/loopback.ts +63 -63
- package/src/host/office-preview.ts +399 -0
- package/src/host/poll-guard.ts +109 -109
- package/src/host/routes.ts +72 -1
- package/src/index.ts +3 -1
- package/src/mount-once.ts +48 -48
- package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
- package/lib/types/client/floating.d.ts +0 -27
- package/lib/types/client/floating.d.ts.map +0 -1
- package/lib/types/client/mention.d.ts +0 -44
- 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
|
+
}
|