mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-22 08:50:17 +08:00
⚡️ perf(webview): 降低首屏加载与 WebView2 内存占用
- Monaco Editor 改为首次使用时按需初始化 - AI 面板改为懒加载,延后加载 Markdown 和图表渲染依赖 - 增加 Windows 低内存视觉模式,支持关闭透明 WebView 和 Acrylic - 补充低内存启动说明与模式解析测试
This commit is contained in:
@@ -137,7 +137,7 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
```shell
|
||||
# Clone
|
||||
git clone https://github.com/Syngnat/GoNavi.git
|
||||
cd GoNavi
|
||||
@@ -150,6 +150,9 @@ node tools/wails-fast-dev.mjs
|
||||
|
||||
# Refresh Wails JS bindings after changing exported Go method signatures
|
||||
node tools/wails-fast-dev.mjs --refresh-bindings
|
||||
|
||||
# Windows PowerShell low-memory visual mode: disables transparent WebView/Acrylic backdrop
|
||||
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
@@ -131,7 +131,7 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
```shell
|
||||
# 克隆项目
|
||||
git clone https://github.com/Syngnat/GoNavi.git
|
||||
cd GoNavi
|
||||
@@ -144,6 +144,9 @@ node tools/wails-fast-dev.mjs
|
||||
|
||||
# 修改 Go 导出方法签名后刷新 Wails JS 绑定
|
||||
node tools/wails-fast-dev.mjs --refresh-bindings
|
||||
|
||||
# Windows PowerShell 低内存视觉模式:关闭透明 WebView 和 Acrylic 背景
|
||||
$env:GONAVI_LOW_MEMORY_MODE="1"; node tools/wails-fast-dev.mjs
|
||||
```
|
||||
|
||||
### 编译构建
|
||||
|
||||
@@ -11,7 +11,6 @@ import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswo
|
||||
import DataSyncModal from './components/DataSyncModal';
|
||||
import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LogPanel from './components/LogPanel';
|
||||
import AIChatPanel from './components/AIChatPanel';
|
||||
import AISettingsModal from './components/AISettingsModal';
|
||||
import SecurityUpdateBanner from './components/SecurityUpdateBanner';
|
||||
import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal';
|
||||
@@ -85,6 +84,8 @@ import {
|
||||
import { ApplyDataRootDirectory, GetDataRootDirectoryInfo, GetSavedConnections, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
const AIChatPanel = React.lazy(() => import('./components/AIChatPanel'));
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
const MIN_UI_SCALE = 0.8;
|
||||
const MAX_UI_SCALE = 1.25;
|
||||
@@ -3007,9 +3008,11 @@ function App() {
|
||||
{renderAIEdgeHandle()}
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => {
|
||||
handleOpenAISettings();
|
||||
}} overlayTheme={overlayTheme} />
|
||||
<React.Suspense fallback={<div style={{ width: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Spin size="small" /></div>}>
|
||||
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => {
|
||||
handleOpenAISettings();
|
||||
}} overlayTheme={overlayTheme} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, S
|
||||
import dayjs from 'dayjs';
|
||||
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import Editor from './MonacoEditor';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import Editor from './MonacoEditor';
|
||||
import { Spin, Alert } from 'antd';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Editor, { type BeforeMount, type OnMount } from "@monaco-editor/react";
|
||||
import Editor, { type BeforeMount, type OnMount } from "./MonacoEditor";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Editor from "@monaco-editor/react";
|
||||
import Editor from "./MonacoEditor";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
|
||||
106
frontend/src/components/MonacoEditor.tsx
Normal file
106
frontend/src/components/MonacoEditor.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import Editor, { loader, type BeforeMount, type EditorProps } from '@monaco-editor/react';
|
||||
|
||||
export type { BeforeMount, OnMount } from '@monaco-editor/react';
|
||||
|
||||
let monacoConfiguredPromise: Promise<void> | null = null;
|
||||
let transparentThemesRegistered = false;
|
||||
|
||||
const isTestRuntime = (): boolean => {
|
||||
const env = (import.meta as unknown as { env?: Record<string, unknown> }).env || {};
|
||||
return env.MODE === 'test' || env.VITEST === true || env.VITEST === 'true';
|
||||
};
|
||||
|
||||
export const registerGonaviMonacoThemes: BeforeMount = (monaco) => {
|
||||
if (transparentThemesRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
monaco.editor.defineTheme('transparent-dark', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#ffffff10',
|
||||
'editorGutter.background': '#00000000',
|
||||
'editorStickyScroll.background': '#1e1e1e',
|
||||
'editorStickyScrollHover.background': '#2a2a2a',
|
||||
},
|
||||
});
|
||||
monaco.editor.defineTheme('transparent-light', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#00000010',
|
||||
'editorGutter.background': '#00000000',
|
||||
'editorStickyScroll.background': '#ffffff',
|
||||
'editorStickyScrollHover.background': '#f5f5f5',
|
||||
},
|
||||
});
|
||||
|
||||
transparentThemesRegistered = true;
|
||||
};
|
||||
|
||||
const ensureMonacoConfigured = (): Promise<void> => {
|
||||
if (isTestRuntime()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (!monacoConfiguredPromise) {
|
||||
monacoConfiguredPromise = import('monaco-editor/esm/nls.messages.zh-cn')
|
||||
.then(() => import('monaco-editor'))
|
||||
.then((monaco) => {
|
||||
loader.config({ monaco });
|
||||
});
|
||||
}
|
||||
|
||||
return monacoConfiguredPromise;
|
||||
};
|
||||
|
||||
const MonacoEditor: React.FC<EditorProps> = ({ beforeMount, loading, ...props }) => {
|
||||
const [ready, setReady] = useState(isTestRuntime);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void ensureMonacoConfigured()
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to configure Monaco Editor', error);
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
|
||||
registerGonaviMonacoThemes(monaco);
|
||||
beforeMount?.(monaco);
|
||||
}, [beforeMount]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div
|
||||
data-monaco-editor-loading="true"
|
||||
style={{ height: props.height || '100%', width: props.width || '100%' }}
|
||||
>
|
||||
{loading || null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Editor {...props} loading={loading} beforeMount={handleBeforeMount} />;
|
||||
};
|
||||
|
||||
export default MonacoEditor;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import Editor, { type OnMount } from './MonacoEditor';
|
||||
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { format } from 'sql-formatter';
|
||||
@@ -924,7 +924,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
|
||||
// 应用透明主题(主题已在 main.tsx 全局注册)
|
||||
// 应用透明主题(主题由 MonacoEditor 包装组件按需注册)
|
||||
monaco.editor.setTheme(darkMode ? 'transparent-dark' : 'transparent-light');
|
||||
|
||||
// 注册 AI 右键菜单操作
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Space, message } from 'antd';
|
||||
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import Editor, { OnMount } from '@monaco-editor/react';
|
||||
import Editor, { type OnMount } from './MonacoEditor';
|
||||
|
||||
interface RedisCommandEditorProps {
|
||||
connectionId: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RadioChangeEvent } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import Editor from './MonacoEditor';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import {
|
||||
blurToFilter,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutline
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import Editor, { loader } from '@monaco-editor/react';
|
||||
import Editor from './MonacoEditor';
|
||||
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
|
||||
import { useStore } from '../store';
|
||||
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
@@ -460,7 +460,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
setCommentEditorValue('');
|
||||
}, []);
|
||||
|
||||
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
|
||||
// 透明 Monaco Editor 主题由 MonacoEditor 包装组件按需注册(含 stickyScroll 不透明背景)
|
||||
|
||||
// 监听字段 Tab 容器高度,为所有 Tab 内表格计算 scroll.y
|
||||
// 当 Tab 切换时,字段 Tab 被 display:none 导致 height=0,跳过该次更新保持有效值
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react';
|
||||
import Editor, { type BeforeMount, type OnMount } from './MonacoEditor';
|
||||
|
||||
interface TableDesignerSqlPreviewProps {
|
||||
sql: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import Editor from './MonacoEditor';
|
||||
import { Spin, Alert } from 'antd';
|
||||
import { TabData } from '../types';
|
||||
import { useStore } from '../store';
|
||||
@@ -20,7 +20,7 @@ const TriggerViewer: React.FC<TriggerViewerProps> = ({ tab }) => {
|
||||
const theme = useStore(state => state.theme);
|
||||
const darkMode = theme === 'dark';
|
||||
|
||||
// 透明 Monaco Editor 主题已在 main.tsx 全局注册(含 stickyScroll 不透明背景)
|
||||
// 透明 Monaco Editor 主题由 MonacoEditor 包装组件按需注册(含 stickyScroll 不透明背景)
|
||||
|
||||
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
|
||||
const quoteSqlServerIdentifier = (raw: string): string => `[${String(raw || '').replace(/]/g, ']]')}]`;
|
||||
|
||||
@@ -9,14 +9,7 @@ import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
// 全局配置 Monaco Editor 使用本地打包的文件,避免从 CDN (jsdelivr) 加载。
|
||||
// Windows WebView2 环境下访问外部 CDN 可能失败,导致编辑器一直显示 Loading。
|
||||
// 中文语言包必须在 monaco-editor 主包之前导入,否则右键菜单等 UI 仍为英文。
|
||||
import 'monaco-editor/esm/nls.messages.zh-cn'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { cloneBrowserMockValue, duplicateBrowserMockConnection, resolveBrowserMockSecretFlag } from './utils/browserMockConnections'
|
||||
loader.config({ monaco })
|
||||
|
||||
if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
const mockConnections: any[] = [];
|
||||
@@ -168,16 +161,6 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
}
|
||||
};
|
||||
}
|
||||
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
|
||||
monaco.editor.defineTheme('transparent-dark', {
|
||||
base: 'vs-dark', inherit: true, rules: [],
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#ffffff10', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#1e1e1e', 'editorStickyScrollHover.background': '#2a2a2a' }
|
||||
})
|
||||
monaco.editor.defineTheme('transparent-light', {
|
||||
base: 'vs', inherit: true, rules: [],
|
||||
colors: { 'editor.background': '#00000000', 'editor.lineHighlightBackground': '#00000010', 'editorGutter.background': '#00000000', 'editorStickyScroll.background': '#ffffff', 'editorStickyScrollHover.background': '#f5f5f5' }
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@@ -1,5 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'monaco-editor/esm/nls.messages.zh-cn';
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS?: string;
|
||||
}
|
||||
|
||||
26
main.go
26
main.go
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
aiservice "GoNavi-Wails/internal/ai/service"
|
||||
"GoNavi-Wails/internal/app"
|
||||
@@ -18,6 +20,13 @@ func main() {
|
||||
// Create an instance of the app structure
|
||||
application := app.NewApp()
|
||||
aiService := aiservice.NewService()
|
||||
lowMemoryMode := isLowMemoryMode()
|
||||
backgroundColour := &options.RGBA{R: 0, G: 0, B: 0, A: 0}
|
||||
windowsBackdrop := windows.Acrylic
|
||||
if lowMemoryMode {
|
||||
backgroundColour = &options.RGBA{R: 255, G: 255, B: 255, A: 255}
|
||||
windowsBackdrop = windows.None
|
||||
}
|
||||
|
||||
// Create application with options
|
||||
err := wails.Run(&options.App{
|
||||
@@ -28,7 +37,7 @@ func main() {
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
|
||||
BackgroundColour: backgroundColour,
|
||||
OnStartup: func(ctx context.Context) {
|
||||
app.InitializeLifecycle(application, ctx)
|
||||
aiservice.InitializeLifecycle(aiService, ctx)
|
||||
@@ -39,9 +48,9 @@ func main() {
|
||||
aiService,
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
WebviewIsTransparent: true,
|
||||
WindowIsTranslucent: true,
|
||||
BackdropType: windows.Acrylic,
|
||||
WebviewIsTransparent: !lowMemoryMode,
|
||||
WindowIsTranslucent: !lowMemoryMode,
|
||||
BackdropType: windowsBackdrop,
|
||||
DisableWindowIcon: false,
|
||||
DisableFramelessWindowDecorations: false,
|
||||
WebviewUserDataPath: resolveWindowsWebviewUserDataPath(),
|
||||
@@ -56,3 +65,12 @@ func main() {
|
||||
logger.Error(err, "应用启动失败")
|
||||
}
|
||||
}
|
||||
|
||||
func isLowMemoryMode() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("GONAVI_LOW_MEMORY_MODE"))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
26
main_test.go
Normal file
26
main_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsLowMemoryMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want bool
|
||||
}{
|
||||
{name: "disabled by default", env: "", want: false},
|
||||
{name: "enabled with one", env: "1", want: true},
|
||||
{name: "enabled with true", env: "true", want: true},
|
||||
{name: "enabled with yes and whitespace", env: " yes ", want: true},
|
||||
{name: "disabled with false", env: "false", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("GONAVI_LOW_MEMORY_MODE", tt.env)
|
||||
if got := isLowMemoryMode(); got != tt.want {
|
||||
t.Fatalf("isLowMemoryMode() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user