Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ vstable 是一款专为开发者设计的现代数据库管理工具,支持可
- **全链路验证**:
- 自动完成 Docker 环境拉起、引擎预编译、模拟用户操作。
- 双向对齐验证:在后端维护同步化的集成测试,确保生成的 DDL 在真实数据库中执行一致。
- **UI 渲染稳定性**:
- 对于包含 Monaco Editor 等对 DOM 物理位置敏感的组件,在实现排序功能时必须采用“稳定 DOM 排序”策略。
- 即 DOM 物理顺序按 ID 保持固定,与 UI 显示顺序解耦,仅通过切换可见性而非物理移动 DOM 节点来处理排序。
- **类型安全性**: 贯穿前端 UI 到后端 AST 编译器的强类型约束。

## Git commit messages
Expand Down
80 changes: 59 additions & 21 deletions frontend/src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<SessionsContentProps> = 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 (
<div className="flex-1 overflow-hidden relative bg-white">
{stableSessions.map((session) => (
<SessionView
key={session.id}
id={session.id}
isActive={activeSessionId === session.id}
initialConfig={session.initialConfig}
initialWorkspace={session.initialWorkspace}
onStateChange={onStateChange}
onUpdateTitle={onUpdateTitle}
/>
))}
</div>
);
}
);

function App() {
const [sessions, setSessions] = useState<Session[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(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<Record<string, any>>({});
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -159,13 +206,14 @@ function App() {
<div className="flex flex-col h-screen bg-white overflow-hidden">
{/* Titlebar / Tab Bar */}
<div className="titlebar h-11 flex items-end bg-[#f3f3f3] border-b border-gray-200 pl-20 pr-4 select-none draggable-region">
<div className="flex items-end gap-1 overflow-x-auto scrollbar-hide h-full">
{sessions.map((session) => (
<div className="flex items-end gap-1 overflow-x-auto scrollbar-hide h-full no-drag">
{sessions.map((session, index) => (
<div
key={session.id}
{...getDragHandlers(index)}
data-testid="session-tab"
onClick={() => 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)}`}
>
<span className="max-w-[120px] truncate flex-1">{session.title}</span>
<button
Expand Down Expand Up @@ -197,22 +245,12 @@ function App() {
</div>

{/* Content Area */}
<div className="flex-1 overflow-hidden relative bg-white">
{sessions.map((session) => (
<SessionView
key={session.id}
id={session.id}
isActive={activeSessionId === session.id}
initialConfig={session.initialConfig}
initialWorkspace={session.initialWorkspace}
onStateChange={handleStateChange}
onUpdateTitle={(title) => {
setSessions((prev) => prev.map((s) => (s.id === session.id ? { ...s, title } : s)));
saveWorkspace();
}}
/>
))}
</div>
<SessionsContent
sessions={sessions}
activeSessionId={activeSessionId}
onStateChange={handleStateChange}
onUpdateTitle={handleUpdateTitle}
/>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface QueryTabPaneProps {
}

export const QueryTabPane: React.FC<QueryTabPaneProps> = ({ tab, isActive, onUpdateTab }) => {
const { query } = useSession();
const { query: runQuery, sessionId } = useSession();
const [executing, setExecuting] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -28,7 +28,7 @@ export const QueryTabPane: React.FC<QueryTabPaneProps> = ({ 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 {
Expand Down Expand Up @@ -69,6 +69,7 @@ export const QueryTabPane: React.FC<QueryTabPaneProps> = ({ 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={{
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/renderer/hooks/useDraggableSort.ts
Original file line number Diff line number Diff line change
@@ -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<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(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,
};
}
136 changes: 68 additions & 68 deletions frontend/src/renderer/layouts/MainLayout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 || '"';
Expand Down Expand Up @@ -309,49 +308,53 @@ const SessionContent: React.FC<{

{/* Tab Content */}
<div className="flex-1 flex flex-col overflow-hidden relative bg-white">
{tabs.map((tab) => (
<div
key={tab.id}
data-testid={
activeTabId === tab.id ? 'active-tab-content' : `inactive-tab-content-${tab.id}`
}
className="w-full h-full"
style={{ display: activeTabId === tab.id ? 'block' : 'none' }}
>
{tab.type === 'table' ? (
<TableTabPane
tab={tab}
isActive={activeTabId === tab.id}
onUpdateTab={(updates) => updateTab(tab.id, updates)}
connectionId={sessionId}
onOpenStructure={(schema, name) => openStructure(schema, name, 'edit')}
/>
) : tab.type === 'query' ? (
<QueryTabPane
tab={tab}
isActive={activeTabId === tab.id}
onUpdateTab={(updates) => updateTab(tab.id, updates)}
/>
) : (
<StructureView
connectionId={sessionId}
schema={tab.initialSchema || currentSchema}
tableName={tab.initialTableName || ''}
mode={tab.mode || 'edit'}
onClose={() => closeTab(tab.id)}
onSaveSuccess={(schema, name) => {
updateTab(tab.id, {
mode: 'edit',
name: `Structure: ${name}`,
initialSchema: schema,
initialTableName: name,
});
fetchTables();
}}
/>
)}
</div>
))}
{[...tabs]
.sort((a, b) => a.id.localeCompare(b.id))
.map((tab) => (
<div
key={tab.id}
data-testid={
activeTabId === tab.id
? 'active-tab-content'
: `inactive-tab-content-${tab.id}`
}
className="w-full h-full"
style={{ display: activeTabId === tab.id ? 'block' : 'none' }}
>
{tab.type === 'table' ? (
<TableTabPane
tab={tab}
isActive={activeTabId === tab.id}
onUpdateTab={(updates) => updateTab(tab.id, updates)}
connectionId={sessionId}
onOpenStructure={(schema, name) => openStructure(schema, name, 'edit')}
/>
) : tab.type === 'query' ? (
<QueryTabPane
tab={tab}
isActive={activeTabId === tab.id}
onUpdateTab={(updates) => updateTab(tab.id, updates)}
/>
) : (
<StructureView
connectionId={sessionId}
schema={tab.initialSchema || currentSchema}
tableName={tab.initialTableName || ''}
mode={tab.mode || 'edit'}
onClose={() => closeTab(tab.id)}
onSaveSuccess={(schema, name) => {
updateTab(tab.id, {
mode: 'edit',
name: `Structure: ${name}`,
initialSchema: schema,
initialTableName: name,
});
fetchTables();
}}
/>
)}
</div>
))}
{tabs.length === 0 && (
<div className="flex-1 flex flex-col items-center justify-center text-gray-300 italic bg-gray-50/30">
Select a table from the sidebar to view its data
Expand Down Expand Up @@ -406,11 +409,11 @@ const SessionContent: React.FC<{
)}
</div>
);
};
});

export const SessionView: React.FC<SessionViewProps> = (props) => {
export const SessionView: React.FC<SessionViewProps> = React.memo((props) => {
return (
<SessionProvider id={props.id} onUpdateTitle={props.onUpdateTitle}>
<SessionProvider id={props.id} onUpdateTitle={(title) => props.onUpdateTitle(props.id, title)}>
<WorkspaceStoreWrapper
isActive={props.isActive}
initialConfig={props.initialConfig}
Expand All @@ -419,7 +422,7 @@ export const SessionView: React.FC<SessionViewProps> = (props) => {
/>
</SessionProvider>
);
};
});

interface WorkspaceStoreWrapperProps {
isActive: boolean;
Expand All @@ -428,21 +431,18 @@ interface WorkspaceStoreWrapperProps {
onStateChange?: (id: string, state: any) => void;
}

const WorkspaceStoreWrapper: React.FC<WorkspaceStoreWrapperProps> = ({
isActive,
initialConfig,
initialWorkspace,
onStateChange,
}) => {
const [store] = useState(() => createWorkspaceStore(initialWorkspace));

return (
<WorkspaceContext.Provider value={store}>
<SessionContent
isActive={isActive}
initialConfig={initialConfig}
onStateChange={onStateChange}
/>
</WorkspaceContext.Provider>
);
};
const WorkspaceStoreWrapper: React.FC<WorkspaceStoreWrapperProps> = React.memo(
({ isActive, initialConfig, initialWorkspace, onStateChange }) => {
const [store] = useState(() => createWorkspaceStore(initialWorkspace));

return (
<WorkspaceContext.Provider value={store}>
<SessionContent
isActive={isActive}
initialConfig={initialConfig}
onStateChange={onStateChange}
/>
</WorkspaceContext.Provider>
);
}
);
Loading
Loading