modified: frontend/src/pages/SqlInterface.tsx
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user