mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-13 00:13:33 +08:00
- 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理 - 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭 - 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区 - 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失 - 补充六语言文案与 detach/store 相关单元测试
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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;
|