diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 971cc3e4..3a54cb7f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import TabManager from './components/TabManager'; import FloatingWorkbenchWindows from './components/FloatingWorkbenchWindows'; import FloatingAIChatWindow from './components/FloatingAIChatWindow'; import FloatingQueryResultWindows from './components/FloatingQueryResultWindows'; +import NativeDetachedWindowController from './components/NativeDetachedWindowController'; import ConnectionModal from './components/ConnectionModal'; import SnippetSettingsModal from './components/SnippetSettingsModal'; import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal'; @@ -7052,6 +7053,7 @@ function App() { + {!isV2Ui && !aiPanelVisible && ( <> diff --git a/frontend/src/components/FloatingQueryResultWindows.tsx b/frontend/src/components/FloatingQueryResultWindows.tsx index b95ae29b..dc120be2 100644 --- a/frontend/src/components/FloatingQueryResultWindows.tsx +++ b/frontend/src/components/FloatingQueryResultWindows.tsx @@ -4,6 +4,7 @@ import { CloseOutlined, CompressOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { t } from '../i18n'; import DataGrid from './DataGrid'; +import { hasNativeDetachedWindowManager } from '../utils/nativeDetachedWindowHost'; import { clamp, DEFAULT_DETACHED_WINDOW_MIN_HEIGHT, @@ -119,7 +120,7 @@ const FloatingQueryResultWindows: React.FC = () => { const windows = useMemo(() => detachedQueryResultWindows, [detachedQueryResultWindows]); - if (windows.length === 0) { + if (hasNativeDetachedWindowManager() || windows.length === 0) { return null; } diff --git a/frontend/src/components/FloatingWorkbenchWindows.tsx b/frontend/src/components/FloatingWorkbenchWindows.tsx index 10defbe1..3e160133 100644 --- a/frontend/src/components/FloatingWorkbenchWindows.tsx +++ b/frontend/src/components/FloatingWorkbenchWindows.tsx @@ -15,6 +15,7 @@ import { resolveDetachedWindowTitle, } from '../utils/detachedWindow'; import WorkbenchTabContent from './WorkbenchTabContent'; +import { hasNativeDetachedWindowManager } from '../utils/nativeDetachedWindowHost'; const getTabKindLabel = (type: string): string => { if (type === 'query') return t('tab_manager.kind_badge.query'); @@ -175,7 +176,7 @@ const FloatingWorkbenchWindows: React.FC = () => { window.addEventListener('pointercancel', stop); }, [focusDetachedWorkbenchTab, updateDetachedWorkbenchBounds]); - if (windowModels.length === 0) { + if (hasNativeDetachedWindowManager() || windowModels.length === 0) { return null; } diff --git a/frontend/src/components/NativeDetachedWindowApp.test.tsx b/frontend/src/components/NativeDetachedWindowApp.test.tsx new file mode 100644 index 00000000..23efde36 --- /dev/null +++ b/frontend/src/components/NativeDetachedWindowApp.test.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TabData } from '../types'; +import type { NativeDetachedWindowBootstrap } from '../utils/nativeDetachedWindowClient'; + +const queryTab: TabData = { + id: 'query-native-1', + title: 'Detached query', + type: 'query', + connectionId: 'connection-1', + dbName: 'main', + query: 'select 1', +}; + +let storeState: Record; +const storeListeners = new Set<() => void>(); + +vi.mock('../store', () => { + const useStore = Object.assign( + (selector: (state: Record) => unknown) => selector(storeState), + { + getState: () => storeState, + setState: (nextState: Record | ((state: Record) => Record)) => { + storeState = typeof nextState === 'function' ? nextState(storeState) : nextState; + storeListeners.forEach((listener) => listener()); + }, + subscribe: (listener: () => void) => { + storeListeners.add(listener); + return () => storeListeners.delete(listener); + }, + }, + ); + return { useStore }; +}); + +vi.mock('../i18n/provider', () => ({ + useOptionalI18n: () => null, +})); + +vi.mock('../i18n', () => ({ + t: (key: string) => key, +})); + +vi.mock('antd', () => ({ + Button: ({ icon, ...props }: React.ButtonHTMLAttributes & { icon?: React.ReactNode }) => ( + + ), + ConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Spin: () => , + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + theme: { + darkAlgorithm: 'dark', + defaultAlgorithm: 'light', + }, +})); + +vi.mock('@ant-design/icons', () => ({ + CloseOutlined: () => , + CompressOutlined: () => , +})); + +vi.mock('./WorkbenchTabContent', () => ({ + default: ({ tab }: { tab: TabData }) =>
, +})); + +vi.mock('./DataGrid', () => ({ + default: () =>
, +})); + +import NativeDetachedWindowApp from './NativeDetachedWindowApp'; + +const flushEffects = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe('NativeDetachedWindowApp', () => { + beforeEach(() => { + storeListeners.clear(); + storeState = { + tabs: [], + theme: 'light', + appearance: { uiVersion: 'v2' }, + fontSize: 14, + updateQueryTabDraft: vi.fn(), + }; + }); + + it('hydrates and attaches a workbench tab through the native action client', async () => { + const bootstrap: NativeDetachedWindowBootstrap = { + id: 'native-window-1', + kind: 'workbench', + title: queryTab.title, + payload: { + storeState: { + tabs: [queryTab], + theme: 'dark', + appearance: { uiVersion: 'v2' }, + fontSize: 15, + }, + tab: queryTab, + resultSession: { + resultSets: [], + activeResultKey: '', + isResultPanelVisible: true, + }, + }, + }; + const client = { + load: vi.fn(async () => bootstrap), + ready: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), + attach: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + closeCurrentWindow: vi.fn(async () => undefined), + }; + + let renderer: TestRenderer.ReactTestRenderer; + await act(async () => { + renderer = TestRenderer.create(); + await flushEffects(); + }); + + expect(storeState.tabs).toEqual([queryTab]); + expect(storeState.theme).toBe('dark'); + expect(typeof storeState.updateQueryTabDraft).toBe('function'); + expect(client.ready).toHaveBeenCalledWith(expect.objectContaining({ + id: bootstrap.id, + kind: 'workbench', + })); + expect(renderer!.root.findByProps({ 'data-workbench-tab': queryTab.id })).toBeTruthy(); + + const attachButton = renderer!.root.findByProps({ + 'aria-label': 'tab_manager.detached.restore', + }); + await act(async () => { + attachButton.props.onClick(); + await flushEffects(); + }); + + expect(client.sync).toHaveBeenCalledWith(expect.objectContaining({ + id: bootstrap.id, + kind: 'workbench', + tab: queryTab, + })); + expect(client.attach).toHaveBeenCalledWith(expect.objectContaining({ + id: bootstrap.id, + kind: 'workbench', + tab: queryTab, + })); + expect(client.close).not.toHaveBeenCalled(); + expect(client.closeCurrentWindow).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/components/NativeDetachedWindowApp.tsx b/frontend/src/components/NativeDetachedWindowApp.tsx new file mode 100644 index 00000000..2e1fd5c3 --- /dev/null +++ b/frontend/src/components/NativeDetachedWindowApp.tsx @@ -0,0 +1,508 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, ConfigProvider, Spin, Tooltip, theme as antdTheme } from 'antd'; +import { CloseOutlined, CompressOutlined } from '@ant-design/icons'; + +import { t as defaultTranslate } from '../i18n'; +import { getAntdLocale } from '../i18n/frameworkLocale'; +import { useOptionalI18n } from '../i18n/provider'; +import { type SqlLog, useStore } from '../store'; +import type { TabData } from '../types'; +import type { DetachedQueryResultWindow } from '../utils/detachedWindow'; +import { + attachNativeDetachedWindow, + buildNativeDetachedSyncStoreSnapshot, + closeCurrentNativeDetachedWindow, + closeNativeDetachedWindow, + fetchNativeDetachedWindowBootstrap, + hydrateNativeDetachedStore, + readyNativeDetachedWindow, + syncNativeDetachedWindow, + type NativeDetachedWindowActionPayload, + type NativeDetachedWindowBootstrap, +} from '../utils/nativeDetachedWindowClient'; +import { + peekQueryEditorResultSession, + saveQueryEditorResultSession, + subscribeQueryEditorResultSession, + type QueryEditorResultSessionSnapshot, +} from '../utils/queryEditorResultSessionCache'; +import DataGrid from './DataGrid'; +import WorkbenchTabContent from './WorkbenchTabContent'; +import NativeDetachedWindowController from './NativeDetachedWindowController'; + +export const NATIVE_DETACHED_SYNC_DEBOUNCE_MS = 180; + +type NativeDetachedWindowClient = { + load: () => Promise; + ready: (payload: NativeDetachedWindowActionPayload) => Promise; + sync: (payload: NativeDetachedWindowActionPayload) => Promise; + attach: (payload: NativeDetachedWindowActionPayload) => Promise; + close: (payload: NativeDetachedWindowActionPayload) => Promise; + closeCurrentWindow: () => Promise; +}; + +const defaultClient: NativeDetachedWindowClient = { + load: fetchNativeDetachedWindowBootstrap, + ready: readyNativeDetachedWindow, + sync: syncNativeDetachedWindow, + attach: attachNativeDetachedWindow, + close: closeNativeDetachedWindow, + closeCurrentWindow: closeCurrentNativeDetachedWindow, +}; + +export interface NativeDetachedWindowAppProps { + client?: NativeDetachedWindowClient; +} + +const isAffectedRowsResult = (columns: string[]): boolean => + columns.length === 1 && columns[0] === 'affectedRows'; + +const buildActionPayload = ( + bootstrap: NativeDetachedWindowBootstrap, + tab?: TabData, + resultSession?: QueryEditorResultSessionSnapshot | null, + includeResultSession = false, + newSqlLogs: SqlLog[] = [], +): NativeDetachedWindowActionPayload => { + const storeState = buildNativeDetachedSyncStoreSnapshot( + useStore.getState(), + bootstrap.kind === 'workbench' ? bootstrap.payload.tab?.id || '' : '', + newSqlLogs, + ); + return { + id: bootstrap.id, + kind: bootstrap.kind, + ...(bootstrap.kind === 'workbench' || Object.keys(storeState).length > 0 + ? { storeState } + : {}), + ...(tab ? { tab } : {}), + ...(bootstrap.kind === 'workbench' && includeResultSession + ? { resultSession: resultSession ?? null } + : {}), + }; +}; + +const NativeDetachedQueryResult: React.FC<{ + windowState: DetachedQueryResultWindow; +}> = ({ windowState }) => { + const result = windowState.result; + const isMessage = result.resultType === 'message' || isAffectedRowsResult(result.columns || []); + const messageText = (result.messages || []).join('\n') + || (isAffectedRowsResult(result.columns || []) + ? String(result.rows?.[0]?.affectedRows ?? '') + : ''); + + if (isMessage) { + return ( +