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:
one 2025-09-03 12:37:32 +08:00 committed by GitHub
parent 82835c56bf
commit 0334c47ac7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 237 additions and 202 deletions

View File

@ -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]
)

View File

@ -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 的 basicSetupoptions 优先
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

View File

@ -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);

View File

@ -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>
)

View File

@ -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'
})
)

View File

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

View File

@ -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(() => {

View File

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

View File

@ -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

View File

@ -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

View File

@ -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(() => {

View File

@ -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 />
) : (

View File

@ -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

View File

@ -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) => (

View File

@ -321,7 +321,7 @@ const ProviderList: FC = () => {
originalList: providers,
filteredList: filteredProviders,
onUpdate: updateProviders,
idKey: 'id'
itemKey: 'id'
})
const handleDragStart = useCallback(() => {

View File

@ -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