Merge pull request #131 from Syngnat/release/0.4.8

Release/0.4.8
This commit is contained in:
Syngnat
2026-02-27 14:24:16 +08:00
committed by GitHub
13 changed files with 1376 additions and 149 deletions

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress } from 'antd';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, BulbOutlined, BulbFilled, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined } from '@ant-design/icons';
import { Environment, EventsOn } from '../wailsjs/runtime/runtime';
import { Environment, EventsOn, WindowFullscreen, WindowIsFullscreen, WindowIsMaximised, WindowMaximise } from '../wailsjs/runtime/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
@@ -26,6 +26,8 @@ function App() {
const setTheme = useStore(state => state.setTheme);
const appearance = useStore(state => state.appearance);
const setAppearance = useStore(state => state.setAppearance);
const startupFullscreen = useStore(state => state.startupFullscreen);
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
const darkMode = themeMode === 'dark';
const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity);
const effectiveBlur = normalizeBlurForPlatform(appearance.blur);
@@ -56,6 +58,84 @@ function App() {
};
}, []);
useEffect(() => {
let cancelled = false;
let startupWindowTimer: number | null = null;
const maxApplyAttempts = 6;
const applyRetryDelayMs = 400;
const settleDelayMs = 160;
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
try {
if (await WindowIsFullscreen()) {
return true;
}
} catch (_) {
// ignore
}
try {
if (await WindowIsMaximised()) {
return true;
}
} catch (_) {
// ignore
}
return false;
};
const applyStartupWindowPreference = (attempt: number) => {
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
startupWindowTimer = window.setTimeout(() => {
if (cancelled) {
return;
}
if (!useStore.getState().startupFullscreen) {
return;
}
Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
return;
}
// 优先尝试全屏,若当前平台/时机不生效,后续走最大化兜底。
WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
return;
}
if (attempt < maxApplyAttempts) {
applyStartupWindowPreference(attempt + 1);
}
});
}, 300);
};
if (useStore.persist.hasHydrated()) {
applyStartupWindowPreference(1);
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled) {
return;
}
applyStartupWindowPreference(1);
});
return () => {
cancelled = true;
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
unsubscribeHydration();
};
}, []);
// Background Helper
const getBg = (darkHex: string, lightHex: string) => {
if (!darkMode) return `rgba(255, 255, 255, ${effectiveOpacity})`; // Light mode usually white
@@ -915,6 +995,16 @@ function App() {
</>
)}
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span></span>
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
*
</div>
</div>
</div>
</Modal>

View File

