@huyooo/ai-chat-frontend-react 0.1.6 → 0.1.8
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/README.md +368 -0
- package/dist/index.css +2575 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +378 -135
- package/dist/index.js +3956 -1042
- package/dist/index.js.map +1 -1
- package/dist/style.css +48 -987
- package/package.json +7 -4
- package/src/adapter.ts +10 -70
- package/src/components/ChatPanel.tsx +373 -117
- package/src/components/common/ConfirmDialog.css +136 -0
- package/src/components/common/ConfirmDialog.tsx +91 -0
- package/src/components/common/CopyButton.css +22 -0
- package/src/components/common/CopyButton.tsx +46 -0
- package/src/components/common/IndexingSettings.css +207 -0
- package/src/components/common/IndexingSettings.tsx +398 -0
- package/src/components/common/SettingsPanel.css +256 -0
- package/src/components/common/SettingsPanel.tsx +120 -0
- package/src/components/common/Toast.css +50 -0
- package/src/components/common/Toast.tsx +38 -0
- package/src/components/common/ToggleSwitch.css +52 -0
- package/src/components/common/ToggleSwitch.tsx +20 -0
- package/src/components/header/ChatHeader.css +285 -0
- package/src/components/header/ChatHeader.tsx +376 -0
- package/src/components/input/AtFilePicker.css +147 -0
- package/src/components/input/AtFilePicker.tsx +519 -0
- package/src/components/input/ChatInput.css +204 -0
- package/src/components/input/ChatInput.tsx +506 -0
- package/src/components/input/DropdownSelector.css +159 -0
- package/src/components/input/DropdownSelector.tsx +195 -0
- package/src/components/input/ImagePreviewModal.css +124 -0
- package/src/components/input/ImagePreviewModal.tsx +118 -0
- package/src/components/input/at-views/AtBranchView.tsx +34 -0
- package/src/components/input/at-views/AtBrowserView.tsx +34 -0
- package/src/components/input/at-views/AtChatsView.tsx +34 -0
- package/src/components/input/at-views/AtDocsView.tsx +34 -0
- package/src/components/input/at-views/AtFilesView.tsx +168 -0
- package/src/components/input/at-views/AtTerminalsView.tsx +34 -0
- package/src/components/input/at-views/AtViewStyles.css +143 -0
- package/src/components/input/at-views/index.ts +9 -0
- package/src/components/message/ContentRenderer.css +9 -0
- package/src/components/message/ContentRenderer.tsx +63 -0
- package/src/components/message/MessageBubble.css +190 -0
- package/src/components/message/MessageBubble.tsx +231 -0
- package/src/components/message/PartsRenderer.css +4 -0
- package/src/components/message/PartsRenderer.tsx +114 -0
- package/src/components/message/ToolResultRenderer.tsx +21 -0
- package/src/components/message/WelcomeMessage.css +221 -0
- package/src/components/message/WelcomeMessage.tsx +93 -0
- package/src/components/message/blocks/CodeBlock.tsx +60 -0
- package/src/components/message/blocks/TextBlock.tsx +15 -0
- package/src/components/message/blocks/blocks.css +141 -0
- package/src/components/message/blocks/index.ts +6 -0
- package/src/components/message/parts/CollapsibleCard.css +78 -0
- package/src/components/message/parts/CollapsibleCard.tsx +77 -0
- package/src/components/message/parts/ErrorPart.css +9 -0
- package/src/components/message/parts/ErrorPart.tsx +40 -0
- package/src/components/message/parts/ImagePart.css +50 -0
- package/src/components/message/parts/ImagePart.tsx +54 -0
- package/src/components/message/parts/SearchPart.css +44 -0
- package/src/components/message/parts/SearchPart.tsx +63 -0
- package/src/components/message/parts/TextPart.css +10 -0
- package/src/components/message/parts/TextPart.tsx +20 -0
- package/src/components/message/parts/ThinkingPart.css +9 -0
- package/src/components/message/parts/ThinkingPart.tsx +48 -0
- package/src/components/message/parts/ToolCallPart.css +220 -0
- package/src/components/message/parts/ToolCallPart.tsx +285 -0
- package/src/components/message/parts/ToolResultPart.css +68 -0
- package/src/components/message/parts/ToolResultPart.tsx +96 -0
- package/src/components/message/parts/index.ts +11 -0
- package/src/components/message/tool-results/DefaultToolResult.tsx +26 -0
- package/src/components/message/tool-results/SearchResults.tsx +69 -0
- package/src/components/message/tool-results/WeatherCard.tsx +63 -0
- package/src/components/message/tool-results/index.ts +7 -0
- package/src/components/message/tool-results/tool-results.css +179 -0
- package/src/components/message/welcome-types.ts +46 -0
- package/src/context/AutoRunConfigContext.tsx +13 -0
- package/src/context/ChatAdapterContext.tsx +8 -0
- package/src/context/ChatInputContext.tsx +40 -0
- package/src/context/RenderersContext.tsx +41 -0
- package/src/hooks/useChat.ts +855 -237
- package/src/hooks/useImageUpload.ts +253 -0
- package/src/index.ts +96 -39
- package/src/styles.css +48 -987
- package/src/types/index.ts +172 -103
- package/src/utils/fileIcon.ts +49 -0
- package/src/components/ChatInput.tsx +0 -368
- package/src/components/chat/messages/ExecutionSteps.tsx +0 -234
- package/src/components/chat/messages/MessageBubble.tsx +0 -130
- package/src/components/chat/ui/ChatHeader.tsx +0 -301
- package/src/components/chat/ui/WelcomeMessage.tsx +0 -107
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Icon } from '@iconify/react'
|
|
4
|
+
import type { ChatAdapter } from '../../adapter'
|
|
5
|
+
import { getFileIcon, basename, dirname } from '../../utils/fileIcon'
|
|
6
|
+
import {
|
|
7
|
+
AtFilesView,
|
|
8
|
+
AtDocsView,
|
|
9
|
+
AtTerminalsView,
|
|
10
|
+
AtChatsView,
|
|
11
|
+
AtBranchView,
|
|
12
|
+
AtBrowserView,
|
|
13
|
+
} from './at-views'
|
|
14
|
+
import type { AtFilesViewHandle, AtPlaceholderViewHandle } from './at-views'
|
|
15
|
+
import './AtFilePicker.css'
|
|
16
|
+
|
|
17
|
+
export interface AtFilePickerProps {
|
|
18
|
+
visible: boolean
|
|
19
|
+
adapter: ChatAdapter
|
|
20
|
+
initialDir?: string
|
|
21
|
+
/** 锚点元素(用于定位下拉面板) */
|
|
22
|
+
anchorEl?: HTMLElement | null
|
|
23
|
+
onClose: () => void
|
|
24
|
+
onSelect: (path: string) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ============ 常量 ============
|
|
28
|
+
const RECENT_KEY = 'ai-chat.at.recentPaths'
|
|
29
|
+
const MAX_RECENT = 6
|
|
30
|
+
const DROPDOWN_WIDTH = 332
|
|
31
|
+
const DROPDOWN_MIN_HEIGHT = 200 // 最小高度
|
|
32
|
+
const DROPDOWN_MAX_HEIGHT = 600 // 最大高度(大屏幕时充分利用)
|
|
33
|
+
|
|
34
|
+
// 分类定义
|
|
35
|
+
type CategoryId = 'files' | 'docs' | 'terminals' | 'chats' | 'branch' | 'browser'
|
|
36
|
+
|
|
37
|
+
interface Category {
|
|
38
|
+
id: CategoryId
|
|
39
|
+
label: string
|
|
40
|
+
icon: string
|
|
41
|
+
placeholder: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const categories: Category[] = [
|
|
45
|
+
{ id: 'files', label: '文件和文件夹', icon: 'lucide:folder-open', placeholder: '搜索文件和文件夹...' },
|
|
46
|
+
{ id: 'docs', label: '文档', icon: 'lucide:book-open', placeholder: '搜索文档...' },
|
|
47
|
+
{ id: 'terminals', label: '终端', icon: 'lucide:terminal', placeholder: '搜索终端...' },
|
|
48
|
+
{ id: 'chats', label: '历史对话', icon: 'lucide:message-square', placeholder: '搜索历史对话...' },
|
|
49
|
+
{ id: 'branch', label: '分支差异', icon: 'lucide:git-branch', placeholder: '搜索分支差异...' },
|
|
50
|
+
{ id: 'browser', label: '网页', icon: 'lucide:globe', placeholder: '搜索网页...' },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
type ViewType = 'categories' | CategoryId
|
|
54
|
+
|
|
55
|
+
// 视图组件的 ref 句柄类型
|
|
56
|
+
type ViewHandle = AtFilesViewHandle | AtPlaceholderViewHandle
|
|
57
|
+
|
|
58
|
+
export function AtFilePicker({
|
|
59
|
+
visible,
|
|
60
|
+
adapter,
|
|
61
|
+
initialDir,
|
|
62
|
+
anchorEl,
|
|
63
|
+
onClose,
|
|
64
|
+
onSelect,
|
|
65
|
+
}: AtFilePickerProps) {
|
|
66
|
+
const searchRef = useRef<HTMLInputElement>(null)
|
|
67
|
+
const bodyRef = useRef<HTMLDivElement>(null)
|
|
68
|
+
const viewRef = useRef<ViewHandle>(null)
|
|
69
|
+
const [query, setQuery] = useState('')
|
|
70
|
+
const [currentView, setCurrentView] = useState<ViewType>('categories')
|
|
71
|
+
const [activeKey, setActiveKey] = useState('')
|
|
72
|
+
const [recentList, setRecentList] = useState<string[]>([])
|
|
73
|
+
const [viewActiveIndex, setViewActiveIndex] = useState(-1)
|
|
74
|
+
const [viewItemCount, setViewItemCount] = useState(0)
|
|
75
|
+
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({})
|
|
76
|
+
const [isScrolling, setIsScrolling] = useState(false)
|
|
77
|
+
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
78
|
+
|
|
79
|
+
// ============ 下拉面板定位 ============
|
|
80
|
+
const updateDropdownPosition = useCallback(() => {
|
|
81
|
+
if (!anchorEl) {
|
|
82
|
+
setDropdownStyle({})
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rect = anchorEl.getBoundingClientRect()
|
|
87
|
+
const viewportHeight = window.innerHeight
|
|
88
|
+
const viewportWidth = window.innerWidth
|
|
89
|
+
|
|
90
|
+
// 计算上方和下方的可用空间(预留 16px 边距)
|
|
91
|
+
const spaceAbove = rect.top - 16
|
|
92
|
+
const spaceBelow = viewportHeight - rect.bottom - 16
|
|
93
|
+
|
|
94
|
+
// 决定向上还是向下展开
|
|
95
|
+
const openUp = spaceAbove > spaceBelow
|
|
96
|
+
|
|
97
|
+
// 计算可用高度
|
|
98
|
+
const availableHeight = openUp ? spaceAbove : spaceBelow
|
|
99
|
+
|
|
100
|
+
// 计算最大高度:
|
|
101
|
+
// 1. 优先使用可用空间(确保不超出窗口)
|
|
102
|
+
// 2. 如果可用空间很大,限制在全局最大高度(大屏幕时充分利用但不浪费)
|
|
103
|
+
// 3. 同时不能超过视口高度的 80%
|
|
104
|
+
const viewportMaxHeight = viewportHeight * 0.8
|
|
105
|
+
const maxHeight = Math.min(
|
|
106
|
+
availableHeight, // 使用可用空间(确保不超出窗口)
|
|
107
|
+
DROPDOWN_MAX_HEIGHT, // 但不超过全局最大高度(大屏幕时限制在 600px)
|
|
108
|
+
viewportMaxHeight // 也不能超过视口的 80%
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
// 计算水平位置,确保不超出右边界
|
|
112
|
+
let left = rect.right - DROPDOWN_WIDTH
|
|
113
|
+
if (left < 8) left = 8
|
|
114
|
+
if (left + DROPDOWN_WIDTH > viewportWidth - 8) {
|
|
115
|
+
left = viewportWidth - DROPDOWN_WIDTH - 8
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (openUp) {
|
|
119
|
+
setDropdownStyle({
|
|
120
|
+
position: 'fixed',
|
|
121
|
+
left: `${left}px`,
|
|
122
|
+
bottom: `${viewportHeight - rect.top + 6}px`,
|
|
123
|
+
top: 'auto',
|
|
124
|
+
maxHeight: `${maxHeight}px`,
|
|
125
|
+
})
|
|
126
|
+
} else {
|
|
127
|
+
setDropdownStyle({
|
|
128
|
+
position: 'fixed',
|
|
129
|
+
left: `${left}px`,
|
|
130
|
+
top: `${rect.bottom + 6}px`,
|
|
131
|
+
bottom: 'auto',
|
|
132
|
+
maxHeight: `${maxHeight}px`,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}, [anchorEl])
|
|
136
|
+
|
|
137
|
+
// ============ 工具函数 ============
|
|
138
|
+
const loadRecent = useCallback(() => {
|
|
139
|
+
try {
|
|
140
|
+
const raw = localStorage.getItem(RECENT_KEY)
|
|
141
|
+
if (!raw) {
|
|
142
|
+
setRecentList([])
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const list = JSON.parse(raw) as unknown
|
|
146
|
+
if (Array.isArray(list)) {
|
|
147
|
+
setRecentList(list.filter((x) => typeof x === 'string').slice(0, MAX_RECENT))
|
|
148
|
+
} else {
|
|
149
|
+
setRecentList([])
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
setRecentList([])
|
|
153
|
+
}
|
|
154
|
+
}, [])
|
|
155
|
+
|
|
156
|
+
const pushRecent = useCallback((path: string) => {
|
|
157
|
+
setRecentList(prev => {
|
|
158
|
+
const next = [path, ...prev.filter((p) => p !== path)].slice(0, MAX_RECENT)
|
|
159
|
+
try {
|
|
160
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|
|
164
|
+
return next
|
|
165
|
+
})
|
|
166
|
+
}, [])
|
|
167
|
+
|
|
168
|
+
const getSearchPlaceholder = useCallback((): string => {
|
|
169
|
+
if (currentView === 'categories') return '添加文件、文件夹、文档...'
|
|
170
|
+
const cat = categories.find((c) => c.id === currentView)
|
|
171
|
+
return cat?.placeholder || 'Search...'
|
|
172
|
+
}, [currentView])
|
|
173
|
+
|
|
174
|
+
// ============ 过滤 ============
|
|
175
|
+
const filteredRecent = useMemo(() => {
|
|
176
|
+
if (currentView !== 'categories') return []
|
|
177
|
+
const q = query.trim().toLowerCase()
|
|
178
|
+
if (!q) return recentList
|
|
179
|
+
return recentList.filter((p) => p.toLowerCase().includes(q))
|
|
180
|
+
}, [currentView, query, recentList])
|
|
181
|
+
|
|
182
|
+
// ============ 视图切换 ============
|
|
183
|
+
const goBackToCategories = useCallback(() => {
|
|
184
|
+
setCurrentView('categories')
|
|
185
|
+
setQuery('')
|
|
186
|
+
setActiveKey('')
|
|
187
|
+
setViewActiveIndex(-1)
|
|
188
|
+
requestAnimationFrame(() => searchRef.current?.focus())
|
|
189
|
+
}, [])
|
|
190
|
+
|
|
191
|
+
const handleCategoryClick = useCallback((cat: Category) => {
|
|
192
|
+
setCurrentView(cat.id)
|
|
193
|
+
setQuery('')
|
|
194
|
+
setActiveKey('')
|
|
195
|
+
setViewActiveIndex(-1)
|
|
196
|
+
requestAnimationFrame(() => searchRef.current?.focus())
|
|
197
|
+
}, [])
|
|
198
|
+
|
|
199
|
+
// ============ 选择与关闭 ============
|
|
200
|
+
const handleEsc = useCallback(() => {
|
|
201
|
+
if (currentView !== 'categories') {
|
|
202
|
+
goBackToCategories()
|
|
203
|
+
} else {
|
|
204
|
+
onClose()
|
|
205
|
+
}
|
|
206
|
+
}, [currentView, goBackToCategories, onClose])
|
|
207
|
+
|
|
208
|
+
const selectPath = useCallback((path: string) => {
|
|
209
|
+
pushRecent(path)
|
|
210
|
+
onSelect(path)
|
|
211
|
+
}, [pushRecent, onSelect])
|
|
212
|
+
|
|
213
|
+
// ============ 滚动处理 ============
|
|
214
|
+
// 处理滚动事件
|
|
215
|
+
const handleScroll = useCallback(() => {
|
|
216
|
+
// 标记正在滚动
|
|
217
|
+
setIsScrolling(true)
|
|
218
|
+
|
|
219
|
+
// 清除之前的定时器
|
|
220
|
+
if (scrollTimerRef.current) {
|
|
221
|
+
clearTimeout(scrollTimerRef.current)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 滚动停止后 150ms 才允许鼠标事件(确保滚动完全停止)
|
|
225
|
+
scrollTimerRef.current = setTimeout(() => {
|
|
226
|
+
setIsScrolling(false)
|
|
227
|
+
scrollTimerRef.current = null
|
|
228
|
+
}, 150)
|
|
229
|
+
}, [])
|
|
230
|
+
|
|
231
|
+
// ============ 键盘导航 ============
|
|
232
|
+
// 包装的 setActiveKey,检查滚动状态
|
|
233
|
+
const handleSetActiveKey = useCallback((key: string) => {
|
|
234
|
+
// 如果正在滚动,完全忽略鼠标悬停事件
|
|
235
|
+
// 因为滚动时鼠标位置不变,但元素在移动,会不断触发 mouseenter
|
|
236
|
+
if (isScrolling) return
|
|
237
|
+
setActiveKey(key)
|
|
238
|
+
}, [isScrolling])
|
|
239
|
+
|
|
240
|
+
// 包装的 setViewActiveIndex,检查滚动状态
|
|
241
|
+
const handleSetViewActiveIndex = useCallback((index: number) => {
|
|
242
|
+
// 如果正在滚动,完全忽略鼠标悬停事件
|
|
243
|
+
// 因为滚动时鼠标位置不变,但元素在移动,会不断触发 mouseenter
|
|
244
|
+
if (isScrolling) return
|
|
245
|
+
setViewActiveIndex(index)
|
|
246
|
+
}, [isScrolling])
|
|
247
|
+
|
|
248
|
+
// 滚动到选中的元素
|
|
249
|
+
const scrollToActive = useCallback(() => {
|
|
250
|
+
requestAnimationFrame(() => {
|
|
251
|
+
if (!bodyRef.current) return
|
|
252
|
+
|
|
253
|
+
let activeElement: HTMLElement | null = null
|
|
254
|
+
|
|
255
|
+
if (currentView === 'categories') {
|
|
256
|
+
// 分类视图:找到对应 activeKey 的按钮
|
|
257
|
+
if (activeKey.startsWith('recent:')) {
|
|
258
|
+
const idx = Number(activeKey.split(':')[1] || -1)
|
|
259
|
+
// 在最近文件列表中查找
|
|
260
|
+
const recentSection = bodyRef.current.querySelector('.at-picker-recent')
|
|
261
|
+
if (recentSection) {
|
|
262
|
+
const buttons = recentSection.querySelectorAll('.at-picker-item')
|
|
263
|
+
if (idx >= 0 && idx < buttons.length) {
|
|
264
|
+
activeElement = buttons[idx] as HTMLElement
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else if (activeKey.startsWith('cat:')) {
|
|
268
|
+
const idx = Number(activeKey.split(':')[1] || -1)
|
|
269
|
+
// 在分类列表中查找
|
|
270
|
+
const categorySection = bodyRef.current.querySelector('.at-picker-section:not(.at-picker-recent)')
|
|
271
|
+
if (categorySection) {
|
|
272
|
+
const buttons = categorySection.querySelectorAll('.at-picker-item')
|
|
273
|
+
if (idx >= 0 && idx < buttons.length) {
|
|
274
|
+
activeElement = buttons[idx] as HTMLElement
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
// 子视图:找到对应 activeIndex 的元素
|
|
280
|
+
const activeItems = bodyRef.current.querySelectorAll('.at-view-item.active')
|
|
281
|
+
if (activeItems.length > 0) {
|
|
282
|
+
activeElement = activeItems[0] as HTMLElement
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 滚动到视窗内
|
|
287
|
+
if (activeElement) {
|
|
288
|
+
// 使用 requestAnimationFrame 确保 DOM 已更新
|
|
289
|
+
requestAnimationFrame(() => {
|
|
290
|
+
activeElement?.scrollIntoView({
|
|
291
|
+
behavior: 'smooth',
|
|
292
|
+
block: 'nearest',
|
|
293
|
+
inline: 'nearest',
|
|
294
|
+
})
|
|
295
|
+
// 滚动事件会通过 handleScroll 自动处理 isScrolling 状态
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}, [currentView, activeKey, filteredRecent.length])
|
|
300
|
+
|
|
301
|
+
const move = useCallback((delta: number) => {
|
|
302
|
+
if (currentView === 'categories') {
|
|
303
|
+
const recentCount = filteredRecent.length
|
|
304
|
+
const catCount = categories.length
|
|
305
|
+
const total = recentCount + catCount
|
|
306
|
+
if (total === 0) return
|
|
307
|
+
|
|
308
|
+
let pos = -1
|
|
309
|
+
if (activeKey.startsWith('recent:')) {
|
|
310
|
+
pos = Number(activeKey.split(':')[1] || -1)
|
|
311
|
+
} else if (activeKey.startsWith('cat:')) {
|
|
312
|
+
pos = recentCount + Number(activeKey.split(':')[1] || -1)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const next = pos < 0 ? 0 : (pos + delta + total) % total
|
|
316
|
+
// 直接更新 activeKey,确保状态立即更新
|
|
317
|
+
if (next < recentCount) {
|
|
318
|
+
setActiveKey(`recent:${next}`)
|
|
319
|
+
} else {
|
|
320
|
+
setActiveKey(`cat:${next - recentCount}`)
|
|
321
|
+
}
|
|
322
|
+
// 立即滚动,滚动事件会自动处理 isScrolling 状态
|
|
323
|
+
scrollToActive()
|
|
324
|
+
} else {
|
|
325
|
+
const total = viewItemCount
|
|
326
|
+
if (total === 0) return
|
|
327
|
+
|
|
328
|
+
const current = viewActiveIndex
|
|
329
|
+
const next = current < 0 ? 0 : (current + delta + total) % total
|
|
330
|
+
// 直接更新 viewActiveIndex,确保状态立即更新
|
|
331
|
+
setViewActiveIndex(next)
|
|
332
|
+
// 立即滚动,滚动事件会自动处理 isScrolling 状态
|
|
333
|
+
scrollToActive()
|
|
334
|
+
}
|
|
335
|
+
}, [currentView, filteredRecent.length, activeKey, viewItemCount, viewActiveIndex, scrollToActive])
|
|
336
|
+
|
|
337
|
+
const confirmActive = useCallback(() => {
|
|
338
|
+
if (currentView === 'categories') {
|
|
339
|
+
if (activeKey.startsWith('recent:')) {
|
|
340
|
+
const idx = Number(activeKey.split(':')[1])
|
|
341
|
+
const p = filteredRecent[idx]
|
|
342
|
+
if (p) selectPath(p)
|
|
343
|
+
} else if (activeKey.startsWith('cat:')) {
|
|
344
|
+
const idx = Number(activeKey.split(':')[1])
|
|
345
|
+
const cat = categories[idx]
|
|
346
|
+
if (cat) handleCategoryClick(cat)
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
viewRef.current?.confirmActive()
|
|
350
|
+
}
|
|
351
|
+
}, [currentView, activeKey, filteredRecent, selectPath, handleCategoryClick])
|
|
352
|
+
|
|
353
|
+
// ============ 键盘事件处理 ============
|
|
354
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
355
|
+
if (e.key === 'ArrowDown') {
|
|
356
|
+
e.preventDefault()
|
|
357
|
+
move(1)
|
|
358
|
+
} else if (e.key === 'ArrowUp') {
|
|
359
|
+
e.preventDefault()
|
|
360
|
+
move(-1)
|
|
361
|
+
} else if (e.key === 'Enter') {
|
|
362
|
+
e.preventDefault()
|
|
363
|
+
confirmActive()
|
|
364
|
+
} else if (e.key === 'Escape') {
|
|
365
|
+
e.preventDefault()
|
|
366
|
+
handleEsc()
|
|
367
|
+
}
|
|
368
|
+
}, [move, confirmActive, handleEsc])
|
|
369
|
+
|
|
370
|
+
// ============ 生命周期 ============
|
|
371
|
+
useEffect(() => {
|
|
372
|
+
if (!visible) return
|
|
373
|
+
loadRecent()
|
|
374
|
+
setQuery('')
|
|
375
|
+
setActiveKey('')
|
|
376
|
+
setCurrentView('categories')
|
|
377
|
+
setViewActiveIndex(-1)
|
|
378
|
+
updateDropdownPosition()
|
|
379
|
+
requestAnimationFrame(() => searchRef.current?.focus())
|
|
380
|
+
}, [visible, loadRecent, updateDropdownPosition])
|
|
381
|
+
|
|
382
|
+
// 窗口大小变化时更新位置
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (!visible) return
|
|
385
|
+
const handleResize = () => updateDropdownPosition()
|
|
386
|
+
window.addEventListener('resize', handleResize)
|
|
387
|
+
window.addEventListener('scroll', handleResize, true)
|
|
388
|
+
return () => {
|
|
389
|
+
window.removeEventListener('resize', handleResize)
|
|
390
|
+
window.removeEventListener('scroll', handleResize, true)
|
|
391
|
+
// 清理滚动定时器
|
|
392
|
+
if (scrollTimerRef.current) {
|
|
393
|
+
clearTimeout(scrollTimerRef.current)
|
|
394
|
+
scrollTimerRef.current = null
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}, [visible, updateDropdownPosition])
|
|
398
|
+
|
|
399
|
+
// anchorEl 变化时更新位置
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (visible) {
|
|
402
|
+
updateDropdownPosition()
|
|
403
|
+
}
|
|
404
|
+
}, [visible, anchorEl, updateDropdownPosition])
|
|
405
|
+
|
|
406
|
+
if (!visible) return null
|
|
407
|
+
|
|
408
|
+
// 渲染子视图组件
|
|
409
|
+
const renderViewComponent = () => {
|
|
410
|
+
const commonProps = {
|
|
411
|
+
query,
|
|
412
|
+
activeIndex: viewActiveIndex,
|
|
413
|
+
onSelect: selectPath,
|
|
414
|
+
onSetActive: handleSetViewActiveIndex,
|
|
415
|
+
onUpdateCount: setViewItemCount,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
switch (currentView) {
|
|
419
|
+
case 'files':
|
|
420
|
+
return <AtFilesView ref={viewRef} adapter={adapter} initialDir={initialDir} {...commonProps} />
|
|
421
|
+
case 'docs':
|
|
422
|
+
return <AtDocsView ref={viewRef} {...commonProps} />
|
|
423
|
+
case 'terminals':
|
|
424
|
+
return <AtTerminalsView ref={viewRef} {...commonProps} />
|
|
425
|
+
case 'chats':
|
|
426
|
+
return <AtChatsView ref={viewRef} {...commonProps} />
|
|
427
|
+
case 'branch':
|
|
428
|
+
return <AtBranchView ref={viewRef} {...commonProps} />
|
|
429
|
+
case 'browser':
|
|
430
|
+
return <AtBrowserView ref={viewRef} {...commonProps} />
|
|
431
|
+
default:
|
|
432
|
+
return null
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return createPortal(
|
|
437
|
+
<div className="at-picker-dropdown" style={dropdownStyle} onClick={(e) => e.stopPropagation()}>
|
|
438
|
+
{/* 头部:搜索框 */}
|
|
439
|
+
<div className="at-picker-header">
|
|
440
|
+
{currentView !== 'categories' ? (
|
|
441
|
+
<button className="at-picker-back" onClick={goBackToCategories}>
|
|
442
|
+
<Icon icon="lucide:arrow-left" width={14} />
|
|
443
|
+
</button>
|
|
444
|
+
) : (
|
|
445
|
+
<Icon icon="lucide:search" width={14} className="at-picker-search-icon" />
|
|
446
|
+
)}
|
|
447
|
+
<input
|
|
448
|
+
ref={searchRef}
|
|
449
|
+
value={query}
|
|
450
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
451
|
+
className="at-picker-search"
|
|
452
|
+
type="text"
|
|
453
|
+
placeholder={getSearchPlaceholder()}
|
|
454
|
+
spellCheck={false}
|
|
455
|
+
autoCorrect="off"
|
|
456
|
+
autoComplete="off"
|
|
457
|
+
autoCapitalize="off"
|
|
458
|
+
onKeyDown={handleKeyDown}
|
|
459
|
+
/>
|
|
460
|
+
</div>
|
|
461
|
+
|
|
462
|
+
<div
|
|
463
|
+
ref={bodyRef}
|
|
464
|
+
className={`at-picker-body chat-scrollbar${isScrolling ? ' is-scrolling' : ''}`}
|
|
465
|
+
onScroll={handleScroll}
|
|
466
|
+
>
|
|
467
|
+
{/* 分类视图 */}
|
|
468
|
+
{currentView === 'categories' ? (
|
|
469
|
+
<>
|
|
470
|
+
{/* 最近文件 */}
|
|
471
|
+
{filteredRecent.length > 0 && (
|
|
472
|
+
<div className="at-picker-section at-picker-recent">
|
|
473
|
+
<div className="at-picker-list">
|
|
474
|
+
{filteredRecent.map((p, i) => (
|
|
475
|
+
<button
|
|
476
|
+
key={p}
|
|
477
|
+
className={`at-picker-item${activeKey === `recent:${i}` ? ' active' : ''}`}
|
|
478
|
+
onMouseEnter={() => handleSetActiveKey(`recent:${i}`)}
|
|
479
|
+
onClick={() => selectPath(p)}
|
|
480
|
+
>
|
|
481
|
+
<Icon icon={getFileIcon(p)} width={14} className="at-picker-item-icon" />
|
|
482
|
+
<span className="at-picker-item-name">{basename(p)}</span>
|
|
483
|
+
<span className="at-picker-item-path">{dirname(p)}</span>
|
|
484
|
+
</button>
|
|
485
|
+
))}
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
)}
|
|
489
|
+
|
|
490
|
+
{/* 分类列表 */}
|
|
491
|
+
<div className="at-picker-section">
|
|
492
|
+
<div className="at-picker-list">
|
|
493
|
+
{categories.map((cat, i) => (
|
|
494
|
+
<button
|
|
495
|
+
key={cat.id}
|
|
496
|
+
className={`at-picker-item at-picker-category${activeKey === `cat:${i}` ? ' active' : ''}`}
|
|
497
|
+
onMouseEnter={() => handleSetActiveKey(`cat:${i}`)}
|
|
498
|
+
onClick={() => handleCategoryClick(cat)}
|
|
499
|
+
>
|
|
500
|
+
<Icon icon={cat.icon} width={14} className="at-picker-item-icon" />
|
|
501
|
+
<span className="at-picker-item-name">{cat.label}</span>
|
|
502
|
+
<Icon icon="lucide:chevron-right" width={12} className="at-picker-chevron" />
|
|
503
|
+
</button>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
</>
|
|
508
|
+
) : (
|
|
509
|
+
renderViewComponent()
|
|
510
|
+
)}
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
<div className="at-picker-footer">
|
|
514
|
+
<span className="at-picker-hint">↑↓ 导航 · Enter 选择 · Esc {currentView === 'categories' ? '关闭' : '返回'}</span>
|
|
515
|
+
</div>
|
|
516
|
+
</div>,
|
|
517
|
+
document.body
|
|
518
|
+
)
|
|
519
|
+
}
|