mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-23 13:47:12 +08:00
✨ feat(tab/window): 支持主工作区与结果Tab拆出为应用内浮动窗口并还原
- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理 - 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭 - 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区 - 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失 - 补充六语言文案与 detach/store 相关单元测试
This commit is contained in:
312
frontend/src/components/FloatingQueryResultWindows.tsx
Normal file
312
frontend/src/components/FloatingQueryResultWindows.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { CloseOutlined, CompressOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { t } from '../i18n';
|
||||
import DataGrid from './DataGrid';
|
||||
import {
|
||||
clamp,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
} from '../utils/detachedWindow';
|
||||
|
||||
type DragMode = 'move' | 'resize-e' | 'resize-s' | 'resize-se';
|
||||
|
||||
const isAffectedRowsResult = (columns: string[]): boolean =>
|
||||
columns.length === 1 && columns[0] === 'affectedRows';
|
||||
|
||||
const FloatingQueryResultWindows: React.FC = () => {
|
||||
const theme = useStore((state) => state.theme);
|
||||
const detachedQueryResultWindows = useStore((state) => state.detachedQueryResultWindows);
|
||||
const attachQueryResultWindow = useStore((state) => state.attachQueryResultWindow);
|
||||
const closeDetachedQueryResultWindow = useStore((state) => state.closeDetachedQueryResultWindow);
|
||||
const updateDetachedQueryResultBounds = useStore((state) => state.updateDetachedQueryResultBounds);
|
||||
const focusDetachedQueryResultWindow = useStore((state) => state.focusDetachedQueryResultWindow);
|
||||
const dragRef = useRef<{
|
||||
id: string;
|
||||
mode: DragMode;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
originW: number;
|
||||
originH: number;
|
||||
} | null>(null);
|
||||
|
||||
const startInteraction = useCallback((
|
||||
event: React.PointerEvent,
|
||||
id: string,
|
||||
mode: DragMode,
|
||||
bounds: { x: number; y: number; width: number; height: number },
|
||||
) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
focusDetachedQueryResultWindow(id);
|
||||
dragRef.current = {
|
||||
id,
|
||||
mode,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: bounds.x,
|
||||
originY: bounds.y,
|
||||
originW: bounds.width,
|
||||
originH: bounds.height,
|
||||
};
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const dx = moveEvent.clientX - drag.startX;
|
||||
const dy = moveEvent.clientY - drag.startY;
|
||||
if (drag.mode === 'move') {
|
||||
const maxX = Math.max(
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
window.innerWidth - drag.originW - DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
);
|
||||
const maxY = Math.max(
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
window.innerHeight - drag.originH - DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
);
|
||||
updateDetachedQueryResultBounds(drag.id, {
|
||||
x: clamp(drag.originX + dx, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
|
||||
y: clamp(drag.originY + dy, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let nextW = drag.originW;
|
||||
let nextH = drag.originH;
|
||||
if (drag.mode === 'resize-e' || drag.mode === 'resize-se') {
|
||||
nextW = clamp(
|
||||
drag.originW + dx,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
||||
window.innerWidth - drag.originX - DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
);
|
||||
}
|
||||
if (drag.mode === 'resize-s' || drag.mode === 'resize-se') {
|
||||
nextH = clamp(
|
||||
drag.originH + dy,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
||||
window.innerHeight - drag.originY - DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
);
|
||||
}
|
||||
updateDetachedQueryResultBounds(drag.id, { width: nextW, height: nextH });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
dragRef.current = null;
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
window.removeEventListener('pointercancel', stop);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', stop);
|
||||
window.addEventListener('pointercancel', stop);
|
||||
}, [focusDetachedQueryResultWindow, updateDetachedQueryResultBounds]);
|
||||
|
||||
const handleRestore = useCallback((id: string) => {
|
||||
const restored = attachQueryResultWindow(id);
|
||||
if (!restored) return;
|
||||
window.dispatchEvent(new CustomEvent('gonavi:restore-query-result', {
|
||||
detail: {
|
||||
sourceQueryTabId: restored.sourceQueryTabId,
|
||||
result: restored.result,
|
||||
},
|
||||
}));
|
||||
}, [attachQueryResultWindow]);
|
||||
|
||||
const windows = useMemo(() => detachedQueryResultWindows, [detachedQueryResultWindows]);
|
||||
|
||||
if (windows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="gn-detached-result-layer" aria-label={t('query_editor.results_panel.detached.title', { index: '' })}>
|
||||
<style>{`
|
||||
.gn-detached-result-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1210;
|
||||
}
|
||||
.gn-detached-result-window {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: ${DEFAULT_DETACHED_WINDOW_MIN_WIDTH}px;
|
||||
min-height: ${DEFAULT_DETACHED_WINDOW_MIN_HEIGHT}px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid ${isDark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.12)'};
|
||||
background: ${isDark ? 'rgba(22,24,28,0.98)' : 'rgba(255,255,255,0.98)'};
|
||||
box-shadow: ${isDark
|
||||
? '0 18px 48px rgba(0,0,0,0.45)'
|
||||
: '0 18px 48px rgba(15,23,42,0.18)'};
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.gn-detached-result-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
border-bottom: 1px solid ${isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
||||
background: ${isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'};
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.gn-detached-result-title {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gn-detached-result-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
.gn-detached-result-message {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: var(--gn-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
.gn-detached-result-resize-e {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: calc(100% - 36px);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.gn-detached-result-resize-s {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.gn-detached-result-resize-se {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
`}</style>
|
||||
{windows.map((windowState) => {
|
||||
const isMessage =
|
||||
windowState.result.resultType === 'message' ||
|
||||
isAffectedRowsResult(windowState.result.columns || []);
|
||||
const messageText = (windowState.result.messages || []).join('\n')
|
||||
|| (isAffectedRowsResult(windowState.result.columns || [])
|
||||
? String(windowState.result.rows?.[0]?.affectedRows ?? '')
|
||||
: '');
|
||||
return (
|
||||
<div
|
||||
key={windowState.id}
|
||||
className="gn-detached-result-window"
|
||||
style={{
|
||||
left: windowState.x,
|
||||
top: windowState.y,
|
||||
width: windowState.width,
|
||||
height: windowState.height,
|
||||
zIndex: windowState.zIndex,
|
||||
}}
|
||||
onMouseDown={() => focusDetachedQueryResultWindow(windowState.id)}
|
||||
>
|
||||
<div
|
||||
className="gn-detached-result-header"
|
||||
onPointerDown={(event) => startInteraction(event, windowState.id, 'move', windowState)}
|
||||
>
|
||||
<div className="gn-detached-result-title" title={windowState.title}>
|
||||
{windowState.title}
|
||||
</div>
|
||||
<div onPointerDown={(event) => event.stopPropagation()} style={{ display: 'inline-flex', gap: 4 }}>
|
||||
<Tooltip title={t('query_editor.results_panel.detached.restore')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CompressOutlined />}
|
||||
aria-label={t('query_editor.results_panel.detached.restore')}
|
||||
onClick={() => handleRestore(windowState.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('query_editor.results_panel.detached.close')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
aria-label={t('query_editor.results_panel.detached.close')}
|
||||
onClick={() => closeDetachedQueryResultWindow(windowState.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gn-detached-result-body">
|
||||
{isMessage ? (
|
||||
<textarea
|
||||
className="gn-detached-result-message"
|
||||
readOnly
|
||||
value={messageText}
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
data={windowState.result.rows || []}
|
||||
columnNames={windowState.result.columns || []}
|
||||
loading={false}
|
||||
tableName={windowState.result.tableName}
|
||||
pkColumns={windowState.result.pkColumns || []}
|
||||
editLocator={windowState.result.editLocator as any}
|
||||
readOnly={windowState.result.readOnly !== false}
|
||||
connectionId={windowState.connectionId}
|
||||
dbName={windowState.dbName || ''}
|
||||
resultSql={windowState.result.exportSql || windowState.result.sql}
|
||||
showRowNumberColumn={windowState.result.showRowNumberColumn}
|
||||
exportScope="queryResult"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="gn-detached-result-resize-e"
|
||||
onPointerDown={(event) => startInteraction(event, windowState.id, 'resize-e', windowState)}
|
||||
/>
|
||||
<div
|
||||
className="gn-detached-result-resize-s"
|
||||
onPointerDown={(event) => startInteraction(event, windowState.id, 'resize-s', windowState)}
|
||||
/>
|
||||
<div
|
||||
className="gn-detached-result-resize-se"
|
||||
onPointerDown={(event) => startInteraction(event, windowState.id, 'resize-se', windowState)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingQueryResultWindows;
|
||||
357
frontend/src/components/FloatingWorkbenchWindows.tsx
Normal file
357
frontend/src/components/FloatingWorkbenchWindows.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { CloseOutlined, CompressOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { t } from '../i18n';
|
||||
import {
|
||||
buildTabDisplayModel,
|
||||
resolveConnectionHostSummary,
|
||||
} from '../utils/tabDisplay';
|
||||
import {
|
||||
clamp,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
resolveDetachedWindowTitle,
|
||||
} from '../utils/detachedWindow';
|
||||
import WorkbenchTabContent from './WorkbenchTabContent';
|
||||
|
||||
const getTabKindLabel = (type: string): string => {
|
||||
if (type === 'query') return t('tab_manager.kind_badge.query');
|
||||
if (type === 'table') return t('tab_manager.kind_badge.table');
|
||||
if (type === 'design') return t('tab_manager.kind_badge.design');
|
||||
if (type === 'table-overview') return t('tab_manager.kind_badge.table_overview');
|
||||
if (type === 'table-export') return t('tab_manager.kind_badge.table_export');
|
||||
if (type === 'sql-file-execution') return t('sidebar.sql_file_exec.title');
|
||||
if (type === 'sql-analysis') return t('tab_manager.kind_badge.sql_analysis');
|
||||
if (type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
|
||||
if (type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
|
||||
if (type === 'trigger') return t('tab_manager.kind_badge.trigger');
|
||||
if (type === 'view-def') return t('tab_manager.kind_badge.view');
|
||||
if (type === 'event-def') return t('tab_manager.kind_badge.event');
|
||||
if (type === 'routine-def') return t('tab_manager.kind_badge.routine');
|
||||
if (type === 'sequence-def') return t('tab_manager.kind_badge.sequence');
|
||||
if (type === 'package-def') return t('tab_manager.kind_badge.package');
|
||||
return t('tab_manager.kind_badge.fallback');
|
||||
};
|
||||
|
||||
type DragMode = 'move' | 'resize-e' | 'resize-s' | 'resize-se';
|
||||
|
||||
const FloatingWorkbenchWindows: React.FC = () => {
|
||||
const tabs = useStore((state) => state.tabs);
|
||||
const connections = useStore((state) => state.connections);
|
||||
const appearance = useStore((state) => state.appearance);
|
||||
const theme = useStore((state) => state.theme);
|
||||
const detachedWorkbenchWindows = useStore((state) => state.detachedWorkbenchWindows);
|
||||
const activeTabId = useStore((state) => state.activeTabId);
|
||||
const attachWorkbenchTab = useStore((state) => state.attachWorkbenchTab);
|
||||
const closeTab = useStore((state) => state.closeTab);
|
||||
const updateDetachedWorkbenchBounds = useStore((state) => state.updateDetachedWorkbenchBounds);
|
||||
const focusDetachedWorkbenchTab = useStore((state) => state.focusDetachedWorkbenchTab);
|
||||
const dragRef = useRef<{
|
||||
tabId: string;
|
||||
mode: DragMode;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
originW: number;
|
||||
originH: number;
|
||||
} | null>(null);
|
||||
|
||||
const windowModels = useMemo(() => {
|
||||
return detachedWorkbenchWindows
|
||||
.map((windowState) => {
|
||||
const tab = tabs.find((item) => item.id === windowState.tabId);
|
||||
if (!tab) return null;
|
||||
const connection = connections.find((conn) => conn.id === tab.connectionId);
|
||||
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t);
|
||||
const kindLabel = getTabKindLabel(tab.type);
|
||||
const objectLabel =
|
||||
tab.tableName ||
|
||||
tab.viewName ||
|
||||
tab.eventName ||
|
||||
tab.routineName ||
|
||||
tab.sequenceName ||
|
||||
tab.packageName ||
|
||||
tab.triggerName ||
|
||||
tab.resourcePath ||
|
||||
tab.filePath ||
|
||||
'';
|
||||
const title = resolveDetachedWindowTitle({
|
||||
kindLabel,
|
||||
objectLabel,
|
||||
fallbackTitle: displayModel.fullTitle || tab.title || t('tab_manager.detached.title_fallback'),
|
||||
});
|
||||
return {
|
||||
windowState,
|
||||
tab,
|
||||
title,
|
||||
hostSummary: resolveConnectionHostSummary(connection?.config),
|
||||
connectionName: connection?.name || '',
|
||||
isFocused: activeTabId === tab.id,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
windowState: (typeof detachedWorkbenchWindows)[number];
|
||||
tab: (typeof tabs)[number];
|
||||
title: string;
|
||||
hostSummary: string;
|
||||
connectionName: string;
|
||||
isFocused: boolean;
|
||||
}>;
|
||||
}, [activeTabId, appearance.tabDisplay, connections, detachedWorkbenchWindows, tabs]);
|
||||
|
||||
const startInteraction = useCallback((
|
||||
event: React.PointerEvent,
|
||||
tabId: string,
|
||||
mode: DragMode,
|
||||
bounds: { x: number; y: number; width: number; height: number },
|
||||
) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
focusDetachedWorkbenchTab(tabId);
|
||||
dragRef.current = {
|
||||
tabId,
|
||||
mode,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: bounds.x,
|
||||
originY: bounds.y,
|
||||
originW: bounds.width,
|
||||
originH: bounds.height,
|
||||
};
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const dx = moveEvent.clientX - drag.startX;
|
||||
const dy = moveEvent.clientY - drag.startY;
|
||||
if (drag.mode === 'move') {
|
||||
const maxX = Math.max(
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
window.innerWidth - drag.originW - DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
);
|
||||
const maxY = Math.max(
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
window.innerHeight - drag.originH - DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
);
|
||||
updateDetachedWorkbenchBounds(drag.tabId, {
|
||||
x: clamp(drag.originX + dx, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
|
||||
y: clamp(drag.originY + dy, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let nextW = drag.originW;
|
||||
let nextH = drag.originH;
|
||||
if (drag.mode === 'resize-e' || drag.mode === 'resize-se') {
|
||||
nextW = clamp(
|
||||
drag.originW + dx,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
||||
window.innerWidth - drag.originX - DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
);
|
||||
}
|
||||
if (drag.mode === 'resize-s' || drag.mode === 'resize-se') {
|
||||
nextH = clamp(
|
||||
drag.originH + dy,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
||||
window.innerHeight - drag.originY - DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
);
|
||||
}
|
||||
updateDetachedWorkbenchBounds(drag.tabId, { width: nextW, height: nextH });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
dragRef.current = null;
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
window.removeEventListener('pointercancel', stop);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', stop);
|
||||
window.addEventListener('pointercancel', stop);
|
||||
}, [focusDetachedWorkbenchTab, updateDetachedWorkbenchBounds]);
|
||||
|
||||
if (windowModels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div className="gn-detached-window-layer" aria-label={t('tab_manager.detached.title_fallback')}>
|
||||
<style>{`
|
||||
.gn-detached-window-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1200;
|
||||
}
|
||||
.gn-detached-window {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: ${DEFAULT_DETACHED_WINDOW_MIN_WIDTH}px;
|
||||
min-height: ${DEFAULT_DETACHED_WINDOW_MIN_HEIGHT}px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid ${isDark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.12)'};
|
||||
background: ${isDark ? 'rgba(22,24,28,0.98)' : 'rgba(255,255,255,0.98)'};
|
||||
box-shadow: ${isDark
|
||||
? '0 18px 48px rgba(0,0,0,0.45)'
|
||||
: '0 18px 48px rgba(15,23,42,0.18)'};
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.gn-detached-window.is-focused {
|
||||
border-color: ${isDark ? 'rgba(255,214,102,0.55)' : 'rgba(22,119,255,0.45)'};
|
||||
box-shadow: ${isDark
|
||||
? '0 0 0 1px rgba(255,214,102,0.25), 0 20px 52px rgba(0,0,0,0.5)'
|
||||
: '0 0 0 1px rgba(22,119,255,0.18), 0 20px 52px rgba(15,23,42,0.2)'};
|
||||
}
|
||||
.gn-detached-window-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 6px 8px 6px 12px;
|
||||
border-bottom: 1px solid ${isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
||||
background: ${isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'};
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
.gn-detached-window-title {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${isDark ? 'rgba(255,255,255,0.92)' : 'rgba(0,0,0,0.88)'};
|
||||
}
|
||||
.gn-detached-window-subtitle {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: ${isDark ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)'};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gn-detached-window-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.gn-detached-window-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gn-detached-window-body > * {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.gn-detached-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
.gn-detached-resize-e {
|
||||
top: 36px;
|
||||
right: 0;
|
||||
width: 8px;
|
||||
height: calc(100% - 36px);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.gn-detached-resize-s {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.gn-detached-resize-se {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
`}</style>
|
||||
{windowModels.map(({ windowState, tab, title, hostSummary, connectionName, isFocused }) => (
|
||||
<div
|
||||
key={windowState.tabId}
|
||||
className={`gn-detached-window${isFocused ? ' is-focused' : ''}`}
|
||||
style={{
|
||||
left: windowState.x,
|
||||
top: windowState.y,
|
||||
width: windowState.width,
|
||||
height: windowState.height,
|
||||
zIndex: windowState.zIndex,
|
||||
}}
|
||||
onMouseDown={() => focusDetachedWorkbenchTab(windowState.tabId)}
|
||||
>
|
||||
<div
|
||||
className="gn-detached-window-header"
|
||||
onPointerDown={(event) => startInteraction(event, windowState.tabId, 'move', windowState)}
|
||||
>
|
||||
<div className="gn-detached-window-title" title={title}>
|
||||
<span>{title}</span>
|
||||
{(connectionName || hostSummary) ? (
|
||||
<span className="gn-detached-window-subtitle">
|
||||
{[connectionName, hostSummary].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="gn-detached-window-actions" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Tooltip title={t('tab_manager.detached.restore')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CompressOutlined />}
|
||||
aria-label={t('tab_manager.detached.restore')}
|
||||
onClick={() => attachWorkbenchTab(windowState.tabId)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('tab_manager.detached.close')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
aria-label={t('tab_manager.detached.close')}
|
||||
onClick={() => closeTab(windowState.tabId)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gn-detached-window-body">
|
||||
<WorkbenchTabContent tab={tab} isActive={isFocused || activeTabId === tab.id} />
|
||||
</div>
|
||||
<div
|
||||
className="gn-detached-resize-handle gn-detached-resize-e"
|
||||
onPointerDown={(event) => startInteraction(event, windowState.tabId, 'resize-e', windowState)}
|
||||
/>
|
||||
<div
|
||||
className="gn-detached-resize-handle gn-detached-resize-s"
|
||||
onPointerDown={(event) => startInteraction(event, windowState.tabId, 'resize-s', windowState)}
|
||||
/>
|
||||
<div
|
||||
className="gn-detached-resize-handle gn-detached-resize-se"
|
||||
onPointerDown={(event) => startInteraction(event, windowState.tabId, 'resize-se', windowState)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingWorkbenchWindows;
|
||||
@@ -54,6 +54,11 @@ import {
|
||||
hasQueryTabDraft,
|
||||
persistQueryTabDraftSnapshot,
|
||||
} from '../utils/sqlFileTabDrafts';
|
||||
import {
|
||||
clearQueryEditorResultSession,
|
||||
saveQueryEditorResultSession,
|
||||
takeQueryEditorResultSession,
|
||||
} from '../utils/queryEditorResultSessionCache';
|
||||
import { buildEditableTriggerSql } from '../utils/triggerEditSql';
|
||||
import {
|
||||
getColumnDefinitionComment,
|
||||
@@ -1102,9 +1107,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
|
||||
type ResultSet = QueryEditorResultSet;
|
||||
|
||||
// Result Sets
|
||||
const [resultSets, setResultSets] = useState<ResultSet[]>([]);
|
||||
const [activeResultKey, setActiveResultKey] = useState<string>('');
|
||||
// Result Sets (session cache survives detach/attach remounts)
|
||||
const restoredResultSessionRef = useRef(takeQueryEditorResultSession(tab.id));
|
||||
const [resultSets, setResultSets] = useState<ResultSet[]>(
|
||||
() => restoredResultSessionRef.current?.resultSets || [],
|
||||
);
|
||||
const [activeResultKey, setActiveResultKey] = useState<string>(
|
||||
() => restoredResultSessionRef.current?.activeResultKey || '',
|
||||
);
|
||||
const resultSetsRef = useRef(resultSets);
|
||||
const activeResultKeyRef = useRef(activeResultKey);
|
||||
resultSetsRef.current = resultSets;
|
||||
activeResultKeyRef.current = activeResultKey;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [executionError, setExecutionError] = useState<string>('');
|
||||
const [, setCurrentQueryId] = useState<string>('');
|
||||
@@ -1246,8 +1260,22 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const sqlEditorTransactionOptions = useStore(state => state.sqlEditorTransactionOptions);
|
||||
const setSqlEditorTransactionOptions = useStore(state => state.setSqlEditorTransactionOptions);
|
||||
const [isResultPanelVisible, setIsResultPanelVisible] = useState(
|
||||
() => tab.resultPanelVisible === true
|
||||
() => restoredResultSessionRef.current?.isResultPanelVisible
|
||||
?? (tab.resultPanelVisible === true)
|
||||
);
|
||||
const isResultPanelVisibleRef = useRef(isResultPanelVisible);
|
||||
isResultPanelVisibleRef.current = isResultPanelVisible;
|
||||
|
||||
useEffect(() => {
|
||||
// Keep result panel state across detach/attach remounts of the same tab.
|
||||
return () => {
|
||||
saveQueryEditorResultSession(tab.id, {
|
||||
resultSets: resultSetsRef.current,
|
||||
activeResultKey: activeResultKeyRef.current,
|
||||
isResultPanelVisible: isResultPanelVisibleRef.current,
|
||||
});
|
||||
};
|
||||
}, [tab.id]);
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
|
||||
const runQueryShortcutBinding = useMemo(
|
||||
@@ -1499,6 +1527,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
});
|
||||
}, [triggerSqlAiCompletionShortcutBinding]);
|
||||
useEffect(() => {
|
||||
// Prefer remount session cache (detach/attach); otherwise follow tab draft flag.
|
||||
if (restoredResultSessionRef.current && restoredResultSessionRef.current.isResultPanelVisible !== undefined) {
|
||||
setIsResultPanelVisible(restoredResultSessionRef.current.isResultPanelVisible === true);
|
||||
return;
|
||||
}
|
||||
setIsResultPanelVisible(tab.resultPanelVisible === true);
|
||||
}, [tab.id, tab.resultPanelVisible]);
|
||||
const updateResultPanelVisibility = useCallback((visible: boolean) => {
|
||||
@@ -7659,6 +7692,84 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
setActiveResultKey('');
|
||||
};
|
||||
|
||||
const openResultInWindow = (key: string) => {
|
||||
const target = resultSets.find((result) => result.key === key);
|
||||
if (!target) return;
|
||||
const index = resultSets.findIndex((result) => result.key === key);
|
||||
const title = target.resultType === 'message'
|
||||
? translate('query_editor.results_panel.tab.message', { index: index + 1 })
|
||||
: translate('query_editor.results_panel.detached.title', { index: index + 1 });
|
||||
const windowId = `query-result:${tab.id}:${target.key}`;
|
||||
useStore.getState().detachQueryResultWindow({
|
||||
id: windowId,
|
||||
sourceQueryTabId: tab.id,
|
||||
connectionId: currentConnectionId || tab.connectionId || '',
|
||||
dbName: currentDb || tab.dbName || '',
|
||||
title,
|
||||
result: {
|
||||
key: target.key,
|
||||
sql: target.sql,
|
||||
exportSql: target.exportSql,
|
||||
sourceStatementIndex: target.sourceStatementIndex,
|
||||
statementResultIndex: target.statementResultIndex,
|
||||
rows: target.rows,
|
||||
columns: target.columns,
|
||||
messages: target.messages,
|
||||
resultType: target.resultType,
|
||||
tableName: target.tableName,
|
||||
pkColumns: target.pkColumns || [],
|
||||
editLocator: target.editLocator as any,
|
||||
readOnly: target.readOnly !== false,
|
||||
showRowNumberColumn: target.showRowNumberColumn,
|
||||
truncated: target.truncated,
|
||||
},
|
||||
});
|
||||
handleCloseResult(key);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleRestoreQueryResult = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail || {};
|
||||
const sourceQueryTabId = String(detail.sourceQueryTabId || '').trim();
|
||||
if (sourceQueryTabId !== tab.id) return;
|
||||
const restored = detail.result;
|
||||
if (!restored || typeof restored !== 'object') return;
|
||||
const restoredKey = String(restored.key || '').trim();
|
||||
if (!restoredKey) return;
|
||||
setResultSets((prev) => {
|
||||
if (prev.some((item) => item.key === restoredKey)) {
|
||||
return prev;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
key: restoredKey,
|
||||
sql: String(restored.sql || ''),
|
||||
exportSql: restored.exportSql,
|
||||
sourceStatementIndex: restored.sourceStatementIndex,
|
||||
statementResultIndex: restored.statementResultIndex,
|
||||
rows: Array.isArray(restored.rows) ? restored.rows : [],
|
||||
columns: Array.isArray(restored.columns) ? restored.columns : [],
|
||||
messages: Array.isArray(restored.messages) ? restored.messages : undefined,
|
||||
resultType: restored.resultType === 'message' ? 'message' : 'grid',
|
||||
tableName: restored.tableName,
|
||||
pkColumns: Array.isArray(restored.pkColumns) ? restored.pkColumns : [],
|
||||
editLocator: restored.editLocator,
|
||||
readOnly: restored.readOnly !== false,
|
||||
showRowNumberColumn: restored.showRowNumberColumn,
|
||||
truncated: restored.truncated,
|
||||
} as ResultSet,
|
||||
];
|
||||
});
|
||||
setActiveResultKey(restoredKey);
|
||||
updateResultPanelVisibility(true);
|
||||
};
|
||||
window.addEventListener('gonavi:restore-query-result', handleRestoreQueryResult as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('gonavi:restore-query-result', handleRestoreQueryResult as EventListener);
|
||||
};
|
||||
}, [tab.id, updateResultPanelVisibility]);
|
||||
|
||||
const toggleQueryResultsPanelShortcutLabel =
|
||||
toggleQueryResultsPanelShortcutBinding.enabled && toggleQueryResultsPanelShortcutBinding.combo
|
||||
? getShortcutDisplayLabel(toggleQueryResultsPanelShortcutBinding.combo, activeShortcutPlatform)
|
||||
@@ -7813,6 +7924,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
onCloseResultTabsToLeft={closeResultTabsToLeft}
|
||||
onCloseResultTabsToRight={closeResultTabsToRight}
|
||||
onCloseAllResultTabs={closeAllResultTabs}
|
||||
onOpenResultInWindow={openResultInWindow}
|
||||
onReloadResult={handleReloadResult}
|
||||
onResultPageChange={handleResultPageChange}
|
||||
onDiagnoseExecutionError={handleDiagnoseExecutionError}
|
||||
|
||||
@@ -50,6 +50,7 @@ interface QueryEditorResultsPanelProps {
|
||||
onCloseResultTabsToLeft: (key: string) => void;
|
||||
onCloseResultTabsToRight: (key: string) => void;
|
||||
onCloseAllResultTabs: () => void;
|
||||
onOpenResultInWindow?: (key: string) => void;
|
||||
onReloadResult: (key: string, sql: string) => void;
|
||||
onResultPageChange: (key: string, page: number, pageSize: number) => void;
|
||||
onDiagnoseExecutionError: () => void;
|
||||
@@ -81,6 +82,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
onCloseResultTabsToLeft,
|
||||
onCloseResultTabsToRight,
|
||||
onCloseAllResultTabs,
|
||||
onOpenResultInWindow,
|
||||
onReloadResult,
|
||||
onResultPageChange,
|
||||
onDiagnoseExecutionError,
|
||||
@@ -196,6 +198,13 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
|
||||
function buildResultTabMenuItems(key: string, index: number): MenuProps['items'] {
|
||||
return [
|
||||
...(onOpenResultInWindow
|
||||
? [{
|
||||
key: 'open-in-window',
|
||||
label: t('query_editor.results_panel.menu.open_in_window'),
|
||||
onClick: () => onOpenResultInWindow(key),
|
||||
}, { type: 'divider' as const }]
|
||||
: []),
|
||||
{ key: 'close-other', label: t('query_editor.results_panel.menu.close_other'), disabled: resultSets.length <= 1, onClick: () => onCloseOtherResultTabs(key) },
|
||||
{ key: 'close-left', label: t('query_editor.results_panel.menu.close_left'), disabled: index <= 0, onClick: () => onCloseResultTabsToLeft(key) },
|
||||
{ key: 'close-right', label: t('query_editor.results_panel.menu.close_right'), disabled: index >= resultSets.length - 1, onClick: () => onCloseResultTabsToRight(key) },
|
||||
|
||||
@@ -7,25 +7,7 @@ import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from
|
||||
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { restrictToHorizontalAxis } from '@dnd-kit/modifiers';
|
||||
import { useStore } from '../store';
|
||||
import DataViewer from './DataViewer';
|
||||
import QueryEditor from './QueryEditor';
|
||||
import TableDesigner from './TableDesigner';
|
||||
import RedisViewer from './RedisViewer';
|
||||
import RedisCommandEditor from './RedisCommandEditor';
|
||||
import RedisMonitor from './RedisMonitor';
|
||||
import TriggerViewer from './TriggerViewer';
|
||||
import DefinitionViewer from './DefinitionViewer';
|
||||
import TableOverview from './TableOverview';
|
||||
import TableExportWorkbench from './TableExportWorkbench';
|
||||
import SQLFileExecutionWorkbench from './SQLFileExecutionWorkbench';
|
||||
import JVMOverview from './JVMOverview';
|
||||
import JVMResourceBrowser from './JVMResourceBrowser';
|
||||
import JVMAuditViewer from './JVMAuditViewer';
|
||||
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
|
||||
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
|
||||
import SqlAnalysisWorkbench from './explain/SqlAnalysisWorkbench';
|
||||
import type { TabData } from '../types';
|
||||
import { t } from '../i18n';
|
||||
import {
|
||||
@@ -44,6 +26,8 @@ import {
|
||||
normalizeSQLFileReadContent,
|
||||
} from '../utils/sqlFileTabDirty';
|
||||
import { clearSQLFileTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
|
||||
import WorkbenchTabContent from './WorkbenchTabContent';
|
||||
import { shouldDetachTabByDrag } from '../utils/detachedWindow';
|
||||
|
||||
const getTabKindLabel = (tab: TabData): string => {
|
||||
if (tab.type === 'query') return t('tab_manager.kind_badge.query');
|
||||
@@ -396,63 +380,9 @@ const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ tab, isActive }) => {
|
||||
if (tab.type === 'query') {
|
||||
return <QueryEditor tab={tab} isActive={isActive} />;
|
||||
}
|
||||
if (tab.type === 'table') {
|
||||
return <DataViewer tab={tab} isActive={isActive} />;
|
||||
}
|
||||
if (tab.type === 'design') {
|
||||
return <TableDesigner tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'redis-keys') {
|
||||
return <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'redis-command') {
|
||||
return <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'redis-monitor') {
|
||||
return <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'trigger') {
|
||||
return <TriggerViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def' || tab.type === 'sequence-def' || tab.type === 'package-def') {
|
||||
return <DefinitionViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'table-overview') {
|
||||
return <TableOverview tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'table-export') {
|
||||
return <TableExportWorkbench tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'sql-file-execution') {
|
||||
return <SQLFileExecutionWorkbench tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'sql-analysis') {
|
||||
return <SqlAnalysisWorkbench tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-overview') {
|
||||
return <JVMOverview tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-resource') {
|
||||
return <JVMResourceBrowser tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-audit') {
|
||||
return <JVMAuditViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-diagnostic') {
|
||||
return <JVMDiagnosticConsole tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-monitoring') {
|
||||
return <JVMMonitoringDashboard tab={tab} />;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const TabManager: React.FC = React.memo(() => {
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const detachedWorkbenchWindows = useStore(state => state.detachedWorkbenchWindows);
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
@@ -466,7 +396,16 @@ const TabManager: React.FC = React.memo(() => {
|
||||
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
||||
const closeAllTabs = useStore(state => state.closeAllTabs);
|
||||
const moveTab = useStore(state => state.moveTab);
|
||||
const detachWorkbenchTab = useStore(state => state.detachWorkbenchTab);
|
||||
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
|
||||
const detachedTabIdSet = useMemo(
|
||||
() => new Set(detachedWorkbenchWindows.map((windowState) => windowState.tabId)),
|
||||
[detachedWorkbenchWindows],
|
||||
);
|
||||
const dockedTabs = useMemo(
|
||||
() => tabs.filter((tab) => !detachedTabIdSet.has(tab.id)),
|
||||
[detachedTabIdSet, tabs],
|
||||
);
|
||||
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const suppressClickUntilRef = useRef<number>(0);
|
||||
@@ -477,6 +416,13 @@ const TabManager: React.FC = React.memo(() => {
|
||||
);
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const hasTabs = tabs.length > 0;
|
||||
const hasDockedTabs = dockedTabs.length > 0;
|
||||
const dockedActiveTabId = useMemo(() => {
|
||||
if (activeTabId && dockedTabs.some((tab) => tab.id === activeTabId)) {
|
||||
return activeTabId;
|
||||
}
|
||||
return dockedTabs[0]?.id || null;
|
||||
}, [activeTabId, dockedTabs]);
|
||||
const pendingCloseTabIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const onChange = (newActiveKey: string) => {
|
||||
@@ -637,8 +583,24 @@ const TabManager: React.FC = React.memo(() => {
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const sourceId = String(event.active.id || '').trim();
|
||||
const targetId = String(event.over?.id || '').trim();
|
||||
const deltaY = Number(event.delta?.y || 0);
|
||||
setDraggingTabId(null);
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
if (!sourceId) {
|
||||
return;
|
||||
}
|
||||
if (shouldDetachTabByDrag(deltaY, targetId || null)) {
|
||||
suppressClickUntilRef.current = Date.now() + 120;
|
||||
const pointerEvent = event.activatorEvent as PointerEvent | MouseEvent | undefined;
|
||||
const preferredX = typeof pointerEvent?.clientX === 'number'
|
||||
? Math.max(16, pointerEvent.clientX - 120)
|
||||
: undefined;
|
||||
const preferredY = typeof pointerEvent?.clientY === 'number'
|
||||
? Math.max(16, pointerEvent.clientY - 20)
|
||||
: undefined;
|
||||
detachWorkbenchTab(sourceId, { x: preferredX, y: preferredY });
|
||||
return;
|
||||
}
|
||||
if (!targetId || sourceId === targetId) {
|
||||
return;
|
||||
}
|
||||
suppressClickUntilRef.current = Date.now() + 120;
|
||||
@@ -702,14 +664,14 @@ const TabManager: React.FC = React.memo(() => {
|
||||
return () => window.removeEventListener('gonavi:insert-sql', handleGlobalInsertSql);
|
||||
}, [tabs, activeTabId, addTab, setActiveTab, connections]);
|
||||
|
||||
const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]);
|
||||
const tabIds = useMemo(() => dockedTabs.map((tab) => tab.id), [dockedTabs]);
|
||||
const hasDoubleLineTabLabel = useMemo(() => (
|
||||
tabs.some((tab) => {
|
||||
dockedTabs.some((tab) => {
|
||||
const connection = connections.find((conn) => conn.id === tab.connectionId);
|
||||
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t);
|
||||
return displayModel.layout === 'double' && Boolean(displayModel.secondaryText);
|
||||
})
|
||||
), [appearance.tabDisplay, connections, tabs]);
|
||||
), [appearance.tabDisplay, connections, dockedTabs]);
|
||||
|
||||
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => (
|
||||
<DefaultTabBar {...tabBarProps}>
|
||||
@@ -717,12 +679,12 @@ const TabManager: React.FC = React.memo(() => {
|
||||
</DefaultTabBar>
|
||||
);
|
||||
|
||||
const items = useMemo(() => tabs.map((tab, index) => {
|
||||
const items = useMemo(() => dockedTabs.map((tab, index) => {
|
||||
const connection = connections.find((conn) => conn.id === tab.connectionId);
|
||||
const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t);
|
||||
const displayTitle = displayModel.fullTitle;
|
||||
const hostSummary = resolveConnectionHostSummary(connection?.config);
|
||||
const tabIsActive = tab.id === activeTabId;
|
||||
const tabIsActive = tab.id === dockedActiveTabId;
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
@@ -731,6 +693,11 @@ const TabManager: React.FC = React.memo(() => {
|
||||
label: t('tab_manager.menu.tab_display_settings'),
|
||||
onClick: openTabDisplaySettings,
|
||||
},
|
||||
{
|
||||
key: 'open-in-window',
|
||||
label: t('tab_manager.menu.open_in_window'),
|
||||
onClick: () => detachWorkbenchTab(tab.id),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'close-other',
|
||||
@@ -742,13 +709,13 @@ const TabManager: React.FC = React.memo(() => {
|
||||
key: 'close-left',
|
||||
label: t('tab_manager.menu.close_left'),
|
||||
disabled: index === 0,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(tabs, tab.id), () => closeTabsToLeft(tab.id)),
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(dockedTabs, tab.id), () => closeTabsToLeft(tab.id)),
|
||||
},
|
||||
{
|
||||
key: 'close-right',
|
||||
label: t('tab_manager.menu.close_right'),
|
||||
disabled: index === tabs.length - 1,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(tabs, tab.id), () => closeTabsToRight(tab.id)),
|
||||
disabled: index === dockedTabs.length - 1,
|
||||
onClick: () => closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(dockedTabs, tab.id), () => closeTabsToRight(tab.id)),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
@@ -774,9 +741,9 @@ const TabManager: React.FC = React.memo(() => {
|
||||
),
|
||||
key: tab.id,
|
||||
closable: !isV2Ui,
|
||||
children: <TabContent tab={tab} isActive={tabIsActive} />,
|
||||
children: <WorkbenchTabContent tab={tab} isActive={tabIsActive} />,
|
||||
};
|
||||
}), [tabs, connections, appearance.tabDisplay, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, isV2Ui, languagePreference]);
|
||||
}), [dockedTabs, dockedActiveTabId, tabs, connections, appearance.tabDisplay, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, closeTabsWithSQLFilePrompt, detachWorkbenchTab, isV2Ui, languagePreference]);
|
||||
|
||||
const handleOpenConnectionModal = () => {
|
||||
const target = document.querySelector<HTMLButtonElement>('[data-gonavi-create-connection-action="true"]');
|
||||
@@ -1015,11 +982,13 @@ body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
`}</style>
|
||||
{isV2Ui && !hasTabs ? (
|
||||
EmptyWorkbench
|
||||
) : !hasDockedTabs ? (
|
||||
// All tabs are floating: keep empty docked area; floating host still shows content.
|
||||
<div className="gn-detached-only-workbench" style={{ flex: 1, minHeight: 0 }} />
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
@@ -1033,7 +1002,7 @@ body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
if (Date.now() < suppressClickUntilRef.current) return;
|
||||
onChange(newActiveKey);
|
||||
}}
|
||||
activeKey={activeTabId || undefined}
|
||||
activeKey={dockedActiveTabId || undefined}
|
||||
onEdit={onEdit}
|
||||
items={items}
|
||||
hideAdd
|
||||
|
||||
78
frontend/src/components/WorkbenchTabContent.tsx
Normal file
78
frontend/src/components/WorkbenchTabContent.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import type { TabData } from '../types';
|
||||
import DataViewer from './DataViewer';
|
||||
import QueryEditor from './QueryEditor';
|
||||
import TableDesigner from './TableDesigner';
|
||||
import RedisViewer from './RedisViewer';
|
||||
import RedisCommandEditor from './RedisCommandEditor';
|
||||
import RedisMonitor from './RedisMonitor';
|
||||
import TriggerViewer from './TriggerViewer';
|
||||
import DefinitionViewer from './DefinitionViewer';
|
||||
import TableOverview from './TableOverview';
|
||||
import TableExportWorkbench from './TableExportWorkbench';
|
||||
import SQLFileExecutionWorkbench from './SQLFileExecutionWorkbench';
|
||||
import JVMOverview from './JVMOverview';
|
||||
import JVMResourceBrowser from './JVMResourceBrowser';
|
||||
import JVMAuditViewer from './JVMAuditViewer';
|
||||
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
|
||||
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
|
||||
import SqlAnalysisWorkbench from './explain/SqlAnalysisWorkbench';
|
||||
|
||||
export const WorkbenchTabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ tab, isActive }) => {
|
||||
if (tab.type === 'query') {
|
||||
return <QueryEditor tab={tab} isActive={isActive} />;
|
||||
}
|
||||
if (tab.type === 'table') {
|
||||
return <DataViewer tab={tab} isActive={isActive} />;
|
||||
}
|
||||
if (tab.type === 'design') {
|
||||
return <TableDesigner tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'redis-keys') {
|
||||
return <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'redis-command') {
|
||||
return <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'redis-monitor') {
|
||||
return <RedisMonitor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
|
||||
}
|
||||
if (tab.type === 'trigger') {
|
||||
return <TriggerViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'view-def' || tab.type === 'event-def' || tab.type === 'routine-def' || tab.type === 'sequence-def' || tab.type === 'package-def') {
|
||||
return <DefinitionViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'table-overview') {
|
||||
return <TableOverview tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'table-export') {
|
||||
return <TableExportWorkbench tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'sql-file-execution') {
|
||||
return <SQLFileExecutionWorkbench tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'sql-analysis') {
|
||||
return <SqlAnalysisWorkbench tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-overview') {
|
||||
return <JVMOverview tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-resource') {
|
||||
return <JVMResourceBrowser tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-audit') {
|
||||
return <JVMAuditViewer tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-diagnostic') {
|
||||
return <JVMDiagnosticConsole tab={tab} />;
|
||||
}
|
||||
if (tab.type === 'jvm-monitoring') {
|
||||
return <JVMMonitoringDashboard tab={tab} />;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
WorkbenchTabContent.displayName = 'WorkbenchTabContent';
|
||||
|
||||
export default WorkbenchTabContent;
|
||||
Reference in New Issue
Block a user