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 { 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 { databasesApi, sqlInterfaceApi, SqlQueryResult } from '@/services/api';
import { Database } from '@/types'; import { Database } from '@/types';
import SqlEditor from '@/components/SqlEditor'; import SqlEditor from '@/components/SqlEditor';
@@ -17,6 +17,7 @@ interface SqlTab {
interface SqlInterfaceState { interface SqlInterfaceState {
tabs: SqlTab[]; tabs: SqlTab[];
activeTabId: string; activeTabId: string;
splitPosition: number; // percentage
} }
const STORAGE_KEY = 'sql_interface_state'; const STORAGE_KEY = 'sql_interface_state';
@@ -46,7 +47,6 @@ const loadState = (): SqlInterfaceState | null => {
const saveState = (state: SqlInterfaceState) => { const saveState = (state: SqlInterfaceState) => {
try { try {
// Don't save isExecuting state and large results
const stateToSave = { const stateToSave = {
...state, ...state,
tabs: state.tabs.map(tab => ({ tabs: state.tabs.map(tab => ({
@@ -54,7 +54,7 @@ const saveState = (state: SqlInterfaceState) => {
isExecuting: false, isExecuting: false,
result: tab.result ? { result: tab.result ? {
...tab.result, ...tab.result,
data: tab.result.data?.slice(0, 100), // Limit saved results data: tab.result.data?.slice(0, 100),
} : null, } : null,
})), })),
}; };
@@ -69,7 +69,6 @@ export default function SqlInterface() {
queryKey: ['databases'], queryKey: ['databases'],
queryFn: async () => { queryFn: async () => {
const { data } = await databasesApi.getAll(); const { data } = await databasesApi.getAll();
// Filter only SQL databases (not AQL)
return data.filter((db: Database) => db.type !== 'aql'); return data.filter((db: Database) => db.type !== 'aql');
}, },
}); });
@@ -77,21 +76,26 @@ export default function SqlInterface() {
const [state, setState] = useState<SqlInterfaceState>(() => { const [state, setState] = useState<SqlInterfaceState>(() => {
const saved = loadState(); const saved = loadState();
if (saved && saved.tabs.length > 0) { if (saved && saved.tabs.length > 0) {
return saved; return {
...saved,
splitPosition: saved.splitPosition || 50,
};
} }
const initialTab = createNewTab(); const initialTab = createNewTab();
return { return {
tabs: [initialTab], tabs: [initialTab],
activeTabId: initialTab.id, activeTabId: initialTab.id,
splitPosition: 50,
}; };
}); });
// Save state to localStorage on changes const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
useEffect(() => { useEffect(() => {
saveState(state); saveState(state);
}, [state]); }, [state]);
// Set default database for new tabs when databases load
useEffect(() => { useEffect(() => {
if (databases.length > 0) { if (databases.length > 0) {
setState(prev => ({ setState(prev => ({
@@ -118,6 +122,7 @@ export default function SqlInterface() {
const defaultDbId = databases.length > 0 ? databases[0].id : ''; const defaultDbId = databases.length > 0 ? databases[0].id : '';
const newTab = createNewTab(defaultDbId, `Запрос ${state.tabs.length + 1}`); const newTab = createNewTab(defaultDbId, `Запрос ${state.tabs.length + 1}`);
setState(prev => ({ setState(prev => ({
...prev,
tabs: [...prev.tabs, newTab], tabs: [...prev.tabs, newTab],
activeTabId: newTab.id, activeTabId: newTab.id,
})); }));
@@ -127,10 +132,10 @@ export default function SqlInterface() {
e.stopPropagation(); e.stopPropagation();
setState(prev => { setState(prev => {
if (prev.tabs.length === 1) { if (prev.tabs.length === 1) {
// Don't close the last tab, just reset it
const defaultDbId = databases.length > 0 ? databases[0].id : ''; const defaultDbId = databases.length > 0 ? databases[0].id : '';
const newTab = createNewTab(defaultDbId); const newTab = createNewTab(defaultDbId);
return { return {
...prev,
tabs: [newTab], tabs: [newTab],
activeTabId: newTab.id, activeTabId: newTab.id,
}; };
@@ -142,6 +147,7 @@ export default function SqlInterface() {
: prev.activeTabId; : prev.activeTabId;
return { return {
...prev,
tabs: newTabs, tabs: newTabs,
activeTabId: newActiveId, activeTabId: newActiveId,
}; };
@@ -175,7 +181,6 @@ export default function SqlInterface() {
} }
}, [activeTab, updateTab]); }, [activeTab, updateTab]);
// Keyboard shortcut for execute (Ctrl+Enter or Cmd+Enter)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
@@ -188,6 +193,44 @@ export default function SqlInterface() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [executeQuery]); }, [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) { if (isLoadingDatabases) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -199,14 +242,13 @@ export default function SqlInterface() {
return ( return (
<div className="flex flex-col -m-8 bg-white" style={{ height: 'calc(100vh - 64px)' }}> <div className="flex flex-col -m-8 bg-white" style={{ height: 'calc(100vh - 64px)' }}>
{/* Tabs bar */} {/* Tabs bar */}
<div className="flex items-center bg-gray-100 border-b border-gray-200 px-2"> <div className="flex items-center bg-gray-100 border-b border-gray-200 px-2 flex-shrink-0 overflow-x-auto">
<div className="flex-1 flex items-center overflow-x-auto">
{state.tabs.map(tab => ( {state.tabs.map(tab => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={` className={`
flex items-center gap-2 px-4 py-2 text-sm border-r border-gray-200 flex items-center gap-2 px-4 py-2 text-sm border-r border-gray-200 flex-shrink-0
${tab.id === state.activeTabId ${tab.id === state.activeTabId
? 'bg-white text-gray-900 font-medium' ? 'bg-white text-gray-900 font-medium'
: 'text-gray-600 hover:bg-gray-50' : 'text-gray-600 hover:bg-gray-50'
@@ -223,10 +265,9 @@ export default function SqlInterface() {
</button> </button>
</button> </button>
))} ))}
</div>
<button <button
onClick={addTab} 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="Новая вкладка" title="Новая вкладка"
> >
<Plus size={18} /> <Plus size={18} />
@@ -234,8 +275,7 @@ export default function SqlInterface() {
</div> </div>
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50"> <div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
{/* Database selector */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DatabaseIcon size={18} className="text-gray-500" /> <DatabaseIcon size={18} className="text-gray-500" />
<select <select
@@ -252,7 +292,6 @@ export default function SqlInterface() {
</select> </select>
</div> </div>
{/* Execute button */}
<button <button
onClick={executeQuery} onClick={executeQuery}
disabled={!activeTab?.databaseId || !activeTab?.query.trim() || activeTab?.isExecuting} 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> <span className="text-xs opacity-75 ml-1">(Ctrl+Enter)</span>
</button> </button>
{/* Tab name editor */}
<input <input
type="text" type="text"
value={activeTab?.name || ''} value={activeTab?.name || ''}
@@ -277,10 +315,13 @@ export default function SqlInterface() {
/> />
</div> </div>
{/* Main content area */} {/* Main content area with resizable split */}
<div className="flex-1 flex flex-col min-h-0"> <div ref={containerRef} className="flex-1 flex flex-col min-h-0 relative">
{/* SQL Editor */} {/* SQL Editor */}
<div className="h-1/2 min-h-48 border-b border-gray-200"> <div
className="overflow-hidden"
style={{ height: `${state.splitPosition}%` }}
>
<SqlEditor <SqlEditor
value={activeTab?.query || ''} value={activeTab?.query || ''}
onChange={(value) => updateTab(activeTab.id, { query: value })} onChange={(value) => updateTab(activeTab.id, { query: value })}
@@ -289,11 +330,22 @@ export default function SqlInterface() {
/> />
</div> </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 */} {/* 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 */} {/* Result header */}
{activeTab?.result && ( {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 ? ( {activeTab.result.success ? (
<> <>
<div className="flex items-center gap-1 text-green-600"> <div className="flex items-center gap-1 text-green-600">