@@ -619,6 +619,8 @@ const DataGrid: React.FC<DataGridProps> = ({
// 使用 ref 来优化拖拽性能,完全避免状态更新
const cellSelectionRafRef = useRef<number | null>(null);
const cellSelectionScrollRafRef = useRef<number | null>(null);
const cellSelectionAutoScrollRafRef = useRef<number | null>(null);
const cellSelectionPointerRef = useRef<{ x: number; y: number } | null>(null);
const isDraggingRef = useRef(false);
// 导入预览 Modal 状态
@@ -1102,6 +1104,11 @@ const DataGrid: React.FC<DataGridProps> = ({
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
}, [batchEditValue, batchEditSetNull, addedRows, modifiedRows, rowKeyStr, updateCellSelection]);
@@ -1111,8 +1118,12 @@ const DataGrid: React.FC<DataGridProps> = ({
const container = containerRef.current;
if (!container) return;
const EDGE_THRESHOLD_PX = 28;
const MIN_SCROLL_STEP = 8;
const MAX_SCROLL_STEP = 24;
const getCellInfo = (target: HTMLElement): { rowKey: string; colName: string } | null => {
const getCellInfo = (target: HTMLElement | null): { rowKey: string; colName: string } | null => {
if (!target) return null;
const td = target.closest('td[data-row-key][data-col-name]') as HTMLElement;
if (!td) return null;
const rowKey = td.getAttribute('data-row-key');
@@ -1121,35 +1132,12 @@ const DataGrid: React.FC<DataGridProps> = ({
return { rowKey, colName };
};
const onMouseDown = (e: MouseEvent) => {
const cellInfo = getCellInfo(e.target as HTMLElement);
if (!cellInfo) return;
e.preventDefault();
isDraggingRef.current = true;
const currentData = displayDataRef.current;
const nextRowIndexMap = new Map<string, number>();
currentData.forEach((r, idx) => {
const k = r?.[GONAVI_ROW_KEY];
if (k === undefined) return;
nextRowIndexMap.set(String(k), idx);
});
rowIndexMapRef.current = nextRowIndexMap;
const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1;
const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex };
currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
updateCellSelection(currentSelectionRef.current);
const getCellInfoFromPoint = (x: number, y: number): { rowKey: string; colName: string } | null => {
const target = document.elementFromPoint(x, y) as HTMLElement | null;
return getCellInfo(target);
};
const onMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current || !selectionStartRef.current) return;
const cellInfo = getCellInfo(e.target as HTMLElement);
if (!cellInfo) return;
// 使用 RAF 节流
const scheduleSelectionUpdate = (cellInfo: { rowKey: string; colName: string }) => {
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
}
@@ -1188,9 +1176,124 @@ const DataGrid: React.FC<DataGridProps> = ({
});
};
const stopAutoScroll = () => {
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
};
const getScrollStep = (distanceToEdge: number): number => {
const ratio = Math.min(1, Math.max(0, distanceToEdge / EDGE_THRESHOLD_PX));
return Math.round(MIN_SCROLL_STEP + (MAX_SCROLL_STEP - MIN_SCROLL_STEP) * ratio);
};
const autoScrollTick = () => {
if (!isDraggingRef.current || !selectionStartRef.current) {
stopAutoScroll();
return;
}
const pointer = cellSelectionPointerRef.current;
const tableBody = container.querySelector('.ant-table-body') as HTMLElement | null;
if (!pointer || !tableBody) {
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
return;
}
const rect = tableBody.getBoundingClientRect();
const maxScrollTop = Math.max(0, tableBody.scrollHeight - tableBody.clientHeight);
const maxScrollLeft = Math.max(0, tableBody.scrollWidth - tableBody.clientWidth);
let deltaY = 0;
let deltaX = 0;
if (pointer.y < rect.top + EDGE_THRESHOLD_PX && tableBody.scrollTop > 0) {
const distance = rect.top + EDGE_THRESHOLD_PX - pointer.y;
deltaY = -getScrollStep(distance);
} else if (pointer.y > rect.bottom - EDGE_THRESHOLD_PX && tableBody.scrollTop < maxScrollTop) {
const distance = pointer.y - (rect.bottom - EDGE_THRESHOLD_PX);
deltaY = getScrollStep(distance);
}
if (pointer.x < rect.left + EDGE_THRESHOLD_PX && tableBody.scrollLeft > 0) {
const distance = rect.left + EDGE_THRESHOLD_PX - pointer.x;
deltaX = -getScrollStep(distance);
} else if (pointer.x > rect.right - EDGE_THRESHOLD_PX && tableBody.scrollLeft < maxScrollLeft) {
const distance = pointer.x - (rect.right - EDGE_THRESHOLD_PX);
deltaX = getScrollStep(distance);
}
let didScroll = false;
if (deltaY !== 0) {
const nextTop = Math.max(0, Math.min(maxScrollTop, tableBody.scrollTop + deltaY));
if (nextTop !== tableBody.scrollTop) {
tableBody.scrollTop = nextTop;
didScroll = true;
}
}
if (deltaX !== 0) {
const nextLeft = Math.max(0, Math.min(maxScrollLeft, tableBody.scrollLeft + deltaX));
if (nextLeft !== tableBody.scrollLeft) {
tableBody.scrollLeft = nextLeft;
didScroll = true;
}
}
if (didScroll) {
const cellInfo = getCellInfoFromPoint(pointer.x, pointer.y);
if (cellInfo) scheduleSelectionUpdate(cellInfo);
}
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
};
const ensureAutoScroll = () => {
if (cellSelectionAutoScrollRafRef.current !== null) return;
cellSelectionAutoScrollRafRef.current = requestAnimationFrame(autoScrollTick);
};
const onMouseDown = (e: MouseEvent) => {
const target = e.target instanceof HTMLElement ? e.target : null;
const cellInfo = getCellInfo(target);
if (!cellInfo) return;
e.preventDefault();
isDraggingRef.current = true;
cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY };
const currentData = displayDataRef.current;
const nextRowIndexMap = new Map<string, number>();
currentData.forEach((r, idx) => {
const k = r?.[GONAVI_ROW_KEY];
if (k === undefined) return;
nextRowIndexMap.set(String(k), idx);
});
rowIndexMapRef.current = nextRowIndexMap;
const startRowIndex = nextRowIndexMap.get(cellInfo.rowKey) ?? -1;
const startColIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
selectionStartRef.current = { rowKey: cellInfo.rowKey, colName: cellInfo.colName, rowIndex: startRowIndex, colIndex: startColIndex };
currentSelectionRef.current = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
updateCellSelection(currentSelectionRef.current);
ensureAutoScroll();
};
const onMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current || !selectionStartRef.current) return;
cellSelectionPointerRef.current = { x: e.clientX, y: e.clientY };
ensureAutoScroll();
const target = e.target instanceof HTMLElement ? e.target : null;
const cellInfo = getCellInfo(target) || getCellInfoFromPoint(e.clientX, e.clientY);
if (!cellInfo) return;
scheduleSelectionUpdate(cellInfo);
};
const onMouseUp = () => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
stopAutoScroll();
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
@@ -1231,6 +1334,8 @@ const DataGrid: React.FC<DataGridProps> = ({
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
stopAutoScroll();
cellSelectionPointerRef.current = null;
isDraggingRef.current = false;
};
}, [cellEditMode, columnNames, columnIndexMap, updateCellSelection]);
@@ -2332,6 +2437,7 @@ const DataGrid: React.FC<DataGridProps> = ({
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
cellSelectionRafRef.current = null;
@@ -2340,6 +2446,10 @@ const DataGrid: React.FC<DataGridProps> = ({
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
if (!next) setBatchEditModalOpen(false);
message.info(next ? '已进入单元格编辑模式,可拖拽选择多个单元格' : '已退出单元格编辑模式');
@@ -2403,12 +2513,26 @@ const DataGrid: React.FC<DataGridProps> = ({
onChange={(val) => {
const nextMode = String(val) as GridViewMode;
if (nextMode === 'json' && cellEditMode) {
setCellEditMode(false);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
updateCellSelection(new Set());
}
setCellEditMode(false);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
cellSelectionRafRef.current = null;
}
if (cellSelectionScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionScrollRafRef.current);
cellSelectionScrollRafRef.current = null;
}
if (cellSelectionAutoScrollRafRef.current !== null) {
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
cellSelectionAutoScrollRafRef.current = null;
}
updateCellSelection(new Set());
}
if (nextMode === 'text') {
const selectedKey = selectedRowKeys[0];
if (selectedKey !== undefined) {

View File

@@ -1,16 +1,19 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Collapse, Modal, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { DeleteOutlined, DownloadOutlined, FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import {
CheckDriverNetworkStatus,
DownloadDriverPackage,
GetDriverVersionList,
GetDriverVersionPackageSize,
GetDriverStatusList,
InstallLocalDriverPackage,
RemoveDriverPackage,
SelectDriverPackageFile,
} from '../../wailsjs/go/app/App';
const { Text } = Typography;
const { Paragraph, Text } = Typography;
type DriverStatusRow = {
type: string;
@@ -23,6 +26,10 @@ type DriverStatusRow = {
packageInstalled: boolean;
connectable: boolean;
defaultDownloadUrl?: string;
installDir?: string;
packagePath?: string;
executablePath?: string;
downloadedAt?: string;
message?: string;
};
@@ -39,6 +46,32 @@ type ProgressState = {
percent: number;
};
type DriverLogEntry = {
time: string;
text: string;
signature: string;
};
type DriverNetworkProbe = {
name: string;
url: string;
reachable: boolean;
httpStatus?: number;
latencyMs?: number;
error?: string;
};
type DriverNetworkStatus = {
reachable: boolean;
summary: string;
recommendedProxy: boolean;
proxyConfigured: boolean;
proxyEnv?: Record<string, string>;
checks: DriverNetworkProbe[];
checkedAt?: string;
logPath?: string;
};
type DriverVersionOption = {
version: string;
downloadUrl: string;
@@ -101,14 +134,65 @@ const buildVersionSelectOptions = (options: DriverVersionOption[]) => {
const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');
const [networkChecking, setNetworkChecking] = useState(false);
const [networkStatus, setNetworkStatus] = useState<DriverNetworkStatus | null>(null);
const [rows, setRows] = useState<DriverStatusRow[]>([]);
const [actionDriver, setActionDriver] = useState('');
const [progressMap, setProgressMap] = useState<Record<string, ProgressState>>({});
const [operationLogMap, setOperationLogMap] = useState<Record<string, DriverLogEntry[]>>({});
const [logDriverType, setLogDriverType] = useState('');
const [logModalOpen, setLogModalOpen] = useState(false);
const [versionMap, setVersionMap] = useState<Record<string, DriverVersionOption[]>>({});
const [selectedVersionMap, setSelectedVersionMap] = useState<Record<string, string>>({});
const [versionLoadingMap, setVersionLoadingMap] = useState<Record<string, boolean>>({});
const [versionSizeLoadingMap, setVersionSizeLoadingMap] = useState<Record<string, boolean>>({});
const appendOperationLog = useCallback((
driverType: string,
text: string,
signature?: string,
mode: 'append' | 'update-last' = 'append',
) => {
const normalized = String(driverType || '').trim().toLowerCase();
const content = String(text || '').trim();
if (!normalized || !content) {
return;
}
const sign = String(signature || content).trim() || content;
const now = new Date().toLocaleTimeString();
setOperationLogMap((prev) => {
const history = prev[normalized] || [];
if (history.length > 0) {
const last = history[history.length - 1];
if (last.signature === sign) {
if (mode === 'update-last') {
if (last.text === content) {
return prev;
}
const nextHistory = [...history];
nextHistory[nextHistory.length - 1] = {
...last,
text: content,
time: now,
};
return { ...prev, [normalized]: nextHistory };
}
return prev;
}
}
const nextHistory = [
...history,
{
time: now,
text: content,
signature: sign,
},
];
const sliced = nextHistory.length > 200 ? nextHistory.slice(nextHistory.length - 200) : nextHistory;
return { ...prev, [normalized]: sliced };
});
}, []);
const refreshStatus = useCallback(async (toastOnError = true) => {
setLoading(true);
try {
@@ -139,6 +223,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
packageInstalled: !!item.packageInstalled,
connectable: !!item.connectable,
defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined,
installDir: String(item.installDir || '').trim() || undefined,
packagePath: String(item.packagePath || '').trim() || undefined,
executablePath: String(item.executablePath || '').trim() || undefined,
downloadedAt: String(item.downloadedAt || '').trim() || undefined,
message: String(item.message || '').trim() || undefined,
}));
setRows(nextRows);
@@ -151,6 +239,45 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
}
}, [downloadDir]);
const checkNetworkStatus = useCallback(async (toastOnError = false) => {
setNetworkChecking(true);
try {
const res = await CheckDriverNetworkStatus();
if (!res?.success) {
if (toastOnError) {
message.error(res?.message || '驱动网络检测失败');
}
return;
}
const data = (res?.data || {}) as any;
const checks = Array.isArray(data.checks) ? data.checks : [];
const normalizedChecks: DriverNetworkProbe[] = checks.map((item: any) => ({
name: String(item.name || '').trim(),
url: String(item.url || '').trim(),
reachable: !!item.reachable,
httpStatus: Number(item.httpStatus || 0) || undefined,
latencyMs: Number(item.latencyMs || 0) || undefined,
error: String(item.error || '').trim() || undefined,
}));
setNetworkStatus({
reachable: !!data.reachable,
summary: String(data.summary || '').trim() || '驱动网络检测已完成',
recommendedProxy: !!data.recommendedProxy,
proxyConfigured: !!data.proxyConfigured,
proxyEnv: (data.proxyEnv || {}) as Record<string, string>,
checkedAt: String(data.checkedAt || '').trim() || undefined,
checks: normalizedChecks,
logPath: String(data.logPath || '').trim() || undefined,
});
} catch (err: any) {
if (toastOnError) {
message.error(`驱动网络检测失败:${err?.message || String(err)}`);
}
} finally {
setNetworkChecking(false);
}
}, []);
const loadVersionOptions = useCallback(async (row: DriverStatusRow, toastOnError = false) => {
if (row.builtIn) {
return [] as DriverVersionOption[];
@@ -305,7 +432,8 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
return;
}
refreshStatus(false);
}, [open, refreshStatus]);
checkNetworkStatus(false);
}, [checkNetworkStatus, open, refreshStatus]);
useEffect(() => {
if (!open) {
@@ -330,11 +458,16 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
percent,
},
}));
const progressText = `${Math.round(percent)}%`;
const statusText = String(status || '').toUpperCase();
const lineText = `[${statusText}] ${messageText || '-'} (${progressText})`;
const lineSignature = `${statusText}|${messageText || '-'}`;
appendOperationLog(driverType, lineText, lineSignature, 'update-last');
});
return () => {
off();
};
}, [open]);
}, [appendOperationLog, open]);
const installDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
@@ -346,6 +479,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
percent: 0,
},
}));
appendOperationLog(row.type, '[START] 开始自动安装');
try {
let options = versionMap[row.type] || [];
if (options.length === 0) {
@@ -361,25 +495,81 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
const result = await DownloadDriverPackage(row.type, selectedVersion, selectedDownloadURL, downloadDir);
if (!result?.success) {
message.error(result?.message || `安装 ${row.name} 失败`);
const errText = result?.message || `安装 ${row.name} 失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
const versionTip = selectedVersion ? `${selectedVersion}` : '';
appendOperationLog(row.type, `[DONE] 自动安装完成 ${versionTip}`);
message.success(`${row.name}${versionTip} 已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
}
}, [downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
}, [appendOperationLog, downloadDir, loadVersionOptions, refreshStatus, selectedVersionMap, versionMap]);
const installDriverFromLocalFile = useCallback(async (row: DriverStatusRow) => {
const fileRes = await SelectDriverPackageFile(downloadDir);
if (!fileRes?.success) {
if (String(fileRes?.message || '') !== 'Cancelled') {
message.error(fileRes?.message || '选择本地驱动包失败');
}
return;
}
const filePath = String((fileRes?.data as any)?.path || '').trim();
if (!filePath) {
message.error('未选择有效的驱动包文件');
return;
}
setActionDriver(row.type);
setProgressMap((prev) => ({
...prev,
[row.type]: {
status: 'start',
message: '开始导入本地驱动包',
percent: 0,
},
}));
appendOperationLog(row.type, `[START] 开始本地导入:${filePath}`);
try {
const result = await InstallLocalDriverPackage(row.type, filePath, downloadDir);
if (!result?.success) {
const errText = result?.message || `导入 ${row.name} 本地驱动包失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
appendOperationLog(row.type, '[DONE] 本地导入安装完成');
message.success(`${row.name} 本地驱动包已安装启用`);
refreshStatus(false);
} finally {
setActionDriver('');
}
}, [appendOperationLog, downloadDir, refreshStatus]);
const openDriverLog = useCallback((driverType: string) => {
const normalized = String(driverType || '').trim().toLowerCase();
if (!normalized) {
return;
}
setLogDriverType(normalized);
setLogModalOpen(true);
}, []);
const removeDriver = useCallback(async (row: DriverStatusRow) => {
setActionDriver(row.type);
appendOperationLog(row.type, '[START] 开始移除驱动');
try {
const result = await RemoveDriverPackage(row.type, downloadDir);
if (!result?.success) {
message.error(result?.message || `移除 ${row.name} 失败`);
const errText = result?.message || `移除 ${row.name} 失败`;
appendOperationLog(row.type, `[ERROR] ${errText}`);
message.error(errText);
return;
}
appendOperationLog(row.type, '[DONE] 驱动移除完成');
message.success(`${row.name} 已移除`);
setProgressMap((prev) => {
const next = { ...prev };
@@ -390,7 +580,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
} finally {
setActionDriver('');
}
}, [downloadDir, refreshStatus]);
}, [appendOperationLog, downloadDir, refreshStatus]);
const columns = useMemo(() => {
return [
@@ -400,6 +590,25 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
key: 'name',
width: 150,
},
{
title: '安装位置',
key: 'installPath',
width: 260,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary"></Text>;
}
const installPath = row.executablePath || row.installDir || '-';
if (installPath === '-') {
return <Text type="secondary">-</Text>;
}
return (
<Text copyable={{ text: installPath }} style={{ fontSize: 12 }}>
{installPath}
</Text>
);
},
},
{
title: '安装包大小',
dataIndex: 'packageSizeText',
@@ -511,7 +720,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
{
title: '操作',
key: 'actions',
width: 190,
width: 320,
render: (_: string, row: DriverStatusRow) => {
if (row.builtIn) {
return <Text type="secondary">-</Text>;
@@ -521,19 +730,20 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
if (isSlimBuildUnavailable && !row.packageInstalled) {
return <Text type="secondary"> Full </Text>;
}
if (row.connectable) {
return (
<Button
danger
icon={<DeleteOutlined />}
loading={loadingAction}
onClick={() => removeDriver(row)}
>
</Button>
);
}
return (
const logs = operationLogMap[row.type] || [];
const hasLogs = logs.length > 0;
const mainAction = row.connectable ? (
<Button
danger
icon={<DeleteOutlined />}
loading={loadingAction}
onClick={() => removeDriver(row)}
>
</Button>
) : (
<Button
type="primary"
icon={<DownloadOutlined />}
@@ -543,10 +753,41 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
</Button>
);
return (
<Space size={8} wrap>
{mainAction}
<Button
icon={<FileSearchOutlined />}
loading={loadingAction}
onClick={() => installDriverFromLocalFile(row)}
>
</Button>
<Button
type={hasLogs ? 'default' : 'text'}
disabled={!hasLogs}
onClick={() => openDriverLog(row.type)}
>
</Button>
</Space>
);
},
},
];
}, [actionDriver, installDriver, loadVersionOptions, loadVersionPackageSize, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
}, [actionDriver, installDriver, installDriverFromLocalFile, loadVersionOptions, loadVersionPackageSize, openDriverLog, operationLogMap, progressMap, removeDriver, selectedVersionMap, versionLoadingMap, versionMap, versionSizeLoadingMap]);
const activeLogRow = useMemo(() => {
if (!logDriverType) {
return undefined;
}
return rows.find((item) => item.type === logDriverType);
}, [logDriverType, rows]);
const activeDriverLogs = operationLogMap[logDriverType] || [];
const activeDriverLogLines = activeDriverLogs.map((item) => `[${item.time}] ${item.text}`);
const proxyEnvEntries = Object.entries(networkStatus?.proxyEnv || {});
return (
<Modal
@@ -554,11 +795,23 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
open={open}
onCancel={onClose}
width={980}
style={{ top: 24 }}
styles={{
body: {
maxHeight: 'calc(100vh - 220px)',
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 18,
},
}}
destroyOnClose
footer={[
<Button key="refresh" icon={<ReloadOutlined />} onClick={() => refreshStatus(true)} loading={loading}>
</Button>,
<Button key="network" onClick={() => checkNetworkStatus(true)} loading={networkChecking}>
</Button>,
<Button key="close" type="primary" onClick={onClose}>
</Button>,
@@ -566,6 +819,67 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text type="secondary"> MySQL / Redis / Oracle / PostgreSQL </Text>
{networkStatus ? (
<Alert
type={networkStatus.reachable ? 'success' : 'warning'}
showIcon
message={networkStatus.summary}
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary">
GitHub Go HTTP/HTTPS/SOCKS5
</Text>
<Collapse
size="small"
items={[
{
key: 'checks',
label: '查看网络检测明细',
children: (
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{networkStatus.checks.map((item) => (
<Text key={`${item.name}-${item.url}`} type={item.reachable ? 'secondary' : 'danger'}>
{item.name}{item.reachable ? '可达' : '不可达'}{item.httpStatus ? `HTTP ${item.httpStatus}` : ''}{item.latencyMs ? `${item.latencyMs}ms` : ''}{item.error ? `${item.error}` : ''}
</Text>
))}
{proxyEnvEntries.length > 0 ? (
<Text type="secondary">
{proxyEnvEntries.map(([key]) => key).join('、')}
</Text>
) : (
<Text type="secondary"></Text>
)}
</Space>
),
},
]}
/>
</Space>
)}
/>
) : (
<Alert type="info" showIcon message={networkChecking ? '正在检测驱动下载网络...' : '尚未完成网络检测'} />
)}
<Alert
type="info"
showIcon
message="驱动目录与复用说明"
description={(
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary"></Text>
<Text type="secondary"> `mariadb-driver-agent` / `mariadb-driver-agent.exe` `GoNavi-DriverAgents.zip`</Text>
<Paragraph copyable={{ text: downloadDir || '-' }} style={{ marginBottom: 0 }}>
{downloadDir || '-'}
</Paragraph>
{networkStatus?.logPath ? (
<Paragraph copyable={{ text: networkStatus.logPath }} style={{ marginBottom: 0 }}>
{networkStatus.logPath}
</Paragraph>
) : null}
</Space>
)}
/>
<Table
rowKey="type"
@@ -574,8 +888,40 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({
dataSource={rows}
pagination={false}
size="middle"
scroll={{ x: 1450 }}
/>
</Space>
<Modal
title={`驱动日志 - ${activeLogRow?.name || logDriverType}`}
open={logModalOpen}
onCancel={() => setLogModalOpen(false)}
footer={[
<Button key="close-log" type="primary" onClick={() => setLogModalOpen(false)}>
</Button>,
]}
width={780}
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{activeLogRow?.installDir ? (
<Paragraph copyable={{ text: activeLogRow.installDir }} style={{ marginBottom: 0 }}>
{activeLogRow.installDir}
</Paragraph>
) : null}
{activeLogRow?.executablePath ? (
<Paragraph copyable={{ text: activeLogRow.executablePath }} style={{ marginBottom: 0 }}>
{activeLogRow.executablePath}
</Paragraph>
) : null}
{activeDriverLogLines.length > 0 ? (
<pre style={{ margin: 0, maxHeight: 360, overflow: 'auto', padding: 12, background: '#fafafa', borderRadius: 8, border: '1px solid #f0f0f0', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{activeDriverLogLines.join('\n')}
</pre>
) : (
<Text type="secondary"></Text>
)}
</Space>
</Modal>
</Modal>
);
};

View File

@@ -14,6 +14,8 @@ const REDIS_TREE_KEY_TYPE_WIDTH = 92;
const REDIS_TREE_KEY_TYPE_WIDTH_NARROW = 84;
const REDIS_TREE_KEY_TTL_WIDTH = 92;
const REDIS_TREE_HIDE_TTL_THRESHOLD = 460;
const REDIS_KEY_INITIAL_LOAD_COUNT = 2000;
const REDIS_KEY_LOAD_MORE_COUNT = 2000;
interface RedisViewerProps {
connectionId: string;
@@ -462,27 +464,34 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
}, [connection, redisDB]);
const loadKeys = useCallback(async (pattern: string = '*', fromCursor: number = 0, append: boolean = false) => {
const loadKeys = useCallback(async (
pattern: string = '*',
fromCursor: number = 0,
append: boolean = false,
targetCount: number = REDIS_KEY_INITIAL_LOAD_COUNT
) => {
const config = getConfig();
if (!config) return;
setLoading(true);
try {
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, 100);
const res = await (window as any).go.app.App.RedisScanKeys(config, pattern, fromCursor, targetCount);
if (res.success) {
const result = res.data;
const scannedKeys = Array.isArray(result?.keys) ? result.keys : [];
const nextCursor = Number(result?.cursor || 0);
if (append) {
setKeys(prev => {
const keyMap = new Map<string, RedisKeyInfo>();
prev.forEach(item => keyMap.set(item.key, item));
result.keys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
scannedKeys.forEach((item: RedisKeyInfo) => keyMap.set(item.key, item));
return Array.from(keyMap.values());
});
} else {
setKeys(result.keys);
setKeys(scannedKeys);
}
setCursor(result.cursor);
setHasMore(result.cursor !== 0);
setCursor(nextCursor);
setHasMore(nextCursor !== 0);
} else {
message.error('加载 Key 失败: ' + res.message);
}
@@ -494,23 +503,26 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}, [getConfig]);
useEffect(() => {
loadKeys(searchPattern, 0, false);
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
}, [redisDB]);
const handleSearch = (value: string) => {
const pattern = value.trim() || '*';
setSearchPattern(pattern);
setCursor(0);
loadKeys(pattern, 0, false);
loadKeys(pattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
};
const handleLoadMore = () => {
loadKeys(searchPattern, cursor, true);
if (!hasMore || loading) {
return;
}
loadKeys(searchPattern, cursor, true, REDIS_KEY_LOAD_MORE_COUNT);
};
const handleRefresh = () => {
setCursor(0);
loadKeys(searchPattern, 0, false);
loadKeys(searchPattern, 0, false, REDIS_KEY_INITIAL_LOAD_COUNT);
};
const loadKeyValue = async (key: string) => {
@@ -1777,7 +1789,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</Spin>
{hasMore && (
<div style={{ padding: 8, textAlign: 'center' }}>
<Button onClick={handleLoadMore} loading={loading}></Button>
<Button onClick={handleLoadMore} loading={loading} disabled={!hasMore || loading}></Button>
</div>
)}
</div>

View File

@@ -47,6 +47,8 @@ interface TreeNode {
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
type BatchObjectType = 'table' | 'view';
type BatchObjectFilterType = 'all' | BatchObjectType;
type BatchSelectionScope = 'filtered' | 'all';
interface BatchObjectItem {
title: string;
@@ -133,11 +135,47 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const [selectedConnection, setSelectedConnection] = useState<string>('');
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
const [batchFilterKeyword, setBatchFilterKeyword] = useState<string>('');
const [batchFilterType, setBatchFilterType] = useState<BatchObjectFilterType>('all');
const [batchSelectionScope, setBatchSelectionScope] = useState<BatchSelectionScope>('filtered');
const filteredBatchObjects = useMemo(() => {
const keyword = batchFilterKeyword.trim().toLowerCase();
return batchTables.filter((item) => {
if (batchFilterType !== 'all' && item.objectType !== batchFilterType) {
return false;
}
if (!keyword) {
return true;
}
return item.title.toLowerCase().includes(keyword) || item.objectName.toLowerCase().includes(keyword);
});
}, [batchFilterKeyword, batchFilterType, batchTables]);
const groupedBatchObjects = useMemo(() => {
const tables = batchTables.filter(item => item.objectType === 'table');
const views = batchTables.filter(item => item.objectType === 'view');
const tables = filteredBatchObjects.filter(item => item.objectType === 'table');
const views = filteredBatchObjects.filter(item => item.objectType === 'view');
return { tables, views };
}, [batchTables]);
}, [filteredBatchObjects]);
const allBatchObjectKeys = useMemo(() => batchTables.map(item => item.key), [batchTables]);
const allBatchObjectKeysByType = useMemo(() => {
if (batchFilterType === 'all') {
return allBatchObjectKeys;
}
return batchTables
.filter((item) => item.objectType === batchFilterType)
.map((item) => item.key);
}, [allBatchObjectKeys, batchFilterType, batchTables]);
const filteredBatchObjectKeys = useMemo(() => filteredBatchObjects.map(item => item.key), [filteredBatchObjects]);
const selectionScopeTargetKeys = useMemo(
() => (batchSelectionScope === 'filtered' ? filteredBatchObjectKeys : allBatchObjectKeysByType),
[allBatchObjectKeysByType, batchSelectionScope, filteredBatchObjectKeys]
);
useEffect(() => {
if (batchFilterType === 'all') {
return;
}
const allowed = new Set(allBatchObjectKeysByType);
setCheckedTableKeys((prev) => prev.filter((key) => allowed.has(key)));
}, [allBatchObjectKeysByType, batchFilterType]);
// Batch Database Operations Modal
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
@@ -1313,6 +1351,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setBatchTables([]);
setCheckedTableKeys([]);
setAvailableDatabases([]);
setBatchFilterKeyword('');
setBatchFilterType('all');
setBatchSelectionScope('filtered');
if (connId) {
const conn = connections.find(c => c.id === connId);
@@ -1413,6 +1454,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setSelectedDatabase('');
setBatchTables([]);
setCheckedTableKeys([]);
setBatchFilterKeyword('');
setBatchFilterType('all');
setBatchSelectionScope('filtered');
const conn = connections.find(c => c.id === connId);
if (conn) {
@@ -1422,6 +1466,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const handleDatabaseChange = async (dbName: string) => {
setSelectedDatabase(dbName);
setBatchFilterKeyword('');
setBatchFilterType('all');
setBatchSelectionScope('filtered');
const conn = connections.find(c => c.id === selectedConnection);
if (conn && dbName) {
@@ -1470,17 +1517,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckedTableKeys(batchTables.map(t => t.key));
} else {
setCheckedTableKeys([]);
if (batchSelectionScope === 'all') {
setCheckedTableKeys(checked ? allBatchObjectKeys : []);
return;
}
if (filteredBatchObjectKeys.length === 0) {
return;
}
if (checked) {
setCheckedTableKeys(prev => {
const nextSet = new Set(prev);
filteredBatchObjectKeys.forEach((key) => nextSet.add(key));
return allBatchObjectKeys.filter((key) => nextSet.has(key));
});
return;
}
const filteredKeySet = new Set(filteredBatchObjectKeys);
setCheckedTableKeys(prev => prev.filter((key) => !filteredKeySet.has(key)));
};
const handleInvertSelection = () => {
const allKeys = batchTables.map(t => t.key);
const newChecked = allKeys.filter(k => !checkedTableKeys.includes(k));
setCheckedTableKeys(newChecked);
if (batchSelectionScope === 'all') {
setCheckedTableKeys(prev => allBatchObjectKeys.filter((key) => !prev.includes(key)));
return;
}
if (filteredBatchObjectKeys.length === 0) {
return;
}
setCheckedTableKeys(prev => {
const nextSet = new Set(prev);
filteredBatchObjectKeys.forEach((key) => {
if (nextSet.has(key)) {
nextSet.delete(key);
} else {
nextSet.add(key);
}
});
return allBatchObjectKeys.filter((key) => nextSet.has(key));
});
};
const openBatchDatabaseModal = async () => {
@@ -2874,6 +2948,43 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
</div>
{batchTables.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Space wrap size={8} style={{ width: '100%' }}>
<Input
allowClear
value={batchFilterKeyword}
onChange={(e) => setBatchFilterKeyword(e.target.value)}
placeholder="筛选表/视图名称"
prefix={<SearchOutlined />}
style={{ width: 260 }}
/>
<Select
value={batchFilterType}
onChange={(value) => setBatchFilterType(value as BatchObjectFilterType)}
style={{ width: 140 }}
options={[
{ label: '全部对象', value: 'all' },
{ label: '仅表', value: 'table' },
{ label: '仅视图', value: 'view' },
]}
/>
<Select
value={batchSelectionScope}
onChange={(value) => setBatchSelectionScope(value as BatchSelectionScope)}
style={{ width: 220 }}
options={[
{ label: '勾选作用于:当前筛选结果', value: 'filtered' },
{ label: '勾选作用于:全部对象', value: 'all' },
]}
/>
</Space>
<div style={{ marginTop: 6, color: '#999', fontSize: 12 }}>
{filteredBatchObjects.length} / {batchTables.length}
</div>
</div>
)}
{batchTables.length > 0 && (
<>
<div style={{ marginBottom: 16 }}>
@@ -2881,18 +2992,21 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
<Button
size="small"
onClick={() => handleCheckAll(true)}
disabled={selectionScopeTargetKeys.length === 0}
>
</Button>
<Button
size="small"
onClick={() => handleCheckAll(false)}
disabled={selectionScopeTargetKeys.length === 0}
>
</Button>
<Button
size="small"
onClick={handleInvertSelection}
disabled={selectionScopeTargetKeys.length === 0}
>
</Button>
@@ -2938,6 +3052,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</Space>
</div>
)}
{groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
<div style={{ color: '#999', padding: '8px 0' }}>
</div>
)}
</div>
</Checkbox.Group>
</div>

View File

@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
import { ConnectionConfig, SavedConnection, TabData, SavedQuery } from './types';
const DEFAULT_APPEARANCE = { opacity: 1.0, blur: 0 };
const DEFAULT_STARTUP_FULLSCREEN = false;
const LEGACY_DEFAULT_OPACITY = 0.95;
const OPACITY_EPSILON = 1e-6;
const MAX_URI_LENGTH = 4096;
@@ -206,10 +207,27 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
return safeConfig;
};
const resolveConnectionConfigPayload = (raw: Record<string, unknown>): unknown => {
if (raw.config && typeof raw.config === 'object') {
return raw.config;
}
// 兼容历史/导入场景:连接对象可能是扁平结构(无 config 包装)。
const hasLegacyFlatConfig =
raw.type !== undefined ||
raw.host !== undefined ||
raw.port !== undefined ||
raw.user !== undefined ||
raw.database !== undefined;
if (hasLegacyFlatConfig) {
return raw;
}
return undefined;
};
const sanitizeSavedConnection = (value: unknown, index: number): SavedConnection | null => {
if (!value || typeof value !== 'object') return null;
const raw = value as Record<string, unknown>;
const config = sanitizeConnectionConfig(raw.config);
const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw));
const id = toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
const fallbackName = config.host ? `${config.type}-${config.host}` : `连接-${index + 1}`;
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
@@ -278,6 +296,7 @@ interface AppState {
savedQueries: SavedQuery[];
theme: 'light' | 'dark';
appearance: { opacity: number; blur: number };
startupFullscreen: boolean;
sqlFormatOptions: { keywordCase: 'upper' | 'lower' };
queryOptions: QueryOptions;
sqlLogs: SqlLog[];
@@ -304,6 +323,7 @@ interface AppState {
setTheme: (theme: 'light' | 'dark') => void;
setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void;
setStartupFullscreen: (enabled: boolean) => void;
setSqlFormatOptions: (options: { keywordCase: 'upper' | 'lower' }) => void;
setQueryOptions: (options: Partial<QueryOptions>) => void;
@@ -392,6 +412,21 @@ const sanitizeAppearance = (
return nextAppearance;
};
const sanitizeStartupFullscreen = (value: unknown): boolean => {
return value === true;
};
const unwrapPersistedAppState = (persistedState: unknown): Record<string, unknown> => {
if (!persistedState || typeof persistedState !== 'object') {
return {};
}
const raw = persistedState as Record<string, unknown>;
if (raw.state && typeof raw.state === 'object') {
return raw.state as Record<string, unknown>;
}
return raw;
};
export const useStore = create<AppState>()(
persist(
(set) => ({
@@ -402,6 +437,7 @@ export const useStore = create<AppState>()(
savedQueries: [],
theme: 'light',
appearance: { ...DEFAULT_APPEARANCE },
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
sqlFormatOptions: { keywordCase: 'upper' },
queryOptions: { maxRows: 5000, showColumnComment: true, showColumnType: true },
sqlLogs: [],
@@ -513,6 +549,7 @@ export const useStore = create<AppState>()(
setTheme: (theme) => set({ theme }),
setAppearance: (appearance) => set((state) => ({ appearance: { ...state.appearance, ...appearance } })),
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
setQueryOptions: (options) => set((state) => ({ queryOptions: { ...state.queryOptions, ...options } })),
@@ -544,15 +581,13 @@ export const useStore = create<AppState>()(
name: 'lite-db-storage', // name of the item in the storage (must be unique)
version: 3,
migrate: (persistedState: unknown, version: number) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as AppState;
}
const state = persistedState as Partial<AppState>;
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
const nextState: Partial<AppState> = { ...state };
nextState.connections = sanitizeConnections(state.connections);
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
nextState.theme = sanitizeTheme(state.theme);
nextState.appearance = sanitizeAppearance(state.appearance, version);
nextState.startupFullscreen = sanitizeStartupFullscreen(state.startupFullscreen);
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(state.sqlFormatOptions);
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
nextState.tableAccessCount = sanitizeTableAccessCount(state.tableAccessCount);
@@ -560,9 +595,7 @@ export const useStore = create<AppState>()(
return nextState as AppState;
},
merge: (persistedState, currentState) => {
const state = (persistedState && typeof persistedState === 'object')
? persistedState as Partial<AppState>
: {};
const state = unwrapPersistedAppState(persistedState) as Partial<AppState>;
return {
...currentState,
...state,
@@ -570,6 +603,7 @@ export const useStore = create<AppState>()(
savedQueries: sanitizeSavedQueries(state.savedQueries),
theme: sanitizeTheme(state.theme),
appearance: sanitizeAppearance(state.appearance, 3),
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
@@ -581,6 +615,7 @@ export const useStore = create<AppState>()(
savedQueries: state.savedQueries,
theme: state.theme,
appearance: state.appearance,
startupFullscreen: state.startupFullscreen,
sqlFormatOptions: state.sqlFormatOptions,
queryOptions: state.queryOptions,
tableAccessCount: state.tableAccessCount,

View File

@@ -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 CheckDriverNetworkStatus():Promise<connection.QueryResult>;
export function CheckForUpdates():Promise<connection.QueryResult>;
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;

View File

@@ -6,6 +6,10 @@ export function ApplyChanges(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['ApplyChanges'](arg1, arg2, arg3, arg4);
}
export function CheckDriverNetworkStatus() {
return window['go']['app']['App']['CheckDriverNetworkStatus']();
}
export function CheckForUpdates() {
return window['go']['app']['App']['CheckForUpdates']();
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -21,6 +22,7 @@ import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/mod/semver"
@@ -79,6 +81,15 @@ type driverDownloadProgressPayload struct {
Message string `json:"message,omitempty"`
}
type driverNetworkProbeItem struct {
Name string `json:"name"`
URL string `json:"url"`
Reachable bool `json:"reachable"`
HTTPStatus int `json:"httpStatus,omitempty"`
LatencyMs int64 `json:"latencyMs,omitempty"`
Error string `json:"error,omitempty"`
}
type pinnedDriverPackage struct {
Version string
DownloadURL string
@@ -188,6 +199,7 @@ const (
driverVersionWarmupMinInterval = 30 * time.Second
driverBundleIndexMaxSize = 1 << 20
driverManifestMaxSize = 2 << 20
driverNetworkProbeTimeout = 4 * time.Second
driverChecksumPolicyStrict = "strict"
driverChecksumPolicyWarn = "warn"
driverChecksumPolicyOff = "off"
@@ -592,6 +604,59 @@ func (a *App) GetDriverStatusList(downloadDir string, manifestURL string) connec
}
}
func (a *App) CheckDriverNetworkStatus() connection.QueryResult {
checks := []driverNetworkProbeItem{
{
Name: "GitHub API",
URL: "https://api.github.com/rate_limit",
},
{
Name: "GitHub 驱动发布",
URL: fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", updateRepo, optionalDriverBundleAssetName),
},
{
Name: "Go 模块代理",
URL: "https://proxy.golang.org/github.com/go-sql-driver/mysql/@v/list",
},
}
allReachable := true
for index := range checks {
checks[index] = probeDriverNetworkEndpoint(checks[index])
if !checks[index].Reachable {
allReachable = false
}
}
proxyEnv := collectDriverProxyEnv()
proxyConfigured := len(proxyEnv) > 0
summary := "驱动下载网络检测通过,可直接安装驱动。"
if !allReachable {
if proxyConfigured {
summary = "检测到部分驱动下载地址不可达,请确认系统代理配置有效后重试。"
} else {
summary = "检测到部分驱动下载地址不可达,建议先配置 HTTP/HTTPS/SOCKS5 代理后再安装驱动。"
}
}
data := map[string]interface{}{
"reachable": allReachable,
"summary": summary,
"recommendedProxy": !allReachable,
"proxyConfigured": proxyConfigured,
"proxyEnv": proxyEnv,
"checkedAt": time.Now().Format(time.RFC3339),
"checks": checks,
}
if logPath := strings.TrimSpace(logger.Path()); logPath != "" {
data["logPath"] = logPath
}
return connection.QueryResult{
Success: true,
Data: data,
}
}
func (a *App) InstallLocalDriverPackage(driverType string, filePath string, downloadDir string) connection.QueryResult {
definition, ok := resolveDriverDefinition(driverType)
if !ok {
@@ -614,28 +679,27 @@ func (a *App) InstallLocalDriverPackage(driverType string, filePath string, down
}
db.SetExternalDriverDownloadDirectory(resolvedDir)
hash := ""
if pathText := strings.TrimSpace(filePath); pathText != "" {
if fileHash, hashErr := hashFileSHA256(pathText); hashErr == nil {
hash = fileHash
a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, "开始安装本地驱动包")
selectedVersion := resolveDriverInstallVersion(definition.PinnedVersion, "local://manual", definition)
meta, installErr := installOptionalDriverAgentFromLocalFile(definition, filePath, resolvedDir, selectedVersion)
if installErr != nil {
errText := normalizeErrorMessage(installErr)
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText)
return connection.QueryResult{
Success: false,
Message: logDriverOperationError(installErr, "导入本地驱动包失败driver=%s file=%s", definition.Type, strings.TrimSpace(filePath)),
}
}
a.emitDriverDownloadProgress(definition.Type, "start", 0, 0, "开始安装")
meta := installedDriverPackage{
DriverType: definition.Type,
Version: resolveDriverInstallVersion(definition.PinnedVersion, "local://activate", definition),
FilePath: "",
FileName: "embedded-go-driver",
DownloadURL: "local://activate",
SHA256: hash,
DownloadedAt: time.Now().Format(time.RFC3339),
}
a.emitDriverDownloadProgress(definition.Type, "downloading", 90, 100, "写入驱动元数据")
if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil {
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error())
return connection.QueryResult{Success: false, Message: err.Error()}
errText := normalizeErrorMessage(err)
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText)
return connection.QueryResult{
Success: false,
Message: logDriverOperationError(err, "写入本地驱动元数据失败driver=%s", definition.Type),
}
}
a.emitDriverDownloadProgress(definition.Type, "done", 1, 1, "安装完成(纯 Go 驱动已启用)")
a.emitDriverDownloadProgress(definition.Type, "done", 100, 100, "本地驱动包导入完成")
return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{
"driverType": definition.Type,
@@ -683,13 +747,21 @@ func (a *App) DownloadDriverPackage(driverType string, version string, downloadU
a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, fmt.Sprintf("开始安装 %s 驱动代理", displayName))
meta, installErr := installOptionalDriverAgentPackage(a, definition, selectedVersion, resolvedDir, urlText)
if installErr != nil {
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, installErr.Error())
return connection.QueryResult{Success: false, Message: installErr.Error()}
errText := normalizeErrorMessage(installErr)
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText)
return connection.QueryResult{
Success: false,
Message: logDriverOperationError(installErr, "驱动下载安装失败driver=%s version=%s url=%s", definition.Type, selectedVersion, urlText),
}
}
a.emitDriverDownloadProgress(definition.Type, "downloading", 95, 100, "写入驱动元数据")
if writeErr := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); writeErr != nil {
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, writeErr.Error())
return connection.QueryResult{Success: false, Message: writeErr.Error()}
errText := normalizeErrorMessage(writeErr)
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText)
return connection.QueryResult{
Success: false,
Message: logDriverOperationError(writeErr, "写入驱动元数据失败driver=%s version=%s", definition.Type, selectedVersion),
}
}
a.emitDriverDownloadProgress(definition.Type, "done", 100, 100, fmt.Sprintf("%s 驱动代理安装完成", displayName))
return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{
@@ -710,8 +782,12 @@ func (a *App) DownloadDriverPackage(driverType string, version string, downloadU
DownloadedAt: time.Now().Format(time.RFC3339),
}
if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil {
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error())
return connection.QueryResult{Success: false, Message: err.Error()}
errText := normalizeErrorMessage(err)
a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, errText)
return connection.QueryResult{
Success: false,
Message: logDriverOperationError(err, "写入驱动元数据失败driver=%s version=%s", definition.Type, selectedVersion),
}
}
a.emitDriverDownloadProgress(definition.Type, "done", 1, 1, "安装完成(纯 Go 驱动已启用)")
@@ -781,6 +857,100 @@ func (a *App) emitDriverDownloadProgress(driverType string, status string, downl
runtime.EventsEmit(a.ctx, driverDownloadProgressEvent, payload)
}
func probeDriverNetworkEndpoint(item driverNetworkProbeItem) driverNetworkProbeItem {
probed := item
probed.Reachable = false
probed.HTTPStatus = 0
probed.Error = ""
probed.LatencyMs = 0
urlText := strings.TrimSpace(item.URL)
if urlText == "" {
probed.Error = "检测地址为空"
return probed
}
client := &http.Client{Timeout: driverNetworkProbeTimeout}
start := time.Now()
req, err := http.NewRequest(http.MethodHead, urlText, nil)
if err != nil {
probed.Error = normalizeErrorMessage(err)
return probed
}
req.Header.Set("User-Agent", "GoNavi-DriverManager")
resp, err := client.Do(req)
if err != nil {
// 某些网关不支持 HEAD请回退为 GET不读取正文
reqGet, reqErr := http.NewRequest(http.MethodGet, urlText, nil)
if reqErr != nil {
probed.Error = normalizeErrorMessage(reqErr)
probed.LatencyMs = time.Since(start).Milliseconds()
return probed
}
reqGet.Header.Set("User-Agent", "GoNavi-DriverManager")
resp, err = client.Do(reqGet)
}
probed.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
probed.Error = normalizeDriverNetworkError(err)
return probed
}
defer resp.Body.Close()
probed.HTTPStatus = resp.StatusCode
if resp.StatusCode >= 500 {
probed.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
return probed
}
probed.Reachable = true
return probed
}
func normalizeDriverNetworkError(err error) string {
if err == nil {
return ""
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "网络连接超时"
}
return normalizeErrorMessage(err)
}
func collectDriverProxyEnv() map[string]string {
keys := []string{
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY",
"http_proxy", "https_proxy", "all_proxy", "no_proxy",
}
result := make(map[string]string)
for _, key := range keys {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
continue
}
result[key] = value
}
return result
}
func driverLogHint() string {
path := strings.TrimSpace(logger.Path())
if path == "" {
return ""
}
return fmt.Sprintf("(详细日志:%s", path)
}
func logDriverOperationError(err error, format string, args ...interface{}) string {
message := normalizeErrorMessage(err)
if strings.TrimSpace(message) == "" {
message = "未知错误"
}
logger.Error(err, format, args...)
return strings.TrimSpace(message) + driverLogHint()
}
func defaultDriverDownloadDirectory() string {
root, err := db.ResolveExternalDriverRoot("")
if err == nil && strings.TrimSpace(root) != "" {
@@ -1378,12 +1548,7 @@ func fetchGoModuleVersionMetas(modulePath string) ([]goModuleVersionMeta, error)
}
endpoint := fmt.Sprintf("https://proxy.golang.org/%s/@v/list", escapeGoModulePathForProxy(trimmed))
client := &http.Client{
Timeout: driverModuleLatestProbeTimeout,
Transport: &http.Transport{
Proxy: nil,
},
}
client := &http.Client{Timeout: driverModuleLatestProbeTimeout}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
@@ -2025,6 +2190,141 @@ func installOptionalDriverAgentPackage(a *App, definition driverDefinition, sele
}, nil
}
func installOptionalDriverAgentFromLocalFile(definition driverDefinition, filePath string, resolvedDir string, selectedVersion string) (installedDriverPackage, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)
pathText := strings.TrimSpace(filePath)
if pathText == "" {
return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为空")
}
if absPath, absErr := filepath.Abs(pathText); absErr == nil {
pathText = absPath
}
info, statErr := os.Stat(pathText)
if statErr != nil {
return installedDriverPackage{}, fmt.Errorf("读取本地驱动包失败:%w", statErr)
}
if info.IsDir() {
return installedDriverPackage{}, fmt.Errorf("本地驱动包路径为目录:%s", pathText)
}
executablePath, err := db.ResolveOptionalDriverAgentExecutablePath(resolvedDir, driverType)
if err != nil {
return installedDriverPackage{}, err
}
if mkErr := os.MkdirAll(filepath.Dir(executablePath), 0o755); mkErr != nil {
return installedDriverPackage{}, fmt.Errorf("创建 %s 驱动目录失败:%w", displayName, mkErr)
}
downloadSource := fmt.Sprintf("local://manual/%s", filepath.Base(pathText))
if strings.EqualFold(filepath.Ext(pathText), ".zip") {
entryName, extractErr := installOptionalDriverAgentFromLocalZip(pathText, definition, executablePath)
if extractErr != nil {
return installedDriverPackage{}, extractErr
}
if strings.TrimSpace(entryName) != "" {
downloadSource = downloadSource + "#" + entryName
}
} else {
if copyErr := copyAgentBinary(pathText, executablePath); copyErr != nil {
return installedDriverPackage{}, fmt.Errorf("导入本地驱动代理失败:%w", copyErr)
}
}
hash, hashErr := hashFileSHA256(executablePath)
if hashErr != nil {
return installedDriverPackage{}, fmt.Errorf("计算 %s 驱动代理摘要失败:%w", displayName, hashErr)
}
return installedDriverPackage{
DriverType: driverType,
Version: strings.TrimSpace(selectedVersion),
FilePath: pathText,
FileName: filepath.Base(pathText),
ExecutablePath: executablePath,
DownloadURL: downloadSource,
SHA256: hash,
DownloadedAt: time.Now().Format(time.RFC3339),
}, nil
}
func installOptionalDriverAgentFromLocalZip(zipPath string, definition driverDefinition, executablePath string) (string, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)
reader, err := zip.OpenReader(zipPath)
if err != nil {
return "", fmt.Errorf("打开本地驱动包失败:%w", err)
}
defer reader.Close()
entryPath := optionalDriverBundleEntryPath(driverType)
expectedBaseName := optionalDriverReleaseAssetName(driverType)
findEntry := func() *zip.File {
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if name == entryPath {
return file
}
}
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if strings.EqualFold(name, entryPath) {
return file
}
}
for _, file := range reader.File {
name := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(file.Name), "./"))
if strings.EqualFold(filepath.Base(name), expectedBaseName) {
return file
}
}
return nil
}
entry := findEntry()
if entry == nil {
return "", fmt.Errorf("本地驱动包内未找到 %s 代理文件(期望路径 %s", displayName, entryPath)
}
src, err := entry.Open()
if err != nil {
return "", fmt.Errorf("读取本地驱动包条目失败:%w", err)
}
defer src.Close()
tempPath := executablePath + ".tmp"
_ = os.Remove(tempPath)
dst, err := os.Create(tempPath)
if err != nil {
return "", fmt.Errorf("创建驱动代理临时文件失败:%w", err)
}
if _, err := io.Copy(dst, src); err != nil {
dst.Close()
_ = os.Remove(tempPath)
return "", fmt.Errorf("写入驱动代理失败:%w", err)
}
if err := dst.Sync(); err != nil {
dst.Close()
_ = os.Remove(tempPath)
return "", fmt.Errorf("落盘驱动代理失败:%w", err)
}
if err := dst.Close(); err != nil {
_ = os.Remove(tempPath)
return "", fmt.Errorf("关闭驱动代理文件失败:%w", err)
}
if chmodErr := os.Chmod(tempPath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
_ = os.Remove(tempPath)
return "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr)
}
if err := os.Rename(tempPath, executablePath); err != nil {
_ = os.Remove(tempPath)
return "", fmt.Errorf("替换驱动代理失败:%w", err)
}
if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" {
return "", fmt.Errorf("设置驱动代理权限失败:%w", chmodErr)
}
return filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(entry.Name), "./")), nil
}
func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, executablePath string, downloadURL string) (string, string, error) {
driverType := normalizeDriverType(definition.Type)
displayName := resolveDriverDisplayName(definition)

View File

@@ -22,6 +22,14 @@ type RedisClientImpl struct {
forwarder *ssh.LocalForwarder
}
const (
redisScanDefaultTargetCount int64 = 2000
redisScanMaxTargetCount int64 = 10000
redisScanMinStepCount int64 = 200
redisScanMaxStepCount int64 = 2000
redisScanMaxRounds = 64
)
// NewRedisClient creates a new Redis client instance
func NewRedisClient() RedisClient {
return &RedisClientImpl{}
@@ -108,21 +116,70 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
if pattern == "" {
pattern = "*"
}
targetCount := normalizeRedisScanTargetCount(count)
scanStepCount := normalizeRedisScanStepCount(targetCount)
currentCursor := cursor
round := 0
keys := make([]string, 0, int(targetCount))
seen := make(map[string]struct{}, int(targetCount))
for len(keys) < int(targetCount) {
batch, nextCursor, err := r.client.Scan(ctx, currentCursor, pattern, scanStepCount).Result()
if err != nil {
return nil, err
}
for _, key := range batch {
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
if len(keys) >= int(targetCount) {
break
}
}
currentCursor = nextCursor
round++
if currentCursor == 0 || round >= redisScanMaxRounds {
break
}
}
return &RedisScanResult{
Keys: r.loadRedisKeyInfos(ctx, keys),
Cursor: currentCursor,
}, nil
}
func normalizeRedisScanTargetCount(count int64) int64 {
if count <= 0 {
count = 100
return redisScanDefaultTargetCount
}
if count > redisScanMaxTargetCount {
return redisScanMaxTargetCount
}
return count
}
func normalizeRedisScanStepCount(targetCount int64) int64 {
if targetCount < redisScanMinStepCount {
return redisScanMinStepCount
}
if targetCount > redisScanMaxStepCount {
return redisScanMaxStepCount
}
return targetCount
}
func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string) []RedisKeyInfo {
result := make([]RedisKeyInfo, 0, len(keys))
if len(keys) == 0 {
return result
}
keys, nextCursor, err := r.client.Scan(ctx, cursor, pattern, count).Result()
if err != nil {
return nil, err
}
result := &RedisScanResult{
Keys: make([]RedisKeyInfo, 0, len(keys)),
Cursor: nextCursor,
}
// Get type and TTL for each key
pipe := r.client.Pipeline()
typeResults := make([]*redis.StatusCmd, len(keys))
ttlResults := make([]*redis.DurationCmd, len(keys))
@@ -132,37 +189,44 @@ func (r *RedisClientImpl) ScanKeys(pattern string, cursor uint64, count int64) (
ttlResults[i] = pipe.TTL(ctx, key)
}
_, err = pipe.Exec(ctx)
_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
// Fallback: get info one by one
for _, key := range keys {
keyType, _ := r.GetKeyType(key)
ttl, _ := r.GetTTL(key)
result.Keys = append(result.Keys, RedisKeyInfo{
keyType, typeErr := r.client.Type(ctx, key).Result()
if typeErr != nil && typeErr != redis.Nil {
keyType = ""
}
ttlValue, ttlErr := r.client.TTL(ctx, key).Result()
if ttlErr != nil && ttlErr != redis.Nil {
ttlValue = -2
}
result = append(result, RedisKeyInfo{
Key: key,
Type: keyType,
TTL: ttl,
TTL: toRedisTTLSeconds(ttlValue),
})
}
return result, nil
return result
}
for i, key := range keys {
keyType := typeResults[i].Val()
ttl := int64(ttlResults[i].Val().Seconds())
if ttlResults[i].Val() == -1 {
ttl = -1
} else if ttlResults[i].Val() == -2 {
ttl = -2
}
result.Keys = append(result.Keys, RedisKeyInfo{
result = append(result, RedisKeyInfo{
Key: key,
Type: keyType,
TTL: ttl,
Type: typeResults[i].Val(),
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
})
}
return result
}
return result, nil
func toRedisTTLSeconds(ttl time.Duration) int64 {
if ttl == -1 {
return -1
}
if ttl == -2 {
return -2
}
return int64(ttl.Seconds())
}
// GetKeyType returns the type of a key

View File

@@ -41,6 +41,7 @@ func main() {
BackdropType: windows.Acrylic,
DisableWindowIcon: false,
DisableFramelessWindowDecorations: false,
WebviewUserDataPath: resolveWindowsWebviewUserDataPath(),
},
Mac: &mac.Options{
WebviewIsTransparent: true,

View File

@@ -0,0 +1,123 @@
//go:build windows
package main
import (
"io"
"os"
"path/filepath"
"strings"
)
func resolveWindowsWebviewUserDataPath() string {
appDataDir := strings.TrimSpace(os.Getenv("APPDATA"))
if appDataDir == "" {
return ""
}
targetDir := filepath.Join(appDataDir, "GoNavi", "WebView2")
_ = migrateLegacyWindowsWebviewUserData(appDataDir, targetDir)
return targetDir
}
func migrateLegacyWindowsWebviewUserData(appDataDir, targetDir string) error {
if dirHasContent(targetDir) {
return nil
}
exeName := "GoNavi.exe"
if exePath, err := os.Executable(); err == nil {
base := strings.TrimSpace(filepath.Base(exePath))
if base != "" {
exeName = base
}
}
exeBase := strings.TrimSuffix(exeName, filepath.Ext(exeName))
candidates := []string{
filepath.Join(appDataDir, exeName),
filepath.Join(appDataDir, exeBase),
filepath.Join(appDataDir, "GoNavi.exe"),
filepath.Join(appDataDir, "GoNavi"),
}
seen := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
src := filepath.Clean(strings.TrimSpace(candidate))
if src == "" || strings.EqualFold(src, filepath.Clean(targetDir)) {
continue
}
key := strings.ToLower(src)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if !dirHasContent(src) {
continue
}
return copyDirTree(src, targetDir)
}
return nil
}
func dirHasContent(path string) bool {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return false
}
entries, err := os.ReadDir(path)
return err == nil && len(entries) > 0
}
func copyDirTree(srcDir, dstDir string) error {
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return err
}
return filepath.WalkDir(srcDir, func(srcPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
relPath, err := filepath.Rel(srcDir, srcPath)
if err != nil {
return err
}
if relPath == "." {
return nil
}
dstPath := filepath.Join(dstDir, relPath)
if d.IsDir() {
return os.MkdirAll(dstPath, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
return copyFileWithMode(srcPath, dstPath, info.Mode())
})
}
func copyFileWithMode(srcPath, dstPath string, mode os.FileMode) error {
srcFile, err := os.Open(srcPath)
if err != nil {
return err
}
defer srcFile.Close()
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,7 @@
//go:build !windows
package main
func resolveWindowsWebviewUserDataPath() string {
return ""
}