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