@civilization/dsh-workspace-files 0.2.0
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 +201 -0
- package/README.md +59 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1633 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +196 -0
- package/package.json +61 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/index.tsx +1545 -0
- package/src/files.ts +126 -0
- package/src/index.ts +121 -0
|
@@ -0,0 +1,1545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry for the enhanced workspace files explorer plugin.
|
|
3
|
+
* Overrides DSH official 'files' tab using 'priority: extension'.
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
+
import {
|
|
8
|
+
FileGlyphIcon,
|
|
9
|
+
IconAt,
|
|
10
|
+
IconCheck,
|
|
11
|
+
IconClose,
|
|
12
|
+
IconCollapseAll,
|
|
13
|
+
IconCopy,
|
|
14
|
+
IconExpandAll,
|
|
15
|
+
IconFolder,
|
|
16
|
+
IconList,
|
|
17
|
+
IconRefresh,
|
|
18
|
+
IconSearch,
|
|
19
|
+
IconTree,
|
|
20
|
+
} from './icons.js'
|
|
21
|
+
|
|
22
|
+
export const inject = ['slots', 'sidebarRightTabs']
|
|
23
|
+
|
|
24
|
+
let globalCtx: Context | null = null
|
|
25
|
+
|
|
26
|
+
const OFFICIAL_FILES_TAB_ID = '@civilization/dsh-workspace-files'
|
|
27
|
+
|
|
28
|
+
export interface FileItem {
|
|
29
|
+
name: string
|
|
30
|
+
path: string
|
|
31
|
+
isDirectory: boolean
|
|
32
|
+
size?: number
|
|
33
|
+
mtime?: number
|
|
34
|
+
children?: FileItem[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WorkspaceFilesResult {
|
|
38
|
+
root: string
|
|
39
|
+
items: FileItem[]
|
|
40
|
+
totalFiles: number
|
|
41
|
+
totalDirs: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function FolderGuideGlyph({ size = 20, className }: { size?: number; className?: string }) {
|
|
45
|
+
return (
|
|
46
|
+
<span className={className} style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
47
|
+
<IconFolder size={size} />
|
|
48
|
+
</span>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function WorkspaceFilesTabTitle({ useTabInfo }: any): JSX.Element {
|
|
53
|
+
const info = useTabInfo ? useTabInfo() : null
|
|
54
|
+
const title = info?.tab?.title || '文件'
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<IconFolder size={15} />
|
|
58
|
+
<span>{title}</span>
|
|
59
|
+
</>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function WorkspaceFilesTabBody(props: any): JSX.Element {
|
|
64
|
+
const { sessionId, useSessions, useTabInfo } = props
|
|
65
|
+
const tabInfo = useTabInfo ? useTabInfo() : null
|
|
66
|
+
const tabActions = tabInfo?.tab?.actions
|
|
67
|
+
|
|
68
|
+
const sessionCwd = useSessions ? useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) : undefined
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<WorkspaceFilesView
|
|
72
|
+
sessionId={sessionId}
|
|
73
|
+
cwd={sessionCwd}
|
|
74
|
+
onOpenFile={(filePath: string) => {
|
|
75
|
+
if (tabActions?.openResource && sessionId) {
|
|
76
|
+
const normalized = filePath.replace(/\\/g, '/').replace(/^\.?\//, '')
|
|
77
|
+
tabActions.openResource(`dsh-resource://file/session/${encodeURIComponent(sessionId)}/${encodeURIComponent(normalized)}`)
|
|
78
|
+
}
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function apply(ctx: Context): void {
|
|
85
|
+
globalCtx = ctx
|
|
86
|
+
const sidebarRightTabs = (ctx as any).sidebarRightTabs
|
|
87
|
+
const slots = (ctx as any).slots
|
|
88
|
+
|
|
89
|
+
if (sidebarRightTabs && slots) {
|
|
90
|
+
// 关键:kind: 'files', priority: 'extension'(Rank 3 > builtin 2),完美接管官方文件标签页
|
|
91
|
+
ctx.effect(() => sidebarRightTabs.register({
|
|
92
|
+
id: OFFICIAL_FILES_TAB_ID,
|
|
93
|
+
kind: 'files',
|
|
94
|
+
priority: 'extension',
|
|
95
|
+
title: () => '文件',
|
|
96
|
+
guide: [{
|
|
97
|
+
order: 10,
|
|
98
|
+
title: () => '文件',
|
|
99
|
+
description: () => '浏览、搜索与一键引用项目文件',
|
|
100
|
+
icon: FolderGuideGlyph,
|
|
101
|
+
}],
|
|
102
|
+
}), 'dsh-workspace-files: override official files tab')
|
|
103
|
+
|
|
104
|
+
ctx.effect(() => slots.inject('sidebar.right.pane.tab', () => slots.register({
|
|
105
|
+
name: 'sidebar.right.pane.tab',
|
|
106
|
+
key: OFFICIAL_FILES_TAB_ID,
|
|
107
|
+
}, WorkspaceFilesTabBody)), 'dsh-workspace-files: official tab body')
|
|
108
|
+
|
|
109
|
+
ctx.effect(() => slots.inject('sidebar.right.pane.tab.title', () => slots.register({
|
|
110
|
+
name: 'sidebar.right.pane.tab.title',
|
|
111
|
+
key: OFFICIAL_FILES_TAB_ID,
|
|
112
|
+
}, WorkspaceFilesTabTitle)), 'dsh-workspace-files: official tab title')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* ── Main Workspace Files View ── */
|
|
117
|
+
function WorkspaceFilesView({
|
|
118
|
+
sessionId,
|
|
119
|
+
cwd,
|
|
120
|
+
onOpenFile,
|
|
121
|
+
}: {
|
|
122
|
+
sessionId?: string
|
|
123
|
+
cwd?: string
|
|
124
|
+
onOpenFile: (path: string) => void
|
|
125
|
+
}): JSX.Element {
|
|
126
|
+
const [data, setData] = useState<WorkspaceFilesResult | null>(null)
|
|
127
|
+
const [loading, setLoading] = useState(true)
|
|
128
|
+
const [error, setError] = useState<string | null>(null)
|
|
129
|
+
const [query, setQuery] = useState('')
|
|
130
|
+
const [toast, setToast] = useState<string | null>(null)
|
|
131
|
+
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set())
|
|
132
|
+
const [showHidden, setShowHidden] = useState(false)
|
|
133
|
+
|
|
134
|
+
const toastTimer = useRef<number | null>(null)
|
|
135
|
+
|
|
136
|
+
const showToastMsg = useCallback((msg: string) => {
|
|
137
|
+
setToast(msg)
|
|
138
|
+
if (toastTimer.current) window.clearTimeout(toastTimer.current)
|
|
139
|
+
toastTimer.current = window.setTimeout(() => setToast(null), 2000)
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
const loadTree = useCallback(async (quiet = false) => {
|
|
143
|
+
if (!quiet) setLoading(true)
|
|
144
|
+
setError(null)
|
|
145
|
+
try {
|
|
146
|
+
const res = await call<WorkspaceFilesResult>('files.tree', { sessionId, cwd, showHidden })
|
|
147
|
+
setData(res)
|
|
148
|
+
|
|
149
|
+
// 默认折叠,并从 localStorage 恢复已记录的展开状态
|
|
150
|
+
setExpandedPaths(prev => {
|
|
151
|
+
if (prev.size > 0) return prev
|
|
152
|
+
const stored = loadStoredExpandedPaths(res.root || cwd)
|
|
153
|
+
if (stored !== null) return stored
|
|
154
|
+
// 默认全部关闭,不展开任何目录
|
|
155
|
+
return new Set<string>()
|
|
156
|
+
})
|
|
157
|
+
} catch (err: any) {
|
|
158
|
+
setError(err?.message || '无法加载文件列表')
|
|
159
|
+
} finally {
|
|
160
|
+
if (!quiet) setLoading(false)
|
|
161
|
+
}
|
|
162
|
+
}, [sessionId, cwd, showHidden])
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
void loadTree()
|
|
166
|
+
}, [loadTree])
|
|
167
|
+
|
|
168
|
+
const handleToggleExpand = (path: string) => {
|
|
169
|
+
setExpandedPaths(prev => {
|
|
170
|
+
const next = new Set(prev)
|
|
171
|
+
if (next.has(path)) next.delete(path)
|
|
172
|
+
else next.add(path)
|
|
173
|
+
saveStoredExpandedPaths(data?.root || cwd, next)
|
|
174
|
+
return next
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handleExpandAll = () => {
|
|
179
|
+
if (!data) return
|
|
180
|
+
const all = new Set<string>()
|
|
181
|
+
function collect(items: FileItem[]) {
|
|
182
|
+
for (const it of items) {
|
|
183
|
+
if (it.isDirectory) {
|
|
184
|
+
all.add(it.path)
|
|
185
|
+
if (it.children) collect(it.children)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
collect(data.items)
|
|
190
|
+
setExpandedPaths(all)
|
|
191
|
+
saveStoredExpandedPaths(data.root || cwd, all)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const handleCollapseAll = () => {
|
|
195
|
+
const empty = new Set<string>()
|
|
196
|
+
setExpandedPaths(empty)
|
|
197
|
+
saveStoredExpandedPaths(data?.root || cwd, empty)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 搜索时点击文件夹:跳回树形视图并展开该目录及全部祖先路径,自动平滑定位
|
|
201
|
+
const handleJumpToFolderInTree = (dirPath: string) => {
|
|
202
|
+
const parts = dirPath.split('/')
|
|
203
|
+
const toExpand: string[] = []
|
|
204
|
+
let acc = ''
|
|
205
|
+
for (const p of parts) {
|
|
206
|
+
if (!p) continue
|
|
207
|
+
acc = acc ? `${acc}/${p}` : p
|
|
208
|
+
toExpand.push(acc)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
setExpandedPaths(prev => {
|
|
212
|
+
const next = new Set(prev)
|
|
213
|
+
for (const p of toExpand) {
|
|
214
|
+
next.add(p)
|
|
215
|
+
}
|
|
216
|
+
saveStoredExpandedPaths(data?.root || cwd, next)
|
|
217
|
+
return next
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// 退出搜索状态回到树形视图
|
|
221
|
+
setQuery('')
|
|
222
|
+
showToastMsg(`已定位并展开目录: ${dirPath}/`)
|
|
223
|
+
|
|
224
|
+
// 平滑滚动定位到该目录节点
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
try {
|
|
227
|
+
const el = document.querySelector(`[data-tree-path="${dirPath}"]`)
|
|
228
|
+
if (el) {
|
|
229
|
+
el.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
230
|
+
}
|
|
231
|
+
} catch {}
|
|
232
|
+
}, 60)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 复制相对路径
|
|
236
|
+
const handleCopyPath = (e: React.MouseEvent, path: string) => {
|
|
237
|
+
e.stopPropagation()
|
|
238
|
+
void navigator.clipboard.writeText(path).then(() => {
|
|
239
|
+
showToastMsg(`已复制路径: ${path}`)
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 一键 @ 引用到当前对话框输入框(优先插入官方原生交互式 Chip 胶囊)
|
|
244
|
+
const handleMention = (e: React.MouseEvent, itemOrPath: string | FileItem) => {
|
|
245
|
+
e.stopPropagation()
|
|
246
|
+
const itemObj: FileItem = typeof itemOrPath === 'string'
|
|
247
|
+
? { name: itemOrPath.split('/').pop() || itemOrPath, path: itemOrPath, isDirectory: false }
|
|
248
|
+
: itemOrPath
|
|
249
|
+
|
|
250
|
+
const mentionText = formatMentionText(itemObj.path, itemObj.isDirectory)
|
|
251
|
+
|
|
252
|
+
// 1. 优先尝试向会话输入框插入官方原生 ReferenceChipNode 胶囊卡片
|
|
253
|
+
let inserted = false
|
|
254
|
+
try {
|
|
255
|
+
inserted = tryInsertNativeReferenceChip(globalCtx, sessionId, itemObj)
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.warn('[dsh-workspace-files] 原生 Chip 插入失败:', err)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 2. 若原生 Chip 插入未成功,无缝降级为规范纯文本插入
|
|
261
|
+
if (!inserted) {
|
|
262
|
+
try {
|
|
263
|
+
inserted = insertMentionToComposer(mentionText)
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.warn('[dsh-workspace-files] 降级纯文本插入失败:', err)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 写入剪贴板作为双重保障
|
|
270
|
+
const displayName = itemObj.isDirectory ? `${itemObj.name}/` : itemObj.name
|
|
271
|
+
void navigator.clipboard.writeText(mentionText.trim()).then(() => {
|
|
272
|
+
if (inserted) {
|
|
273
|
+
showToastMsg(`已引用${itemObj.isDirectory ? '目录' : '文件'}: ${displayName}`)
|
|
274
|
+
} else {
|
|
275
|
+
showToastMsg(`已复制引用: ${mentionText.trim()}`)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const [searchViewMode, setSearchViewMode] = useState<'flat' | 'tree'>('flat')
|
|
281
|
+
const [searchExpandedPaths, setSearchExpandedPaths] = useState<Set<string>>(() => new Set())
|
|
282
|
+
|
|
283
|
+
// 过滤树与扁平结果
|
|
284
|
+
const { filteredTreeItems, flatSearchResults } = useMemo(() => {
|
|
285
|
+
if (!data) return { filteredTreeItems: [], flatSearchResults: [] }
|
|
286
|
+
const q = query.trim().toLowerCase()
|
|
287
|
+
if (!q) return { filteredTreeItems: data.items, flatSearchResults: [] }
|
|
288
|
+
|
|
289
|
+
// 扁平结果列表
|
|
290
|
+
const flat: Array<{ item: FileItem; parentDir: string }> = []
|
|
291
|
+
function traverseFlat(items: FileItem[], parentDir: string) {
|
|
292
|
+
for (const it of items) {
|
|
293
|
+
const match = it.name.toLowerCase().includes(q) || it.path.toLowerCase().includes(q)
|
|
294
|
+
if (match) {
|
|
295
|
+
flat.push({ item: it, parentDir })
|
|
296
|
+
}
|
|
297
|
+
if (it.isDirectory && it.children) {
|
|
298
|
+
traverseFlat(it.children, it.path)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
traverseFlat(data.items, '')
|
|
303
|
+
|
|
304
|
+
// 树形过滤集合
|
|
305
|
+
function filterNode(node: FileItem): FileItem | null {
|
|
306
|
+
const selfMatch = node.name.toLowerCase().includes(q) || node.path.toLowerCase().includes(q)
|
|
307
|
+
if (node.isDirectory) {
|
|
308
|
+
const filteredChildren: FileItem[] = []
|
|
309
|
+
for (const child of node.children || []) {
|
|
310
|
+
const matchedChild = filterNode(child)
|
|
311
|
+
if (matchedChild) filteredChildren.push(matchedChild)
|
|
312
|
+
}
|
|
313
|
+
if (selfMatch || filteredChildren.length > 0) {
|
|
314
|
+
return { ...node, children: filteredChildren }
|
|
315
|
+
}
|
|
316
|
+
return null
|
|
317
|
+
}
|
|
318
|
+
return selfMatch ? node : null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const tree: FileItem[] = []
|
|
322
|
+
for (const rootNode of data.items) {
|
|
323
|
+
const matched = filterNode(rootNode)
|
|
324
|
+
if (matched) tree.push(matched)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return { filteredTreeItems: tree, flatSearchResults: flat }
|
|
328
|
+
}, [data, query])
|
|
329
|
+
|
|
330
|
+
// 搜索关键字变化时,自动展开匹配的目录树节点,并允许用户后续自由折叠/展开
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
if (!query.trim() || !data) return
|
|
333
|
+
const q = query.trim().toLowerCase()
|
|
334
|
+
const autoExpand = new Set<string>()
|
|
335
|
+
function collect(items: FileItem[]) {
|
|
336
|
+
for (const it of items) {
|
|
337
|
+
if (it.isDirectory) {
|
|
338
|
+
const selfMatch = it.name.toLowerCase().includes(q) || it.path.toLowerCase().includes(q)
|
|
339
|
+
const childMatch = it.children?.some(c => c.name.toLowerCase().includes(q) || c.path.toLowerCase().includes(q))
|
|
340
|
+
if (selfMatch || childMatch) autoExpand.add(it.path)
|
|
341
|
+
if (it.children) collect(it.children)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
collect(data.items)
|
|
346
|
+
setSearchExpandedPaths(autoExpand)
|
|
347
|
+
}, [query, data])
|
|
348
|
+
|
|
349
|
+
const isSearchActive = Boolean(query.trim())
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div className="dsh-files-root">
|
|
353
|
+
<GlobalFilesStyle />
|
|
354
|
+
|
|
355
|
+
{/* Header */}
|
|
356
|
+
<header className="dsh-files-header">
|
|
357
|
+
<div className="dsh-files-header-info">
|
|
358
|
+
<div className="dsh-files-title-row">
|
|
359
|
+
<span className="dsh-files-title">文件</span>
|
|
360
|
+
{data && (
|
|
361
|
+
<span className="dsh-files-stats-badge">
|
|
362
|
+
{data.totalFiles} 文件 · {data.totalDirs} 目录
|
|
363
|
+
</span>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
<div className="dsh-files-root-path" title={data?.root || cwd}>
|
|
367
|
+
{data?.root || cwd || '当前会话没有工作目录'}
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</header>
|
|
371
|
+
|
|
372
|
+
{/* Search & Actions Toolbar */}
|
|
373
|
+
<div className="dsh-files-toolbar">
|
|
374
|
+
<div className="dsh-files-search-wrap">
|
|
375
|
+
<span className="dsh-files-search-icon"><IconSearch size={13} /></span>
|
|
376
|
+
<input
|
|
377
|
+
type="text"
|
|
378
|
+
className="dsh-files-search-input"
|
|
379
|
+
placeholder="搜索文件 (支持路径筛选)..."
|
|
380
|
+
value={query}
|
|
381
|
+
onChange={e => setQuery(e.target.value)}
|
|
382
|
+
onKeyDown={e => {
|
|
383
|
+
if (e.key === 'Escape') setQuery('')
|
|
384
|
+
}}
|
|
385
|
+
/>
|
|
386
|
+
{query && (
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
className="dsh-files-clear-btn"
|
|
390
|
+
onClick={() => setQuery('')}
|
|
391
|
+
title="清除搜索"
|
|
392
|
+
>
|
|
393
|
+
<IconClose size={12} />
|
|
394
|
+
</button>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<div className="dsh-files-toolbar-btns">
|
|
399
|
+
{isSearchActive ? (
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
className="dsh-files-tool-btn is-active"
|
|
403
|
+
onClick={() => setSearchViewMode(searchViewMode === 'flat' ? 'tree' : 'flat')}
|
|
404
|
+
title={searchViewMode === 'flat' ? '当前为扁平列表,点击切换为树形视图' : '当前为树形视图,点击切换为扁平列表'}
|
|
405
|
+
>
|
|
406
|
+
{searchViewMode === 'flat' ? <IconTree size={13} /> : <IconList size={13} />}
|
|
407
|
+
</button>
|
|
408
|
+
) : (
|
|
409
|
+
<>
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
className="dsh-files-tool-btn"
|
|
413
|
+
onClick={handleExpandAll}
|
|
414
|
+
title="全部展开"
|
|
415
|
+
>
|
|
416
|
+
<IconExpandAll size={13} />
|
|
417
|
+
</button>
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
className="dsh-files-tool-btn"
|
|
421
|
+
onClick={handleCollapseAll}
|
|
422
|
+
title="全部折叠"
|
|
423
|
+
>
|
|
424
|
+
<IconCollapseAll size={13} />
|
|
425
|
+
</button>
|
|
426
|
+
</>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
className={`dsh-files-tool-btn ${showHidden ? 'is-active' : ''}`}
|
|
432
|
+
onClick={() => setShowHidden(!showHidden)}
|
|
433
|
+
title={showHidden ? '隐藏点号开头的文件' : '显示点号开头的文件'}
|
|
434
|
+
>
|
|
435
|
+
<span style={{ fontSize: 11, fontWeight: 700 }}>.*</span>
|
|
436
|
+
</button>
|
|
437
|
+
<button
|
|
438
|
+
type="button"
|
|
439
|
+
className="dsh-files-tool-btn"
|
|
440
|
+
onClick={() => void loadTree(true)}
|
|
441
|
+
disabled={loading}
|
|
442
|
+
title="刷新文件树"
|
|
443
|
+
>
|
|
444
|
+
<IconRefresh size={13} />
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
{/* Error Banner */}
|
|
450
|
+
{error && (
|
|
451
|
+
<div className="dsh-files-banner-error">
|
|
452
|
+
<span>{error}</span>
|
|
453
|
+
<button type="button" className="dsh-files-banner-close" onClick={() => setError(null)}>
|
|
454
|
+
<IconClose size={12} />
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{/* Tree / Search Results Content */}
|
|
460
|
+
<div className="dsh-files-body">
|
|
461
|
+
{loading && !data ? (
|
|
462
|
+
<div className="dsh-files-centered">
|
|
463
|
+
<div className="dsh-files-spinner" />
|
|
464
|
+
<span>加载文件…</span>
|
|
465
|
+
</div>
|
|
466
|
+
) : isSearchActive && flatSearchResults.length === 0 ? (
|
|
467
|
+
<div className="dsh-files-empty">
|
|
468
|
+
<div className="dsh-files-empty-icon"><IconFolder size={26} /></div>
|
|
469
|
+
<div className="dsh-files-empty-title">未搜索到匹配的文件</div>
|
|
470
|
+
<div className="dsh-files-empty-detail">请尝试更换搜索关键字,或按 Esc 清除搜索。</div>
|
|
471
|
+
</div>
|
|
472
|
+
) : !isSearchActive && (!data || data.items.length === 0) ? (
|
|
473
|
+
<div className="dsh-files-empty">
|
|
474
|
+
<div className="dsh-files-empty-icon"><IconFolder size={26} /></div>
|
|
475
|
+
<div className="dsh-files-empty-title">工作区为空</div>
|
|
476
|
+
<div className="dsh-files-empty-detail">当前工作目录下暂无可显示的文件。</div>
|
|
477
|
+
</div>
|
|
478
|
+
) : isSearchActive && searchViewMode === 'flat' ? (
|
|
479
|
+
/* ── 扁平搜索结果列表 ── */
|
|
480
|
+
<div className="dsh-files-search-container">
|
|
481
|
+
<div className="dsh-files-search-header">
|
|
482
|
+
<span className="dsh-files-search-count">找到 {flatSearchResults.length} 个匹配项</span>
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
className="dsh-files-view-toggle-btn"
|
|
486
|
+
onClick={() => setSearchViewMode('tree')}
|
|
487
|
+
title="切换为可折叠的树形视图"
|
|
488
|
+
>
|
|
489
|
+
<IconTree size={12} />
|
|
490
|
+
<span>切为树形</span>
|
|
491
|
+
</button>
|
|
492
|
+
</div>
|
|
493
|
+
<div className="dsh-files-flat-list">
|
|
494
|
+
{flatSearchResults.map(({ item, parentDir }) => (
|
|
495
|
+
<div
|
|
496
|
+
key={item.path}
|
|
497
|
+
className={`dsh-files-flat-row ${item.isDirectory ? 'is-directory' : ''}`}
|
|
498
|
+
onClick={() => {
|
|
499
|
+
if (item.isDirectory) {
|
|
500
|
+
handleJumpToFolderInTree(item.path)
|
|
501
|
+
} else {
|
|
502
|
+
onOpenFile(item.path)
|
|
503
|
+
}
|
|
504
|
+
}}
|
|
505
|
+
title={item.isDirectory ? `点击退出搜索并定位展开目录: ${item.path}/` : `点击预览打开文件: ${item.path}`}
|
|
506
|
+
>
|
|
507
|
+
<span className="dsh-files-flat-icon">
|
|
508
|
+
{item.isDirectory ? <IconFolder size={14} /> : <FileGlyphIcon filename={item.name} size={14} />}
|
|
509
|
+
</span>
|
|
510
|
+
<div className="dsh-files-flat-info">
|
|
511
|
+
<div className="dsh-files-flat-top">
|
|
512
|
+
<span className="dsh-files-flat-name">
|
|
513
|
+
<HighlightText text={item.name} query={query} />
|
|
514
|
+
{item.isDirectory && '/'}
|
|
515
|
+
</span>
|
|
516
|
+
{item.size !== undefined && (
|
|
517
|
+
<span className="dsh-files-size">{formatFileSize(item.size)}</span>
|
|
518
|
+
)}
|
|
519
|
+
{item.isDirectory && (
|
|
520
|
+
<span className="dsh-files-jump-hint">跳回展开 ›</span>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
{parentDir && (
|
|
524
|
+
<div className="dsh-files-flat-parent" title={parentDir}>
|
|
525
|
+
{parentDir}
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
<div className="dsh-files-row-actions">
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
className="dsh-files-action-btn dsh-files-action-at"
|
|
533
|
+
onClick={e => handleMention(e, item)}
|
|
534
|
+
title={`一键 @ 引用此${item.isDirectory ? '目录' : '文件'}到当前会话`}
|
|
535
|
+
>
|
|
536
|
+
<IconAt size={13} />
|
|
537
|
+
<span className="dsh-files-action-at-text">@引用</span>
|
|
538
|
+
</button>
|
|
539
|
+
<button
|
|
540
|
+
type="button"
|
|
541
|
+
className="dsh-files-action-btn"
|
|
542
|
+
onClick={e => handleCopyPath(e, item.path)}
|
|
543
|
+
title="复制相对路径"
|
|
544
|
+
>
|
|
545
|
+
<IconCopy size={12} />
|
|
546
|
+
</button>
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
))}
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
) : (
|
|
553
|
+
/* ── 树形视图(普通模式或搜索树模式) ── */
|
|
554
|
+
<div className="dsh-files-tree">
|
|
555
|
+
{isSearchActive && (
|
|
556
|
+
<div className="dsh-files-search-header">
|
|
557
|
+
<span className="dsh-files-search-count">树形匹配(自由折叠/展开)</span>
|
|
558
|
+
<button
|
|
559
|
+
type="button"
|
|
560
|
+
className="dsh-files-view-toggle-btn"
|
|
561
|
+
onClick={() => setSearchViewMode('flat')}
|
|
562
|
+
title="切换为扁平搜索结果列表"
|
|
563
|
+
>
|
|
564
|
+
<IconList size={12} />
|
|
565
|
+
<span>切为列表</span>
|
|
566
|
+
</button>
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
{filteredTreeItems.map(item => (
|
|
570
|
+
<FileTreeNode
|
|
571
|
+
key={item.path}
|
|
572
|
+
item={item}
|
|
573
|
+
depth={0}
|
|
574
|
+
query={query}
|
|
575
|
+
isSearchMode={isSearchActive}
|
|
576
|
+
expandedPaths={isSearchActive ? searchExpandedPaths : expandedPaths}
|
|
577
|
+
onToggleExpand={(path: string) => {
|
|
578
|
+
if (isSearchActive) {
|
|
579
|
+
setSearchExpandedPaths(prev => {
|
|
580
|
+
const next = new Set(prev)
|
|
581
|
+
if (next.has(path)) next.delete(path)
|
|
582
|
+
else next.add(path)
|
|
583
|
+
return next
|
|
584
|
+
})
|
|
585
|
+
} else {
|
|
586
|
+
handleToggleExpand(path)
|
|
587
|
+
}
|
|
588
|
+
}}
|
|
589
|
+
onOpenFile={onOpenFile}
|
|
590
|
+
onMention={handleMention}
|
|
591
|
+
onCopyPath={handleCopyPath}
|
|
592
|
+
/>
|
|
593
|
+
))}
|
|
594
|
+
</div>
|
|
595
|
+
)}
|
|
596
|
+
</div>
|
|
597
|
+
|
|
598
|
+
{/* Toast Notification */}
|
|
599
|
+
{toast && (
|
|
600
|
+
<div className="dsh-files-toast">
|
|
601
|
+
<IconCheck size={12} />
|
|
602
|
+
<span>{toast}</span>
|
|
603
|
+
</div>
|
|
604
|
+
)}
|
|
605
|
+
</div>
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/* ── Tree Node Component ── */
|
|
610
|
+
function FileTreeNode(props: {
|
|
611
|
+
item: FileItem
|
|
612
|
+
depth: number
|
|
613
|
+
query: string
|
|
614
|
+
isSearchMode: boolean
|
|
615
|
+
expandedPaths: Set<string>
|
|
616
|
+
onToggleExpand: (path: string) => void
|
|
617
|
+
onOpenFile: (path: string) => void
|
|
618
|
+
onMention: (e: React.MouseEvent, item: FileItem) => void
|
|
619
|
+
onCopyPath: (e: React.MouseEvent, path: string) => void
|
|
620
|
+
}): JSX.Element {
|
|
621
|
+
const { item, depth, query, isSearchMode, expandedPaths, onToggleExpand, onOpenFile, onMention, onCopyPath } = props
|
|
622
|
+
const isExpanded = expandedPaths.has(item.path)
|
|
623
|
+
|
|
624
|
+
if (item.isDirectory) {
|
|
625
|
+
const childCount = item.children?.length ?? 0
|
|
626
|
+
return (
|
|
627
|
+
<div className="dsh-files-dir-block">
|
|
628
|
+
<div
|
|
629
|
+
className="dsh-files-dir-row"
|
|
630
|
+
data-tree-path={item.path}
|
|
631
|
+
style={{ paddingLeft: `${depth * 14 + 10}px` }}
|
|
632
|
+
onClick={() => onToggleExpand(item.path)}
|
|
633
|
+
>
|
|
634
|
+
<span className="dsh-files-chevron" data-collapsed={!isExpanded ? 'true' : undefined}>▾</span>
|
|
635
|
+
<span className="dsh-files-folder-icon">
|
|
636
|
+
<IconFolder size={14} open={isExpanded} />
|
|
637
|
+
</span>
|
|
638
|
+
<span className="dsh-files-folder-name" title={item.path}>
|
|
639
|
+
<HighlightText text={item.name} query={query} />
|
|
640
|
+
</span>
|
|
641
|
+
<span className="dsh-files-count-badge">{childCount}</span>
|
|
642
|
+
|
|
643
|
+
<div className="dsh-files-row-actions">
|
|
644
|
+
<button
|
|
645
|
+
type="button"
|
|
646
|
+
className="dsh-files-action-btn dsh-files-action-at"
|
|
647
|
+
onClick={e => onMention(e, item)}
|
|
648
|
+
title="一键 @ 引用此目录到当前会话"
|
|
649
|
+
>
|
|
650
|
+
<IconAt size={13} />
|
|
651
|
+
<span className="dsh-files-action-at-text">@引用</span>
|
|
652
|
+
</button>
|
|
653
|
+
<button
|
|
654
|
+
type="button"
|
|
655
|
+
className="dsh-files-action-btn"
|
|
656
|
+
onClick={e => onCopyPath(e, item.path)}
|
|
657
|
+
title="复制目录路径"
|
|
658
|
+
>
|
|
659
|
+
<IconCopy size={12} />
|
|
660
|
+
</button>
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
{isExpanded && item.children && (
|
|
664
|
+
<div className="dsh-files-children">
|
|
665
|
+
{item.children.map(child => (
|
|
666
|
+
<FileTreeNode
|
|
667
|
+
key={child.path}
|
|
668
|
+
item={child}
|
|
669
|
+
depth={depth + 1}
|
|
670
|
+
query={query}
|
|
671
|
+
isSearchMode={isSearchMode}
|
|
672
|
+
expandedPaths={expandedPaths}
|
|
673
|
+
onToggleExpand={onToggleExpand}
|
|
674
|
+
onOpenFile={onOpenFile}
|
|
675
|
+
onMention={onMention}
|
|
676
|
+
onCopyPath={onCopyPath}
|
|
677
|
+
/>
|
|
678
|
+
))}
|
|
679
|
+
</div>
|
|
680
|
+
)}
|
|
681
|
+
</div>
|
|
682
|
+
)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// File item row
|
|
686
|
+
return (
|
|
687
|
+
<div
|
|
688
|
+
className="dsh-files-row"
|
|
689
|
+
data-tree-path={item.path}
|
|
690
|
+
style={{ paddingLeft: `${depth * 14 + 10}px` }}
|
|
691
|
+
onClick={() => onOpenFile(item.path)}
|
|
692
|
+
title={`点击打开: ${item.path}`}
|
|
693
|
+
>
|
|
694
|
+
<FileGlyphIcon filename={item.name} size={14} />
|
|
695
|
+
<span className="dsh-files-name">
|
|
696
|
+
<HighlightText text={item.name} query={query} />
|
|
697
|
+
</span>
|
|
698
|
+
|
|
699
|
+
{item.size !== undefined && (
|
|
700
|
+
<span className="dsh-files-size" title={`大小: ${item.size} 字节`}>
|
|
701
|
+
{formatFileSize(item.size)}
|
|
702
|
+
</span>
|
|
703
|
+
)}
|
|
704
|
+
|
|
705
|
+
{/* Row Hover Actions */}
|
|
706
|
+
<div className="dsh-files-row-actions">
|
|
707
|
+
<button
|
|
708
|
+
type="button"
|
|
709
|
+
className="dsh-files-action-btn dsh-files-action-at"
|
|
710
|
+
onClick={e => onMention(e, item)}
|
|
711
|
+
title="一键 @ 引用此文件到当前会话"
|
|
712
|
+
>
|
|
713
|
+
<IconAt size={13} />
|
|
714
|
+
<span className="dsh-files-action-at-text">@引用</span>
|
|
715
|
+
</button>
|
|
716
|
+
<button
|
|
717
|
+
type="button"
|
|
718
|
+
className="dsh-files-action-btn"
|
|
719
|
+
onClick={e => onCopyPath(e, item.path)}
|
|
720
|
+
title="复制相对路径"
|
|
721
|
+
>
|
|
722
|
+
<IconCopy size={12} />
|
|
723
|
+
</button>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/* ── Helpers ── */
|
|
730
|
+
function HighlightText({ text, query }: { text: string; query: string }): JSX.Element {
|
|
731
|
+
const q = query.trim().toLowerCase()
|
|
732
|
+
if (!q) return <span>{text}</span>
|
|
733
|
+
const index = text.toLowerCase().indexOf(q)
|
|
734
|
+
if (index === -1) return <span>{text}</span>
|
|
735
|
+
|
|
736
|
+
const before = text.slice(0, index)
|
|
737
|
+
const match = text.slice(index, index + q.length)
|
|
738
|
+
const after = text.slice(index + q.length)
|
|
739
|
+
|
|
740
|
+
return (
|
|
741
|
+
<span>
|
|
742
|
+
{before}
|
|
743
|
+
<mark className="dsh-files-highlight">{match}</mark>
|
|
744
|
+
{after}
|
|
745
|
+
</span>
|
|
746
|
+
)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function formatFileSize(bytes: number): string {
|
|
750
|
+
if (bytes < 1024) return `${bytes} B`
|
|
751
|
+
const kb = bytes / 1024
|
|
752
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
|
753
|
+
const mb = kb / 1024
|
|
754
|
+
return `${mb.toFixed(1)} MB`
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* 按照 DSH 官方规范格式化引用路径:
|
|
759
|
+
* - 纯路径无空格:@path
|
|
760
|
+
* - 路径含空格:@"path"
|
|
761
|
+
* - 目录末尾保留斜杠:@folder/ 或 @"folder/"
|
|
762
|
+
*/
|
|
763
|
+
function formatMentionText(path: string, isDirectory = false): string {
|
|
764
|
+
const cleanPath = path.replace(/\\/g, '/').replace(/^\.?\//, '')
|
|
765
|
+
const finalPath = isDirectory ? (cleanPath.endsWith('/') ? cleanPath : `${cleanPath}/`) : cleanPath
|
|
766
|
+
if (/\s/u.test(finalPath)) {
|
|
767
|
+
return `@"${finalPath}" `
|
|
768
|
+
}
|
|
769
|
+
return `@${finalPath} `
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function getStorageKey(workspaceRoot?: string): string {
|
|
773
|
+
const safe = (workspaceRoot || 'default').replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
774
|
+
return `dsh_files_expanded_${safe}`
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function loadStoredExpandedPaths(workspaceRoot?: string): Set<string> | null {
|
|
778
|
+
if (typeof window === 'undefined' || !window.localStorage) return null
|
|
779
|
+
try {
|
|
780
|
+
const raw = window.localStorage.getItem(getStorageKey(workspaceRoot))
|
|
781
|
+
if (!raw) return null
|
|
782
|
+
const list = JSON.parse(raw)
|
|
783
|
+
if (Array.isArray(list)) return new Set<string>(list)
|
|
784
|
+
} catch {}
|
|
785
|
+
return null
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function saveStoredExpandedPaths(workspaceRoot: string | undefined, paths: Set<string>): void {
|
|
789
|
+
if (typeof window === 'undefined' || !window.localStorage) return
|
|
790
|
+
try {
|
|
791
|
+
window.localStorage.setItem(getStorageKey(workspaceRoot), JSON.stringify(Array.from(paths)))
|
|
792
|
+
} catch {}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* 从当前 DOM 的 React Fiber 树或 Cordis RootContext 中提取当前活跃输入框的 SessionInputShell 实例
|
|
797
|
+
*/
|
|
798
|
+
function findComposerShell(ctx: Context | null, sessionId?: string): any {
|
|
799
|
+
// 1. 优先从 DOM 的 React Fiber 提取(直接命中当前活跃输入框的组件 props)
|
|
800
|
+
try {
|
|
801
|
+
const composerDom = document.querySelector<HTMLElement>(
|
|
802
|
+
'[data-composer-input="true"], [data-composer-input], [data-composer-card], div[role="textbox"][contenteditable="true"]'
|
|
803
|
+
)
|
|
804
|
+
if (composerDom) {
|
|
805
|
+
const fiberKey = Object.keys(composerDom).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance'))
|
|
806
|
+
if (fiberKey) {
|
|
807
|
+
let curr = (composerDom as any)[fiberKey]
|
|
808
|
+
let depth = 0
|
|
809
|
+
while (curr && depth < 25) {
|
|
810
|
+
const p = curr.memoizedProps
|
|
811
|
+
if (p) {
|
|
812
|
+
if (p.keyboard && typeof p.keyboard.insertReference === 'function') {
|
|
813
|
+
return p.keyboard
|
|
814
|
+
}
|
|
815
|
+
if (p.shell && typeof p.shell.insertReference === 'function') {
|
|
816
|
+
return p.shell
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
curr = curr.return
|
|
820
|
+
depth++
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
} catch (e) {
|
|
825
|
+
console.debug('[dsh-workspace-files] 从 Fiber 查找 keyboard 失败:', e)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// 2. 从 Cordis RootContext 查找
|
|
829
|
+
if (ctx) {
|
|
830
|
+
try {
|
|
831
|
+
const root: any = (ctx as any).root || ctx
|
|
832
|
+
const conversation = root.conversation || root.get?.('conversation') || (window as any).__dsh_conversation
|
|
833
|
+
const inputService = conversation?.input || root['conversation.input'] || root.get?.('conversation.input')
|
|
834
|
+
if (inputService) {
|
|
835
|
+
if (sessionId) {
|
|
836
|
+
const s = inputService.shell?.(sessionId) || inputService.shells?.get?.(sessionId)
|
|
837
|
+
if (s) return s
|
|
838
|
+
}
|
|
839
|
+
if (inputService.shells instanceof Map && inputService.shells.size > 0) {
|
|
840
|
+
const values = Array.from(inputService.shells.values())
|
|
841
|
+
return values[values.length - 1]
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
} catch (e) {
|
|
845
|
+
console.debug('[dsh-workspace-files] 从 RootContext 查找 shell 失败:', e)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return null
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* 尝试向会话输入框插入原生的 ReferenceChipNode(官方胶囊 Tag 卡片)
|
|
854
|
+
*/
|
|
855
|
+
function tryInsertNativeReferenceChip(ctx: Context | null, sessionId: string | undefined, item: FileItem): boolean {
|
|
856
|
+
if (typeof document === 'undefined') return false
|
|
857
|
+
try {
|
|
858
|
+
// 聚焦输入框
|
|
859
|
+
const composerDom = document.querySelector<HTMLElement>(
|
|
860
|
+
'[data-composer-input="true"], [data-composer-input], [data-composer-card] [contenteditable="true"], div[role="textbox"][contenteditable="true"]'
|
|
861
|
+
)
|
|
862
|
+
if (composerDom) composerDom.focus()
|
|
863
|
+
|
|
864
|
+
const shell = findComposerShell(ctx, sessionId)
|
|
865
|
+
if (!shell) {
|
|
866
|
+
return false
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const cleanPath = item.path.replace(/\\/g, '/').replace(/^\.?\//, '')
|
|
870
|
+
const mention = item.isDirectory ? (cleanPath.endsWith('/') ? `@${cleanPath}` : `@${cleanPath}/`) : `@${cleanPath}`
|
|
871
|
+
const label = item.isDirectory ? (item.name.endsWith('/') ? item.name : `${item.name}/`) : item.name
|
|
872
|
+
|
|
873
|
+
const reference = {
|
|
874
|
+
source: 'reference',
|
|
875
|
+
ref: mention,
|
|
876
|
+
label,
|
|
877
|
+
appearance: item.isDirectory ? 'folder' : 'file',
|
|
878
|
+
clipboardText: mention,
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const text = shell.projection?.detectText || ''
|
|
882
|
+
let caret = typeof shell.projection?.caret === 'number' ? shell.projection.caret : text.length
|
|
883
|
+
let start = caret
|
|
884
|
+
const end = caret
|
|
885
|
+
|
|
886
|
+
// 智能消除:若当前光标前是 '@',回退一个字符将其吃掉替换
|
|
887
|
+
if (start > 0 && text[start - 1] === '@') {
|
|
888
|
+
start = start - 1
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const span = {
|
|
892
|
+
draftRev: shell.rev,
|
|
893
|
+
start,
|
|
894
|
+
end,
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// 优先调用原生 shell.insertReference 插入 Chip Tag
|
|
898
|
+
if (typeof shell.insertReference === 'function') {
|
|
899
|
+
const ok = shell.insertReference(reference, span)
|
|
900
|
+
if (ok) return true
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// 备用:尝试通过 session actx 派发事件
|
|
904
|
+
const actx = shell.deps?.actx
|
|
905
|
+
if (actx && typeof actx.bail === 'function') {
|
|
906
|
+
const ok = actx.bail(actx, 'slash/input-insert-reference', { reference, span })
|
|
907
|
+
if (ok === true) return true
|
|
908
|
+
}
|
|
909
|
+
} catch (err) {
|
|
910
|
+
console.debug('[dsh-workspace-files] 插入原生 Chip 异常:', err)
|
|
911
|
+
}
|
|
912
|
+
return false
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* 将引用文本注入到 DSH 当前会话的主输入框。
|
|
917
|
+
* 兼容 DSH 官方基于 Lexical 的 ComposerContentEditable (div[data-composer-input])
|
|
918
|
+
* 以及传统的 textarea / input,并自动消除用户手动输入的冗余 '@'。
|
|
919
|
+
*/
|
|
920
|
+
function insertMentionToComposer(mentionText: string): boolean {
|
|
921
|
+
if (typeof document === 'undefined') return false
|
|
922
|
+
|
|
923
|
+
// 1. 查找 DSH 官方输入框(Lexical contenteditable 容器优先)
|
|
924
|
+
const candidates = [
|
|
925
|
+
document.querySelector<HTMLElement>('[data-composer-input="true"]'),
|
|
926
|
+
document.querySelector<HTMLElement>('[data-composer-input]'),
|
|
927
|
+
document.querySelector<HTMLElement>('[data-composer-card] [contenteditable="true"]'),
|
|
928
|
+
document.querySelector<HTMLElement>('[contenteditable="true"][role="textbox"]'),
|
|
929
|
+
document.querySelector<HTMLTextAreaElement>('textarea:not([readonly]):not(.dsh-files-search-input)'),
|
|
930
|
+
].filter(Boolean) as HTMLElement[]
|
|
931
|
+
|
|
932
|
+
const target = candidates[0]
|
|
933
|
+
if (!target) return false
|
|
934
|
+
|
|
935
|
+
target.focus()
|
|
936
|
+
|
|
937
|
+
// 策略 A: contenteditable (DSH 官方 Lexical 编辑器)
|
|
938
|
+
if (target.getAttribute('contenteditable') === 'true' || target.isContentEditable) {
|
|
939
|
+
const sel = window.getSelection()
|
|
940
|
+
if (sel) {
|
|
941
|
+
let isInTarget = false
|
|
942
|
+
if (sel.rangeCount > 0) {
|
|
943
|
+
const anchor = sel.anchorNode
|
|
944
|
+
if (anchor && (anchor === target || target.contains(anchor))) {
|
|
945
|
+
isInTarget = true
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (!isInTarget) {
|
|
949
|
+
const range = document.createRange()
|
|
950
|
+
range.selectNodeContents(target)
|
|
951
|
+
range.collapse(false) // 光标定位到文本最末尾
|
|
952
|
+
sel.removeAllRanges()
|
|
953
|
+
sel.addRange(range)
|
|
954
|
+
} else {
|
|
955
|
+
// 智能消除:若光标前一个字符是已键入的 '@',将其纳入选区进行覆盖替换,避免出现 '@@file'
|
|
956
|
+
const range = sel.getRangeAt(0)
|
|
957
|
+
if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
958
|
+
const nodeText = range.startContainer.textContent || ''
|
|
959
|
+
const offset = range.startOffset
|
|
960
|
+
if (offset > 0 && nodeText[offset - 1] === '@') {
|
|
961
|
+
range.setStart(range.startContainer, offset - 1)
|
|
962
|
+
sel.removeAllRanges()
|
|
963
|
+
sel.addRange(range)
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// 原生 execCommand('insertText') 会直接触发 Lexical 的 input/beforeinput 事件流并更新 React 状态
|
|
970
|
+
let success = false
|
|
971
|
+
try {
|
|
972
|
+
success = document.execCommand('insertText', false, mentionText)
|
|
973
|
+
} catch {}
|
|
974
|
+
|
|
975
|
+
if (!success) {
|
|
976
|
+
try {
|
|
977
|
+
const evt = new InputEvent('beforeinput', {
|
|
978
|
+
bubbles: true,
|
|
979
|
+
cancelable: true,
|
|
980
|
+
inputType: 'insertText',
|
|
981
|
+
data: mentionText,
|
|
982
|
+
})
|
|
983
|
+
target.dispatchEvent(evt)
|
|
984
|
+
success = true
|
|
985
|
+
} catch {}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return success
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// 策略 B: 普通 textarea / input
|
|
992
|
+
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
|
|
993
|
+
let start = target.selectionStart ?? target.value.length
|
|
994
|
+
const end = target.selectionEnd ?? target.value.length
|
|
995
|
+
const prev = target.value
|
|
996
|
+
|
|
997
|
+
// 智能消除:若前面紧随 '@',覆盖它
|
|
998
|
+
if (start > 0 && prev[start - 1] === '@') {
|
|
999
|
+
start = start - 1
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
let success = false
|
|
1003
|
+
try {
|
|
1004
|
+
target.selectionStart = start
|
|
1005
|
+
target.selectionEnd = end
|
|
1006
|
+
success = document.execCommand('insertText', false, mentionText)
|
|
1007
|
+
} catch {}
|
|
1008
|
+
|
|
1009
|
+
if (!success) {
|
|
1010
|
+
const next = prev.slice(0, start) + mentionText + prev.slice(end)
|
|
1011
|
+
const proto = target instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
|
|
1012
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
1013
|
+
if (setter) {
|
|
1014
|
+
setter.call(target, next)
|
|
1015
|
+
} else {
|
|
1016
|
+
target.value = next
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
target.selectionStart = target.selectionEnd = start + mentionText.length
|
|
1020
|
+
target.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: mentionText }))
|
|
1021
|
+
target.dispatchEvent(new Event('change', { bubbles: true }))
|
|
1022
|
+
success = true
|
|
1023
|
+
}
|
|
1024
|
+
return success
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return false
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function call<T = any>(method: string, payload: Record<string, unknown> = {}): Promise<T> {
|
|
1031
|
+
let response: Response | null = await fetch(`/dsh-workspace-files/api/${method}`, {
|
|
1032
|
+
method: 'POST',
|
|
1033
|
+
headers: { 'content-type': 'application/json' },
|
|
1034
|
+
body: JSON.stringify(payload),
|
|
1035
|
+
}).catch(() => null)
|
|
1036
|
+
|
|
1037
|
+
if (!response || !response.ok) {
|
|
1038
|
+
response = await fetch(`/sidebar/api/${method}`, {
|
|
1039
|
+
method: 'POST',
|
|
1040
|
+
headers: { 'content-type': 'application/json' },
|
|
1041
|
+
body: JSON.stringify(payload),
|
|
1042
|
+
}).catch(() => null)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (!response) {
|
|
1046
|
+
throw new Error('网络请求失败:无法连接到文件服务')
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const envelope = (await response.json().catch(() => null)) as {
|
|
1050
|
+
ok?: boolean
|
|
1051
|
+
value?: T
|
|
1052
|
+
error?: { message?: string }
|
|
1053
|
+
} | null
|
|
1054
|
+
|
|
1055
|
+
if (!response.ok || envelope?.ok !== true || envelope.value === undefined) {
|
|
1056
|
+
throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`)
|
|
1057
|
+
}
|
|
1058
|
+
return envelope.value
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/* ── CSS Styles ── */
|
|
1062
|
+
function GlobalFilesStyle(): JSX.Element {
|
|
1063
|
+
return (
|
|
1064
|
+
<style>{`
|
|
1065
|
+
.dsh-files-root {
|
|
1066
|
+
display: flex;
|
|
1067
|
+
flex-direction: column;
|
|
1068
|
+
height: 100%;
|
|
1069
|
+
min-height: 0;
|
|
1070
|
+
box-sizing: border-box;
|
|
1071
|
+
background: var(--dsw-alias-bg-base, #ffffff);
|
|
1072
|
+
color: var(--dsw-alias-label-primary, #17233c);
|
|
1073
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
1074
|
+
font-size: 12px;
|
|
1075
|
+
line-height: 1.4;
|
|
1076
|
+
overflow: hidden;
|
|
1077
|
+
position: relative;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/* ── Header ── */
|
|
1081
|
+
.dsh-files-header {
|
|
1082
|
+
flex: none;
|
|
1083
|
+
display: flex;
|
|
1084
|
+
align-items: center;
|
|
1085
|
+
justify-content: space-between;
|
|
1086
|
+
gap: 8px;
|
|
1087
|
+
padding: 8px 12px;
|
|
1088
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(118,137,166,.18));
|
|
1089
|
+
}
|
|
1090
|
+
.dsh-files-header-info {
|
|
1091
|
+
flex: 1;
|
|
1092
|
+
min-width: 0;
|
|
1093
|
+
}
|
|
1094
|
+
.dsh-files-title-row {
|
|
1095
|
+
display: flex;
|
|
1096
|
+
align-items: center;
|
|
1097
|
+
gap: 8px;
|
|
1098
|
+
}
|
|
1099
|
+
.dsh-files-title {
|
|
1100
|
+
font-weight: 600;
|
|
1101
|
+
font-size: 13px;
|
|
1102
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
1103
|
+
}
|
|
1104
|
+
.dsh-files-stats-badge {
|
|
1105
|
+
font-size: 10px;
|
|
1106
|
+
font-weight: 500;
|
|
1107
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1108
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(100,120,150,.1));
|
|
1109
|
+
padding: 1px 6px;
|
|
1110
|
+
border-radius: 99px;
|
|
1111
|
+
}
|
|
1112
|
+
.dsh-files-root-path {
|
|
1113
|
+
font-size: 11px;
|
|
1114
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1115
|
+
overflow: hidden;
|
|
1116
|
+
text-overflow: ellipsis;
|
|
1117
|
+
white-space: nowrap;
|
|
1118
|
+
margin-top: 1px;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/* ── Toolbar & Search ── */
|
|
1122
|
+
.dsh-files-toolbar {
|
|
1123
|
+
display: flex;
|
|
1124
|
+
align-items: center;
|
|
1125
|
+
gap: 6px;
|
|
1126
|
+
padding: 6px 8px 6px 12px;
|
|
1127
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(118,137,166,.15));
|
|
1128
|
+
background: var(--dsw-alias-bg-base, #ffffff);
|
|
1129
|
+
}
|
|
1130
|
+
.dsh-files-search-wrap {
|
|
1131
|
+
position: relative;
|
|
1132
|
+
flex: 1;
|
|
1133
|
+
min-width: 0;
|
|
1134
|
+
display: flex;
|
|
1135
|
+
align-items: center;
|
|
1136
|
+
}
|
|
1137
|
+
.dsh-files-search-icon {
|
|
1138
|
+
position: absolute;
|
|
1139
|
+
left: 7px;
|
|
1140
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1141
|
+
pointer-events: none;
|
|
1142
|
+
display: flex;
|
|
1143
|
+
}
|
|
1144
|
+
.dsh-files-search-input {
|
|
1145
|
+
box-sizing: border-box;
|
|
1146
|
+
width: 100%;
|
|
1147
|
+
height: 25px;
|
|
1148
|
+
padding: 0 22px 0 26px;
|
|
1149
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(118,137,166,.25));
|
|
1150
|
+
border-radius: 4px;
|
|
1151
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(100,120,150,.06));
|
|
1152
|
+
color: inherit;
|
|
1153
|
+
font-size: 11.5px;
|
|
1154
|
+
outline: none;
|
|
1155
|
+
transition: all 0.15s ease;
|
|
1156
|
+
}
|
|
1157
|
+
.dsh-files-search-input:focus {
|
|
1158
|
+
background: var(--dsw-alias-bg-base, #ffffff);
|
|
1159
|
+
border-color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1160
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #4b70e2) 15%, transparent);
|
|
1161
|
+
}
|
|
1162
|
+
.dsh-files-clear-btn {
|
|
1163
|
+
position: absolute;
|
|
1164
|
+
right: 5px;
|
|
1165
|
+
border: none;
|
|
1166
|
+
background: transparent;
|
|
1167
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1168
|
+
cursor: pointer;
|
|
1169
|
+
padding: 2px;
|
|
1170
|
+
display: flex;
|
|
1171
|
+
}
|
|
1172
|
+
.dsh-files-clear-btn:hover {
|
|
1173
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
1174
|
+
}
|
|
1175
|
+
.dsh-files-toolbar-btns {
|
|
1176
|
+
display: flex;
|
|
1177
|
+
align-items: center;
|
|
1178
|
+
gap: 2px;
|
|
1179
|
+
}
|
|
1180
|
+
.dsh-files-tool-btn {
|
|
1181
|
+
display: inline-flex;
|
|
1182
|
+
align-items: center;
|
|
1183
|
+
justify-content: center;
|
|
1184
|
+
width: 24px;
|
|
1185
|
+
height: 24px;
|
|
1186
|
+
border: none;
|
|
1187
|
+
border-radius: 4px;
|
|
1188
|
+
background: transparent;
|
|
1189
|
+
color: var(--dsw-alias-label-secondary, #6f7f9b);
|
|
1190
|
+
cursor: pointer;
|
|
1191
|
+
transition: all 0.12s ease;
|
|
1192
|
+
}
|
|
1193
|
+
.dsh-files-tool-btn:hover:not(:disabled) {
|
|
1194
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
1195
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(100,120,150,.1));
|
|
1196
|
+
}
|
|
1197
|
+
.dsh-files-tool-btn.is-active {
|
|
1198
|
+
color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1199
|
+
background: var(--dsw-alias-interactive-bg-active, rgba(75,112,226,.15));
|
|
1200
|
+
}
|
|
1201
|
+
.dsh-files-tool-btn:disabled {
|
|
1202
|
+
opacity: 0.4;
|
|
1203
|
+
cursor: not-allowed;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/* ── Tree Body ── */
|
|
1207
|
+
.dsh-files-body {
|
|
1208
|
+
flex: 1;
|
|
1209
|
+
min-height: 0;
|
|
1210
|
+
overflow-y: auto;
|
|
1211
|
+
overflow-x: hidden;
|
|
1212
|
+
padding: 4px 0;
|
|
1213
|
+
}
|
|
1214
|
+
.dsh-files-dir-block {
|
|
1215
|
+
display: flex;
|
|
1216
|
+
flex-direction: column;
|
|
1217
|
+
}
|
|
1218
|
+
.dsh-files-dir-row {
|
|
1219
|
+
display: flex;
|
|
1220
|
+
align-items: center;
|
|
1221
|
+
gap: 6px;
|
|
1222
|
+
height: 26px;
|
|
1223
|
+
padding-right: 8px;
|
|
1224
|
+
cursor: pointer;
|
|
1225
|
+
user-select: none;
|
|
1226
|
+
position: relative;
|
|
1227
|
+
color: var(--dsw-alias-label-secondary, #6f7f9b);
|
|
1228
|
+
transition: background 0.12s ease;
|
|
1229
|
+
}
|
|
1230
|
+
.dsh-files-dir-row:hover {
|
|
1231
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(100,120,150,.08));
|
|
1232
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
1233
|
+
}
|
|
1234
|
+
.dsh-files-chevron {
|
|
1235
|
+
font-size: 10px;
|
|
1236
|
+
transition: transform 0.15s ease;
|
|
1237
|
+
display: inline-block;
|
|
1238
|
+
opacity: 0.8;
|
|
1239
|
+
}
|
|
1240
|
+
.dsh-files-chevron[data-collapsed='true'] {
|
|
1241
|
+
transform: rotate(-90deg);
|
|
1242
|
+
}
|
|
1243
|
+
.dsh-files-folder-icon {
|
|
1244
|
+
display: flex;
|
|
1245
|
+
}
|
|
1246
|
+
.dsh-files-folder-name {
|
|
1247
|
+
flex: 1;
|
|
1248
|
+
min-width: 0;
|
|
1249
|
+
font-weight: 500;
|
|
1250
|
+
font-size: 12px;
|
|
1251
|
+
overflow: hidden;
|
|
1252
|
+
text-overflow: ellipsis;
|
|
1253
|
+
white-space: nowrap;
|
|
1254
|
+
}
|
|
1255
|
+
.dsh-files-count-badge {
|
|
1256
|
+
font-size: 10px;
|
|
1257
|
+
font-weight: 600;
|
|
1258
|
+
padding: 0 5px;
|
|
1259
|
+
border-radius: 99px;
|
|
1260
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(100,120,150,.1));
|
|
1261
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/* ── File Row ── */
|
|
1265
|
+
.dsh-files-row {
|
|
1266
|
+
display: flex;
|
|
1267
|
+
align-items: center;
|
|
1268
|
+
gap: 6px;
|
|
1269
|
+
height: 26px;
|
|
1270
|
+
padding-right: 8px;
|
|
1271
|
+
cursor: pointer;
|
|
1272
|
+
user-select: none;
|
|
1273
|
+
position: relative;
|
|
1274
|
+
transition: background 0.12s ease;
|
|
1275
|
+
}
|
|
1276
|
+
.dsh-files-row:hover {
|
|
1277
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(100,120,150,.08));
|
|
1278
|
+
}
|
|
1279
|
+
.dsh-files-name {
|
|
1280
|
+
flex: 1;
|
|
1281
|
+
min-width: 0;
|
|
1282
|
+
font-size: 12px;
|
|
1283
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
1284
|
+
overflow: hidden;
|
|
1285
|
+
text-overflow: ellipsis;
|
|
1286
|
+
white-space: nowrap;
|
|
1287
|
+
}
|
|
1288
|
+
.dsh-files-size {
|
|
1289
|
+
font-size: 10px;
|
|
1290
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1291
|
+
flex-shrink: 0;
|
|
1292
|
+
margin-right: 4px;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/* ── Hover Actions on Rows ── */
|
|
1296
|
+
.dsh-files-row-actions {
|
|
1297
|
+
display: none;
|
|
1298
|
+
align-items: center;
|
|
1299
|
+
gap: 3px;
|
|
1300
|
+
flex-shrink: 0;
|
|
1301
|
+
}
|
|
1302
|
+
.dsh-files-row:hover .dsh-files-row-actions,
|
|
1303
|
+
.dsh-files-dir-row:hover .dsh-files-row-actions,
|
|
1304
|
+
.dsh-files-flat-row:hover .dsh-files-row-actions {
|
|
1305
|
+
display: flex;
|
|
1306
|
+
}
|
|
1307
|
+
.dsh-files-row:hover .dsh-files-size,
|
|
1308
|
+
.dsh-files-dir-row:hover .dsh-files-count-badge,
|
|
1309
|
+
.dsh-files-flat-row:hover .dsh-files-size {
|
|
1310
|
+
display: none;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/* ── Flat Search Result List ── */
|
|
1314
|
+
.dsh-files-search-container {
|
|
1315
|
+
display: flex;
|
|
1316
|
+
flex-direction: column;
|
|
1317
|
+
}
|
|
1318
|
+
.dsh-files-search-header {
|
|
1319
|
+
display: flex;
|
|
1320
|
+
align-items: center;
|
|
1321
|
+
justify-content: space-between;
|
|
1322
|
+
padding: 6px 12px;
|
|
1323
|
+
font-size: 11px;
|
|
1324
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1325
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b70e2) 4%, transparent);
|
|
1326
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(118,137,166,.15));
|
|
1327
|
+
}
|
|
1328
|
+
.dsh-files-search-count {
|
|
1329
|
+
font-weight: 500;
|
|
1330
|
+
}
|
|
1331
|
+
.dsh-files-view-toggle-btn {
|
|
1332
|
+
display: inline-flex;
|
|
1333
|
+
align-items: center;
|
|
1334
|
+
gap: 4px;
|
|
1335
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(118,137,166,.25));
|
|
1336
|
+
background: var(--dsw-alias-container-bg, var(--dsw-alias-bg-base, #ffffff));
|
|
1337
|
+
color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1338
|
+
cursor: pointer;
|
|
1339
|
+
font-size: 10.5px;
|
|
1340
|
+
font-weight: 500;
|
|
1341
|
+
padding: 2px 7px;
|
|
1342
|
+
border-radius: 4px;
|
|
1343
|
+
transition: all 0.12s ease;
|
|
1344
|
+
}
|
|
1345
|
+
.dsh-files-view-toggle-btn:hover {
|
|
1346
|
+
background: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1347
|
+
color: #ffffff;
|
|
1348
|
+
border-color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1349
|
+
}
|
|
1350
|
+
.dsh-files-flat-list {
|
|
1351
|
+
padding: 4px 0;
|
|
1352
|
+
}
|
|
1353
|
+
.dsh-files-flat-row {
|
|
1354
|
+
display: flex;
|
|
1355
|
+
align-items: center;
|
|
1356
|
+
gap: 8px;
|
|
1357
|
+
padding: 4px 12px;
|
|
1358
|
+
cursor: pointer;
|
|
1359
|
+
user-select: none;
|
|
1360
|
+
position: relative;
|
|
1361
|
+
transition: background 0.12s ease;
|
|
1362
|
+
min-height: 32px;
|
|
1363
|
+
}
|
|
1364
|
+
.dsh-files-flat-row:hover {
|
|
1365
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(100,120,150,.08));
|
|
1366
|
+
}
|
|
1367
|
+
.dsh-files-flat-icon {
|
|
1368
|
+
display: flex;
|
|
1369
|
+
align-items: center;
|
|
1370
|
+
justify-content: center;
|
|
1371
|
+
flex-shrink: 0;
|
|
1372
|
+
}
|
|
1373
|
+
.dsh-files-flat-info {
|
|
1374
|
+
flex: 1;
|
|
1375
|
+
min-width: 0;
|
|
1376
|
+
display: flex;
|
|
1377
|
+
flex-direction: column;
|
|
1378
|
+
gap: 1px;
|
|
1379
|
+
}
|
|
1380
|
+
.dsh-files-flat-top {
|
|
1381
|
+
display: flex;
|
|
1382
|
+
align-items: center;
|
|
1383
|
+
justify-content: space-between;
|
|
1384
|
+
gap: 6px;
|
|
1385
|
+
}
|
|
1386
|
+
.dsh-files-flat-name {
|
|
1387
|
+
font-size: 12px;
|
|
1388
|
+
font-weight: 500;
|
|
1389
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
1390
|
+
overflow: hidden;
|
|
1391
|
+
text-overflow: ellipsis;
|
|
1392
|
+
white-space: nowrap;
|
|
1393
|
+
}
|
|
1394
|
+
.dsh-files-flat-parent {
|
|
1395
|
+
font-size: 10.5px;
|
|
1396
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1397
|
+
overflow: hidden;
|
|
1398
|
+
text-overflow: ellipsis;
|
|
1399
|
+
white-space: nowrap;
|
|
1400
|
+
opacity: 0.8;
|
|
1401
|
+
}
|
|
1402
|
+
.dsh-files-jump-hint {
|
|
1403
|
+
font-size: 10px;
|
|
1404
|
+
color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1405
|
+
opacity: 0;
|
|
1406
|
+
transition: opacity 0.12s ease;
|
|
1407
|
+
margin-right: 6px;
|
|
1408
|
+
font-weight: 500;
|
|
1409
|
+
white-space: nowrap;
|
|
1410
|
+
}
|
|
1411
|
+
.dsh-files-flat-row:hover .dsh-files-jump-hint {
|
|
1412
|
+
opacity: 0.85;
|
|
1413
|
+
}
|
|
1414
|
+
.dsh-files-flat-row.is-directory:hover {
|
|
1415
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b70e2) 6%, transparent);
|
|
1416
|
+
}
|
|
1417
|
+
.dsh-files-action-btn {
|
|
1418
|
+
display: inline-flex;
|
|
1419
|
+
align-items: center;
|
|
1420
|
+
justify-content: center;
|
|
1421
|
+
gap: 3px;
|
|
1422
|
+
height: 20px;
|
|
1423
|
+
padding: 0 5px;
|
|
1424
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(118,137,166,.25));
|
|
1425
|
+
border-radius: 3px;
|
|
1426
|
+
background: var(--dsw-alias-container-bg, var(--dsw-alias-bg-base, #ffffff));
|
|
1427
|
+
color: var(--dsw-alias-label-secondary, #6f7f9b);
|
|
1428
|
+
cursor: pointer;
|
|
1429
|
+
transition: all 0.12s ease;
|
|
1430
|
+
}
|
|
1431
|
+
.dsh-files-action-btn:hover {
|
|
1432
|
+
color: var(--dsw-alias-label-primary, inherit);
|
|
1433
|
+
border-color: var(--dsw-alias-border-l3, rgba(118,137,166,.4));
|
|
1434
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(100,120,150,.12));
|
|
1435
|
+
}
|
|
1436
|
+
.dsh-files-action-at {
|
|
1437
|
+
color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1438
|
+
border-color: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b70e2) 30%, transparent);
|
|
1439
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b70e2) 8%, transparent);
|
|
1440
|
+
}
|
|
1441
|
+
.dsh-files-action-at:hover {
|
|
1442
|
+
color: #ffffff;
|
|
1443
|
+
background: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1444
|
+
border-color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1445
|
+
}
|
|
1446
|
+
.dsh-files-action-at-text {
|
|
1447
|
+
font-size: 10px;
|
|
1448
|
+
font-weight: 600;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/* ── Highlight & Toast ── */
|
|
1452
|
+
.dsh-files-highlight {
|
|
1453
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b70e2) 25%, transparent);
|
|
1454
|
+
color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1455
|
+
font-weight: 600;
|
|
1456
|
+
border-radius: 2px;
|
|
1457
|
+
padding: 0 1px;
|
|
1458
|
+
}
|
|
1459
|
+
.dsh-files-toast {
|
|
1460
|
+
position: absolute;
|
|
1461
|
+
bottom: 12px;
|
|
1462
|
+
left: 50%;
|
|
1463
|
+
transform: translateX(-50%);
|
|
1464
|
+
display: inline-flex;
|
|
1465
|
+
align-items: center;
|
|
1466
|
+
gap: 6px;
|
|
1467
|
+
padding: 6px 12px;
|
|
1468
|
+
background: var(--dsw-alias-label-primary, #17233c);
|
|
1469
|
+
color: var(--dsw-alias-bg-base, #ffffff);
|
|
1470
|
+
font-size: 11px;
|
|
1471
|
+
border-radius: 99px;
|
|
1472
|
+
box-shadow: 0 4px 16px rgba(0,0,0,.25);
|
|
1473
|
+
animation: dsh-files-fade-in 0.15s ease;
|
|
1474
|
+
z-index: 100;
|
|
1475
|
+
pointer-events: none;
|
|
1476
|
+
white-space: nowrap;
|
|
1477
|
+
}
|
|
1478
|
+
@keyframes dsh-files-fade-in {
|
|
1479
|
+
from { opacity: 0; transform: translate(-50%, 6px); }
|
|
1480
|
+
to { opacity: 1; transform: translate(-50%, 0); }
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/* ── Empty & Centered ── */
|
|
1484
|
+
.dsh-files-centered {
|
|
1485
|
+
display: flex;
|
|
1486
|
+
flex-direction: column;
|
|
1487
|
+
align-items: center;
|
|
1488
|
+
justify-content: center;
|
|
1489
|
+
height: 180px;
|
|
1490
|
+
gap: 10px;
|
|
1491
|
+
color: var(--dsw-alias-label-secondary, #6f7f9b);
|
|
1492
|
+
}
|
|
1493
|
+
.dsh-files-spinner {
|
|
1494
|
+
width: 18px;
|
|
1495
|
+
height: 18px;
|
|
1496
|
+
border: 2px solid var(--dsw-alias-border-l2, rgba(118,137,166,.3));
|
|
1497
|
+
border-top-color: var(--dsw-alias-brand-primary, #4b70e2);
|
|
1498
|
+
border-radius: 50%;
|
|
1499
|
+
animation: dsh-files-spin 0.8s linear infinite;
|
|
1500
|
+
}
|
|
1501
|
+
@keyframes dsh-files-spin {
|
|
1502
|
+
to { transform: rotate(360deg); }
|
|
1503
|
+
}
|
|
1504
|
+
.dsh-files-empty {
|
|
1505
|
+
display: flex;
|
|
1506
|
+
flex-direction: column;
|
|
1507
|
+
align-items: center;
|
|
1508
|
+
justify-content: center;
|
|
1509
|
+
padding: 36px 16px;
|
|
1510
|
+
text-align: center;
|
|
1511
|
+
color: var(--dsw-alias-label-secondary, #6f7f9b);
|
|
1512
|
+
}
|
|
1513
|
+
.dsh-files-empty-icon {
|
|
1514
|
+
margin-bottom: 8px;
|
|
1515
|
+
opacity: 0.6;
|
|
1516
|
+
}
|
|
1517
|
+
.dsh-files-empty-title {
|
|
1518
|
+
font-weight: 600;
|
|
1519
|
+
font-size: 13px;
|
|
1520
|
+
margin-bottom: 4px;
|
|
1521
|
+
}
|
|
1522
|
+
.dsh-files-empty-detail {
|
|
1523
|
+
font-size: 11.5px;
|
|
1524
|
+
color: var(--dsw-alias-label-tertiary, #8c9ba5);
|
|
1525
|
+
}
|
|
1526
|
+
.dsh-files-banner-error {
|
|
1527
|
+
display: flex;
|
|
1528
|
+
align-items: center;
|
|
1529
|
+
justify-content: space-between;
|
|
1530
|
+
gap: 8px;
|
|
1531
|
+
padding: 6px 12px;
|
|
1532
|
+
font-size: 11.5px;
|
|
1533
|
+
color: var(--dsw-alias-state-error-primary, #e34c59);
|
|
1534
|
+
background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #e34c59) 10%, transparent);
|
|
1535
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(118,137,166,.2));
|
|
1536
|
+
}
|
|
1537
|
+
.dsh-files-banner-close {
|
|
1538
|
+
border: none;
|
|
1539
|
+
background: transparent;
|
|
1540
|
+
color: inherit;
|
|
1541
|
+
cursor: pointer;
|
|
1542
|
+
}
|
|
1543
|
+
`}</style>
|
|
1544
|
+
)
|
|
1545
|
+
}
|