From de868a276314febb42adaf4c3a9764bfb4e4c3c2 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 9 Jul 2026 09:08:46 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(tab/window):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=B8=BB=E5=B7=A5=E4=BD=9C=E5=8C=BA=E4=B8=8E=E7=BB=93?= =?UTF-8?q?=E6=9E=9CTab=E6=8B=86=E5=87=BA=E4=B8=BA=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=86=85=E6=B5=AE=E5=8A=A8=E7=AA=97=E5=8F=A3=E5=B9=B6=E8=BF=98?= =?UTF-8?q?=E5=8E=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 detached 会话态:主工作区窗口与查询结果窗口独立管理 - 主Tab支持右键拆出与垂直拖出,浮动窗可拖拽缩放并还原/关闭 - 结果Tab右键拆出为浮动结果窗,支持还原回源查询结果区 - 抽离 WorkbenchTabContent,QueryEditor 结果会话缓存避免拆出丢失 - 补充六语言文案与 detach/store 相关单元测试 --- frontend/src/App.tsx | 4 + .../components/FloatingQueryResultWindows.tsx | 312 ++++++ .../components/FloatingWorkbenchWindows.tsx | 357 +++++++ frontend/src/components/QueryEditor.tsx | 120 ++- .../components/QueryEditorResultsPanel.tsx | 9 + frontend/src/components/TabManager.tsx | 141 ++- .../src/components/WorkbenchTabContent.tsx | 78 ++ frontend/src/store.test.ts | 69 ++ frontend/src/store.ts | 422 +++++++- frontend/src/utils/detachedWindow.test.ts | 40 + frontend/src/utils/detachedWindow.ts | 129 +++ .../utils/queryEditorResultSessionCache.ts | 48 + shared/i18n/de-DE.json | 914 ++++++++--------- shared/i18n/en-US.json | 916 +++++++++--------- shared/i18n/ja-JP.json | 914 ++++++++--------- shared/i18n/ru-RU.json | 914 ++++++++--------- shared/i18n/zh-CN.json | 914 ++++++++--------- shared/i18n/zh-TW.json | 914 ++++++++--------- 18 files changed, 4397 insertions(+), 2818 deletions(-) create mode 100644 frontend/src/components/FloatingQueryResultWindows.tsx create mode 100644 frontend/src/components/FloatingWorkbenchWindows.tsx create mode 100644 frontend/src/components/WorkbenchTabContent.tsx create mode 100644 frontend/src/utils/detachedWindow.test.ts create mode 100644 frontend/src/utils/detachedWindow.ts create mode 100644 frontend/src/utils/queryEditorResultSessionCache.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed513064..55b9e8fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() {
+ +
{!isV2Ui && !aiPanelVisible && ( <> diff --git a/frontend/src/components/FloatingQueryResultWindows.tsx b/frontend/src/components/FloatingQueryResultWindows.tsx new file mode 100644 index 00000000..f4cabf0c --- /dev/null +++ b/frontend/src/components/FloatingQueryResultWindows.tsx @@ -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 ( +
+ + {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 ( +
focusDetachedQueryResultWindow(windowState.id)} + > +
startInteraction(event, windowState.id, 'move', windowState)} + > +
+ {windowState.title} +
+
event.stopPropagation()} style={{ display: 'inline-flex', gap: 4 }}> + +
+
+
+ {isMessage ? ( +