@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.
@@ -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
+ }