mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 00:13:33 +08:00
✨ feat(tab/window): 支持主工作区与结果Tab拆出为应用内浮动窗口并还原
- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理 - 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭 - 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区 - 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失 - 补充六语言文案与 detach/store 相关单元测试
This commit is contained in:
@@ -8,6 +8,8 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { BrowserOpenURL, Environment, EventsOn, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetDarkTheme, WindowSetLightTheme, WindowSetPosition, WindowSetSize, WindowSetSystemDefaultTheme, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
import FloatingWorkbenchWindows from './components/FloatingWorkbenchWindows';
|
||||
import FloatingQueryResultWindows from './components/FloatingQueryResultWindows';
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import SnippetSettingsModal from './components/SnippetSettingsModal';
|
||||
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
|
||||
@@ -5360,6 +5362,8 @@ function App() {
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row', position: 'relative' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? 'var(--gonavi-border-radius)' : 0, clipPath: isLogPanelOpen ? 'inset(0 round var(--gonavi-border-radius))' : 'none' }}>
|
||||
<TabManager />
|
||||
<FloatingWorkbenchWindows />
|
||||
<FloatingQueryResultWindows />
|
||||
</div>
|
||||
{!isV2Ui && !aiPanelVisible && (
|
||||
<>
|
||||
|
||||
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;
|
||||
@@ -1548,6 +1548,75 @@ describe('store appearance persistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('detaches and restores workbench tabs as floating windows', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().addTab({
|
||||
id: 'table-users',
|
||||
title: 'users',
|
||||
type: 'table',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'sys',
|
||||
tableName: 'users',
|
||||
});
|
||||
useStore.getState().addTab({
|
||||
id: 'query-1',
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'sys',
|
||||
query: 'select 1;',
|
||||
});
|
||||
|
||||
useStore.getState().detachWorkbenchTab('table-users', { x: 80, y: 90, width: 800, height: 500 });
|
||||
expect(useStore.getState().isWorkbenchTabDetached('table-users')).toBe(true);
|
||||
expect(useStore.getState().detachedWorkbenchWindows).toEqual([
|
||||
expect.objectContaining({
|
||||
tabId: 'table-users',
|
||||
x: 80,
|
||||
y: 90,
|
||||
width: 800,
|
||||
height: 500,
|
||||
}),
|
||||
]);
|
||||
expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['table-users', 'query-1']);
|
||||
|
||||
useStore.getState().attachWorkbenchTab('table-users');
|
||||
expect(useStore.getState().isWorkbenchTabDetached('table-users')).toBe(false);
|
||||
expect(useStore.getState().detachedWorkbenchWindows).toEqual([]);
|
||||
expect(useStore.getState().activeTabId).toBe('table-users');
|
||||
|
||||
useStore.getState().detachWorkbenchTab('table-users');
|
||||
useStore.getState().closeTab('table-users');
|
||||
expect(useStore.getState().detachedWorkbenchWindows).toEqual([]);
|
||||
expect(useStore.getState().tabs.map((tab) => tab.id)).toEqual(['query-1']);
|
||||
});
|
||||
|
||||
it('detaches and restores query result floating windows', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().detachQueryResultWindow({
|
||||
id: 'query-result:tab-1:rs-1',
|
||||
sourceQueryTabId: 'tab-1',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'sys',
|
||||
title: '结果 1',
|
||||
result: {
|
||||
key: 'rs-1',
|
||||
sql: 'select 1',
|
||||
rows: [{ a: 1 }],
|
||||
columns: ['a'],
|
||||
pkColumns: [],
|
||||
readOnly: true,
|
||||
},
|
||||
});
|
||||
expect(useStore.getState().detachedQueryResultWindows).toHaveLength(1);
|
||||
|
||||
const restored = useStore.getState().attachQueryResultWindow('query-result:tab-1:rs-1');
|
||||
expect(restored?.result.key).toBe('rs-1');
|
||||
expect(useStore.getState().detachedQueryResultWindows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns to the source tab after closing an object edit tab opened from a hyperlink', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
|
||||
@@ -48,6 +48,14 @@ import {
|
||||
resolveOceanBaseProtocolFromQueryText,
|
||||
} from "./utils/oceanBaseProtocol";
|
||||
import { sanitizeFontFamilyInput } from "./utils/fontFamilies";
|
||||
import {
|
||||
createDefaultDetachedBounds,
|
||||
nextDetachedZIndex,
|
||||
type DetachedQueryResultWindow,
|
||||
type DetachedWorkbenchWindow,
|
||||
type DetachedWindowBounds,
|
||||
} from "./utils/detachedWindow";
|
||||
import { clearQueryEditorResultSession } from "./utils/queryEditorResultSessionCache";
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_PREFERENCES,
|
||||
@@ -1328,6 +1336,10 @@ interface AppState {
|
||||
connectionTags: ConnectionTag[];
|
||||
sidebarRootOrder: string[];
|
||||
tabs: TabData[];
|
||||
/** 主工作区已拆出的浮动窗口(会话态,不持久化) */
|
||||
detachedWorkbenchWindows: DetachedWorkbenchWindow[];
|
||||
/** SQL 结果区已拆出的浮动窗口(会话态,不持久化) */
|
||||
detachedQueryResultWindows: DetachedQueryResultWindow[];
|
||||
activeTabId: string | null;
|
||||
activeContext: { connectionId: string; dbName: string } | null;
|
||||
savedQueries: SavedQuery[];
|
||||
@@ -1441,6 +1453,29 @@ interface AppState {
|
||||
setActiveContext: (
|
||||
context: { connectionId: string; dbName: string } | null,
|
||||
) => void;
|
||||
detachWorkbenchTab: (
|
||||
tabId: string,
|
||||
preferred?: Partial<Pick<DetachedWindowBounds, "x" | "y" | "width" | "height">>,
|
||||
) => void;
|
||||
attachWorkbenchTab: (tabId: string) => void;
|
||||
updateDetachedWorkbenchBounds: (
|
||||
tabId: string,
|
||||
bounds: Partial<Pick<DetachedWindowBounds, "x" | "y" | "width" | "height">>,
|
||||
) => void;
|
||||
focusDetachedWorkbenchTab: (tabId: string) => void;
|
||||
isWorkbenchTabDetached: (tabId: string) => boolean;
|
||||
detachQueryResultWindow: (
|
||||
windowState: Omit<DetachedQueryResultWindow, keyof DetachedWindowBounds> &
|
||||
Partial<Pick<DetachedWindowBounds, "x" | "y" | "width" | "height">>,
|
||||
) => void;
|
||||
attachQueryResultWindow: (id: string) => DetachedQueryResultWindow | null;
|
||||
closeDetachedQueryResultWindow: (id: string) => void;
|
||||
updateDetachedQueryResultBounds: (
|
||||
id: string,
|
||||
bounds: Partial<Pick<DetachedWindowBounds, "x" | "y" | "width" | "height">>,
|
||||
) => void;
|
||||
focusDetachedQueryResultWindow: (id: string) => void;
|
||||
closeDetachedQueryResultWindowsBySourceTab: (sourceQueryTabId: string) => void;
|
||||
|
||||
replaceSavedQueries: (queries: SavedQuery[]) => void;
|
||||
saveQuery: (query: SavedQuery) => Promise<SavedQuery>;
|
||||
@@ -2517,11 +2552,13 @@ export async function loadAISessionFromBackend(
|
||||
|
||||
export const useStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
connections: [],
|
||||
connectionTags: [],
|
||||
sidebarRootOrder: [],
|
||||
tabs: [],
|
||||
detachedWorkbenchWindows: [],
|
||||
detachedQueryResultWindows: [],
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
savedQueries: [],
|
||||
@@ -3061,11 +3098,21 @@ export const useStore = create<AppState>()(
|
||||
const closedTab = state.tabs.find((t) => t.id === id);
|
||||
if (closedTab?.type === "query") {
|
||||
clearQueryTabDraft(closedTab.id);
|
||||
clearQueryEditorResultSession(id);
|
||||
}
|
||||
const newTabs = state.tabs.filter((t) => t.id !== id);
|
||||
let newActiveId = state.activeTabId;
|
||||
if (state.activeTabId === id) {
|
||||
newActiveId = resolveCloseTabActiveTabId(closedTab, newTabs);
|
||||
// Prefer next docked tab when closing the active one
|
||||
const dockedCandidates = newTabs.filter(
|
||||
(tab) =>
|
||||
!state.detachedWorkbenchWindows.some(
|
||||
(windowState) => windowState.tabId === tab.id,
|
||||
),
|
||||
);
|
||||
newActiveId =
|
||||
resolveCloseTabActiveTabId(closedTab, dockedCandidates) ||
|
||||
resolveCloseTabActiveTabId(closedTab, newTabs);
|
||||
}
|
||||
return {
|
||||
tabs: newTabs,
|
||||
@@ -3075,6 +3122,12 @@ export const useStore = create<AppState>()(
|
||||
newActiveId,
|
||||
state.activeContext,
|
||||
),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.filter(
|
||||
(windowState) => windowState.tabId !== id,
|
||||
),
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.filter(
|
||||
(windowState) => windowState.sourceQueryTabId !== id,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -3084,11 +3137,20 @@ export const useStore = create<AppState>()(
|
||||
if (!keep) return state;
|
||||
state.tabs
|
||||
.filter((tab) => tab.id !== id && tab.type === "query")
|
||||
.forEach((tab) => clearQueryTabDraft(tab.id));
|
||||
.forEach((tab) => {
|
||||
clearQueryTabDraft(tab.id);
|
||||
clearQueryEditorResultSession(tab.id);
|
||||
});
|
||||
return {
|
||||
tabs: [keep],
|
||||
activeTabId: id,
|
||||
activeContext: resolveActiveContextFromTab(keep),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.filter(
|
||||
(windowState) => windowState.tabId === id,
|
||||
),
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.filter(
|
||||
(windowState) => windowState.sourceQueryTabId === id,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -3099,8 +3161,12 @@ export const useStore = create<AppState>()(
|
||||
state.tabs
|
||||
.slice(0, index)
|
||||
.filter((tab) => tab.type === "query")
|
||||
.forEach((tab) => clearQueryTabDraft(tab.id));
|
||||
.forEach((tab) => {
|
||||
clearQueryTabDraft(tab.id);
|
||||
clearQueryEditorResultSession(tab.id);
|
||||
});
|
||||
const newTabs = state.tabs.slice(index);
|
||||
const keptIds = new Set(newTabs.map((tab) => tab.id));
|
||||
const activeStillExists = state.activeTabId
|
||||
? newTabs.some((t) => t.id === state.activeTabId)
|
||||
: false;
|
||||
@@ -3112,6 +3178,12 @@ export const useStore = create<AppState>()(
|
||||
activeStillExists ? state.activeTabId : id,
|
||||
state.activeContext,
|
||||
),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.filter(
|
||||
(windowState) => keptIds.has(windowState.tabId),
|
||||
),
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.filter(
|
||||
(windowState) => keptIds.has(windowState.sourceQueryTabId),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -3122,8 +3194,12 @@ export const useStore = create<AppState>()(
|
||||
state.tabs
|
||||
.slice(index + 1)
|
||||
.filter((tab) => tab.type === "query")
|
||||
.forEach((tab) => clearQueryTabDraft(tab.id));
|
||||
.forEach((tab) => {
|
||||
clearQueryTabDraft(tab.id);
|
||||
clearQueryEditorResultSession(tab.id);
|
||||
});
|
||||
const newTabs = state.tabs.slice(0, index + 1);
|
||||
const keptIds = new Set(newTabs.map((tab) => tab.id));
|
||||
const activeStillExists = state.activeTabId
|
||||
? newTabs.some((t) => t.id === state.activeTabId)
|
||||
: false;
|
||||
@@ -3135,6 +3211,12 @@ export const useStore = create<AppState>()(
|
||||
activeStillExists ? state.activeTabId : id,
|
||||
state.activeContext,
|
||||
),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.filter(
|
||||
(windowState) => keptIds.has(windowState.tabId),
|
||||
),
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.filter(
|
||||
(windowState) => keptIds.has(windowState.sourceQueryTabId),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -3148,7 +3230,10 @@ export const useStore = create<AppState>()(
|
||||
tab.type === "query" &&
|
||||
String(tab.connectionId || "").trim() === targetConnectionId,
|
||||
)
|
||||
.forEach((tab) => clearQueryTabDraft(tab.id));
|
||||
.forEach((tab) => {
|
||||
clearQueryTabDraft(tab.id);
|
||||
clearQueryEditorResultSession(tab.id);
|
||||
});
|
||||
const newTabs = state.tabs.filter(
|
||||
(t) => String(t.connectionId || "").trim() !== targetConnectionId,
|
||||
);
|
||||
@@ -3164,6 +3249,7 @@ export const useStore = create<AppState>()(
|
||||
state.activeContext?.connectionId === targetConnectionId
|
||||
? null
|
||||
: state.activeContext;
|
||||
const keptIds = new Set(newTabs.map((tab) => tab.id));
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: nextActiveTabId,
|
||||
@@ -3172,6 +3258,12 @@ export const useStore = create<AppState>()(
|
||||
nextActiveTabId,
|
||||
nextFallbackContext,
|
||||
),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.filter(
|
||||
(windowState) => keptIds.has(windowState.tabId),
|
||||
),
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.filter(
|
||||
(windowState) => keptIds.has(windowState.sourceQueryTabId),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -3188,7 +3280,10 @@ export const useStore = create<AppState>()(
|
||||
const sameDb = String(tab.dbName || "").trim() === targetDbName;
|
||||
return sameConnection && sameDb;
|
||||
})
|
||||
.forEach((tab) => clearQueryTabDraft(tab.id));
|
||||
.forEach((tab) => {
|
||||
clearQueryTabDraft(tab.id);
|
||||
clearQueryEditorResultSession(tab.id);
|
||||
});
|
||||
const newTabs = state.tabs.filter((tab) => {
|
||||
const sameConnection =
|
||||
String(tab.connectionId || "").trim() === targetConnectionId;
|
||||
@@ -3210,6 +3305,7 @@ export const useStore = create<AppState>()(
|
||||
const nextFallbackContext = sameActiveContext
|
||||
? null
|
||||
: state.activeContext;
|
||||
const keptIds = new Set(newTabs.map((tab) => tab.id));
|
||||
return {
|
||||
tabs: newTabs,
|
||||
activeTabId: nextActiveTabId,
|
||||
@@ -3218,6 +3314,12 @@ export const useStore = create<AppState>()(
|
||||
nextActiveTabId,
|
||||
nextFallbackContext,
|
||||
),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.filter(
|
||||
(windowState) => keptIds.has(windowState.tabId),
|
||||
),
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.filter(
|
||||
(windowState) => keptIds.has(windowState.sourceQueryTabId),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -3243,20 +3345,307 @@ export const useStore = create<AppState>()(
|
||||
set((state) => {
|
||||
state.tabs
|
||||
.filter((tab) => tab.type === "query")
|
||||
.forEach((tab) => clearQueryTabDraft(tab.id));
|
||||
return { tabs: [], activeTabId: null, activeContext: null };
|
||||
.forEach((tab) => {
|
||||
clearQueryTabDraft(tab.id);
|
||||
clearQueryEditorResultSession(tab.id);
|
||||
});
|
||||
return {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
activeContext: null,
|
||||
detachedWorkbenchWindows: [],
|
||||
detachedQueryResultWindows: [],
|
||||
};
|
||||
}),
|
||||
|
||||
setActiveTab: (id) =>
|
||||
set((state) => {
|
||||
const tabId = String(id || "").trim();
|
||||
const isDetached = state.detachedWorkbenchWindows.some(
|
||||
(windowState) => windowState.tabId === tabId,
|
||||
);
|
||||
if (!isDetached) {
|
||||
return {
|
||||
activeTabId: tabId,
|
||||
activeContext: resolveActiveContextForTabId(
|
||||
state.tabs,
|
||||
tabId,
|
||||
state.activeContext,
|
||||
),
|
||||
};
|
||||
}
|
||||
// Detached tab: keep active context, raise floating window
|
||||
const zIndex = nextDetachedZIndex(state.detachedWorkbenchWindows);
|
||||
return {
|
||||
activeTabId: tabId,
|
||||
activeContext: resolveActiveContextForTabId(
|
||||
state.tabs,
|
||||
tabId,
|
||||
state.activeContext,
|
||||
),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.map(
|
||||
(windowState) =>
|
||||
windowState.tabId === tabId
|
||||
? { ...windowState, zIndex }
|
||||
: windowState,
|
||||
),
|
||||
};
|
||||
}),
|
||||
setActiveContext: (context) => set({ activeContext: context }),
|
||||
|
||||
detachWorkbenchTab: (tabId, preferred) =>
|
||||
set((state) => {
|
||||
const id = String(tabId || "").trim();
|
||||
if (!id || !state.tabs.some((tab) => tab.id === id)) {
|
||||
return state;
|
||||
}
|
||||
const existing = state.detachedWorkbenchWindows.find(
|
||||
(windowState) => windowState.tabId === id,
|
||||
);
|
||||
if (existing) {
|
||||
const zIndex = nextDetachedZIndex(state.detachedWorkbenchWindows);
|
||||
return {
|
||||
activeTabId: id,
|
||||
activeContext: resolveActiveContextForTabId(
|
||||
state.tabs,
|
||||
id,
|
||||
state.activeContext,
|
||||
),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.map(
|
||||
(windowState) =>
|
||||
windowState.tabId === id
|
||||
? { ...windowState, zIndex }
|
||||
: windowState,
|
||||
),
|
||||
};
|
||||
}
|
||||
const bounds = createDefaultDetachedBounds(
|
||||
state.detachedWorkbenchWindows,
|
||||
preferred,
|
||||
);
|
||||
const detachedTab = state.tabs.find((tab) => tab.id === id);
|
||||
return {
|
||||
detachedWorkbenchWindows: [
|
||||
...state.detachedWorkbenchWindows,
|
||||
{ tabId: id, ...bounds },
|
||||
],
|
||||
// Keep detached tab active so connection context and focus stay correct;
|
||||
// TabManager only renders docked tabs and falls back visually.
|
||||
activeTabId: id,
|
||||
activeContext: resolveActiveContextFromTab(detachedTab) ||
|
||||
resolveActiveContextForTabId(
|
||||
state.tabs,
|
||||
id,
|
||||
state.activeContext,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
attachWorkbenchTab: (tabId) =>
|
||||
set((state) => {
|
||||
const id = String(tabId || "").trim();
|
||||
if (!id) return state;
|
||||
if (
|
||||
!state.detachedWorkbenchWindows.some(
|
||||
(windowState) => windowState.tabId === id,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
activeTabId: id,
|
||||
activeContext: resolveActiveContextForTabId(
|
||||
state.tabs,
|
||||
id,
|
||||
state.activeContext,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.filter(
|
||||
(windowState) => windowState.tabId !== id,
|
||||
),
|
||||
activeTabId: id,
|
||||
activeContext: resolveActiveContextForTabId(
|
||||
state.tabs,
|
||||
id,
|
||||
state.activeContext,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
updateDetachedWorkbenchBounds: (tabId, bounds) =>
|
||||
set((state) => {
|
||||
const id = String(tabId || "").trim();
|
||||
if (!id) return state;
|
||||
return {
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.map(
|
||||
(windowState) =>
|
||||
windowState.tabId === id
|
||||
? {
|
||||
...windowState,
|
||||
...(bounds.x !== undefined ? { x: bounds.x } : {}),
|
||||
...(bounds.y !== undefined ? { y: bounds.y } : {}),
|
||||
...(bounds.width !== undefined
|
||||
? { width: bounds.width }
|
||||
: {}),
|
||||
...(bounds.height !== undefined
|
||||
? { height: bounds.height }
|
||||
: {}),
|
||||
}
|
||||
: windowState,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
focusDetachedWorkbenchTab: (tabId) =>
|
||||
set((state) => {
|
||||
const id = String(tabId || "").trim();
|
||||
if (
|
||||
!id ||
|
||||
!state.detachedWorkbenchWindows.some(
|
||||
(windowState) => windowState.tabId === id,
|
||||
)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const zIndex = nextDetachedZIndex(state.detachedWorkbenchWindows);
|
||||
return {
|
||||
activeTabId: id,
|
||||
activeContext: resolveActiveContextForTabId(
|
||||
state.tabs,
|
||||
id,
|
||||
state.activeContext,
|
||||
),
|
||||
detachedWorkbenchWindows: state.detachedWorkbenchWindows.map(
|
||||
(windowState) =>
|
||||
windowState.tabId === id
|
||||
? { ...windowState, zIndex }
|
||||
: windowState,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
isWorkbenchTabDetached: (tabId): boolean => {
|
||||
const id = String(tabId || "").trim();
|
||||
return get().detachedWorkbenchWindows.some(
|
||||
(windowState) => windowState.tabId === id,
|
||||
);
|
||||
},
|
||||
|
||||
detachQueryResultWindow: (windowState) =>
|
||||
set((state) => {
|
||||
const id = String(windowState.id || "").trim();
|
||||
if (!id) return state;
|
||||
const existingIndex = state.detachedQueryResultWindows.findIndex(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const zIndex = nextDetachedZIndex(state.detachedQueryResultWindows);
|
||||
const next = [...state.detachedQueryResultWindows];
|
||||
next[existingIndex] = {
|
||||
...next[existingIndex],
|
||||
...windowState,
|
||||
zIndex,
|
||||
};
|
||||
return { detachedQueryResultWindows: next };
|
||||
}
|
||||
const bounds = createDefaultDetachedBounds(
|
||||
state.detachedQueryResultWindows,
|
||||
windowState,
|
||||
);
|
||||
return {
|
||||
detachedQueryResultWindows: [
|
||||
...state.detachedQueryResultWindows,
|
||||
{
|
||||
id,
|
||||
sourceQueryTabId: String(windowState.sourceQueryTabId || ""),
|
||||
connectionId: String(windowState.connectionId || ""),
|
||||
dbName: windowState.dbName,
|
||||
title: String(windowState.title || id),
|
||||
result: windowState.result,
|
||||
...bounds,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
|
||||
attachQueryResultWindow: (id) => {
|
||||
const windowId = String(id || "").trim();
|
||||
if (!windowId) return null;
|
||||
const current = get().detachedQueryResultWindows;
|
||||
const found =
|
||||
current.find((windowState) => windowState.id === windowId) || null;
|
||||
if (!found) return null;
|
||||
set({
|
||||
detachedQueryResultWindows: current.filter(
|
||||
(windowState) => windowState.id !== windowId,
|
||||
),
|
||||
});
|
||||
return found;
|
||||
},
|
||||
|
||||
closeDetachedQueryResultWindow: (id) =>
|
||||
set((state) => ({
|
||||
activeTabId: id,
|
||||
activeContext: resolveActiveContextForTabId(
|
||||
state.tabs,
|
||||
id,
|
||||
state.activeContext,
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.filter(
|
||||
(windowState) => windowState.id !== String(id || "").trim(),
|
||||
),
|
||||
})),
|
||||
setActiveContext: (context) => set({ activeContext: context }),
|
||||
|
||||
updateDetachedQueryResultBounds: (id, bounds) =>
|
||||
set((state) => {
|
||||
const windowId = String(id || "").trim();
|
||||
if (!windowId) return state;
|
||||
return {
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.map(
|
||||
(windowState) =>
|
||||
windowState.id === windowId
|
||||
? {
|
||||
...windowState,
|
||||
...(bounds.x !== undefined ? { x: bounds.x } : {}),
|
||||
...(bounds.y !== undefined ? { y: bounds.y } : {}),
|
||||
...(bounds.width !== undefined
|
||||
? { width: bounds.width }
|
||||
: {}),
|
||||
...(bounds.height !== undefined
|
||||
? { height: bounds.height }
|
||||
: {}),
|
||||
}
|
||||
: windowState,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
focusDetachedQueryResultWindow: (id) =>
|
||||
set((state) => {
|
||||
const windowId = String(id || "").trim();
|
||||
if (
|
||||
!windowId ||
|
||||
!state.detachedQueryResultWindows.some(
|
||||
(windowState) => windowState.id === windowId,
|
||||
)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const zIndex = nextDetachedZIndex(state.detachedQueryResultWindows);
|
||||
return {
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.map(
|
||||
(windowState) =>
|
||||
windowState.id === windowId
|
||||
? { ...windowState, zIndex }
|
||||
: windowState,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
closeDetachedQueryResultWindowsBySourceTab: (sourceQueryTabId) =>
|
||||
set((state) => {
|
||||
const sourceId = String(sourceQueryTabId || "").trim();
|
||||
if (!sourceId) return state;
|
||||
return {
|
||||
detachedQueryResultWindows: state.detachedQueryResultWindows.filter(
|
||||
(windowState) => windowState.sourceQueryTabId !== sourceId,
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
replaceSavedQueries: (queries) =>
|
||||
set({ savedQueries: sanitizeSavedQueries(queries) }),
|
||||
@@ -3957,6 +4346,9 @@ export const useStore = create<AppState>()(
|
||||
connectionTags: persistedConnectionTags,
|
||||
sidebarRootOrder: persistedSidebarRootOrder,
|
||||
tabs: safeTabs,
|
||||
// Floating windows are session-only and must not be restored from disk.
|
||||
detachedWorkbenchWindows: [],
|
||||
detachedQueryResultWindows: [],
|
||||
activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs),
|
||||
savedQueries: currentState.savedQueries,
|
||||
externalSQLDirectories: sanitizeExternalSQLDirectories(
|
||||
|
||||
40
frontend/src/utils/detachedWindow.test.ts
Normal file
40
frontend/src/utils/detachedWindow.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createDefaultDetachedBounds,
|
||||
DETACH_TAB_DRAG_Y_THRESHOLD,
|
||||
nextDetachedZIndex,
|
||||
resolveDetachedWindowTitle,
|
||||
shouldDetachTabByDrag,
|
||||
} from './detachedWindow';
|
||||
|
||||
describe('detachedWindow helpers', () => {
|
||||
it('computes next z-index above existing windows', () => {
|
||||
expect(nextDetachedZIndex([])).toBe(1201);
|
||||
expect(nextDetachedZIndex([{ zIndex: 1300 }, { zIndex: 1250 }])).toBe(1301);
|
||||
});
|
||||
|
||||
it('detaches only when vertical drag exceeds threshold', () => {
|
||||
expect(shouldDetachTabByDrag(DETACH_TAB_DRAG_Y_THRESHOLD - 1)).toBe(false);
|
||||
expect(shouldDetachTabByDrag(DETACH_TAB_DRAG_Y_THRESHOLD)).toBe(true);
|
||||
expect(shouldDetachTabByDrag(-DETACH_TAB_DRAG_Y_THRESHOLD)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds a default floating window bounds', () => {
|
||||
const bounds = createDefaultDetachedBounds([]);
|
||||
expect(bounds.width).toBeGreaterThan(0);
|
||||
expect(bounds.height).toBeGreaterThan(0);
|
||||
expect(bounds.zIndex).toBeGreaterThan(1200);
|
||||
});
|
||||
|
||||
it('resolves floating window titles with object labels', () => {
|
||||
expect(resolveDetachedWindowTitle({
|
||||
kindLabel: '表数据',
|
||||
objectLabel: 'users',
|
||||
fallbackTitle: 'users',
|
||||
})).toBe('表数据 · users');
|
||||
expect(resolveDetachedWindowTitle({
|
||||
kindLabel: 'SQL 查询',
|
||||
fallbackTitle: 'Query 1',
|
||||
})).toBe('Query 1');
|
||||
});
|
||||
});
|
||||
129
frontend/src/utils/detachedWindow.ts
Normal file
129
frontend/src/utils/detachedWindow.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
export type DetachedWindowBounds = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
};
|
||||
|
||||
export type DetachedWorkbenchWindow = DetachedWindowBounds & {
|
||||
tabId: string;
|
||||
};
|
||||
|
||||
export type DetachedQueryResultSnapshot = {
|
||||
key: string;
|
||||
sql: string;
|
||||
exportSql?: string;
|
||||
sourceStatementIndex?: number;
|
||||
statementResultIndex?: number;
|
||||
rows: any[];
|
||||
columns: string[];
|
||||
messages?: string[];
|
||||
resultType?: 'grid' | 'message';
|
||||
tableName?: string;
|
||||
pkColumns: string[];
|
||||
editLocator?: {
|
||||
strategy?: string;
|
||||
columns?: string[];
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
readOnly: boolean;
|
||||
showRowNumberColumn?: boolean;
|
||||
truncated?: boolean;
|
||||
};
|
||||
|
||||
export type DetachedQueryResultWindow = DetachedWindowBounds & {
|
||||
id: string;
|
||||
sourceQueryTabId: string;
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
title: string;
|
||||
result: DetachedQueryResultSnapshot;
|
||||
};
|
||||
|
||||
export const DETACH_TAB_DRAG_Y_THRESHOLD = 56;
|
||||
export const DEFAULT_DETACHED_WINDOW_WIDTH = 960;
|
||||
export const DEFAULT_DETACHED_WINDOW_HEIGHT = 640;
|
||||
export const DEFAULT_DETACHED_WINDOW_MIN_WIDTH = 480;
|
||||
export const DEFAULT_DETACHED_WINDOW_MIN_HEIGHT = 320;
|
||||
export const DETACHED_WINDOW_VIEWPORT_PADDING = 16;
|
||||
|
||||
export const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
export const nextDetachedZIndex = (windows: Array<{ zIndex?: number }>): number => {
|
||||
let max = 1200;
|
||||
for (const windowState of windows) {
|
||||
const z = Number(windowState?.zIndex);
|
||||
if (Number.isFinite(z) && z > max) {
|
||||
max = z;
|
||||
}
|
||||
}
|
||||
return max + 1;
|
||||
};
|
||||
|
||||
const getViewportSize = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
width: DEFAULT_DETACHED_WINDOW_WIDTH + DETACHED_WINDOW_VIEWPORT_PADDING * 2,
|
||||
height: DEFAULT_DETACHED_WINDOW_HEIGHT + DETACHED_WINDOW_VIEWPORT_PADDING * 2,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
};
|
||||
|
||||
export const createDefaultDetachedBounds = (
|
||||
windows: Array<{ zIndex?: number }>,
|
||||
preferred?: Partial<Pick<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>>,
|
||||
): DetachedWindowBounds => {
|
||||
const viewport = getViewportSize();
|
||||
const width = clamp(
|
||||
Number(preferred?.width) || DEFAULT_DETACHED_WINDOW_WIDTH,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_WIDTH,
|
||||
Math.max(DEFAULT_DETACHED_WINDOW_MIN_WIDTH, viewport.width - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
|
||||
);
|
||||
const height = clamp(
|
||||
Number(preferred?.height) || DEFAULT_DETACHED_WINDOW_HEIGHT,
|
||||
DEFAULT_DETACHED_WINDOW_MIN_HEIGHT,
|
||||
Math.max(DEFAULT_DETACHED_WINDOW_MIN_HEIGHT, viewport.height - DETACHED_WINDOW_VIEWPORT_PADDING * 2),
|
||||
);
|
||||
const cascade = (windows.length % 8) * 28;
|
||||
const defaultX = Math.max(
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
Math.round((viewport.width - width) / 2) + cascade,
|
||||
);
|
||||
const defaultY = Math.max(
|
||||
DETACHED_WINDOW_VIEWPORT_PADDING,
|
||||
Math.round((viewport.height - height) / 2) + cascade,
|
||||
);
|
||||
const maxX = Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, viewport.width - width - DETACHED_WINDOW_VIEWPORT_PADDING);
|
||||
const maxY = Math.max(DETACHED_WINDOW_VIEWPORT_PADDING, viewport.height - height - DETACHED_WINDOW_VIEWPORT_PADDING);
|
||||
return {
|
||||
x: clamp(Number.isFinite(Number(preferred?.x)) ? Number(preferred?.x) : defaultX, DETACHED_WINDOW_VIEWPORT_PADDING, maxX),
|
||||
y: clamp(Number.isFinite(Number(preferred?.y)) ? Number(preferred?.y) : defaultY, DETACHED_WINDOW_VIEWPORT_PADDING, maxY),
|
||||
width,
|
||||
height,
|
||||
zIndex: nextDetachedZIndex(windows),
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldDetachTabByDrag = (deltaY: number, overId?: string | null): boolean => {
|
||||
if (!Number.isFinite(deltaY)) return false;
|
||||
// 垂直拖出超过阈值即判定为独立窗口;即使仍 hover 在其他 tab 上也可拆出
|
||||
return Math.abs(deltaY) >= DETACH_TAB_DRAG_Y_THRESHOLD;
|
||||
};
|
||||
|
||||
export const resolveDetachedWindowTitle = (params: {
|
||||
kindLabel: string;
|
||||
objectLabel?: string;
|
||||
fallbackTitle: string;
|
||||
}): string => {
|
||||
const objectLabel = String(params.objectLabel || '').trim();
|
||||
if (objectLabel) {
|
||||
return `${params.kindLabel} · ${objectLabel}`;
|
||||
}
|
||||
return String(params.fallbackTitle || params.kindLabel || '').trim() || params.kindLabel;
|
||||
};
|
||||
48
frontend/src/utils/queryEditorResultSessionCache.ts
Normal file
48
frontend/src/utils/queryEditorResultSessionCache.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { QueryEditorResultSet } from '../components/QueryEditorResultsPanel';
|
||||
|
||||
type QueryEditorResultSessionSnapshot = {
|
||||
resultSets: QueryEditorResultSet[];
|
||||
activeResultKey: string;
|
||||
isResultPanelVisible?: boolean;
|
||||
};
|
||||
|
||||
const cache = new Map<string, QueryEditorResultSessionSnapshot>();
|
||||
|
||||
export const saveQueryEditorResultSession = (
|
||||
tabId: string,
|
||||
snapshot: QueryEditorResultSessionSnapshot,
|
||||
): void => {
|
||||
const id = String(tabId || '').trim();
|
||||
if (!id) return;
|
||||
cache.set(id, {
|
||||
resultSets: Array.isArray(snapshot.resultSets) ? snapshot.resultSets : [],
|
||||
activeResultKey: String(snapshot.activeResultKey || ''),
|
||||
isResultPanelVisible: snapshot.isResultPanelVisible,
|
||||
});
|
||||
};
|
||||
|
||||
export const takeQueryEditorResultSession = (
|
||||
tabId: string,
|
||||
): QueryEditorResultSessionSnapshot | null => {
|
||||
const id = String(tabId || '').trim();
|
||||
if (!id) return null;
|
||||
const snapshot = cache.get(id) || null;
|
||||
if (snapshot) {
|
||||
cache.delete(id);
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
export const peekQueryEditorResultSession = (
|
||||
tabId: string,
|
||||
): QueryEditorResultSessionSnapshot | null => {
|
||||
const id = String(tabId || '').trim();
|
||||
if (!id) return null;
|
||||
return cache.get(id) || null;
|
||||
};
|
||||
|
||||
export const clearQueryEditorResultSession = (tabId: string): void => {
|
||||
const id = String(tabId || '').trim();
|
||||
if (!id) return;
|
||||
cache.delete(id);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user