From fa9d21be8b47ce14b52ff677ed4b3fd235e29cc5 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 16 Jul 2026 20:25:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(native-window):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=A0=87=E7=AD=BE=E9=A1=B5=E6=8B=96=E5=87=BA=E4=B8=BA?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E7=8B=AC=E7=AB=8B=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持 SQL、数据和结果标签拖出到跨显示器原生窗口 - 通过受认证 loopback bridge 复用主进程后端与状态 - 支持子窗口继续拆窗、聚焦、关闭、还原与异常退出恢复 - 补充多窗口协议、状态同步和跨平台回归测试 --- frontend/src/App.tsx | 2 + .../components/FloatingQueryResultWindows.tsx | 3 +- .../components/FloatingWorkbenchWindows.tsx | 3 +- .../NativeDetachedWindowApp.test.tsx | 156 ++++ .../components/NativeDetachedWindowApp.tsx | 508 ++++++++++++ .../NativeDetachedWindowController.test.ts | 398 +++++++++ .../NativeDetachedWindowController.tsx | 232 ++++++ frontend/src/components/QueryEditor.tsx | 40 +- .../components/QueryEditorResultsPanel.tsx | 36 +- frontend/src/components/TabManager.tsx | 54 +- frontend/src/main.tsx | 6 +- frontend/src/utils/detachedWindow.test.ts | 33 + frontend/src/utils/detachedWindow.ts | 36 + .../utils/nativeDetachedWindowClient.test.ts | 236 ++++++ .../src/utils/nativeDetachedWindowClient.ts | 376 +++++++++ .../utils/nativeDetachedWindowHost.test.ts | 132 +++ .../src/utils/nativeDetachedWindowHost.ts | 238 ++++++ .../utils/queryEditorResultSessionCache.ts | 37 +- .../nativewindow/activation_policy_darwin.go | 31 + .../nativewindow/activation_policy_other.go | 5 + internal/nativewindow/bridge.go | 395 +++++++++ internal/nativewindow/child.go | 193 +++++ internal/nativewindow/manager.go | 775 ++++++++++++++++++ internal/nativewindow/manager_test.go | 486 +++++++++++ internal/nativewindow/process.go | 59 ++ internal/nativewindow/runtime_script.go | 110 +++ internal/nativewindow/runtime_script_test.go | 37 + internal/nativewindow/types.go | 125 +++ internal/nativewindow/window_bounds_darwin.go | 66 ++ internal/nativewindow/window_bounds_other.go | 14 + internal/webserver/server.go | 148 +++- internal/webserver/server_test.go | 65 ++ main.go | 34 +- 33 files changed, 5036 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/NativeDetachedWindowApp.test.tsx create mode 100644 frontend/src/components/NativeDetachedWindowApp.tsx create mode 100644 frontend/src/components/NativeDetachedWindowController.test.ts create mode 100644 frontend/src/components/NativeDetachedWindowController.tsx create mode 100644 frontend/src/utils/nativeDetachedWindowClient.test.ts create mode 100644 frontend/src/utils/nativeDetachedWindowClient.ts create mode 100644 frontend/src/utils/nativeDetachedWindowHost.test.ts create mode 100644 frontend/src/utils/nativeDetachedWindowHost.ts create mode 100644 internal/nativewindow/activation_policy_darwin.go create mode 100644 internal/nativewindow/activation_policy_other.go create mode 100644 internal/nativewindow/bridge.go create mode 100644 internal/nativewindow/child.go create mode 100644 internal/nativewindow/manager.go create mode 100644 internal/nativewindow/manager_test.go create mode 100644 internal/nativewindow/process.go create mode 100644 internal/nativewindow/runtime_script.go create mode 100644 internal/nativewindow/runtime_script_test.go create mode 100644 internal/nativewindow/types.go create mode 100644 internal/nativewindow/window_bounds_darwin.go create mode 100644 internal/nativewindow/window_bounds_other.go 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 ( +