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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff