From 4d485ca1ad1a39282ca20326f26742d464d0f964 Mon Sep 17 00:00:00 2001 From: circle33 Date: Mon, 16 Mar 2026 10:09:11 +0800 Subject: [PATCH] feat(ui): support draggable tab reordering with stable dom ordering --- AGENTS.md | 3 + frontend/src/renderer/App.tsx | 80 ++++++++--- .../features/query-editor/QueryEditorTab.tsx | 5 +- .../src/renderer/hooks/useDraggableSort.ts | 49 +++++++ frontend/src/renderer/layouts/MainLayout.tsx | 136 +++++++++--------- .../src/renderer/layouts/TabWorkspace.tsx | 20 ++- .../src/renderer/stores/useWorkspaceStore.ts | 11 ++ 7 files changed, 208 insertions(+), 96 deletions(-) create mode 100644 frontend/src/renderer/hooks/useDraggableSort.ts diff --git a/AGENTS.md b/AGENTS.md index 2e809a2..90eece1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,9 @@ vstable 是一款专为开发者设计的现代数据库管理工具,支持可 - **全链路验证**: - 自动完成 Docker 环境拉起、引擎预编译、模拟用户操作。 - 双向对齐验证:在后端维护同步化的集成测试,确保生成的 DDL 在真实数据库中执行一致。 +- **UI 渲染稳定性**: + - 对于包含 Monaco Editor 等对 DOM 物理位置敏感的组件,在实现排序功能时必须采用“稳定 DOM 排序”策略。 + - 即 DOM 物理顺序按 ID 保持固定,与 UI 显示顺序解耦,仅通过切换可见性而非物理移动 DOM 节点来处理排序。 - **类型安全性**: 贯穿前端 UI 到后端 AST 编译器的强类型约束。 ## Git commit messages diff --git a/frontend/src/renderer/App.tsx b/frontend/src/renderer/App.tsx index 268716b..30f1c73 100644 --- a/frontend/src/renderer/App.tsx +++ b/frontend/src/renderer/App.tsx @@ -1,7 +1,7 @@ import { Plus, X } from 'lucide-react'; -import type React from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { apiClient } from './api/client'; +import { useDraggableSort } from './hooks/useDraggableSort'; import { SessionView } from './layouts/MainLayout'; import type { PersistedSession, PersistedWorkspace } from './types/session'; @@ -12,11 +12,50 @@ interface Session { initialWorkspace?: any; } +interface SessionsContentProps { + sessions: Session[]; + activeSessionId: string | null; + onStateChange: (id: string, state: any) => void; + onUpdateTitle: (id: string, title: string) => void; +} + +const SessionsContent: React.FC = React.memo( + ({ sessions, activeSessionId, onStateChange, onUpdateTitle }) => { + // Sort by ID to maintain stable DOM position regardless of tab order + const stableSessions = [...sessions].sort((a, b) => a.id.localeCompare(b.id)); + + return ( +
+ {stableSessions.map((session) => ( + + ))} +
+ ); + } +); + function App() { const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [isInitializing, setIsInitializing] = useState(true); + const { getDragHandlers, getIndicatorClass } = useDraggableSort((dragIdx, dropIdx) => { + setSessions((prev) => { + const newSessions = [...prev]; + const [draggedItem] = newSessions.splice(dragIdx, 1); + newSessions.splice(dropIdx, 0, draggedItem); + return newSessions; + }); + }); + // Store the latest state of each session reported by SessionView const sessionStatesRef = useRef>({}); const saveTimeoutRef = useRef | null>(null); @@ -120,6 +159,14 @@ function App() { [saveWorkspace] ); + const handleUpdateTitle = useCallback( + (id: string, title: string) => { + setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s))); + saveWorkspace(); + }, + [saveWorkspace] + ); + const handleAddSession = () => { const newSession = { id: crypto.randomUUID(), title: 'New Connection' }; setSessions([...sessions, newSession]); @@ -159,13 +206,14 @@ function App() {
{/* Titlebar / Tab Bar */}
-
- {sessions.map((session) => ( +
+ {sessions.map((session, index) => (
setActiveSessionId(session.id)} - className={`group flex items-center gap-2 px-4 h-9 text-[11px] font-medium rounded-t-lg cursor-pointer transition-all border-t border-x no-drag ${activeSessionId === session.id ? 'bg-white text-gray-800 border-gray-200 -mb-[1px] z-10 shadow-[0_-1px_3px_rgba(0,0,0,0.02)]' : 'bg-transparent text-gray-500 hover:bg-gray-200/50 border-transparent'}`} + className={`group flex items-center gap-2 px-4 h-9 text-[11px] font-medium rounded-t-lg cursor-pointer transition-all border-t border-x ${activeSessionId === session.id ? 'bg-white text-gray-800 border-gray-200 -mb-[1px] z-10 shadow-[0_-1px_3px_rgba(0,0,0,0.02)]' : 'bg-transparent text-gray-500 hover:bg-gray-200/50 border-transparent'} ${getIndicatorClass(index)}`} > {session.title}
); } diff --git a/frontend/src/renderer/features/query-editor/QueryEditorTab.tsx b/frontend/src/renderer/features/query-editor/QueryEditorTab.tsx index 110880c..4722737 100644 --- a/frontend/src/renderer/features/query-editor/QueryEditorTab.tsx +++ b/frontend/src/renderer/features/query-editor/QueryEditorTab.tsx @@ -13,7 +13,7 @@ interface QueryTabPaneProps { } export const QueryTabPane: React.FC = ({ tab, isActive, onUpdateTab }) => { - const { query } = useSession(); + const { query: runQuery, sessionId } = useSession(); const [executing, setExecuting] = useState(false); const [error, setError] = useState(null); @@ -28,7 +28,7 @@ export const QueryTabPane: React.FC = ({ tab, isActive, onUpd onUpdateTab({ results: null }); try { - const res = await query(tab.query); + const res = await runQuery(tab.query); if (res.success) { onUpdateTab({ results: { rows: res.rows || [], fields: res.fields || [] } }); } else { @@ -69,6 +69,7 @@ export const QueryTabPane: React.FC = ({ tab, isActive, onUpd height="100%" defaultLanguage="sql" theme="vs" + path={`session-${sessionId}-tab-${tab.id}.sql`} value={tab.query} onChange={(val) => onUpdateTab({ query: val || '' })} options={{ diff --git a/frontend/src/renderer/hooks/useDraggableSort.ts b/frontend/src/renderer/hooks/useDraggableSort.ts new file mode 100644 index 0000000..e35cc80 --- /dev/null +++ b/frontend/src/renderer/hooks/useDraggableSort.ts @@ -0,0 +1,49 @@ +import type React from 'react'; +import { useState } from 'react'; + +export function useDraggableSort(onReorder: (dragIdx: number, dropIdx: number) => void) { + const [draggedIdx, setDraggedIdx] = useState(null); + const [dragOverIdx, setDragOverIdx] = useState(null); + + const getDragHandlers = (index: number) => ({ + draggable: true, + onDragStart: (e: React.DragEvent) => { + setDraggedIdx(index); + e.dataTransfer.effectAllowed = 'move'; + }, + onDragOver: (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverIdx((prev) => (prev === index ? prev : index)); + }, + onDragLeave: () => { + if (dragOverIdx === index) setDragOverIdx(null); + }, + onDrop: (e: React.DragEvent) => { + e.preventDefault(); + if (draggedIdx !== null && draggedIdx !== index) { + onReorder(draggedIdx, index); + } + setDraggedIdx(null); + setDragOverIdx(null); + }, + onDragEnd: () => { + setDraggedIdx(null); + setDragOverIdx(null); + }, + }); + + const getIndicatorClass = (index: number) => { + if (dragOverIdx !== index || draggedIdx === null || draggedIdx === index) return ''; + return draggedIdx < index + ? '!border-r-primary-500 !border-r-2' + : '!border-l-primary-500 !border-l-2'; + }; + + return { + getDragHandlers, + getIndicatorClass, + draggedIdx, + dragOverIdx, + }; +} diff --git a/frontend/src/renderer/layouts/MainLayout.tsx b/frontend/src/renderer/layouts/MainLayout.tsx index b9f9a19..d9bff79 100644 --- a/frontend/src/renderer/layouts/MainLayout.tsx +++ b/frontend/src/renderer/layouts/MainLayout.tsx @@ -1,5 +1,4 @@ -import type React from 'react'; -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { AlertModal } from '../components/ui/AlertModal'; import { ConfirmModal } from '../components/ui/ConfirmModal'; import { TabSwitcher } from '../components/ui/TabSwitcher'; @@ -33,7 +32,7 @@ const SessionContent: React.FC<{ isActive: boolean; initialConfig?: any; onStateChange?: (id: string, state: any) => void; -}> = ({ isActive, initialConfig, onStateChange }) => { +}> = React.memo(({ isActive, initialConfig, onStateChange }) => { const { isConnected, config, sessionId, query, buildQuery, connect, disconnect, capabilities } = useSession(); const q = capabilities?.quoteChar || '"'; @@ -309,49 +308,53 @@ const SessionContent: React.FC<{ {/* Tab Content */}
- {tabs.map((tab) => ( -
- {tab.type === 'table' ? ( - updateTab(tab.id, updates)} - connectionId={sessionId} - onOpenStructure={(schema, name) => openStructure(schema, name, 'edit')} - /> - ) : tab.type === 'query' ? ( - updateTab(tab.id, updates)} - /> - ) : ( - closeTab(tab.id)} - onSaveSuccess={(schema, name) => { - updateTab(tab.id, { - mode: 'edit', - name: `Structure: ${name}`, - initialSchema: schema, - initialTableName: name, - }); - fetchTables(); - }} - /> - )} -
- ))} + {[...tabs] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((tab) => ( +
+ {tab.type === 'table' ? ( + updateTab(tab.id, updates)} + connectionId={sessionId} + onOpenStructure={(schema, name) => openStructure(schema, name, 'edit')} + /> + ) : tab.type === 'query' ? ( + updateTab(tab.id, updates)} + /> + ) : ( + closeTab(tab.id)} + onSaveSuccess={(schema, name) => { + updateTab(tab.id, { + mode: 'edit', + name: `Structure: ${name}`, + initialSchema: schema, + initialTableName: name, + }); + fetchTables(); + }} + /> + )} +
+ ))} {tabs.length === 0 && (
Select a table from the sidebar to view its data @@ -406,11 +409,11 @@ const SessionContent: React.FC<{ )}
); -}; +}); -export const SessionView: React.FC = (props) => { +export const SessionView: React.FC = React.memo((props) => { return ( - + props.onUpdateTitle(props.id, title)}> = (props) => { /> ); -}; +}); interface WorkspaceStoreWrapperProps { isActive: boolean; @@ -428,21 +431,18 @@ interface WorkspaceStoreWrapperProps { onStateChange?: (id: string, state: any) => void; } -const WorkspaceStoreWrapper: React.FC = ({ - isActive, - initialConfig, - initialWorkspace, - onStateChange, -}) => { - const [store] = useState(() => createWorkspaceStore(initialWorkspace)); - - return ( - - - - ); -}; +const WorkspaceStoreWrapper: React.FC = React.memo( + ({ isActive, initialConfig, initialWorkspace, onStateChange }) => { + const [store] = useState(() => createWorkspaceStore(initialWorkspace)); + + return ( + + + + ); + } +); diff --git a/frontend/src/renderer/layouts/TabWorkspace.tsx b/frontend/src/renderer/layouts/TabWorkspace.tsx index 32bb3fd..789ea17 100644 --- a/frontend/src/renderer/layouts/TabWorkspace.tsx +++ b/frontend/src/renderer/layouts/TabWorkspace.tsx @@ -2,6 +2,7 @@ import { Play, Settings, Table as TableIcon, X } from 'lucide-react'; import type React from 'react'; import { useEffect, useRef, useState } from 'react'; import { Tooltip } from '../components/ui/Tooltip'; +import { useDraggableSort } from '../hooks/useDraggableSort'; import { useWorkspaceStore } from '../stores/useWorkspaceStore'; import type { TableTab } from '../types/session'; @@ -11,16 +12,24 @@ interface TabWorkspaceProps { } export const TabWorkspace: React.FC = ({ isMaximized, setIsMaximized }) => { - const { tabs, activeTabId, setActiveTabId, closeTab, closeOthers, closeToRight, closeAll } = - useWorkspaceStore((s) => s); + const { + tabs, + activeTabId, + setActiveTabId, + closeTab, + closeOthers, + closeToRight, + closeAll, + moveTab, + } = useWorkspaceStore((s) => s); const [tabContextMenu, setTabContextMenu] = useState<{ x: number; y: number; tabId: string; } | null>(null); + const { getDragHandlers, getIndicatorClass } = useDraggableSort(moveTab); const tabContainerRef = useRef(null); - // Scroll active tab into view useEffect(() => { if (activeTabId && tabContainerRef.current) { @@ -46,12 +55,13 @@ export const TabWorkspace: React.FC = ({ isMaximized, setIsMa if (e.target === e.currentTarget) setIsMaximized(!isMaximized); }} > - {tabs.map((tab: TableTab) => ( + {tabs.map((tab: TableTab, index: number) => (
setActiveTabId(tab.id)} @@ -60,7 +70,7 @@ export const TabWorkspace: React.FC = ({ isMaximized, setIsMa e.preventDefault(); setTabContextMenu({ x: e.clientX, y: e.clientY, tabId: tab.id }); }} - className={`group flex items-center gap-2 px-4 h-9 text-[11px] font-medium rounded-t-lg cursor-pointer transition-all border-x border-t ${activeTabId === tab.id ? 'bg-white text-primary-600 border-gray-200 -mb-[1px] z-10 shadow-[0_-1px_3px_rgba(0,0,0,0.02)]' : 'bg-transparent text-gray-500 hover:bg-gray-200/50 border-transparent'}`} + className={`group flex items-center gap-2 px-4 h-9 text-[11px] font-medium rounded-t-lg cursor-pointer transition-all border-t ${activeTabId === tab.id ? 'bg-white text-primary-600 border-x border-gray-200 -mb-[1px] z-10 shadow-[0_-1px_3px_rgba(0,0,0,0.02)]' : 'bg-transparent text-gray-500 hover:bg-gray-200/50 border-x border-transparent'} ${getIndicatorClass(index)}`} > {tab.type === 'table' ? ( void; closeToRight: (id: string) => void; closeAll: () => void; + moveTab: (dragIndex: number, hoverIndex: number) => void; } type WorkspaceStore = ReturnType; @@ -186,6 +187,16 @@ export const createWorkspaceStore = (initialState?: Partial) => }, closeAll: () => set({ tabs: [], activeTabId: null, mruTabIds: [] }), + + moveTab: (dragIndex, hoverIndex) => { + set((state) => { + const newTabs = [...state.tabs]; + const dragItem = newTabs[dragIndex]; + newTabs.splice(dragIndex, 1); + newTabs.splice(hoverIndex, 0, dragItem); + return { tabs: newTabs }; + }); + }, })); };