feat(tab/window): 支持主工作区与结果Tab拆出为应用内浮动窗口并还原

- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理
- 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭
- 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区
- 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失
- 补充六语言文案与 detach/store 相关单元测试
This commit is contained in:
Syngnat
2026-07-09 09:08:46 +08:00
parent 5fc33102a9
commit de868a2763
18 changed files with 4397 additions and 2818 deletions

View File

@@ -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 && (
<>

View 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;

View 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;

View File

@@ -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}

View File

@@ -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) },

View File

@@ -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

View 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;

View File

@@ -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();

View File

@@ -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(

View 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');
});
});

View 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;
};

View 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);
};