mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined } from '@ant-design/icons';
|
||||
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TabManager from './components/TabManager';
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
resolveAIEdgeHandleDockStyle,
|
||||
resolveAIEdgeHandleStyle,
|
||||
} from './utils/aiEntryLayout';
|
||||
import { SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import { ApplyDataRootDirectory, GetDataRootDirectoryInfo, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
@@ -1411,6 +1411,11 @@ function App() {
|
||||
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
|
||||
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
|
||||
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
|
||||
const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false);
|
||||
const [dataRootInfo, setDataRootInfo] = useState<any>(null);
|
||||
const [selectedDataRootPath, setSelectedDataRootPath] = useState('');
|
||||
const [dataRootLoading, setDataRootLoading] = useState(false);
|
||||
const [dataRootApplying, setDataRootApplying] = useState(false);
|
||||
const [isAISettingsOpen, setIsAISettingsOpen] = useState(false);
|
||||
const aiEntryPlacement = resolveAIEntryPlacement();
|
||||
const aiEdgeHandleAttachment = resolveAIEdgeHandleAttachment(aiPanelVisible);
|
||||
@@ -1468,6 +1473,84 @@ function App() {
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const loadDataRootInfo = useCallback(async () => {
|
||||
setDataRootLoading(true);
|
||||
try {
|
||||
const res = await GetDataRootDirectoryInfo();
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || '加载数据目录信息失败');
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
setDataRootInfo(data);
|
||||
setSelectedDataRootPath(String(data.path || ''));
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
void message.error(`加载数据目录信息失败: ${errMsg}`);
|
||||
} finally {
|
||||
setDataRootLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDataRootModalOpen) {
|
||||
return;
|
||||
}
|
||||
void loadDataRootInfo();
|
||||
}, [isDataRootModalOpen, loadDataRootInfo]);
|
||||
|
||||
const handleSelectDataRoot = useCallback(async () => {
|
||||
try {
|
||||
const res = await SelectDataRootDirectory(selectedDataRootPath || dataRootInfo?.path || '');
|
||||
if (!res?.success) {
|
||||
if (String(res?.message || '') !== '已取消') {
|
||||
throw new Error(res?.message || '选择数据目录失败');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
setSelectedDataRootPath(String(data.path || ''));
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
void message.error(`选择数据目录失败: ${errMsg}`);
|
||||
}
|
||||
}, [dataRootInfo?.path, selectedDataRootPath]);
|
||||
|
||||
const handleApplyDataRoot = useCallback(async (migrate: boolean, useDefaultPath = false) => {
|
||||
const nextPath = useDefaultPath ? String(dataRootInfo?.defaultPath || '') : String(selectedDataRootPath || '').trim();
|
||||
if (!nextPath) {
|
||||
void message.warning('请先选择有效的数据目录');
|
||||
return;
|
||||
}
|
||||
setDataRootApplying(true);
|
||||
try {
|
||||
const res = await ApplyDataRootDirectory(nextPath, migrate);
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || '应用数据目录失败');
|
||||
}
|
||||
const data = (res?.data || {}) as any;
|
||||
setDataRootInfo(data);
|
||||
setSelectedDataRootPath(String(data.path || nextPath));
|
||||
void message.success(res?.message || '数据目录已更新');
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
void message.error(`应用数据目录失败: ${errMsg}`);
|
||||
} finally {
|
||||
setDataRootApplying(false);
|
||||
}
|
||||
}, [dataRootInfo?.defaultPath, selectedDataRootPath]);
|
||||
|
||||
const handleOpenDataRoot = useCallback(async () => {
|
||||
try {
|
||||
const res = await OpenDataRootDirectory();
|
||||
if (!res?.success) {
|
||||
throw new Error(res?.message || '打开数据目录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : String(error || '未知错误');
|
||||
void message.error(`打开数据目录失败: ${errMsg}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
|
||||
const LOG_PANEL_TOOLBAR_HEIGHT = 32;
|
||||
@@ -2179,6 +2262,16 @@ function App() {
|
||||
setIsDriverModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'data-root',
|
||||
icon: <HddOutlined />,
|
||||
title: '数据目录',
|
||||
description: '查看、切换或迁移本地数据存储位置。',
|
||||
onClick: () => {
|
||||
setIsToolsModalOpen(false);
|
||||
setIsDataRootModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'shortcut-settings',
|
||||
icon: <LinkOutlined />,
|
||||
@@ -2202,6 +2295,74 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<HddOutlined />, '数据存储位置', '统一管理连接、代理、AI 配置与驱动等文件型数据的根目录。')}
|
||||
open={isDataRootModalOpen}
|
||||
onCancel={() => setIsDataRootModalOpen(false)}
|
||||
footer={null}
|
||||
width={720}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
{dataRootLoading ? (
|
||||
<div style={{ padding: '16px 0', textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>当前目录</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<Input readOnly value={dataRootInfo?.path || ''} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontWeight: 500 }}>默认目录</div>
|
||||
<div style={utilityMutedTextStyle}>{dataRootInfo?.defaultPath || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 6, fontWeight: 500 }}>驱动目录</div>
|
||||
<div style={utilityMutedTextStyle}>{dataRootInfo?.driverPath || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>切换目标</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<Input
|
||||
readOnly
|
||||
value={selectedDataRootPath}
|
||||
placeholder="选择新的数据目录"
|
||||
/>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
<Button icon={<FolderOpenOutlined />} onClick={() => void handleSelectDataRoot()}>
|
||||
选择目录
|
||||
</Button>
|
||||
<Button onClick={() => void handleOpenDataRoot()}>
|
||||
打开当前目录
|
||||
</Button>
|
||||
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false, true)}>
|
||||
恢复默认目录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={utilityPanelStyle}>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>应用方式</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false)}>
|
||||
仅切换到所选目录
|
||||
</Button>
|
||||
<Button type="primary" loading={dataRootApplying} onClick={() => void handleApplyDataRoot(true)}>
|
||||
迁移现有数据并切换
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
|
||||
切换后建议重启应用,以确保 AI 与其他长生命周期模块完全切换到新目录。敏感密码仍保存在系统 secret store,不会随文件目录迁移。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<DataSyncModal
|
||||
open={isSyncModalOpen}
|
||||
onClose={() => setIsSyncModalOpen(false)}
|
||||
|
||||
@@ -21,6 +21,13 @@ loader.config({ monaco })
|
||||
if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
const mockConnections: any[] = [];
|
||||
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
|
||||
let mockDataRootInfo: any = {
|
||||
path: 'C:/mock/.gonavi',
|
||||
defaultPath: 'C:/mock/.gonavi',
|
||||
driverPath: 'C:/mock/.gonavi/drivers',
|
||||
isDefaultPath: true,
|
||||
bootstrapPath: 'C:/mock/.gonavi/storage_root.json',
|
||||
};
|
||||
|
||||
const upsertMockConnection = (view: any) => {
|
||||
const index = mockConnections.findIndex((item) => item.id === view.id);
|
||||
@@ -118,14 +125,27 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
SaveQuery: async () => null,
|
||||
DeleteQuery: async () => null,
|
||||
GetAppInfo: async () => ({}),
|
||||
GetDataRootDirectoryInfo: async () => ({ success: true, data: cloneBrowserMockValue(mockDataRootInfo) }),
|
||||
CheckForUpdates: async () => ({ success: false }),
|
||||
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
|
||||
OpenDataRootDirectory: async () => ({ success: true }),
|
||||
InstallUpdateAndRestart: async () => ({ success: false }),
|
||||
ImportConfigFile: async () => ({ success: false }),
|
||||
ExportData: async () => ({ success: false }),
|
||||
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
|
||||
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
|
||||
ImportLegacyGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
|
||||
SelectDataRootDirectory: async (currentPath: string) => ({ success: true, data: { ...mockDataRootInfo, path: currentPath || mockDataRootInfo.path } }),
|
||||
ApplyDataRootDirectory: async (path: string) => {
|
||||
const nextPath = String(path || mockDataRootInfo.defaultPath);
|
||||
mockDataRootInfo = {
|
||||
...mockDataRootInfo,
|
||||
path: nextPath,
|
||||
driverPath: `${nextPath}/drivers`,
|
||||
isDefaultPath: nextPath === mockDataRootInfo.defaultPath,
|
||||
};
|
||||
return { success: true, message: '数据目录已更新', data: cloneBrowserMockValue(mockDataRootInfo) };
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
8
frontend/wailsjs/go/app/App.d.ts
vendored
8
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -6,6 +6,8 @@ import {redis} from '../models';
|
||||
|
||||
export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
|
||||
|
||||
export function ApplyDataRootDirectory(arg1:string,arg2:boolean):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelQuery(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function CancelSQLFileExecution(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -86,6 +88,8 @@ export function GenerateQueryID():Promise<string>;
|
||||
|
||||
export function GetAppInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDataRootDirectoryInfo():Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverStatusList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function GetDriverVersionList(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
@@ -126,6 +130,8 @@ export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:strin
|
||||
|
||||
export function OpenDownloadedUpdateDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDataRootDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -198,6 +204,8 @@ export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<co
|
||||
|
||||
export function SelectDatabaseFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverPackageDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ApplyDataRootDirectory(arg1, arg2) {
|
||||
return window['go']['app']['App']['ApplyDataRootDirectory'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function CancelQuery(arg1) {
|
||||
return window['go']['app']['App']['CancelQuery'](arg1);
|
||||
}
|
||||
@@ -166,6 +170,10 @@ export function GetAppInfo() {
|
||||
return window['go']['app']['App']['GetAppInfo']();
|
||||
}
|
||||
|
||||
export function GetDataRootDirectoryInfo() {
|
||||
return window['go']['app']['App']['GetDataRootDirectoryInfo']();
|
||||
}
|
||||
|
||||
export function GetDriverStatusList(arg1, arg2) {
|
||||
return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2);
|
||||
}
|
||||
@@ -246,6 +254,10 @@ export function OpenDownloadedUpdateDirectory() {
|
||||
return window['go']['app']['App']['OpenDownloadedUpdateDirectory']();
|
||||
}
|
||||
|
||||
export function OpenDataRootDirectory() {
|
||||
return window['go']['app']['App']['OpenDataRootDirectory']();
|
||||
}
|
||||
|
||||
export function OpenSQLFile() {
|
||||
return window['go']['app']['App']['OpenSQLFile']();
|
||||
}
|
||||
@@ -390,6 +402,10 @@ export function SelectDatabaseFile(arg1, arg2) {
|
||||
return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SelectDataRootDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDataRootDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
aicontext "GoNavi-Wails/internal/ai/context"
|
||||
"GoNavi-Wails/internal/ai/provider"
|
||||
"GoNavi-Wails/internal/ai/safety"
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
|
||||
@@ -1143,11 +1144,7 @@ func (s *Service) AIDeleteSession(sessionID string) error {
|
||||
// --- 工具函数 ---
|
||||
|
||||
func resolveConfigDir() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
homeDir = "."
|
||||
}
|
||||
return filepath.Join(homeDir, ".gonavi")
|
||||
return appdata.MustResolveActiveRoot()
|
||||
}
|
||||
|
||||
func maskAPIKey(apiKey string) string {
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
@@ -89,6 +91,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
if strings.TrimSpace(a.configDir) == "" {
|
||||
a.configDir = resolveAppConfigDir()
|
||||
}
|
||||
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(a.configDir))
|
||||
logger.Init()
|
||||
a.loadPersistedGlobalProxy()
|
||||
installMacNativeWindowDiagnostics(logger.Path())
|
||||
@@ -136,6 +139,21 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
logger.Close()
|
||||
}
|
||||
|
||||
func dataRootInfoPayload(activeRoot string) map[string]interface{} {
|
||||
defaultRoot := appdata.DefaultRoot()
|
||||
currentRoot := strings.TrimSpace(activeRoot)
|
||||
if currentRoot == "" {
|
||||
currentRoot = appdata.MustResolveActiveRoot()
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"path": currentRoot,
|
||||
"defaultPath": defaultRoot,
|
||||
"driverPath": appdata.DriverRoot(currentRoot),
|
||||
"isDefaultPath": filepath.Clean(currentRoot) == filepath.Clean(defaultRoot),
|
||||
"bootstrapPath": appdata.BootstrapPath(),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
|
||||
normalized := config
|
||||
normalized.ID = ""
|
||||
|
||||
199
internal/app/methods_data_root.go
Normal file
199
internal/app/methods_data_root.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
stdRuntime "runtime"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
var migratableDataRootEntries = []string{
|
||||
"connections.json",
|
||||
"global_proxy.json",
|
||||
"ai_config.json",
|
||||
"sessions",
|
||||
"drivers",
|
||||
}
|
||||
|
||||
func (a *App) GetDataRootDirectoryInfo() connection.QueryResult {
|
||||
return connection.QueryResult{Success: true, Message: "OK", Data: dataRootInfoPayload(a.configDir)}
|
||||
}
|
||||
|
||||
func (a *App) SelectDataRootDirectory(currentDir string) connection.QueryResult {
|
||||
defaultDir := strings.TrimSpace(currentDir)
|
||||
if defaultDir == "" {
|
||||
defaultDir = appdata.MustResolveActiveRoot()
|
||||
}
|
||||
if !filepath.IsAbs(defaultDir) {
|
||||
if abs, err := filepath.Abs(defaultDir); err == nil {
|
||||
defaultDir = abs
|
||||
}
|
||||
}
|
||||
|
||||
selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "选择 GoNavi 数据目录",
|
||||
DefaultDirectory: defaultDir,
|
||||
CanCreateDirectories: true,
|
||||
})
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(selection) == "" {
|
||||
return connection.QueryResult{Success: false, Message: "已取消"}
|
||||
}
|
||||
resolved, err := appdata.ResolveRoot(selection)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: dataRootInfoPayload(resolved)}
|
||||
}
|
||||
|
||||
func (a *App) ApplyDataRootDirectory(directory string, migrate bool) connection.QueryResult {
|
||||
currentRoot := appdata.MustResolveActiveRoot()
|
||||
targetRoot, err := appdata.ResolveRoot(directory)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if filepath.Clean(currentRoot) == filepath.Clean(targetRoot) {
|
||||
a.configDir = targetRoot
|
||||
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(targetRoot))
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: "数据目录未发生变化",
|
||||
Data: dataRootInfoPayload(targetRoot),
|
||||
}
|
||||
}
|
||||
|
||||
if migrate {
|
||||
if err := migrateDataRootContents(currentRoot, targetRoot); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
}
|
||||
|
||||
appliedRoot, err := appdata.SetActiveRoot(targetRoot)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
a.configDir = appliedRoot
|
||||
db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(appliedRoot))
|
||||
message := "数据目录已更新,建议重启应用以让 AI 与其他运行态模块完全切换到新目录"
|
||||
if migrate {
|
||||
message = "数据已迁移并切换到新目录,建议重启应用以完成全部模块切换"
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: message, Data: dataRootInfoPayload(appliedRoot)}
|
||||
}
|
||||
|
||||
func (a *App) OpenDataRootDirectory() connection.QueryResult {
|
||||
root := appdata.MustResolveActiveRoot()
|
||||
if stat, err := os.Stat(root); err != nil || !stat.IsDir() {
|
||||
return connection.QueryResult{Success: false, Message: "数据目录不存在或不可访问"}
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
switch stdRuntime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", root)
|
||||
case "windows":
|
||||
cmd = exec.Command("explorer", root)
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", root)
|
||||
default:
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("当前平台暂不支持打开目录:%s", stdRuntime.GOOS)}
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
logger.Error(err, "打开数据目录失败")
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("打开数据目录失败:%v", err)}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: "已打开数据目录", Data: dataRootInfoPayload(root)}
|
||||
}
|
||||
|
||||
func migrateDataRootContents(sourceRoot string, targetRoot string) error {
|
||||
sourceRoot = strings.TrimSpace(sourceRoot)
|
||||
targetRoot = strings.TrimSpace(targetRoot)
|
||||
if sourceRoot == "" || targetRoot == "" {
|
||||
return fmt.Errorf("数据目录不能为空")
|
||||
}
|
||||
if filepath.Clean(sourceRoot) == filepath.Clean(targetRoot) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
|
||||
return fmt.Errorf("创建目标数据目录失败:%w", err)
|
||||
}
|
||||
for _, name := range migratableDataRootEntries {
|
||||
sourcePath := filepath.Join(sourceRoot, name)
|
||||
info, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("读取源数据失败(%s):%w", name, err)
|
||||
}
|
||||
targetPath := filepath.Join(targetRoot, name)
|
||||
if info.IsDir() {
|
||||
if err := copyDir(sourcePath, targetPath); err != nil {
|
||||
return fmt.Errorf("迁移目录失败(%s):%w", name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := copyFile(sourcePath, targetPath, info.Mode()); err != nil {
|
||||
return fmt.Errorf("迁移文件失败(%s):%w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyDir(sourceDir string, targetDir string) error {
|
||||
return filepath.WalkDir(sourceDir, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
relativePath, err := filepath.Rel(sourceDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath := filepath.Join(targetDir, relativePath)
|
||||
if entry.IsDir() {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(targetPath, info.Mode())
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return copyFile(path, targetPath, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(sourcePath string, targetPath string, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceFile, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
targetFile, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer targetFile.Close()
|
||||
|
||||
if _, err := io.Copy(targetFile, sourceFile); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(targetPath, mode)
|
||||
}
|
||||
32
internal/app/methods_data_root_test.go
Normal file
32
internal/app/methods_data_root_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateDataRootContentsCopiesKnownFilesAndDirectories(t *testing.T) {
|
||||
sourceRoot := t.TempDir()
|
||||
targetRoot := filepath.Join(t.TempDir(), "gonavi-data")
|
||||
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "connections.json"), []byte(`{"connections":[]}`), 0o644); err != nil {
|
||||
t.Fatalf("write connections.json failed: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(sourceRoot, "sessions"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions failed: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceRoot, "sessions", "s1.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatalf("write session file failed: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateDataRootContents(sourceRoot, targetRoot); err != nil {
|
||||
t.Fatalf("migrateDataRootContents returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(targetRoot, "connections.json")); err != nil {
|
||||
t.Fatalf("expected connections.json in target root: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(targetRoot, "sessions", "s1.json")); err != nil {
|
||||
t.Fatalf("expected session file in target root: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/secretstore"
|
||||
"github.com/google/uuid"
|
||||
@@ -38,11 +39,7 @@ type savedConnectionRepository struct {
|
||||
}
|
||||
|
||||
func resolveAppConfigDir() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(homeDir) == "" {
|
||||
return "."
|
||||
}
|
||||
return filepath.Join(homeDir, ".gonavi")
|
||||
return appdata.MustResolveActiveRoot()
|
||||
}
|
||||
|
||||
func newSavedConnectionRepository(configDir string, store secretstore.SecretStore) *savedConnectionRepository {
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
|
||||
package app
|
||||
|
||||
func installMacNativeWindowDiagnostics(logPath string) {}
|
||||
|
||||
func setMacNativeWindowControls(enabled bool) {}
|
||||
|
||||
10
internal/app/window_style_stub_test.go
Normal file
10
internal/app/window_style_stub_test.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build !darwin
|
||||
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInstallMacNativeWindowDiagnosticsNoopOnNonDarwin(t *testing.T) {
|
||||
installMacNativeWindowDiagnostics("")
|
||||
installMacNativeWindowDiagnostics("ignored.log")
|
||||
}
|
||||
112
internal/appdata/root.go
Normal file
112
internal/appdata/root.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package appdata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const bootstrapFileName = "storage_root.json"
|
||||
|
||||
type bootstrapConfig struct {
|
||||
DataRoot string `json:"dataRoot"`
|
||||
}
|
||||
|
||||
func DefaultRoot() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(homeDir) == "" {
|
||||
return "."
|
||||
}
|
||||
return filepath.Join(homeDir, ".gonavi")
|
||||
}
|
||||
|
||||
func BootstrapPath() string {
|
||||
return filepath.Join(DefaultRoot(), bootstrapFileName)
|
||||
}
|
||||
|
||||
func normalizeRoot(root string) (string, error) {
|
||||
trimmed := strings.TrimSpace(root)
|
||||
if trimmed == "" {
|
||||
trimmed = DefaultRoot()
|
||||
}
|
||||
abs, err := filepath.Abs(trimmed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func ResolveRoot(root string) (string, error) {
|
||||
return normalizeRoot(root)
|
||||
}
|
||||
|
||||
func ResolveActiveRoot() (string, error) {
|
||||
defaultRoot, err := normalizeRoot(DefaultRoot())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(BootstrapPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return defaultRoot, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
var cfg bootstrapConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(cfg.DataRoot) == "" {
|
||||
return defaultRoot, nil
|
||||
}
|
||||
return normalizeRoot(cfg.DataRoot)
|
||||
}
|
||||
|
||||
func MustResolveActiveRoot() string {
|
||||
root, err := ResolveActiveRoot()
|
||||
if err != nil {
|
||||
return DefaultRoot()
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func DriverRoot(activeRoot string) string {
|
||||
root := strings.TrimSpace(activeRoot)
|
||||
if root == "" {
|
||||
root = MustResolveActiveRoot()
|
||||
}
|
||||
return filepath.Join(root, "drivers")
|
||||
}
|
||||
|
||||
func SetActiveRoot(root string) (string, error) {
|
||||
targetRoot, err := normalizeRoot(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defaultRoot, err := normalizeRoot(DefaultRoot())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(targetRoot, 0o755); err != nil {
|
||||
return "", fmt.Errorf("创建数据目录失败:%w", err)
|
||||
}
|
||||
if targetRoot == defaultRoot {
|
||||
if err := os.Remove(BootstrapPath()); err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
return defaultRoot, nil
|
||||
}
|
||||
if err := os.MkdirAll(defaultRoot, 0o755); err != nil {
|
||||
return "", fmt.Errorf("创建默认引导目录失败:%w", err)
|
||||
}
|
||||
payload, err := json.MarshalIndent(bootstrapConfig{DataRoot: targetRoot}, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(BootstrapPath(), payload, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return targetRoot, nil
|
||||
}
|
||||
69
internal/appdata/root_test.go
Normal file
69
internal/appdata/root_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package appdata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveActiveRootDefaultsToLegacyGonaviDir(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
root, err := ResolveActiveRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveActiveRoot returned error: %v", err)
|
||||
}
|
||||
expected := filepath.Join(homeDir, ".gonavi")
|
||||
if root != expected {
|
||||
t.Fatalf("expected default root %q, got %q", expected, root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveRootWritesBootstrapAndResolveUsesIt(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
customRoot := filepath.Join(t.TempDir(), "gonavi-data")
|
||||
savedRoot, err := SetActiveRoot(customRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("SetActiveRoot returned error: %v", err)
|
||||
}
|
||||
if savedRoot != customRoot {
|
||||
t.Fatalf("expected saved root %q, got %q", customRoot, savedRoot)
|
||||
}
|
||||
if _, err := os.Stat(BootstrapPath()); err != nil {
|
||||
t.Fatalf("expected bootstrap file to exist: %v", err)
|
||||
}
|
||||
resolvedRoot, err := ResolveActiveRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveActiveRoot returned error: %v", err)
|
||||
}
|
||||
if resolvedRoot != customRoot {
|
||||
t.Fatalf("expected custom root %q, got %q", customRoot, resolvedRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveRootResetToDefaultRemovesBootstrap(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("USERPROFILE", homeDir)
|
||||
|
||||
customRoot := filepath.Join(t.TempDir(), "gonavi-data")
|
||||
if _, err := SetActiveRoot(customRoot); err != nil {
|
||||
t.Fatalf("SetActiveRoot custom returned error: %v", err)
|
||||
}
|
||||
defaultRoot, err := SetActiveRoot("")
|
||||
if err != nil {
|
||||
t.Fatalf("SetActiveRoot default returned error: %v", err)
|
||||
}
|
||||
expectedDefault := filepath.Join(homeDir, ".gonavi")
|
||||
if defaultRoot != expectedDefault {
|
||||
t.Fatalf("expected default root %q, got %q", expectedDefault, defaultRoot)
|
||||
}
|
||||
if _, err := os.Stat(BootstrapPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected bootstrap file to be removed, got err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"GoNavi-Wails/internal/appdata"
|
||||
)
|
||||
|
||||
// coreBuiltinDrivers 是始终内置可用的核心驱动,无需额外安装即可使用。
|
||||
@@ -110,13 +112,7 @@ func IsBuiltinDriver(driverType string) bool {
|
||||
}
|
||||
|
||||
func defaultExternalDriverDownloadDirectory() string {
|
||||
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
|
||||
return filepath.Join(home, ".gonavi", "drivers")
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
|
||||
return filepath.Join(wd, ".gonavi-drivers")
|
||||
}
|
||||
return ".gonavi-drivers"
|
||||
return appdata.DriverRoot("")
|
||||
}
|
||||
|
||||
func resolveExternalDriverRoot(downloadDir string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user