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 { 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,34 +242,32 @@ 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
|
||||||
|
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
|
<button
|
||||||
key={tab.id}
|
onClick={(e) => closeTab(tab.id, e)}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
className="ml-1 p-0.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-600"
|
||||||
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'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<span className="max-w-32 truncate">{tab.name}</span>
|
<X size={14} />
|
||||||
{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>
|
|
||||||
</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">
|
||||||
|
|||||||
Reference in New Issue
Block a user