mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-21 12:51:47 +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);
|
||||
};
|
||||
Reference in New Issue
Block a user