modified: frontend/src/pages/SqlInterface.tsx

This commit is contained in:
2026-01-27 23:23:44 +03:00
parent eeebcdac57
commit 6b507425aa

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Play, Plus, X, Database as DatabaseIcon, Clock, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { Play, Plus, X, Database as DatabaseIcon, Clock, CheckCircle, XCircle, Loader2, GripHorizontal } from 'lucide-react';
import { databasesApi, sqlInterfaceApi, SqlQueryResult } from '@/services/api';
import { Database } from '@/types';
import SqlEditor from '@/components/SqlEditor';
@@ -17,6 +17,7 @@ interface SqlTab {
interface SqlInterfaceState {
tabs: SqlTab[];
activeTabId: string;
splitPosition: number; // percentage
}
const STORAGE_KEY = 'sql_interface_state';
@@ -46,7 +47,6 @@ const loadState = (): SqlInterfaceState | null => {
const saveState = (state: SqlInterfaceState) => {
try {
// Don't save isExecuting state and large results
const stateToSave = {
...state,
tabs: state.tabs.map(tab => ({
@@ -54,7 +54,7 @@ const saveState = (state: SqlInterfaceState) => {
isExecuting: false,
result: tab.result ? {
...tab.result,
data: tab.result.data?.slice(0, 100), // Limit saved results
data: tab.result.data?.slice(0, 100),
} : null,
})),
};
@@ -69,7 +69,6 @@ export default function SqlInterface() {
queryKey: ['databases'],
queryFn: async () => {
const { data } = await databasesApi.getAll();
// Filter only SQL databases (not AQL)
return data.filter((db: Database) => db.type !== 'aql');
},
});
@@ -77,21 +76,26 @@ export default function SqlInterface() {
const [state, setState] = useState<SqlInterfaceState>(() => {
const saved = loadState();
if (saved && saved.tabs.length > 0) {
return saved;
return {
...saved,
splitPosition: saved.splitPosition || 50,
};
}
const initialTab = createNewTab();
return {
tabs: [initialTab],
activeTabId: initialTab.id,
splitPosition: 50,
};
});
// Save state to localStorage on changes
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
useEffect(() => {
saveState(state);
}, [state]);
// Set default database for new tabs when databases load
useEffect(() => {
if (databases.length > 0) {
setState(prev => ({
@@ -118,6 +122,7 @@ export default function SqlInterface() {
const defaultDbId = databases.length > 0 ? databases[0].id : '';
const newTab = createNewTab(defaultDbId, `Запрос ${state.tabs.length + 1}`);
setState(prev => ({
...prev,
tabs: [...prev.tabs, newTab],
activeTabId: newTab.id,
}));
@@ -127,10 +132,10 @@ export default function SqlInterface() {
e.stopPropagation();
setState(prev => {
if (prev.tabs.length === 1) {
// Don't close the last tab, just reset it
const defaultDbId = databases.length > 0 ? databases[0].id : '';
const newTab = createNewTab(defaultDbId);
return {
...prev,
tabs: [newTab],
activeTabId: newTab.id,
};
@@ -142,6 +147,7 @@ export default function SqlInterface() {
: prev.activeTabId;
return {
...prev,
tabs: newTabs,
activeTabId: newActiveId,
};
@@ -175,7 +181,6 @@ export default function SqlInterface() {
}
}, [activeTab, updateTab]);
// Keyboard shortcut for execute (Ctrl+Enter or Cmd+Enter)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
@@ -188,6 +193,44 @@ export default function SqlInterface() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [executeQuery]);
// Drag to resize
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
}, []);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current || !containerRef.current) return;
const container = containerRef.current;
const rect = container.getBoundingClientRect();
const y = e.clientY - rect.top;
const percentage = (y / rect.height) * 100;
const clamped = Math.min(Math.max(percentage, 20), 80);
setState(prev => ({ ...prev, splitPosition: clamped }));
};
const handleMouseUp = () => {
if (isDragging.current) {
isDragging.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, []);
if (isLoadingDatabases) {
return (
<div className="flex items-center justify-center h-64">
@@ -199,34 +242,32 @@ export default function SqlInterface() {
return (
<div className="flex flex-col -m-8 bg-white" style={{ height: 'calc(100vh - 64px)' }}>
{/* Tabs bar */}
<div className="flex items-center bg-gray-100 border-b border-gray-200 px-2">
<div className="flex-1 flex items-center overflow-x-auto">
{state.tabs.map(tab => (
<div className="flex items-center bg-gray-100 border-b border-gray-200 px-2 flex-shrink-0 overflow-x-auto">
{state.tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center gap-2 px-4 py-2 text-sm border-r border-gray-200 flex-shrink-0
${tab.id === state.activeTabId
? 'bg-white text-gray-900 font-medium'
: 'text-gray-600 hover:bg-gray-50'
}
`}
>
<span className="max-w-32 truncate">{tab.name}</span>
{tab.isExecuting && <Loader2 className="animate-spin" size={14} />}
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center gap-2 px-4 py-2 text-sm border-r border-gray-200
${tab.id === state.activeTabId
? 'bg-white text-gray-900 font-medium'
: 'text-gray-600 hover:bg-gray-50'
}
`}
onClick={(e) => closeTab(tab.id, e)}
className="ml-1 p-0.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-600"
>
<span className="max-w-32 truncate">{tab.name}</span>
{tab.isExecuting && <Loader2 className="animate-spin" size={14} />}
<button
onClick={(e) => closeTab(tab.id, e)}
className="ml-1 p-0.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-600"
>
<X size={14} />
</button>
<X size={14} />
</button>
))}
</div>
</button>
))}
<button
onClick={addTab}
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded flex-shrink-0"
title="Новая вкладка"
>
<Plus size={18} />
@@ -234,8 +275,7 @@ export default function SqlInterface() {
</div>
{/* Toolbar */}
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50">
{/* Database selector */}
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
<div className="flex items-center gap-2">
<DatabaseIcon size={18} className="text-gray-500" />
<select
@@ -252,7 +292,6 @@ export default function SqlInterface() {
</select>
</div>
{/* Execute button */}
<button
onClick={executeQuery}
disabled={!activeTab?.databaseId || !activeTab?.query.trim() || activeTab?.isExecuting}
@@ -267,7 +306,6 @@ export default function SqlInterface() {
<span className="text-xs opacity-75 ml-1">(Ctrl+Enter)</span>
</button>
{/* Tab name editor */}
<input
type="text"
value={activeTab?.name || ''}
@@ -277,10 +315,13 @@ export default function SqlInterface() {
/>
</div>
{/* Main content area */}
<div className="flex-1 flex flex-col min-h-0">
{/* Main content area with resizable split */}
<div ref={containerRef} className="flex-1 flex flex-col min-h-0 relative">
{/* SQL Editor */}
<div className="h-1/2 min-h-48 border-b border-gray-200">
<div
className="overflow-hidden"
style={{ height: `${state.splitPosition}%` }}
>
<SqlEditor
value={activeTab?.query || ''}
onChange={(value) => updateTab(activeTab.id, { query: value })}
@@ -289,11 +330,22 @@ export default function SqlInterface() {
/>
</div>
{/* Resize handle */}
<div
onMouseDown={handleMouseDown}
className="h-2 bg-gray-200 hover:bg-primary-300 cursor-row-resize flex items-center justify-center border-y border-gray-300 flex-shrink-0 transition-colors"
>
<GripHorizontal size={16} className="text-gray-400" />
</div>
{/* Results panel */}
<div className="flex-1 min-h-0 flex flex-col bg-white">
<div
className="flex flex-col bg-white overflow-hidden"
style={{ height: `calc(${100 - state.splitPosition}% - 8px)` }}
>
{/* Result header */}
{activeTab?.result && (
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 border-b border-gray-200 text-sm">
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 border-b border-gray-200 text-sm flex-shrink-0">
{activeTab.result.success ? (
<>
<div className="flex items-center gap-1 text-green-600">