mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-12 08:59:02 +08:00
fix(dnd): horizontal sortable (#9827)
* refactor(CodeViewer): improve props, aligned to CodeEditor (#9786) * refactor(CodeViewer): improve props, aligned to CodeEditor * refactor: simplify internal variables * refactor: remove default lineNumbers * fix: shiki theme container style * revert: use ReactMarkdown for prompt editing * fix: draggable list id type (#9809) * refactor(dnd): rename idKey to itemKey for clarity * refactor: key and id type for draggable lists * chore: update yarn lock * fix: type error * refactor: improve getId fallbacks * feat: integrate file selection and upload functionality in KnowledgeFiles component (#9815) * feat: integrate file selection and upload functionality in KnowledgeFiles component - Added useFiles hook to manage file selection. - Updated handleAddFile to utilize the new file selection logic, allowing multiple file uploads. - Improved user experience by handling file uploads asynchronously and logging the results. * feat: enhance file upload interaction in KnowledgeFiles component - Wrapped Dragger component in a div to allow for custom click handling. - Prevented default click behavior to improve user experience when adding files. - Maintained existing file upload functionality while enhancing the UI interaction. * refactor(KnowledgeFiles): 提取文件处理逻辑到独立函数 将重复的文件上传和处理逻辑提取到独立的processFiles函数中,提高代码复用性和可维护性 --------- Co-authored-by: icarus <eurfelux@gmail.com> * fix(Sortable): correct gap and horizontal style * feat: make tabs sortable (example) * refactor: improve sortable direction and gap * refactor: update example * fix: remove useless states --------- Co-authored-by: beyondkmp <beyondkmp@gmail.com> Co-authored-by: icarus <eurfelux@gmail.com> Co-authored-by: Pleasure1234 <3196812536@qq.com>
This commit is contained in:
parent
82835c56bf
commit
0334c47ac7
@ -257,12 +257,13 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
) : (
|
||||
<CodeViewer
|
||||
className="source-view"
|
||||
value={children}
|
||||
language={language}
|
||||
onHeightChange={handleHeightChange}
|
||||
expanded={shouldExpand}
|
||||
wrapped={shouldWrap}
|
||||
onHeightChange={handleHeightChange}>
|
||||
{children}
|
||||
</CodeViewer>
|
||||
maxHeight={`${MAX_COLLAPSED_CODE_HEIGHT}px`}
|
||||
/>
|
||||
),
|
||||
[children, codeEditor.enabled, handleHeightChange, language, onSave, shouldExpand, shouldWrap]
|
||||
)
|
||||
|
||||
@ -48,8 +48,6 @@ export interface CodeEditorProps {
|
||||
maxHeight?: string
|
||||
/** Minimum editor height. */
|
||||
minHeight?: string
|
||||
/** Font size that overrides the app setting. */
|
||||
fontSize?: string
|
||||
/** Editor options that extend BasicSetupOptions. */
|
||||
options?: {
|
||||
/**
|
||||
@ -70,6 +68,8 @@ export interface CodeEditorProps {
|
||||
} & BasicSetupOptions
|
||||
/** Additional extensions for CodeMirror. */
|
||||
extensions?: Extension[]
|
||||
/** Font size that overrides the app setting. */
|
||||
fontSize?: number
|
||||
/** Style overrides for the editor, passed directly to CodeMirror's style property. */
|
||||
style?: React.CSSProperties
|
||||
/** CSS class name appended to the default `code-editor` class. */
|
||||
@ -108,9 +108,9 @@ const CodeEditor = ({
|
||||
height,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
fontSize,
|
||||
options,
|
||||
extensions,
|
||||
fontSize: customFontSize,
|
||||
style,
|
||||
className,
|
||||
editable = true,
|
||||
@ -121,7 +121,7 @@ const CodeEditor = ({
|
||||
const enableKeymap = useMemo(() => options?.keymap ?? codeEditor.keymap, [options?.keymap, codeEditor.keymap])
|
||||
|
||||
// 合并 codeEditor 和 options 的 basicSetup,options 优先
|
||||
const customBasicSetup = useMemo(() => {
|
||||
const basicSetup = useMemo(() => {
|
||||
return {
|
||||
lineNumbers: _lineNumbers,
|
||||
...(codeEditor as BasicSetupOptions),
|
||||
@ -129,7 +129,7 @@ const CodeEditor = ({
|
||||
}
|
||||
}, [codeEditor, _lineNumbers, options])
|
||||
|
||||
const customFontSize = useMemo(() => fontSize ?? `${_fontSize - 1}px`, [fontSize, _fontSize])
|
||||
const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize])
|
||||
|
||||
const { activeCmTheme } = useCodeStyle()
|
||||
const initialContent = useRef(options?.stream ? (value ?? '').trimEnd() : (value ?? ''))
|
||||
@ -214,10 +214,10 @@ const CodeEditor = ({
|
||||
foldKeymap: enableKeymap,
|
||||
completionKeymap: enableKeymap,
|
||||
lintKeymap: enableKeymap,
|
||||
...customBasicSetup // override basicSetup
|
||||
...basicSetup // override basicSetup
|
||||
}}
|
||||
style={{
|
||||
fontSize: customFontSize,
|
||||
fontSize,
|
||||
marginTop: 0,
|
||||
borderRadius: 'inherit',
|
||||
...style
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@ -11,13 +10,49 @@ import { ThemedToken } from 'shiki/core'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CodeViewerProps {
|
||||
/** Code string value. */
|
||||
value: string
|
||||
/**
|
||||
* Code language string.
|
||||
* - Case-insensitive.
|
||||
* - Supports common names: javascript, json, python, etc.
|
||||
* - Supports shiki aliases: c#/csharp, objective-c++/obj-c++/objc++, etc.
|
||||
*/
|
||||
language: string
|
||||
children: string
|
||||
expanded?: boolean
|
||||
wrapped?: boolean
|
||||
/** Fired when the editor height changes. */
|
||||
onHeightChange?: (scrollHeight: number) => void
|
||||
className?: string
|
||||
/**
|
||||
* Height of the scroll container.
|
||||
* Only works when expanded is false.
|
||||
*/
|
||||
height?: string | number
|
||||
/**
|
||||
* Maximum height of the scroll container.
|
||||
* Only works when expanded is false.
|
||||
*/
|
||||
maxHeight?: string | number
|
||||
/** Viewer options. */
|
||||
options?: {
|
||||
/**
|
||||
* Whether to show line numbers.
|
||||
*/
|
||||
lineNumbers?: boolean
|
||||
}
|
||||
/** Font size that overrides the app setting. */
|
||||
fontSize?: number
|
||||
/** CSS class name appended to the default `code-viewer` class. */
|
||||
className?: string
|
||||
/**
|
||||
* Whether the editor is expanded.
|
||||
* If true, the height and maxHeight props are ignored.
|
||||
* @default true
|
||||
*/
|
||||
expanded?: boolean
|
||||
/**
|
||||
* Whether the code lines are wrapped.
|
||||
* @default true
|
||||
*/
|
||||
wrapped?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -26,19 +61,33 @@ interface CodeViewerProps {
|
||||
* - 使用虚拟滚动和按需高亮,改善页面内有大量长代码块时的响应
|
||||
* - 并发安全
|
||||
*/
|
||||
const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, className, height }: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers, fontSize } = useSettings()
|
||||
const CodeViewer = ({
|
||||
value,
|
||||
language,
|
||||
height,
|
||||
maxHeight,
|
||||
onHeightChange,
|
||||
options,
|
||||
fontSize: customFontSize,
|
||||
className,
|
||||
expanded = true,
|
||||
wrapped = true
|
||||
}: CodeViewerProps) => {
|
||||
const { codeShowLineNumbers: _lineNumbers, fontSize: _fontSize } = useSettings()
|
||||
const { getShikiPreProperties, isShikiThemeDark } = useCodeStyle()
|
||||
const shikiThemeRef = useRef<HTMLDivElement>(null)
|
||||
const scrollerRef = useRef<HTMLDivElement>(null)
|
||||
const callerId = useRef(`${Date.now()}-${uuid()}`).current
|
||||
|
||||
const rawLines = useMemo(() => (typeof children === 'string' ? children.trimEnd().split('\n') : []), [children])
|
||||
const fontSize = useMemo(() => customFontSize ?? _fontSize - 1, [customFontSize, _fontSize])
|
||||
const lineNumbers = useMemo(() => options?.lineNumbers ?? _lineNumbers, [options?.lineNumbers, _lineNumbers])
|
||||
|
||||
const rawLines = useMemo(() => (typeof value === 'string' ? value.trimEnd().split('\n') : []), [value])
|
||||
|
||||
// 计算行号数字位数
|
||||
const gutterDigits = useMemo(
|
||||
() => (codeShowLineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
||||
[codeShowLineNumbers, rawLines.length]
|
||||
() => (lineNumbers ? Math.max(rawLines.length.toString().length, 1) : 0),
|
||||
[lineNumbers, rawLines.length]
|
||||
)
|
||||
|
||||
// 设置 pre 标签属性
|
||||
@ -68,7 +117,7 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
const getScrollElement = useCallback(() => scrollerRef.current, [])
|
||||
const getItemKey = useCallback((index: number) => `${callerId}-${index}`, [callerId])
|
||||
// `line-height: 1.6` 为全局样式,但是为了避免测量误差在这里取整
|
||||
const estimateSize = useCallback(() => Math.round((fontSize - 1) * 1.6), [fontSize])
|
||||
const estimateSize = useCallback(() => Math.round(fontSize * 1.6), [fontSize])
|
||||
|
||||
// 创建 virtualizer 实例
|
||||
const virtualizer = useVirtualizer({
|
||||
@ -105,20 +154,19 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
}, [rawLines.length, onHeightChange])
|
||||
|
||||
return (
|
||||
<div ref={shikiThemeRef} style={height ? { height } : undefined}>
|
||||
<div ref={shikiThemeRef} style={expanded ? undefined : { height }}>
|
||||
<ScrollContainer
|
||||
ref={scrollerRef}
|
||||
className="shiki-scroller"
|
||||
$wrap={wrapped}
|
||||
$expanded={expanded}
|
||||
$expand={expanded}
|
||||
$lineHeight={estimateSize()}
|
||||
$height={height}
|
||||
style={
|
||||
{
|
||||
'--gutter-width': `${gutterDigits}ch`,
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
maxHeight: expanded ? undefined : height ? undefined : MAX_COLLAPSED_CODE_HEIGHT,
|
||||
height: height,
|
||||
fontSize,
|
||||
height: expanded ? undefined : height,
|
||||
maxHeight: expanded ? undefined : maxHeight,
|
||||
overflowY: expanded ? 'hidden' : 'auto'
|
||||
} as React.CSSProperties
|
||||
}>
|
||||
@ -142,7 +190,7 @@ const CodeViewer = ({ children, language, expanded, wrapped, onHeightChange, cla
|
||||
<VirtualizedRow
|
||||
rawLine={rawLines[virtualItem.index]}
|
||||
tokenLine={tokenLines[virtualItem.index]}
|
||||
showLineNumbers={codeShowLineNumbers}
|
||||
showLineNumbers={lineNumbers}
|
||||
index={virtualItem.index}
|
||||
/>
|
||||
</div>
|
||||
@ -226,9 +274,8 @@ VirtualizedRow.displayName = 'VirtualizedRow'
|
||||
|
||||
const ScrollContainer = styled.div<{
|
||||
$wrap?: boolean
|
||||
$expanded?: boolean
|
||||
$expand?: boolean
|
||||
$lineHeight?: number
|
||||
$height?: string | number
|
||||
}>`
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
@ -244,7 +291,7 @@ const ScrollContainer = styled.div<{
|
||||
line-height: ${(props) => props.$lineHeight}px;
|
||||
/* contain 优化 wrap 时滚动性能,will-change 优化 unwrap 时滚动性能 */
|
||||
contain: ${(props) => (props.$wrap ? 'content' : 'none')};
|
||||
will-change: ${(props) => (!props.$wrap && !props.$expanded ? 'transform' : 'auto')};
|
||||
will-change: ${(props) => (!props.$wrap && !props.$expand ? 'transform' : 'auto')};
|
||||
|
||||
.line-number {
|
||||
width: var(--gutter-width, 1.2ch);
|
||||
|
||||
@ -71,8 +71,9 @@ describe('DraggableList', () => {
|
||||
})
|
||||
|
||||
it('should render nothing when list is empty', () => {
|
||||
const emptyList: Array<{ id: string; name: string }> = []
|
||||
render(
|
||||
<DraggableList list={[]} onUpdate={() => {}}>
|
||||
<DraggableList list={emptyList} onUpdate={() => {}}>
|
||||
{(item) => <div data-testid="item">{item.name}</div>}
|
||||
</DraggableList>
|
||||
)
|
||||
|
||||
@ -33,7 +33,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList, // 列表未过滤
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
@ -61,7 +61,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
@ -89,7 +89,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
@ -110,7 +110,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList: mockOriginalList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
@ -136,7 +136,7 @@ describe('useDraggableReorder', () => {
|
||||
originalList: mockOriginalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
ResponderProvided
|
||||
} from '@hello-pangea/dnd'
|
||||
import { droppableReorder } from '@renderer/utils'
|
||||
import { FC, HTMLAttributes } from 'react'
|
||||
import { HTMLAttributes, Key, useCallback } from 'react'
|
||||
|
||||
interface Props<T> {
|
||||
list: T[]
|
||||
@ -17,23 +17,25 @@ interface Props<T> {
|
||||
listStyle?: React.CSSProperties
|
||||
listProps?: HTMLAttributes<HTMLDivElement>
|
||||
children: (item: T, index: number) => React.ReactNode
|
||||
itemKey?: keyof T | ((item: T) => Key)
|
||||
onUpdate: (list: T[]) => void
|
||||
onDragStart?: OnDragStartResponder
|
||||
onDragEnd?: OnDragEndResponder
|
||||
droppableProps?: Partial<DroppableProps>
|
||||
}
|
||||
|
||||
const DraggableList: FC<Props<any>> = ({
|
||||
function DraggableList<T>({
|
||||
children,
|
||||
list,
|
||||
style,
|
||||
listStyle,
|
||||
listProps,
|
||||
itemKey,
|
||||
droppableProps,
|
||||
onDragStart,
|
||||
onUpdate,
|
||||
onDragEnd
|
||||
}) => {
|
||||
}: Props<T>) {
|
||||
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
|
||||
onDragEnd?.(result, provided)
|
||||
if (result.destination) {
|
||||
@ -46,6 +48,17 @@ const DraggableList: FC<Props<any>> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const getId = useCallback(
|
||||
(item: T) => {
|
||||
if (typeof itemKey === 'function') return itemKey(item)
|
||||
if (itemKey) return item[itemKey] as Key
|
||||
if (typeof item === 'string') return item as Key
|
||||
if (item && typeof item === 'object' && 'id' in item) return item.id as Key
|
||||
return undefined
|
||||
},
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
return (
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={_onDragEnd}>
|
||||
<Droppable droppableId="droppable" {...droppableProps}>
|
||||
@ -53,9 +66,9 @@ const DraggableList: FC<Props<any>> = ({
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} style={style}>
|
||||
<div {...listProps} className="draggable-list-container">
|
||||
{list.map((item, index) => {
|
||||
const id = item.id || item
|
||||
const draggableId = String(getId(item) ?? index)
|
||||
return (
|
||||
<Draggable key={`draggable_${id}_${index}`} draggableId={id} index={index}>
|
||||
<Draggable key={`draggable_${draggableId}`} draggableId={draggableId} index={index}>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
|
||||
@ -9,7 +9,7 @@ interface UseDraggableReorderParams<T> {
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
itemKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -19,8 +19,16 @@ interface UseDraggableReorderParams<T> {
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 DraggableVirtualList 的 props: { onDragEnd, itemKey }
|
||||
*/
|
||||
export function useDraggableReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDraggableReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
export function useDraggableReorder<T>({
|
||||
originalList,
|
||||
filteredList,
|
||||
onUpdate,
|
||||
itemKey
|
||||
}: UseDraggableReorderParams<T>) {
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
|
||||
@ -208,7 +208,7 @@ const VirtualRow = memo(
|
||||
const draggableId = String(virtualItem.key)
|
||||
return (
|
||||
<Draggable
|
||||
key={`draggable_${draggableId}_${virtualItem.index}`}
|
||||
key={`draggable_${draggableId}`}
|
||||
draggableId={draggableId}
|
||||
isDragDisabled={disabled}
|
||||
index={virtualItem.index}>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { Sortable, useDndReorder } from '@renderer/components/dnd'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useFullscreen } from '@renderer/hooks/useFullscreen'
|
||||
@ -7,7 +8,7 @@ import { getThemeModeLabel, getTitleLabel } from '@renderer/i18n/label'
|
||||
import tabsService from '@renderer/services/TabsService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import type { Tab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, reorderTabs, setActiveTab } from '@renderer/store/tabs'
|
||||
import { addTab, removeTab, setActiveTab, setTabs } from '@renderer/store/tabs'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Tooltip } from 'antd'
|
||||
@ -28,7 +29,7 @@ import {
|
||||
Terminal,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
@ -81,11 +82,6 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
const { settedTheme, toggleTheme } = useTheme()
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const [dragState, setDragState] = useState<{
|
||||
isDragging: boolean
|
||||
dragIndex: number
|
||||
dragOverIndex: number
|
||||
}>({ isDragging: false, dragIndex: -1, dragOverIndex: -1 })
|
||||
|
||||
const getTabId = (path: string): string => {
|
||||
if (path === '/') return 'home'
|
||||
@ -147,88 +143,48 @@ const TabsContainer: React.FC<TabsContainerProps> = ({ children }) => {
|
||||
navigate(tab.path)
|
||||
}
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
if (tabs[index].id === 'home') {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
setDragState({ isDragging: true, dragIndex: index, dragOverIndex: -1 })
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', index.toString())
|
||||
}
|
||||
const filteredTabs = useMemo(() => tabs.filter((tab) => !specialTabs.includes(tab.id)), [tabs])
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
if (tabs[index].id === 'home') return
|
||||
e.preventDefault()
|
||||
setDragState((prev) => ({ ...prev, dragOverIndex: index }))
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragState((prev) => ({ ...prev, dragOverIndex: -1 }))
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||||
e.preventDefault()
|
||||
const dragIndex = dragState.dragIndex
|
||||
|
||||
if (dragIndex !== -1 && dragIndex !== dropIndex && tabs[dragIndex].id !== 'home' && tabs[dropIndex].id !== 'home') {
|
||||
dispatch(reorderTabs({ fromIndex: dragIndex, toIndex: dropIndex }))
|
||||
}
|
||||
|
||||
setDragState({ isDragging: false, dragIndex: -1, dragOverIndex: -1 })
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDragState({ isDragging: false, dragIndex: -1, dragOverIndex: -1 })
|
||||
}
|
||||
|
||||
const visibleTabs = tabs.filter((tab) => !specialTabs.includes(tab.id))
|
||||
const { onSortEnd } = useDndReorder<Tab>({
|
||||
originalList: tabs,
|
||||
filteredList: filteredTabs,
|
||||
onUpdate: (newTabs) => dispatch(setTabs(newTabs)),
|
||||
itemKey: 'id'
|
||||
})
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TabsBar $isFullscreen={isFullscreen}>
|
||||
<TabsWrapper $tabCount={visibleTabs.length}>
|
||||
{visibleTabs.map((tab, index) => {
|
||||
const isDragOver = dragState.dragOverIndex === index
|
||||
const isDragging = dragState.isDragging && dragState.dragIndex === index
|
||||
const canDrag = tab.id !== 'home'
|
||||
|
||||
return (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
active={tab.id === activeTabId}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
draggable={canDrag}
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
$isDragOver={isDragOver}
|
||||
$isDragging={isDragging}
|
||||
$canDrag={canDrag}
|
||||
$tabCount={visibleTabs.length}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
)
|
||||
})}
|
||||
<AddTabButton onClick={handleAddTab} className={classNames({ active: activeTabId === 'launchpad' })}>
|
||||
<PlusOutlined />
|
||||
</AddTabButton>
|
||||
</TabsWrapper>
|
||||
<Sortable
|
||||
items={filteredTabs}
|
||||
itemKey="id"
|
||||
layout="list"
|
||||
horizontal
|
||||
gap={'6px'}
|
||||
onSortEnd={onSortEnd}
|
||||
renderItem={(tab) => (
|
||||
<Tab key={tab.id} active={tab.id === activeTabId} onClick={() => handleTabClick(tab)}>
|
||||
<TabHeader>
|
||||
{tab.id && <TabIcon>{getTabIcon(tab.id)}</TabIcon>}
|
||||
<TabTitle>{getTitleLabel(tab.id)}</TabTitle>
|
||||
</TabHeader>
|
||||
{tab.id !== 'home' && (
|
||||
<CloseButton
|
||||
className="close-button"
|
||||
data-no-dnd
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeTab(tab.id)
|
||||
}}>
|
||||
<X size={12} />
|
||||
</CloseButton>
|
||||
)}
|
||||
</Tab>
|
||||
)}
|
||||
/>
|
||||
<AddTabButton onClick={handleAddTab} className={classNames({ active: activeTabId === 'launchpad' })}>
|
||||
<PlusOutlined />
|
||||
</AddTabButton>
|
||||
<RightButtonsContainer>
|
||||
<TopNavbarOpenedMinappTabs />
|
||||
<Tooltip
|
||||
@ -280,29 +236,6 @@ const TabsBar = styled.div<{ $isFullscreen: boolean }>`
|
||||
}
|
||||
`
|
||||
|
||||
const TabsWrapper = styled.div<{ $tabCount: number }>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
min-width: 0;
|
||||
margin-right: 10px;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 0;
|
||||
}
|
||||
`
|
||||
|
||||
const Tab = styled.div<{
|
||||
active?: boolean
|
||||
$isDragOver?: boolean
|
||||
|
||||
@ -56,6 +56,8 @@ interface SortableProps<T> {
|
||||
listStyle?: React.CSSProperties
|
||||
/** Ghost item style */
|
||||
ghostItemStyle?: React.CSSProperties
|
||||
/** Item gap */
|
||||
gap?: number | string
|
||||
}
|
||||
|
||||
function Sortable<T>({
|
||||
@ -70,7 +72,8 @@ function Sortable<T>({
|
||||
useDragOverlay = true,
|
||||
showGhost = false,
|
||||
className,
|
||||
listStyle
|
||||
listStyle,
|
||||
gap
|
||||
}: SortableProps<T>) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PortalSafePointerSensor, {
|
||||
@ -150,7 +153,12 @@ function Sortable<T>({
|
||||
onDragCancel={handleDragCancel}
|
||||
modifiers={modifiers}>
|
||||
<SortableContext items={itemIds} strategy={strategy}>
|
||||
<ListWrapper className={className} data-layout={layout} style={listStyle}>
|
||||
<ListWrapper
|
||||
className={className}
|
||||
data-layout={layout}
|
||||
data-direction={horizontal ? 'horizontal' : 'vertical'}
|
||||
$gap={gap}
|
||||
style={listStyle}>
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={itemIds[index]}
|
||||
@ -176,17 +184,31 @@ function Sortable<T>({
|
||||
)
|
||||
}
|
||||
|
||||
const ListWrapper = styled.div`
|
||||
const ListWrapper = styled.div<{ $gap?: number | string }>`
|
||||
gap: ${({ $gap }) => $gap};
|
||||
|
||||
&[data-layout='grid'] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-layout='list'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
[data-direction='horizontal'] {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
[data-direction='vertical'] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default Sortable
|
||||
|
||||
@ -8,7 +8,7 @@ interface UseDndReorderParams<T> {
|
||||
/** 用于更新原始列表状态的函数 */
|
||||
onUpdate: (newList: T[]) => void
|
||||
/** 用于从列表项中获取唯一ID的属性名或函数 */
|
||||
idKey: keyof T | ((item: T) => Key)
|
||||
itemKey: keyof T | ((item: T) => Key)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -18,8 +18,11 @@ interface UseDndReorderParams<T> {
|
||||
* @param params - { originalList, filteredList, onUpdate, idKey }
|
||||
* @returns 返回可以直接传递给 Sortable 的 onSortEnd 回调
|
||||
*/
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
|
||||
export function useDndReorder<T>({ originalList, filteredList, onUpdate, itemKey }: UseDndReorderParams<T>) {
|
||||
const getId = useCallback(
|
||||
(item: T) => (typeof itemKey === 'function' ? itemKey(item) : (item[itemKey] as Key)),
|
||||
[itemKey]
|
||||
)
|
||||
|
||||
// 创建从 item ID 到其在 *原始列表* 中索引的映射
|
||||
const itemIndexMap = useMemo(() => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import { useFiles } from '@renderer/hooks/useFiles'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import StatusIcon from '@renderer/pages/knowledge/components/StatusIcon'
|
||||
@ -48,6 +49,7 @@ const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap, preprocessMap }) => {
|
||||
const { t } = useTranslation()
|
||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight)
|
||||
const { onSelectFile, selecting } = useFiles({ extensions: fileTypes })
|
||||
|
||||
const { base, fileItems, addFiles, refreshItem, removeItem, getProcessingStatus } = useKnowledge(
|
||||
selectedBase.id || ''
|
||||
@ -71,19 +73,12 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
return null
|
||||
}
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (disabled) {
|
||||
const handleAddFile = async () => {
|
||||
if (disabled || selecting) {
|
||||
return
|
||||
}
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.accept = fileTypes.join(',')
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
files && handleDrop(Array.from(files))
|
||||
}
|
||||
input.click()
|
||||
const selectedFiles = await onSelectFile({ multipleSelections: true })
|
||||
processFiles(selectedFiles)
|
||||
}
|
||||
|
||||
const handleDrop = async (files: File[]) => {
|
||||
@ -118,8 +113,14 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
}
|
||||
})
|
||||
.filter(({ ext }) => fileTypes.includes(ext))
|
||||
const uploadedFiles = await FileManager.uploadFiles(_files)
|
||||
logger.debug('uploadedFiles', uploadedFiles)
|
||||
processFiles(_files)
|
||||
}
|
||||
}
|
||||
|
||||
const processFiles = async (files: FileMetadata[]) => {
|
||||
logger.debug('processFiles', files)
|
||||
if (files.length > 0) {
|
||||
const uploadedFiles = await FileManager.uploadFiles(files)
|
||||
addFiles(uploadedFiles)
|
||||
}
|
||||
}
|
||||
@ -150,16 +151,23 @@ const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase, progressMap,
|
||||
</ItemHeader>
|
||||
|
||||
<ItemFlexColumn>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
multiple={true}
|
||||
accept={fileTypes.join(',')}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddFile()
|
||||
}}>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
multiple={true}
|
||||
accept={fileTypes.join(',')}
|
||||
openFileDialogOnClick={false}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
</div>
|
||||
{fileItems.length === 0 ? (
|
||||
<KnowledgeEmptyView />
|
||||
) : (
|
||||
|
||||
@ -2,7 +2,6 @@ import 'emoji-picker-element'
|
||||
|
||||
import { CloseCircleFilled } from '@ant-design/icons'
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import CodeViewer from '@renderer/components/CodeViewer'
|
||||
import EmojiPicker from '@renderer/components/EmojiPicker'
|
||||
import { Box, HSpaceBetweenStack, HStack } from '@renderer/components/Layout'
|
||||
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||
@ -14,6 +13,7 @@ import { Button, Input, Popover } from 'antd'
|
||||
import { Edit, HelpCircle, Save } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingDivider } from '..'
|
||||
@ -122,7 +122,9 @@ const AssistantPromptSettings: React.FC<Props> = ({ assistant, updateAssistant }
|
||||
<TextAreaContainer>
|
||||
<RichEditorContainer>
|
||||
{showPreview ? (
|
||||
<CodeViewer children={processedPrompt} language="markdown" expanded={true} height="100%" />
|
||||
<MarkdownContainer>
|
||||
<ReactMarkdown>{processedPrompt || prompt}</ReactMarkdown>
|
||||
</MarkdownContainer>
|
||||
) : (
|
||||
<CodeEditor
|
||||
value={prompt}
|
||||
@ -214,4 +216,10 @@ const RichEditorContainer = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const MarkdownContainer = styled.div.attrs({ className: 'markdown' })`
|
||||
height: 100%;
|
||||
padding: 0.5em;
|
||||
overflow: auto;
|
||||
`
|
||||
|
||||
export default AssistantPromptSettings
|
||||
|
||||
@ -55,7 +55,7 @@ const McpServersList: FC = () => {
|
||||
originalList: mcpServers,
|
||||
filteredList: filteredMcpServers,
|
||||
onUpdate: updateMcpServers,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@ -251,6 +251,7 @@ const McpServersList: FC = () => {
|
||||
itemKey="id"
|
||||
onSortEnd={onSortEnd}
|
||||
layout="grid"
|
||||
gap={'12px'}
|
||||
useDragOverlay
|
||||
showGhost
|
||||
renderItem={(server) => (
|
||||
|
||||
@ -321,7 +321,7 @@ const ProviderList: FC = () => {
|
||||
originalList: providers,
|
||||
filteredList: filteredProviders,
|
||||
onUpdate: updateProviders,
|
||||
idKey: 'id'
|
||||
itemKey: 'id'
|
||||
})
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
|
||||
@ -24,6 +24,9 @@ const tabsSlice = createSlice({
|
||||
name: 'tabs',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTabs: (state, action: PayloadAction<Tab[]>) => {
|
||||
state.tabs = action.payload
|
||||
},
|
||||
addTab: (state, action: PayloadAction<Tab>) => {
|
||||
const existingTab = state.tabs.find((tab) => tab.path === action.payload.path)
|
||||
if (!existingTab) {
|
||||
@ -46,25 +49,12 @@ const tabsSlice = createSlice({
|
||||
if (tab) {
|
||||
Object.assign(tab, action.payload.updates)
|
||||
}
|
||||
},
|
||||
}
|
||||
setActiveTab: (state, action: PayloadAction<string>) => {
|
||||
state.activeTabId = action.payload
|
||||
},
|
||||
reorderTabs: (state, action: PayloadAction<{ fromIndex: number; toIndex: number }>) => {
|
||||
const { fromIndex, toIndex } = action.payload
|
||||
if (
|
||||
fromIndex !== toIndex &&
|
||||
fromIndex >= 0 &&
|
||||
toIndex >= 0 &&
|
||||
fromIndex < state.tabs.length &&
|
||||
toIndex < state.tabs.length
|
||||
) {
|
||||
const [movedTab] = state.tabs.splice(fromIndex, 1)
|
||||
state.tabs.splice(toIndex, 0, movedTab)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { addTab, removeTab, setActiveTab, updateTab, reorderTabs } = tabsSlice.actions
|
||||
export const { setTabs, addTab, removeTab, setActiveTab, updateTab } = tabsSlice.actions
|
||||
export default tabsSlice.reducer
|
||||
|
||||
Loading…
Reference in New Issue
Block a user