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 ? ( +