Files
MyGoNavi/frontend/src/App.tsx
2026-07-09 15:25:48 +08:00

8047 lines
404 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Modal from './components/common/ResizableDraggableModal';
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip, Alert } from 'antd';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined, TableOutlined, MenuOutlined, PoweroffOutlined, TagOutlined, UserOutlined, UpCircleOutlined, MessageOutlined, FileTextOutlined, SyncOutlined, SendOutlined } from '@ant-design/icons';
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { BrowserOpenURL, Environment, EventsOn, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetDarkTheme, WindowSetLightTheme, WindowSetPosition, WindowSetSize, WindowSetSystemDefaultTheme, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
import FloatingWorkbenchWindows from './components/FloatingWorkbenchWindows';
import FloatingAIChatWindow from './components/FloatingAIChatWindow';
import FloatingQueryResultWindows from './components/FloatingQueryResultWindows';
import ConnectionModal from './components/ConnectionModal';
import SnippetSettingsModal from './components/SnippetSettingsModal';
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
import DataSyncModal from './components/DataSyncModal';
import { type DataSyncEntryMode } from './components/dataSyncEntryMode';
import DriverManagerModal from './components/DriverManagerModal';
import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';
import LogPanel from './components/LogPanel';
import { AISettingsContent } from './components/AISettingsModal';
import AIChatPanel from './components/AIChatPanel';
import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';
import SecurityUpdateBanner from './components/SecurityUpdateBanner';
import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal';
import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModal';
import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModal';
import LanguageSettingsPanel from './components/LanguageSettingsPanel';
import WebAuthSettingsPanel from './components/WebAuthSettingsPanel';
import {
DEFAULT_APPEARANCE,
MAX_V2_SIDEBAR_RAIL_SCALE,
MIN_V2_SIDEBAR_RAIL_SCALE,
sanitizeV2SidebarRailScale,
useStore,
} from './store';
import { GlobalProxyConfig, SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isMacLikePlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { buildFontFamilyOptions, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, getLinuxCJKFontInstallHint, matchFontFamilyOption, resolveMonoFontFamily, resolveUIFontFamily, sanitizeFontFamilyInput, type FontFamilyOption, type InstalledFontFamily } from './utils/fontFamilies';
import {
DENSITY_OPTIONS,
sanitizeDataTableDensity,
sanitizeDataTableFontSize,
sanitizeSidebarTreeFontSize,
} from './utils/dataGridDisplay';
import {
TAB_DISPLAY_SECONDARY_DEFAULT_KEYS,
TAB_DISPLAY_ELEMENT_META,
applyTabDisplaySettingsPatch,
resolveTabDisplayElementOrder,
sanitizeTabDisplaySettings,
switchTabDisplayLayout,
type TabDisplayElementKey,
type TabDisplayLayout,
type TabDisplaySettings,
} from './utils/tabDisplay';
import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow';
import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics';
import { getConnectionWorkbenchState } from './utils/startupReadiness';
import { createGlobalProxyDraft, toSaveGlobalProxyInput } from './utils/globalProxyDraft';
import {
detectConnectionImportKind,
isConnectionPackagePasswordRequiredError,
resolveConnectionPackageExportResult,
normalizeConnectionPackagePassword,
} from './utils/connectionExport';
import {
bootstrapSecureConfig,
finalizeSecurityUpdateStatus,
mergeSecurityUpdateStatusWithLegacySource,
prepareSecureConfigForExternalMCP,
startSecurityUpdateFromBootstrap,
} from './utils/secureConfigBootstrap';
import { bootstrapSavedQueries } from './utils/savedQueryPersistence';
import {
LEGACY_PERSIST_KEY,
hasLegacyMigratableSensitiveItems,
stripLegacyPersistedConnectionById,
} from './utils/legacyConnectionStorage';
import { DEFAULT_QUERY_TEMPLATE } from './components/queryEditor/QueryEditorHelpers';
import {
DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS,
SIDEBAR_TABLE_METADATA_FIELDS,
applySidebarTableMetadataFieldOrder,
resolveSidebarTableMetadataFieldOrder,
resolveSidebarTableMetadataFields,
setSidebarTableMetadataFieldSelected,
type SidebarTableMetadataField,
} from './utils/sidebarTableMetadata';
import {
getSecurityUpdateStatusMeta,
resolveSecurityUpdateEntryVisibility,
} from './utils/securityUpdatePresentation';
import {
hasSecurityUpdateRecentResult,
resolveSecurityUpdateRepairEntry,
resolveSecurityUpdateSettingsFocusTarget,
shouldRefreshSecurityUpdateDetailsFocus,
shouldReopenSecurityUpdateDetails,
shouldRetrySecurityUpdateAfterRepairSave,
type SecurityUpdateRepairSource,
type SecurityUpdateSettingsFocusTarget,
} from './utils/securityUpdateRepairFlow';
import { getWindowsScaleFixNudgedWidth, hasWindowsViewportScaleDrift } from './utils/windowsScaleFix';
import {
clearStartupWindowRestorePending,
isStartupWindowRestorePending,
markStartupWindowRestorePending,
resolveDefaultStartupWindowBounds,
} from './utils/windowStartupLayout';
import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
ShortcutAction,
canRecordShortcutForAction,
eventToShortcut,
findReservedConflictsForAction,
getShortcutDisplay,
getShortcutDisplayLabel,
getShortcutPlatform,
installGlobalImeCompositionTracking,
isEditableElement,
isShortcutMatch,
normalizeShortcutCombo,
resolveShortcutBinding,
splitConflictsByContext,
type ConflictInfo,
} from './utils/shortcuts';
import { resolveTitleBarToggleIconKey, resolveWindowsScaleCheckDelayMs, shouldApplyWindowsScaleFix, shouldResetWebViewZoomForScaleFix, shouldToggleMaximisedWindowForScaleFix, type WindowScaleFixReason, type WindowsScaleCheckTrigger } from './utils/windowStateUi';
import { resolveVisibleStartupWindowBounds } from './utils/windowRestoreBounds';
import { resolveWailsWindowVisibleViewport } from './utils/wailsWindowViewport';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
resolveAIEntryPlacement,
resolveLegacyAIEdgeHandleAttachment,
resolveLegacyAIEdgeHandleDockStyle,
resolveLegacyAIEdgeHandleStyle,
} from './utils/aiEntryLayout';
import { DEFAULT_AI_PANEL_WIDTH, resolveOverlayAIPanelWidth, shouldOverlayAIPanel } from './utils/aiPanelLayout';
import { safeWindowRuntimeCall } from './utils/wailsRuntime';
import {
buildApplicationQuitUnsavedSQLLabel,
collectApplicationQuitUnsavedSQLTargets,
saveApplicationQuitUnsavedSQLTargets,
} from './utils/sqlEditorApplicationQuit';
import { useAppUpdateManager } from './hooks/useAppUpdateManager';
import { useAppLogPanelResize } from './hooks/useAppLogPanelResize';
import { useAppSidebarResize } from './hooks/useAppSidebarResize';
import { useAppUtilityStyles } from './hooks/useAppUtilityStyles';
import { ApplyDataRootDirectory, CancelApplicationQuit, ForceQuitApplication, GetDataRootDirectoryInfo, GetSavedConnections, ListInstalledFontFamilies, OpenDataRootDirectory, SelectDataRootDirectory, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
import { getAntdLocale } from './i18n/frameworkLocale';
import { useI18n } from './i18n/provider';
import './App.css';
import './v2-theme.css';
import './styles/v2-theme-workbench.css';
const { Sider, Content } = Layout;
const MIN_UI_SCALE = 0.8;
const MAX_UI_SCALE = 1.25;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 20;
/** 设置页 Slider 底部预设刻度 */
const UI_SCALE_SLIDER_MARKS: Record<number, string> = {
0.8: '80%',
0.9: '90%',
1: '100%',
1.1: '110%',
1.25: '125%',
};
const FONT_SIZE_SLIDER_MARKS: Record<number, string> = {
12: '12',
14: '14',
16: '16',
18: '18',
20: '20',
};
const SIDEBAR_RAIL_SCALE_SLIDER_MARKS: Record<number, string> = {
1: '100%',
1.25: '125%',
1.5: '150%',
1.8: '180%',
};
const OPACITY_SLIDER_MARKS: Record<number, string> = {
0.1: '10%',
0.5: '50%',
1: '100%',
};
const BLUR_SLIDER_MARKS: Record<number, string> = {
0: '0',
6: '6',
12: '12',
20: '20',
};
const DATA_TABLE_FONT_SLIDER_MARKS: Record<number, string> = {
10: '10',
12: '12',
14: '14',
16: '16',
18: '18',
};
const DEFAULT_UI_SCALE = 1.0;
const DEFAULT_FONT_SIZE = 14;
const EMPTY_INSTALLED_FONT_FAMILIES: InstalledFontFamily[] = [];
type ThemeSettingsSliderUnit = 'percent' | 'px' | 'none';
type ThemeSettingsSliderProps = {
min: number;
max: number;
step?: number;
value: number;
onChange: (value: number) => void;
disabled?: boolean;
marks?: Record<number, string>;
/** percent右侧按百分比输入内部仍用 0~1 / 0.8~1.25 比例) */
unit?: ThemeSettingsSliderUnit;
};
const clampThemeSliderValue = (value: number, min: number, max: number, step?: number): number => {
let next = Math.min(max, Math.max(min, value));
if (step && step > 0) {
const steps = Math.round((next - min) / step);
next = min + steps * step;
// 消除浮点误差
const decimals = String(step).includes('.') ? (String(step).split('.')[1]?.length ?? 0) : 0;
if (decimals > 0) {
next = Number(next.toFixed(decimals));
}
next = Math.min(max, Math.max(min, next));
}
return next;
};
/** 主题设置页:滑条 + 底部预设 + 可编辑数值 */
const ThemeSettingsSlider: React.FC<ThemeSettingsSliderProps> = ({
min,
max,
step,
value,
onChange,
disabled,
marks,
unit = 'none',
}) => {
const isPercent = unit === 'percent';
const minN = Number(min);
const maxN = Number(max);
const span = maxN - minN || 1;
const stepN = step === undefined || step === null ? undefined : Number(step);
const current = Number(value);
const displayMin = isPercent ? minN * 100 : minN;
const displayMax = isPercent ? maxN * 100 : maxN;
const displayStep = isPercent
? (stepN !== undefined ? stepN * 100 : 1)
: (stepN !== undefined ? stepN : 1);
const displayValue = Number.isFinite(current)
? (isPercent ? Number((current * 100).toFixed(4)) : current)
: displayMin;
const commitDisplayValue = (raw: number | string | null) => {
if (raw === null || raw === undefined || raw === '') {
return;
}
const parsed = typeof raw === 'number' ? raw : Number(String(raw).trim().replace(/%/g, ''));
if (!Number.isFinite(parsed)) {
return;
}
const modelValue = isPercent ? parsed / 100 : parsed;
onChange(clampThemeSliderValue(modelValue, minN, maxN, stepN));
};
const markEntries = marks
? Object.entries(marks).map(([raw, label]) => ({
value: Number(raw),
label,
}))
: [];
return (
<div className={`gonavi-settings-slider-row${marks ? ' has-marks' : ''}`}>
<div className="gonavi-settings-slider-main">
<div className="gonavi-settings-slider-track-wrap">
<Slider
min={minN}
max={maxN}
step={stepN}
value={current}
onChange={(next) => {
const n = Array.isArray(next) ? next[0] : next;
if (typeof n === 'number' && Number.isFinite(n)) {
onChange(clampThemeSliderValue(n, minN, maxN, stepN));
}
}}
disabled={disabled}
tooltip={{ open: false }}
/>
</div>
{markEntries.length > 0 ? (
<div className="gonavi-settings-slider-presets" role="group">
{markEntries.map((mark) => {
const pct = ((mark.value - minN) / span) * 100;
const active = Number.isFinite(current)
? (stepN && stepN > 0
? Math.abs(current - mark.value) <= stepN / 2 + 1e-9
: Math.abs(current - mark.value) < 1e-6)
: false;
return (
<button
key={String(mark.value)}
type="button"
className={`gonavi-settings-slider-preset${active ? ' is-active' : ''}`}
style={{ left: `${pct}%` }}
disabled={Boolean(disabled)}
onClick={() => {
if (disabled) return;
onChange(clampThemeSliderValue(mark.value, minN, maxN, stepN));
}}
>
{mark.label}
</button>
);
})}
</div>
) : null}
</div>
<InputNumber
className="gonavi-settings-slider-value-input"
size="small"
min={displayMin}
max={displayMax}
step={displayStep}
value={displayValue}
disabled={disabled}
controls={false}
keyboard
stringMode={false}
style={{ width: unit === 'none' ? 48 : 62 }}
addonAfter={unit === 'percent' ? '%' : unit === 'px' ? 'px' : undefined}
onChange={(next) => {
if (typeof next === 'number') {
commitDisplayValue(next);
}
}}
onBlur={(event) => {
commitDisplayValue(event.target.value);
}}
onPressEnter={(event) => {
commitDisplayValue((event.target as HTMLInputElement).value);
(event.target as HTMLInputElement).blur();
}}
/>
</div>
);
};
const createEmptySecurityUpdateStatus = (): SecurityUpdateStatus => ({
overallStatus: 'not_detected',
summary: {
total: 0,
updated: 0,
pending: 0,
skipped: 0,
failed: 0,
},
issues: [],
});
const detectNavigatorPlatform = (): string => {
if (typeof navigator === 'undefined') {
return '';
}
const uaDataPlatform = (navigator as Navigator & {
userAgentData?: { platform?: string };
}).userAgentData?.platform;
if (uaDataPlatform) {
return uaDataPlatform;
}
return navigator.userAgent || '';
};
const readCurrentVisibleViewport = () => resolveWailsWindowVisibleViewport(
window.screen as Screen & { availLeft?: number; availTop?: number },
{ innerWidth: window.innerWidth, innerHeight: window.innerHeight },
{ useMonitorLocalOrigin: isMacLikePlatform() },
);
const getSystemThemeMode = (): 'light' | 'dark' => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return 'light';
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnection[]): SavedConnection[] => {
const merged = new Map<string, SavedConnection>();
current.forEach((conn) => merged.set(conn.id, conn));
imported.forEach((conn) => merged.set(conn.id, conn));
return Array.from(merged.values());
};
type ConnectionPackageDialogMode = 'import' | 'export';
type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';
type ToolCenterPaneKey =
| 'connection-package'
| 'data-root'
| 'security-update'
| 'schema-compare'
| 'data-compare'
| 'sync'
| 'drivers'
| 'snippet-settings'
| 'shortcut-settings';
type ToolCenterPaneState = {
key: ToolCenterPaneKey;
group: ToolCenterGroupKey;
};
type SettingsCenterGroupKey = 'preferences' | 'services' | 'about';
type SettingsCenterPaneKey = 'language' | 'theme' | 'sidebar-metadata' | 'proxy' | 'web-auth' | 'ai' | 'about-go-navi';
type SettingsCenterPaneState = {
key: SettingsCenterPaneKey;
group: SettingsCenterGroupKey;
};
const resolveSettingsCenterGroupInitialPane = (group: SettingsCenterGroupKey): SettingsCenterPaneState | null => (
group === 'about' ? { key: 'about-go-navi', group: 'about' } : null
);
const DEFAULT_GLOBAL_PROXY_TEST_URL = 'https://api.github.com/';
type GlobalProxyTestResultState = {
success: boolean;
message: string;
url?: string;
finalUrl?: string;
statusCode?: number;
durationMs?: number;
viaProxy?: boolean;
};
const getGlobalProxyDefaultPort = (type: GlobalProxyConfig['type']): number => (
type === 'http' ? 8080 : 1080
);
const createGlobalProxyComparableDraft = (
value: Partial<GlobalProxyConfig> = {},
): GlobalProxyConfig => ({
...createGlobalProxyDraft(value),
password: typeof value.password === 'string' ? value.password : '',
});
const areGlobalProxyDraftsEqual = (
left: Partial<GlobalProxyConfig>,
right: Partial<GlobalProxyConfig>,
): boolean => {
const normalizedLeft = createGlobalProxyComparableDraft(left);
const normalizedRight = createGlobalProxyComparableDraft(right);
return (
normalizedLeft.enabled === normalizedRight.enabled &&
normalizedLeft.type === normalizedRight.type &&
normalizedLeft.host === normalizedRight.host &&
normalizedLeft.port === normalizedRight.port &&
normalizedLeft.user === normalizedRight.user &&
normalizedLeft.password === normalizedRight.password &&
normalizedLeft.hasPassword === normalizedRight.hasPassword
);
};
const formatAboutCheckedAt = (value: Date): string => {
const pad = (input: number) => String(input).padStart(2, '0');
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}`;
};
const formatAboutReleaseTime = (value: string | undefined): string => {
const text = String(value || '').trim();
if (!text) {
return '-';
}
const date = new Date(text);
if (Number.isNaN(date.getTime())) {
return '-';
}
return formatAboutCheckedAt(date);
};
type ConnectionPackageDialogState = {
open: boolean;
mode: ConnectionPackageDialogMode;
includeSecrets: boolean;
useFilePassword: boolean;
password: string;
error: string;
confirmLoading: boolean;
};
const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogState => ({
open: false,
mode: 'export',
includeSecrets: true,
useFilePassword: false,
password: '',
error: '',
confirmLoading: false,
});
type SidebarMetadataSortableRowProps = {
field: SidebarTableMetadataField;
label: string;
checked: boolean;
dividerColor: string;
titleColor: string;
mutedColor: string;
onToggle: (selected: boolean) => void;
};
const SidebarMetadataSortableRow: React.FC<SidebarMetadataSortableRowProps> = ({
field,
label,
checked,
dividerColor,
titleColor,
mutedColor,
onToggle,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: field });
return (
<div
ref={setNodeRef}
data-sidebar-metadata-field={field}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
paddingBottom: 12,
borderBottom: `1px solid ${dividerColor}`,
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.72 : 1,
position: 'relative',
zIndex: isDragging ? 2 : undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<button
type="button"
aria-label={`Drag ${label}`}
{...attributes}
{...listeners}
style={{
width: 24,
height: 24,
border: 'none',
borderRadius: 8,
background: 'transparent',
color: mutedColor,
cursor: isDragging ? 'grabbing' : 'grab',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 0,
touchAction: 'none',
}}
>
<MenuOutlined />
</button>
<span style={{ fontWeight: 500, color: titleColor, minWidth: 0 }}>{label}</span>
</div>
<Switch
checked={checked}
onChange={(selected) => onToggle(selected)}
/>
</div>
);
};
function App() {
const { language, t } = useI18n();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
const [syncModalEntryMode, setSyncModalEntryMode] = useState<DataSyncEntryMode>('sync');
const [isDriverModalOpen, setIsDriverModalOpen] = useState(false);
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
const connectionModalWarmupDoneRef = useRef(false);
const windowState = useStore(state => state.windowState);
const themeMode = useStore(state => state.theme);
const themePreference = useStore(state => state.themePreference);
const setTheme = useStore(state => state.setTheme);
const setThemePreference = useStore(state => state.setThemePreference);
const appearance = useStore(state => state.appearance);
const setAppearance = useStore(state => state.setAppearance);
const uiScale = useStore(state => state.uiScale);
const setUiScale = useStore(state => state.setUiScale);
const fontSize = useStore(state => state.fontSize);
const setFontSize = useStore(state => state.setFontSize);
const startupFullscreen = useStore(state => state.startupFullscreen);
const setStartupFullscreen = useStore(state => state.setStartupFullscreen);
const globalProxy = useStore(state => state.globalProxy);
const replaceConnections = useStore(state => state.replaceConnections);
const replaceGlobalProxy = useStore(state => state.replaceGlobalProxy);
const replaceSavedQueries = useStore(state => state.replaceSavedQueries);
const queryOptions = useStore(state => state.queryOptions);
const setQueryOptions = useStore(state => state.setQueryOptions);
const shortcutOptions = useStore(state => state.shortcutOptions);
const updateShortcut = useStore(state => state.updateShortcut);
const resetShortcutOptions = useStore(state => state.resetShortcutOptions);
const [systemThemeMode, setSystemThemeMode] = useState<'light' | 'dark'>(() => getSystemThemeMode());
const darkMode = themeMode === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
const effectiveFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(Number(fontSize) || DEFAULT_FONT_SIZE)));
const tokenFontSize = Math.round(effectiveFontSize * effectiveUiScale);
const titleBarToggleIconKey = resolveTitleBarToggleIconKey(
windowState === 'fullscreen' ? 'fullscreen' : (windowState === 'maximized' ? 'maximized' : 'normal')
);
const tokenFontSizeSM = Math.max(10, Math.round(tokenFontSize * 0.86));
const tokenFontSizeLG = Math.max(tokenFontSize + 1, Math.round(tokenFontSize * 1.14));
const tokenControlHeight = Math.max(24, Math.round(32 * effectiveUiScale));
const tokenControlHeightSM = Math.max(20, Math.round(24 * effectiveUiScale));
const tokenControlHeightLG = Math.max(30, Math.round(40 * effectiveUiScale));
const dataTableFontSizeFollowsGlobal = appearance.dataTableFontSizeFollowGlobal !== false;
const sidebarTreeFontSizeFollowsGlobal = appearance.sidebarTreeFontSizeFollowGlobal !== false;
const effectiveDataTableFontSize = dataTableFontSizeFollowsGlobal
? effectiveFontSize
: (sanitizeDataTableFontSize(appearance.dataTableFontSize) ?? effectiveFontSize);
const effectiveSidebarTreeFontSize = sidebarTreeFontSizeFollowsGlobal
? effectiveFontSize
: (sanitizeSidebarTreeFontSize(appearance.sidebarTreeFontSize) ?? effectiveFontSize);
const effectiveSidebarRailScale = sanitizeV2SidebarRailScale(appearance.v2SidebarRailScale);
const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';
const newQuerySqlTemplate = appearance.newQuerySqlTemplate ?? DEFAULT_QUERY_TEMPLATE;
const sidebarTableMetadataFieldOrder = useMemo(
() => resolveSidebarTableMetadataFieldOrder(queryOptions?.sidebarTableMetadataFieldOrder),
[queryOptions?.sidebarTableMetadataFieldOrder],
);
const sidebarTableMetadataFields = useMemo(
() => resolveSidebarTableMetadataFields(
queryOptions?.sidebarTableMetadataFields,
queryOptions?.showSidebarTableComment === true,
sidebarTableMetadataFieldOrder,
),
[queryOptions?.showSidebarTableComment, queryOptions?.sidebarTableMetadataFields, sidebarTableMetadataFieldOrder],
);
const sidebarMetadataDragSensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 4 },
}),
);
const tabDisplaySettings = useMemo(
() => sanitizeTabDisplaySettings(appearance.tabDisplay),
[appearance.tabDisplay],
);
const tabDisplayElementOrder = useMemo(
() => resolveTabDisplayElementOrder(tabDisplaySettings),
[tabDisplaySettings],
);
const visibleTabDisplayElementKeys = useMemo(
() => new Set<TabDisplayElementKey>([
...tabDisplaySettings.primaryElements,
...tabDisplaySettings.secondaryElements,
]),
[tabDisplaySettings],
);
const getTabDisplayElementLabel = useCallback(
(key: TabDisplayElementKey) => t(TAB_DISPLAY_ELEMENT_META[key].labelKey),
[t],
);
const getTabDisplayElementDescription = useCallback(
(key: TabDisplayElementKey) => t(TAB_DISPLAY_ELEMENT_META[key].descriptionKey),
[t],
);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return;
}
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const applySystemTheme = (matches: boolean) => {
setSystemThemeMode(matches ? 'dark' : 'light');
};
applySystemTheme(mediaQueryList.matches);
const handleChange = (event: MediaQueryListEvent) => {
applySystemTheme(event.matches);
};
if (typeof mediaQueryList.addEventListener === 'function') {
mediaQueryList.addEventListener('change', handleChange);
return () => {
mediaQueryList.removeEventListener('change', handleChange);
};
}
mediaQueryList.addListener(handleChange);
return () => {
mediaQueryList.removeListener(handleChange);
};
}, []);
useEffect(() => {
const resolvedTheme = themePreference === 'system' ? systemThemeMode : themePreference;
if (themeMode !== resolvedTheme) {
setTheme(resolvedTheme);
}
if (themePreference === 'system') {
void safeWindowRuntimeCall(() => WindowSetSystemDefaultTheme(), undefined);
return;
}
if (resolvedTheme === 'dark') {
void safeWindowRuntimeCall(() => WindowSetDarkTheme(), undefined);
return;
}
void safeWindowRuntimeCall(() => WindowSetLightTheme(), undefined);
}, [setTheme, systemThemeMode, themeMode, themePreference]);
const setTabDisplaySettings = useCallback((settings: Partial<TabDisplaySettings>) => {
setAppearance({
tabDisplay: applyTabDisplaySettingsPatch(tabDisplaySettings, settings),
});
}, [setAppearance, tabDisplaySettings]);
const setTabDisplayLayout = useCallback((layout: TabDisplayLayout) => {
if (layout === tabDisplaySettings.layout) return;
setAppearance({
tabDisplay: switchTabDisplayLayout(tabDisplaySettings, layout),
});
}, [setAppearance, tabDisplaySettings]);
const updateTabDisplayElementVisibility = useCallback((key: TabDisplayElementKey, checked: boolean) => {
setFocusedTabDisplayElementKey(key);
const removeKey = (keys: TabDisplayElementKey[]) => keys.filter((item) => item !== key);
if (!checked) {
setTabDisplaySettings({
layout: tabDisplaySettings.layout,
primaryElements: removeKey(tabDisplaySettings.primaryElements),
secondaryElements: removeKey(tabDisplaySettings.secondaryElements),
});
return;
}
const primaryElements = removeKey(tabDisplaySettings.primaryElements);
const secondaryElements = removeKey(tabDisplaySettings.secondaryElements);
if (tabDisplaySettings.layout === 'double' && TAB_DISPLAY_SECONDARY_DEFAULT_KEYS.includes(key)) {
secondaryElements.push(key);
} else {
primaryElements.push(key);
}
setTabDisplaySettings({
layout: tabDisplaySettings.layout,
primaryElements,
secondaryElements,
});
}, [setTabDisplaySettings, tabDisplaySettings]);
const moveTabDisplayElement = useCallback((key: TabDisplayElementKey, offset: -1 | 1) => {
setFocusedTabDisplayElementKey(key);
const moveWithin = (keys: TabDisplayElementKey[]) => {
const index = keys.indexOf(key);
if (index < 0) return keys;
const nextIndex = index + offset;
if (nextIndex < 0 || nextIndex >= keys.length) return keys;
const next = [...keys];
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
return next;
};
setTabDisplaySettings({
layout: tabDisplaySettings.layout,
primaryElements: moveWithin(tabDisplaySettings.primaryElements),
secondaryElements: moveWithin(tabDisplaySettings.secondaryElements),
});
}, [setTabDisplaySettings, tabDisplaySettings]);
const setTabDisplayElementRow = useCallback((key: TabDisplayElementKey, row: 'primary' | 'secondary') => {
setFocusedTabDisplayElementKey(key);
const primaryElements = tabDisplaySettings.primaryElements.filter((item) => item !== key);
const secondaryElements = tabDisplaySettings.secondaryElements.filter((item) => item !== key);
if (row === 'primary') {
primaryElements.push(key);
} else {
secondaryElements.push(key);
}
setTabDisplaySettings({
layout: tabDisplaySettings.layout,
primaryElements,
secondaryElements,
});
}, [setTabDisplaySettings, tabDisplaySettings]);
const resolvedUiFontFamily = resolveUIFontFamily(appearance.customUIFontFamily);
const resolvedMonoFontFamily = resolveMonoFontFamily(appearance.customMonoFontFamily);
const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle');
const titleBarHeight = Math.max(28, Math.round(32 * effectiveUiScale));
const titleBarButtonWidth = Math.max(40, Math.round(46 * effectiveUiScale));
const floatingLogButtonHeight = Math.max(30, Math.round(34 * effectiveUiScale));
const resolvedAppearance = resolveAppearanceValues(appearance);
const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const effectiveBlur = normalizeBlurForPlatform(resolvedAppearance.blur);
const blurFilter = blurToFilter(effectiveBlur);
const [runtimePlatform, setRuntimePlatform] = useState('');
const [runtimeBuildType, setRuntimeBuildType] = useState('');
const [isLinuxRuntime, setIsLinuxRuntime] = useState(false);
const isWebRuntime = runtimeBuildType === 'web';
const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);
const [isFontFamiliesLoading, setIsFontFamiliesLoading] = useState(false);
const [fontFamiliesLoadError, setFontFamiliesLoadError] = useState<string | null>(null);
const hasLoadedInstalledFontsRef = useRef(false);
const uiFontOptions = useMemo(
() => buildFontFamilyOptions(runtimePlatform, 'ui', installedFontFamilies, t),
[installedFontFamilies, runtimePlatform, t],
);
const monoFontOptions = useMemo(
() => buildFontFamilyOptions(runtimePlatform, 'mono', installedFontFamilies, t),
[installedFontFamilies, runtimePlatform, t],
);
const linuxCJKFontInstallHint = getLinuxCJKFontInstallHint(runtimePlatform, installedFontFamilies);
const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated());
const [hasLoadedSecureConfig, setHasLoadedSecureConfig] = useState(false);
const [viewportWidth, setViewportWidth] = useState(() => (typeof window === 'undefined' ? 1280 : window.innerWidth || 1280));
const [securityUpdateStatus, setSecurityUpdateStatus] = useState<SecurityUpdateStatus>(() => createEmptySecurityUpdateStatus());
const [securityUpdateRawPayload, setSecurityUpdateRawPayload] = useState<string | null>(null);
const [securityUpdateHasLegacySensitiveItems, setSecurityUpdateHasLegacySensitiveItems] = useState(false);
const [isSecurityUpdateIntroOpen, setIsSecurityUpdateIntroOpen] = useState(false);
const [isSecurityUpdateBannerDismissed, setIsSecurityUpdateBannerDismissed] = useState(false);
const [isSecurityUpdateSettingsOpen, setIsSecurityUpdateSettingsOpen] = useState(false);
const [securityUpdateSettingsFocusTarget, setSecurityUpdateSettingsFocusTarget] = useState<SecurityUpdateSettingsFocusTarget | null>(null);
const [securityUpdateSettingsFocusRequest, setSecurityUpdateSettingsFocusRequest] = useState(0);
const [isSecurityUpdateProgressOpen, setIsSecurityUpdateProgressOpen] = useState(false);
const [securityUpdateProgressStage, setSecurityUpdateProgressStage] = useState(() => t('app.security_update.stage.checking_saved_config'));
const [securityUpdateRepairSource, setSecurityUpdateRepairSource] = useState<SecurityUpdateRepairSource | null>(null);
const [focusedTabDisplayElementKey, setFocusedTabDisplayElementKey] = useState<TabDisplayElementKey | null>(null);
const [focusedAIProviderId, setFocusedAIProviderId] = useState<string | undefined>(undefined);
const [connectionPackageDialog, setConnectionPackageDialog] = useState<ConnectionPackageDialogState>(() => createClosedConnectionPackageDialogState());
const [pendingConnectionImportPayload, setPendingConnectionImportPayload] = useState<string | null>(null);
const [aiPanelRenderNonce, setAiPanelRenderNonce] = useState(0);
const sidebarWidth = useStore(state => state.sidebarWidth);
const setSidebarWidth = useStore(state => state.setSidebarWidth);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
const detachedAIChatWindow = useStore(state => state.detachedAIChatWindow);
const detachAIChatPanel = useStore(state => state.detachAIChatPanel);
const aiChatDetached = Boolean(detachedAIChatWindow);
const toggleAIPanel = useStore(state => state.toggleAIPanel);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const windowDiagSequenceRef = React.useRef(0);
const windowDiagLastSignatureRef = React.useRef('');
const windowDiagLastAtRef = React.useRef(0);
const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig);
const securityUpdateStatusMeta = useMemo(
() => getSecurityUpdateStatusMeta(securityUpdateStatus, t),
[securityUpdateStatus, t],
);
const securityUpdateEntryVisibility = useMemo(
() => resolveSecurityUpdateEntryVisibility(securityUpdateStatus),
[securityUpdateStatus],
);
const windowCornerRadius = 14;
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const syncViewportWidth = () => {
setViewportWidth(window.innerWidth || document.documentElement?.clientWidth || 1280);
};
syncViewportWidth();
window.addEventListener('resize', syncViewportWidth);
return () => window.removeEventListener('resize', syncViewportWidth);
}, []);
useEffect(()=>{
if (typeof document === 'undefined' || !document.body) {
return;
}
switch(windowState){
case 'fullscreen':
case 'maximized':
document.body.style.setProperty('--gonavi-border-radius', '0px');
break;
default:
document.body.style.setProperty('--gonavi-border-radius', `${windowCornerRadius}px`);
break;
}
}, [windowState]);
// 同步 macOS 窗口透明度opacity=1.0 且 blur=0 时关闭 NSVisualEffectView
// 避免 GPU 持续计算窗口背后的模糊合成
useEffect(() => {
try {
void SetWindowTranslucency(resolvedAppearance.opacity, resolvedAppearance.blur).catch(() => undefined);
} catch(e) { /* ignore */ }
}, [resolvedAppearance.blur, resolvedAppearance.opacity]);
useEffect(() => {
let cancelled = false;
try {
Environment()
.then((env) => {
if (cancelled) return;
const platform = String(env?.platform || '').toLowerCase();
setRuntimePlatform(platform);
setRuntimeBuildType(String(env?.buildType || '').toLowerCase());
setIsLinuxRuntime(platform === 'linux');
})
.catch(() => {
if (cancelled) return;
const platform = detectNavigatorPlatform();
const normalized = /linux/i.test(platform)
? 'linux'
: (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : ''));
setRuntimePlatform(normalized);
setIsLinuxRuntime(normalized === 'linux');
});
} catch(e) {
if (cancelled) return;
const platform = detectNavigatorPlatform();
const normalized = /linux/i.test(platform)
? 'linux'
: (/mac/i.test(platform) ? 'darwin' : (/win/i.test(platform) ? 'windows' : ''));
setRuntimePlatform(normalized);
setIsLinuxRuntime(normalized === 'linux');
}
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (isStoreHydrated) {
return;
}
const unsubscribe = useStore.persist.onFinishHydration(() => {
setIsStoreHydrated(true);
});
return () => {
unsubscribe();
};
}, [isStoreHydrated]);
useEffect(() => {
if (!isStoreHydrated) {
return;
}
let cancelled = false;
const loadSavedQueries = async () => {
try {
await bootstrapSavedQueries({
backend: (window as any).go?.app?.App,
replaceSavedQueries: (queries) => {
if (!cancelled) {
replaceSavedQueries(queries);
}
},
});
} catch (err) {
console.warn('Failed to bootstrap saved queries', err);
}
};
void loadSavedQueries();
return () => {
cancelled = true;
};
}, [isStoreHydrated, replaceSavedQueries]);
const normalizeSecurityUpdateStatus = useCallback((status?: Partial<SecurityUpdateStatus> | null): SecurityUpdateStatus => {
const fallback = createEmptySecurityUpdateStatus();
return {
...fallback,
...(status ?? {}),
summary: {
...fallback.summary,
...(status?.summary ?? {}),
},
issues: Array.isArray(status?.issues) ? status.issues : [],
};
}, []);
const applySecurityUpdateStatus = useCallback((
status?: Partial<SecurityUpdateStatus> | null,
options?: {
openSettings?: boolean;
refreshFocus?: boolean;
resetBannerDismissed?: boolean;
},
) => {
const nextStatus = normalizeSecurityUpdateStatus(status);
const visibility = resolveSecurityUpdateEntryVisibility(nextStatus);
setSecurityUpdateStatus(nextStatus);
setIsSecurityUpdateIntroOpen(visibility.showIntro);
if (options?.resetBannerDismissed !== false) {
setIsSecurityUpdateBannerDismissed(false);
}
if (options?.openSettings) {
if (options.refreshFocus !== false) {
setSecurityUpdateSettingsFocusTarget(resolveSecurityUpdateSettingsFocusTarget(nextStatus));
setSecurityUpdateSettingsFocusRequest((current) => current + 1);
}
setIsSecurityUpdateSettingsOpen(true);
}
return nextStatus;
}, [normalizeSecurityUpdateStatus]);
useEffect(() => {
if (!isStoreHydrated) {
return;
}
let cancelled = false;
const loadSecureConfig = async () => {
try {
const result = await bootstrapSecureConfig({
backend: (window as any).go?.app?.App,
autoStartLegacySecurityUpdate: true,
replaceConnections,
replaceGlobalProxy,
t,
});
if (cancelled) {
return;
}
setSecurityUpdateRawPayload(result.rawPayload);
setSecurityUpdateHasLegacySensitiveItems(result.hasLegacySensitiveItems);
applySecurityUpdateStatus(result.status);
} catch (err) {
console.warn('Failed to bootstrap secure config', err);
} finally {
if (!cancelled) {
setHasLoadedSecureConfig(true);
}
}
};
void loadSecureConfig();
return () => {
cancelled = true;
};
}, [applySecurityUpdateStatus, isStoreHydrated, replaceConnections, replaceGlobalProxy, t]);
useEffect(() => {
let cancelled = false;
let startupWindowTimer: number | null = null;
let restoredOnce = false;
const maxApplyAttempts = 8;
const applyRetryDelayMs = 350;
const settleDelayMs = 180;
const useMaximiseForStartup = isWindowsPlatform();
const checkStartupPreferenceApplied = async (): Promise<boolean> => {
try {
if (await WindowIsFullscreen()) {
return true;
}
} catch (_) {
// ignore
}
try {
if (await WindowIsMaximised()) {
return true;
}
} catch (_) {
// ignore
}
return false;
};
const markAppliedMaximisedOrFullscreen = (mode: 'maximised' | 'fullscreen') => {
// 启动偏好成功后立刻固化 windowState避免宽限期内被写成 normal 导致下次半窗
if (mode === 'maximised' || useMaximiseForStartup) {
useStore.getState().setWindowState('maximized');
} else {
useStore.getState().setWindowState('fullscreen');
}
clearStartupWindowRestorePending();
};
// mode:
// - maximised: 始终最大化Windows 启动偏好 / 记忆的 maximized / Windows 上的 fullscreen 记忆)
// - fullscreen: 非 Windows 优先真全屏,失败再最大化
// 第 1 次立即执行delay=0避免 Windows 先闪 1024×768 半窗再最大化
const applyStartupWindowChrome = (attempt: number, mode: 'maximised' | 'fullscreen') => {
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
const delayMs = attempt <= 1 ? 0 : applyRetryDelayMs;
startupWindowTimer = window.setTimeout(() => {
if (cancelled) {
return;
}
void Promise.resolve()
.then(async () => {
if (await checkStartupPreferenceApplied()) {
markAppliedMaximisedOrFullscreen(mode);
return;
}
try {
if (mode === 'maximised') {
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
} else {
await WindowFullscreen();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
if (await checkStartupPreferenceApplied()) {
markAppliedMaximisedOrFullscreen(mode);
return;
}
await WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, settleDelayMs));
}
} catch (e) {
console.warn("Wails Window APIs unavailable", e);
}
if (await checkStartupPreferenceApplied()) {
markAppliedMaximisedOrFullscreen(mode);
return;
}
if (attempt < maxApplyAttempts) {
applyStartupWindowChrome(attempt + 1, mode);
} else {
// 最终仍失败:结束宽限,避免长期阻断 bounds 记忆;下次启动会再试
clearStartupWindowRestorePending();
void emitWindowDiagnostic('warn:startup-maximise-failed', {
mode,
attempts: attempt,
});
}
});
}, delayMs);
};
const restoreWindowState = async () => {
if (cancelled) return;
// 仅在 hydration 完成后跑一次(或显式重入);避免未水合默认态先写半窗 bounds
if (!useStore.persist.hasHydrated()) {
return;
}
if (restoredOnce) {
return;
}
restoredOnce = true;
const state = useStore.getState();
// 1) 「启动时最大化」开关优先Windows 按 Maximize 处理)
if (state.startupFullscreen) {
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, useMaximiseForStartup ? 'maximised' : 'fullscreen');
return;
}
// 2) 记忆用户上次窗口态:最大化/全屏
const savedState = state.windowState;
if (savedState === 'fullscreen') {
// Windows 上记忆的 fullscreen 也走最大化,避免真全屏后标题栏交互困难
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, useMaximiseForStartup ? 'maximised' : 'fullscreen');
return;
}
if (savedState === 'maximized') {
// 必须重试Windows 冷启动 HWND/WebView2 未就绪时单次 Maximise 经常失败,
// 会残留 main.go 默认 1024x768 贴左上角;任务栏恢复后才“突然正常”。
markStartupWindowRestorePending(3200);
applyStartupWindowChrome(1, 'maximised');
return;
}
// 3) 普通窗口:恢复用户调整过的尺寸和位置
const bounds = state.windowBounds;
if (!bounds || bounds.width < 400 || bounds.height < 300) {
// 无记忆尺寸时Windows 默认左上角小窗看起来像“只开了一半”,改为居中可用尺寸
if (isWindowsPlatform()) {
try {
const nextBounds = resolveDefaultStartupWindowBounds(readCurrentVisibleViewport());
WindowSetSize(nextBounds.width, nextBounds.height);
WindowSetPosition(nextBounds.x, nextBounds.y);
state.setWindowBounds(nextBounds);
state.setWindowState('normal');
void emitWindowDiagnostic('adjust:startup-default-window-bounds', {
to: nextBounds,
});
} catch (e) {
console.warn('Failed to apply default Windows startup bounds', e);
}
}
return;
}
try {
const nextBounds = resolveVisibleStartupWindowBounds(bounds, readCurrentVisibleViewport());
if (
nextBounds.x !== bounds.x ||
nextBounds.y !== bounds.y ||
nextBounds.width !== bounds.width ||
nextBounds.height !== bounds.height
) {
void emitWindowDiagnostic('adjust:startup-window-bounds', {
from: bounds,
to: nextBounds,
});
state.setWindowBounds(nextBounds);
}
WindowSetSize(nextBounds.width, nextBounds.height);
WindowSetPosition(nextBounds.x, nextBounds.y);
state.setWindowState('normal');
} catch (e) {
console.warn('Failed to restore window bounds', e);
}
};
if (useStore.persist.hasHydrated()) {
void restoreWindowState();
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled) {
return;
}
// hydration 完成后再恢复,确保读到 startupFullscreen / windowState / windowBounds
restoredOnce = false;
void restoreWindowState();
});
return () => {
cancelled = true;
if (startupWindowTimer !== null) {
window.clearTimeout(startupWindowTimer);
}
unsubscribeHydration();
};
}, []);
// 定时保存窗口状态、尺寸与位置
useEffect(() => {
const SAVE_INTERVAL_MS = 2000;
let cancelled = false;
let hydrated = useStore.persist.hasHydrated();
let eventSaveTimer: number | null = null;
let boundsRepairTimer: number | null = null;
let lastSaved = '';
const saveWindowState = async () => {
if (cancelled || !hydrated) {
return;
}
try {
const [isFs, isMax] = await Promise.all([
safeWindowRuntimeCall(() => WindowIsFullscreen(), false),
safeWindowRuntimeCall(() => WindowIsMaximised(), false),
]);
// 启动最大化/全屏尚未 settle 时,禁止把状态写回 normal
// 否则下次冷启动会落到默认 1024x768 左上角Windows 首次打开“只显示一半”)。
if (isStartupWindowRestorePending()) {
if (!isFs && !isMax) {
return;
}
clearStartupWindowRestorePending();
}
// 保存窗口状态
const store = useStore.getState();
const newState = isFs ? 'fullscreen' : (isMax ? 'maximized' : 'normal');
if (store.windowState !== newState) {
void emitWindowDiagnostic('transition:windowState', {
from: store.windowState,
to: newState,
});
store.setWindowState(newState);
}
// 只在普通窗口模式下保存尺寸和位置
if (isFs || isMax) return;
const [size, pos] = await Promise.all([
safeWindowRuntimeCall(() => WindowGetSize(), null),
safeWindowRuntimeCall(() => WindowGetPosition(), null),
]);
if (!size || !pos) return;
const w = Math.trunc(Number(size.w || 0));
const h = Math.trunc(Number(size.h || 0));
const x = Math.trunc(Number(pos.x || 0));
const y = Math.trunc(Number(pos.y || 0));
if (w < 400 || h < 300) return;
const key = `${w},${h},${x},${y}`;
if (key === lastSaved) return;
lastSaved = key;
if (Math.abs(x) > 5000 || Math.abs(y) > 5000) {
void emitWindowDiagnostic('anomaly:windowBounds', { width: w, height: h, x, y });
}
store.setWindowBounds({ width: w, height: h, x, y });
} catch (e) {
// 静默忽略
}
};
const scheduleWindowStateSave = (delayMs = 120) => {
if (cancelled || !hydrated) {
return;
}
if (eventSaveTimer !== null) {
window.clearTimeout(eventSaveTimer);
}
eventSaveTimer = window.setTimeout(() => {
eventSaveTimer = null;
void saveWindowState();
}, delayMs);
};
const repairRuntimeWindowBounds = async () => {
if (cancelled || !hydrated) {
return;
}
// 启动最大化 settle 期间不要抢跑普通 bounds 校正
if (isStartupWindowRestorePending()) {
return;
}
try {
const [isFs, isMax] = await Promise.all([
safeWindowRuntimeCall(() => WindowIsFullscreen(), false),
safeWindowRuntimeCall(() => WindowIsMaximised(), false),
]);
if (isFs || isMax) {
return;
}
const [size, pos] = await Promise.all([
safeWindowRuntimeCall(() => WindowGetSize(), null),
safeWindowRuntimeCall(() => WindowGetPosition(), null),
]);
if (!size || !pos) {
return;
}
const currentBounds = {
width: Math.trunc(Number(size.w || 0)),
height: Math.trunc(Number(size.h || 0)),
x: Math.trunc(Number(pos.x || 0)),
y: Math.trunc(Number(pos.y || 0)),
};
if (currentBounds.width <= 0 || currentBounds.height <= 0) {
return;
}
const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());
if (
nextBounds.x === currentBounds.x &&
nextBounds.y === currentBounds.y &&
nextBounds.width === currentBounds.width &&
nextBounds.height === currentBounds.height
) {
return;
}
void emitWindowDiagnostic('adjust:runtime-window-bounds', {
from: currentBounds,
to: nextBounds,
});
WindowSetSize(nextBounds.width, nextBounds.height);
WindowSetPosition(nextBounds.x, nextBounds.y);
lastSaved = `${nextBounds.width},${nextBounds.height},${nextBounds.x},${nextBounds.y}`;
useStore.getState().setWindowBounds(nextBounds);
window.dispatchEvent(new Event('resize'));
} catch {
// Wails runtime window APIs are best-effort here.
}
};
const scheduleWindowBoundsRepair = (delayMs = 80) => {
if (cancelled || !hydrated) {
return;
}
if (boundsRepairTimer !== null) {
window.clearTimeout(boundsRepairTimer);
}
boundsRepairTimer = window.setTimeout(() => {
boundsRepairTimer = null;
void repairRuntimeWindowBounds();
}, delayMs);
};
const handleWindowRuntimeChange = () => {
scheduleWindowBoundsRepair();
scheduleWindowStateSave(260);
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
scheduleWindowBoundsRepair();
scheduleWindowStateSave(260);
}
};
const handleWindowLifecycleFlush = () => {
void saveWindowState();
};
if (hydrated) {
scheduleWindowBoundsRepair(360);
scheduleWindowStateSave(320);
}
const unsubscribeHydration = useStore.persist.onFinishHydration(() => {
if (cancelled || hydrated) {
return;
}
hydrated = true;
scheduleWindowBoundsRepair(360);
scheduleWindowStateSave(320);
});
const timer = window.setInterval(() => {
void saveWindowState();
}, SAVE_INTERVAL_MS);
window.addEventListener('resize', handleWindowRuntimeChange);
window.addEventListener('focus', handleWindowRuntimeChange);
window.addEventListener('pageshow', handleWindowRuntimeChange);
window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });
window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
if (eventSaveTimer !== null) {
window.clearTimeout(eventSaveTimer);
}
if (boundsRepairTimer !== null) {
window.clearTimeout(boundsRepairTimer);
}
window.clearInterval(timer);
window.removeEventListener('resize', handleWindowRuntimeChange);
window.removeEventListener('focus', handleWindowRuntimeChange);
window.removeEventListener('pageshow', handleWindowRuntimeChange);
window.removeEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });
window.removeEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });
document.removeEventListener('visibilitychange', handleVisibilityChange);
unsubscribeHydration();
};
}, []);
useEffect(() => {
if (!isWindowsPlatform()) {
return;
}
let cancelled = false;
let inFlight = false;
let lastRatio = Number(window.devicePixelRatio) || 1;
let lastFixAt = 0;
let activationTimer: number | null = null;
let resizeTimer: number | null = null;
let minimisedSeen = false;
let hiddenSeen = document.visibilityState === 'hidden';
const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
const fixWindowScaleIfNeeded = async (reason: WindowScaleFixReason) => {
if (cancelled || inFlight) return;
const now = Date.now();
if (now - lastFixAt < 700) return;
inFlight = true;
try {
const [isFullscreen, isMaximised] = await Promise.all([
safeWindowRuntimeCall(() => WindowIsFullscreen(), false),
safeWindowRuntimeCall(() => WindowIsMaximised(), false),
]);
// 全屏状态下只广播 resize避免破坏用户的全屏上下文。
if (isFullscreen) {
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
}
const size = await safeWindowRuntimeCall(() => WindowGetSize(), null);
const width = Math.trunc(Number(size?.w || 0));
const height = Math.trunc(Number(size?.h || 0));
const hasViewportScaleDrift = hasWindowsViewportScaleDrift({
windowWidth: width,
innerWidth: window.innerWidth,
devicePixelRatio: Number(window.devicePixelRatio) || 1,
visualViewportScale: window.visualViewport?.scale,
});
const shouldResetWebViewZoom = shouldResetWebViewZoomForScaleFix(reason, hasViewportScaleDrift);
if (shouldResetWebViewZoom && !isMaximised) {
try {
const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.();
if (!res?.success) {
console.warn('ResetWebViewZoom unavailable in fixWindowScaleIfNeeded:', res?.message);
}
} catch (e) {
console.warn('ResetWebViewZoom call failed in fixWindowScaleIfNeeded', e);
}
}
if (isMaximised) {
if (!shouldToggleMaximisedWindowForScaleFix(reason, hasViewportScaleDrift)) {
// restore任务栏点击恢复后字体异常变大/变糊)的零感知修复路径:
// 调 backend App.ResetWebViewZoom 触发 WebView2 ICoreWebView2Controller::put_ZoomFactor(1.0)
// 让 WebView2 重算 D2D/DirectWrite 字体度量。该异常不一定表现为 viewport ratio drift
// 所以 restore 场景不能依赖 hasViewportScaleDrift。完全不动窗口、零动画。
// backend 失败wails 升级破坏反射 / 非 Windows时回退到 dispatch resize 兜底;
// 用户仍可按 Ctrl+Shift+0 手动 toggle 修复。
if (shouldResetWebViewZoom) {
try {
const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.();
if (!res?.success) {
console.warn('ResetWebViewZoom unavailable in fixWindowScaleIfNeeded:', res?.message);
}
} catch (e) {
console.warn('ResetWebViewZoom call failed in fixWindowScaleIfNeeded', e);
}
}
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
}
try {
WindowUnmaximise();
await wait(96);
WindowMaximise();
await wait(96);
} catch (e) {
console.warn("Wails Window maximise restore unavailable in fixWindowScaleIfNeeded", e);
}
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
}
if (width <= 0 || height <= 0) {
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
}
if (!shouldApplyWindowsScaleFix(reason, hasViewportScaleDrift)) {
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
}
const nudgedWidth = getWindowsScaleFixNudgedWidth(width);
try {
WindowSetSize(nudgedWidth, height);
await wait(28);
WindowSetSize(width, height);
} catch(e) {}
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
} catch(e) {
console.warn("Wails Window APIs unavailable in fixWindowScaleIfNeeded", e);
} finally {
inFlight = false;
}
};
const rememberMinimisedState = async (): Promise<boolean> => {
if (cancelled) return false;
const isMinimised = await safeWindowRuntimeCall(() => WindowIsMinimised(), false);
if (isMinimised) {
minimisedSeen = true;
}
return isMinimised;
};
const rememberMinimisedStateSoon = () => {
window.setTimeout(() => {
if (cancelled) return;
void rememberMinimisedState();
}, 120);
};
const checkDevicePixelRatio = () => {
if (cancelled) return;
const currentRatio = Number(window.devicePixelRatio) || 1;
if (Math.abs(currentRatio - lastRatio) < 0.02) {
return;
}
lastRatio = currentRatio;
if (minimisedSeen || hiddenSeen) {
scheduleActivationFix();
return;
}
void fixWindowScaleIfNeeded('ratio-change');
};
const scheduleDevicePixelRatioCheck = (trigger: WindowsScaleCheckTrigger) => {
if (cancelled) return;
const delayMs = resolveWindowsScaleCheckDelayMs(trigger);
if (delayMs <= 0) {
checkDevicePixelRatio();
return;
}
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
}
resizeTimer = window.setTimeout(() => {
resizeTimer = null;
if (cancelled) return;
checkDevicePixelRatio();
}, delayMs);
};
const scheduleActivationFix = () => {
if (cancelled) return;
if (activationTimer !== null) {
window.clearTimeout(activationTimer);
}
const delayMs = (minimisedSeen || hiddenSeen) ? 260 : 80;
activationTimer = window.setTimeout(async () => {
activationTimer = null;
if (cancelled) return;
if (await rememberMinimisedState()) {
return;
}
const reason: WindowScaleFixReason = (minimisedSeen || hiddenSeen) ? 'restore' : 'activation';
minimisedSeen = false;
hiddenSeen = false;
void fixWindowScaleIfNeeded(reason);
}, delayMs);
};
const handleWindowFocus = () => {
if (cancelled) return;
scheduleDevicePixelRatioCheck('focus');
scheduleActivationFix();
};
const handleWindowBlur = () => {
if (cancelled) return;
if (document.visibilityState === 'hidden') {
hiddenSeen = true;
}
rememberMinimisedStateSoon();
};
const handleVisibilityChange = () => {
if (cancelled) return;
if (document.visibilityState !== 'visible') {
hiddenSeen = true;
rememberMinimisedStateSoon();
return;
}
scheduleDevicePixelRatioCheck('visibilitychange');
scheduleActivationFix();
};
const handlePageShow = () => {
if (cancelled) return;
scheduleDevicePixelRatioCheck('pageshow');
scheduleActivationFix();
};
const handleWindowResize = () => {
rememberMinimisedStateSoon();
scheduleDevicePixelRatioCheck('resize');
};
const pollTimer = window.setInterval(() => {
void rememberMinimisedState();
checkDevicePixelRatio();
}, 900);
// Windows 冷启动WebView2 首次布局常只铺满左上角一部分,任务栏恢复才会走 restore 修复。
// 这里在启动后主动按 startup 原因做几次轻量 settle避免用户必须双击任务栏。
// 间隔需大于 fixWindowScaleIfNeeded 的 700ms 节流,确保多次都能真正执行。
const startupLayoutFixTimers = [220, 1000, 1900].map((delayMs) => (
window.setTimeout(() => {
if (cancelled) return;
void fixWindowScaleIfNeeded('startup');
}, delayMs)
));
window.addEventListener('resize', handleWindowResize);
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('blur', handleWindowBlur);
window.addEventListener('pageshow', handlePageShow);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
if (activationTimer !== null) {
window.clearTimeout(activationTimer);
}
if (resizeTimer !== null) {
window.clearTimeout(resizeTimer);
}
for (const timer of startupLayoutFixTimers) {
window.clearTimeout(timer);
}
window.clearInterval(pollTimer);
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener('blur', handleWindowBlur);
window.removeEventListener('pageshow', handlePageShow);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
const {
bgContent, bgMain,
floatingLogButtonBgColor, floatingLogButtonBorderColor, floatingLogButtonShadow, floatingLogButtonTextColor,
isSidebarCompact, isSidebarNarrow, isSidebarUltraCompact,
overlayTheme, renderUtilityModalTitle,
sidebarCreateConnectionActionStyle, sidebarHorizontalPadding, sidebarQueryActionStyle,
toolCenterContentPanelStyle, toolCenterDetailBodyStyle, toolCenterDetailPanelStyle,
toolCenterModalContentStyle, toolCenterModalSplitStyle, toolCenterModalWorkspaceStyle,
toolCenterNavPanelStyle, toolCenterNavScrollStyle, toolCenterRowDescriptionStyle, toolCenterRowStyle,
toolCenterScrollableListStyle, utilityButtonStyle,
utilityModalShellStyle, utilityMutedTextStyle, utilityPanelStyle,
} = useAppUtilityStyles({
blurFilter,
darkMode,
effectiveOpacity,
effectiveUiScale,
isV2Ui,
resolvedAppearance,
sidebarWidth,
});
const addTab = useStore(state => state.addTab);
const activeContext = useStore(state => state.activeContext);
const connections = useStore(state => state.connections);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const savedQueries = useStore(state => state.savedQueries);
const saveQuery = useStore(state => state.saveQuery);
const applicationQuitConfirmRef = useRef<{ destroy: () => void } | null>(null);
const applicationQuitHandlingRef = useRef(false);
const openSecurityUpdateSettings = useCallback((focusTarget: SecurityUpdateSettingsFocusTarget | null = null) => {
setIsSecurityUpdateIntroOpen(false);
setSecurityUpdateSettingsFocusTarget(focusTarget);
setSecurityUpdateSettingsFocusRequest((current) => current + 1);
setIsSecurityUpdateSettingsOpen(true);
}, []);
const handleOpenSecurityUpdateSettings = useCallback((focusTarget: SecurityUpdateSettingsFocusTarget | null = null) => {
openSecurityUpdateSettings(focusTarget);
}, [openSecurityUpdateSettings]);
const runSecurityUpdateRound = useCallback(async (mode: 'start' | 'retry' | 'restart') => {
const backendApp = (window as any).go?.app?.App;
const stageText = mode === 'start'
? t('app.security_update.stage.checking_saved_config')
: (mode === 'retry'
? t('app.security_update.stage.verifying_result')
: t('app.security_update.stage.updating_secure_storage'));
const detailsWereOpen = isSecurityUpdateSettingsOpen;
setSecurityUpdateProgressStage(stageText);
setIsSecurityUpdateProgressOpen(true);
setIsSecurityUpdateIntroOpen(false);
setIsSecurityUpdateSettingsOpen(false);
let nextStatus: SecurityUpdateStatus | null = null;
let shouldOpenSettings = false;
let refreshSettingsFocus = false;
try {
if (mode === 'start') {
const result = await startSecurityUpdateFromBootstrap({
backend: backendApp,
replaceConnections,
replaceGlobalProxy,
t,
});
if (result.error) {
throw result.error;
}
nextStatus = normalizeSecurityUpdateStatus(result.status);
} else if (mode === 'retry') {
if (typeof backendApp?.RetrySecurityUpdateCurrentRound !== 'function') {
throw new Error(t('app.security_update.error.capability_unavailable'));
}
nextStatus = normalizeSecurityUpdateStatus(await backendApp.RetrySecurityUpdateCurrentRound({
migrationId: securityUpdateStatus.migrationId,
}));
} else {
if (typeof backendApp?.RestartSecurityUpdate !== 'function') {
throw new Error(t('app.security_update.error.capability_unavailable'));
}
nextStatus = normalizeSecurityUpdateStatus(await backendApp.RestartSecurityUpdate({
migrationId: securityUpdateStatus.migrationId,
sourceType: 'current_app_saved_config',
rawPayload: securityUpdateRawPayload ?? '',
options: {
allowPartial: true,
writeBackup: true,
},
}));
}
if (mode !== 'start') {
nextStatus = await finalizeSecurityUpdateStatus({
backend: backendApp,
replaceConnections,
replaceGlobalProxy,
t,
}, nextStatus);
}
shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back';
refreshSettingsFocus = shouldRefreshSecurityUpdateDetailsFocus({
requestedOpen: shouldOpenSettings,
wasOpen: detailsWereOpen,
});
} catch (err: any) {
console.warn('Failed to execute security update round', err);
setIsSecurityUpdateProgressOpen(false);
if (detailsWereOpen) {
setIsSecurityUpdateSettingsOpen(true);
}
void message.error(err?.message || t('app.security_update.message.not_finished_retry_later'));
return;
}
if (!nextStatus) {
setIsSecurityUpdateProgressOpen(false);
return;
}
setIsSecurityUpdateProgressOpen(false);
applySecurityUpdateStatus(nextStatus, {
openSettings: shouldOpenSettings,
refreshFocus: refreshSettingsFocus,
});
if (nextStatus.overallStatus === 'completed') {
setSecurityUpdateHasLegacySensitiveItems(false);
setSecurityUpdateRawPayload(null);
setIsSecurityUpdateSettingsOpen(false);
void message.success(t('app.security_update.message.completed'));
} else if (nextStatus.overallStatus === 'needs_attention') {
void message.warning(t('app.security_update.message.needs_attention'));
} else if (nextStatus.overallStatus === 'rolled_back') {
void message.warning(t('app.security_update.message.rolled_back'));
}
}, [
applySecurityUpdateStatus,
isSecurityUpdateSettingsOpen,
normalizeSecurityUpdateStatus,
replaceConnections,
replaceGlobalProxy,
securityUpdateRawPayload,
securityUpdateStatus.migrationId,
t,
]);
const handleStartSecurityUpdate = useCallback(() => {
void runSecurityUpdateRound('start');
}, [runSecurityUpdateRound]);
const handlePrepareExternalMCPUse = useCallback(async () => {
const backendApp = (window as any).go?.app?.App;
const result = await prepareSecureConfigForExternalMCP({
backend: backendApp,
replaceConnections,
replaceGlobalProxy,
t,
});
if (result.error) {
throw result.error;
}
if (!result.status) {
return;
}
const nextStatus = normalizeSecurityUpdateStatus(result.status);
const shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back';
applySecurityUpdateStatus(nextStatus, {
openSettings: shouldOpenSettings,
refreshFocus: shouldOpenSettings,
});
if (nextStatus.overallStatus === 'completed') {
setSecurityUpdateHasLegacySensitiveItems(false);
setSecurityUpdateRawPayload(null);
setIsSecurityUpdateSettingsOpen(false);
return;
}
const hasConnectionIssue = nextStatus.issues.some((issue) =>
issue.scope === 'connection' && issue.status !== 'updated',
);
if (nextStatus.overallStatus === 'rolled_back' || hasConnectionIssue) {
throw new Error(t('app.security_update.message.needs_attention'));
}
if (nextStatus.overallStatus === 'needs_attention') {
void message.warning(t('app.security_update.message.needs_attention'));
}
}, [
applySecurityUpdateStatus,
normalizeSecurityUpdateStatus,
replaceConnections,
replaceGlobalProxy,
t,
]);
const handleRetrySecurityUpdate = useCallback(() => {
void runSecurityUpdateRound('retry');
}, [runSecurityUpdateRound]);
const handleRestartSecurityUpdate = useCallback(() => {
void runSecurityUpdateRound('restart');
}, [runSecurityUpdateRound]);
const handlePostponeSecurityUpdate = useCallback(async () => {
const backendApp = (window as any).go?.app?.App;
setIsSecurityUpdateIntroOpen(false);
try {
if (typeof backendApp?.DismissSecurityUpdateReminder === 'function') {
const nextStatus = mergeSecurityUpdateStatusWithLegacySource(
await backendApp.DismissSecurityUpdateReminder(),
securityUpdateRawPayload,
{ t },
);
applySecurityUpdateStatus(nextStatus);
return;
}
applySecurityUpdateStatus({
overallStatus: 'postponed',
canStart: true,
canPostpone: true,
summary: securityUpdateStatus.summary,
issues: securityUpdateStatus.issues,
});
} catch (err: any) {
console.warn('Failed to dismiss security update reminder', err);
void message.error(err?.message || t('app.security_update.message.postpone_failed'));
}
}, [
applySecurityUpdateStatus,
securityUpdateRawPayload,
securityUpdateStatus.issues,
securityUpdateStatus.summary,
t,
]);
const handleSecurityUpdateIssueAction = useCallback((issue: SecurityUpdateIssue) => {
const repairEntry = resolveSecurityUpdateRepairEntry(issue, connections, securityUpdateStatus, t);
if (repairEntry.type === 'warning') {
void message.warning(repairEntry.message);
return;
}
if (repairEntry.type === 'connection') {
setIsSecurityUpdateSettingsOpen(false);
setSecurityUpdateRepairSource(repairEntry.repairSource);
setEditingConnection(repairEntry.connection);
setIsModalOpen(true);
return;
}
if (repairEntry.type === 'proxy') {
setIsSecurityUpdateSettingsOpen(false);
setSecurityUpdateRepairSource(repairEntry.repairSource);
setIsProxyModalOpen(true);
return;
}
if (repairEntry.type === 'ai') {
setIsSecurityUpdateSettingsOpen(false);
setSecurityUpdateRepairSource(repairEntry.repairSource);
setFocusedAIProviderId(repairEntry.providerId);
setActiveSettingsCenterGroupKey('services');
setActiveSettingsCenterPane({ key: 'ai', group: 'services' });
setIsSettingsModalOpen(true);
return;
}
if (repairEntry.type === 'retry') {
void runSecurityUpdateRound('retry');
return;
}
setSecurityUpdateRepairSource(null);
openSecurityUpdateSettings(repairEntry.focusTarget);
}, [connections, openSecurityUpdateSettings, runSecurityUpdateRound, securityUpdateStatus, t]);
const isMacRuntime = runtimePlatform === 'darwin'
|| (runtimePlatform === '' && /mac/i.test(detectNavigatorPlatform()));
const isWindowsRuntime = runtimePlatform === 'windows'
|| (runtimePlatform === '' && isWindowsPlatform());
const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true;
const activeShortcutPlatform = getShortcutPlatform(isMacRuntime);
const macWindowDiagnosticsEnabled = shouldEnableMacWindowDiagnostics(
isMacRuntime,
import.meta.env.DEV,
import.meta.env.VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS,
);
useEffect(() => {
return installGlobalImeCompositionTracking(window, document);
}, []);
// 启动发现更新时打开设置中心「关于」页(由 useAppUpdateManager 通过 bridge 调用)
const updateCenterBridgeRef = useRef<{
open: () => void;
close: () => void;
isOpen: () => boolean;
} | null>(null);
const {
aboutDisplayVersion,
aboutInfo,
aboutLoading,
aboutUpdateStatus,
canShowProgressEntry,
changeUpdateChannel,
checkForUpdates,
downloadUpdate,
formatBytes,
handleInstallFromProgress,
hideUpdateDownloadProgress,
isAboutOpen,
isBackgroundProgressForLatestUpdate,
isLatestUpdateDownloaded,
isUpdateChannelLoading,
isUpdateChannelSaving,
lastUpdateInfo,
markUpdateProgressDismissed,
muteLatestUpdate,
openDownloadedUpdateDirectory,
prepareAboutSurface,
setIsAboutOpen,
showUpdateDownloadProgress,
updateChannel,
updateDownloadProgress,
} = useAppUpdateManager({
runtimeBuildType,
t,
updateCenterBridgeRef,
});
const [aboutLastCheckedAt, setAboutLastCheckedAt] = useState('');
useEffect(() => {
if (!lastUpdateInfo) {
return;
}
setAboutLastCheckedAt(formatAboutCheckedAt(new Date()));
}, [
lastUpdateInfo?.channel,
lastUpdateInfo?.currentVersion,
lastUpdateInfo?.hasUpdate,
lastUpdateInfo?.latestVersion,
]);
const emitWindowDiagnostic = useCallback(async (stage: string, extra: Record<string, unknown> = {}) => {
if (!macWindowDiagnosticsEnabled) {
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.LogWindowDiagnostic !== 'function') {
return;
}
try {
const [isFullscreen, isMaximised, isMinimised, isNormal, size, position] = await Promise.all([
safeWindowRuntimeCall(() => WindowIsFullscreen(), false),
safeWindowRuntimeCall(() => WindowIsMaximised(), false),
safeWindowRuntimeCall(() => WindowIsMinimised(), false),
safeWindowRuntimeCall(() => WindowIsNormal(), false),
safeWindowRuntimeCall(() => WindowGetSize(), null),
safeWindowRuntimeCall(() => WindowGetPosition(), null),
]);
const payload = {
seq: ++windowDiagSequenceRef.current,
ts: new Date().toISOString(),
stage,
nativeControls: useNativeMacWindowControls,
documentVisible: document.visibilityState,
documentHasFocus: document.hasFocus(),
devicePixelRatio: Number(window.devicePixelRatio) || 1,
windowState: {
isFullscreen,
isMaximised,
isMinimised,
isNormal,
},
size: size ? { w: Math.trunc(Number(size.w || 0)), h: Math.trunc(Number(size.h || 0)) } : null,
position: position ? { x: Math.trunc(Number(position.x || 0)), y: Math.trunc(Number(position.y || 0)) } : null,
extra,
};
const signature = JSON.stringify({
stage,
nativeControls: payload.nativeControls,
visible: payload.documentVisible,
focus: payload.documentHasFocus,
state: payload.windowState,
size: payload.size,
position: payload.position,
extra,
});
const now = Date.now();
if (signature === windowDiagLastSignatureRef.current && now-windowDiagLastAtRef.current < 250) {
return;
}
windowDiagLastSignatureRef.current = signature;
windowDiagLastAtRef.current = now;
await backendApp.LogWindowDiagnostic(stage, JSON.stringify(payload));
} catch (error) {
console.warn('Failed to emit window diagnostic', error);
}
}, [macWindowDiagnosticsEnabled, useNativeMacWindowControls]);
useEffect(() => {
if (!isStoreHydrated || !isMacRuntime) {
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.SetMacNativeWindowControls !== 'function') {
return;
}
void safeWindowRuntimeCall(() => SetMacNativeWindowControls(useNativeMacWindowControls), undefined);
}, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]);
useEffect(() => {
if (!macWindowDiagnosticsEnabled) {
return;
}
let cancelled = false;
let pollTimer: number | null = null;
let burstTimer: number | null = null;
const stopBurst = () => {
if (pollTimer !== null) {
window.clearInterval(pollTimer);
pollTimer = null;
}
if (burstTimer !== null) {
window.clearTimeout(burstTimer);
burstTimer = null;
}
};
const startBurst = (reason: string, extra: Record<string, unknown> = {}) => {
if (cancelled) {
return;
}
void emitWindowDiagnostic(`burst:start:${reason}`, extra);
if (pollTimer === null) {
pollTimer = window.setInterval(() => {
void emitWindowDiagnostic(`burst:tick:${reason}`);
}, 250);
}
if (burstTimer !== null) {
window.clearTimeout(burstTimer);
}
burstTimer = window.setTimeout(() => {
stopBurst();
void emitWindowDiagnostic(`burst:stop:${reason}`);
}, 6000);
};
const handleFocus = () => {
void emitWindowDiagnostic('event:focus');
};
const handleBlur = () => {
void emitWindowDiagnostic('event:blur');
};
const handleResize = () => {
void emitWindowDiagnostic('event:resize');
};
const handleVisibilityChange = () => {
void emitWindowDiagnostic('event:visibilitychange', { visibility: document.visibilityState });
};
const handleEditableKeydown = (event: KeyboardEvent) => {
if (!isEditableElement(event.target)) {
return;
}
const key = String(event.key || '');
const maybeFullscreenKey = key === 'Escape' || key.toLowerCase() === 'f' || key === 'Process';
const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
startBurst('editable-keydown', {
key,
code: String(event.code || ''),
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
altKey: event.altKey,
shiftKey: event.shiftKey,
maybeFullscreenKey,
hasModifier,
});
};
const handleCompositionStart = () => {
startBurst('compositionstart');
};
const handleCompositionEnd = () => {
startBurst('compositionend');
};
void emitWindowDiagnostic('session:start');
window.addEventListener('focus', handleFocus);
window.addEventListener('blur', handleBlur);
window.addEventListener('resize', handleResize);
window.addEventListener('keydown', handleEditableKeydown, true);
window.addEventListener('compositionstart', handleCompositionStart, true);
window.addEventListener('compositionend', handleCompositionEnd, true);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
stopBurst();
window.removeEventListener('focus', handleFocus);
window.removeEventListener('blur', handleBlur);
window.removeEventListener('resize', handleResize);
window.removeEventListener('keydown', handleEditableKeydown, true);
window.removeEventListener('compositionstart', handleCompositionStart, true);
window.removeEventListener('compositionend', handleCompositionEnd, true);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [emitWindowDiagnostic, macWindowDiagnosticsEnabled]);
const handleNewQuery = useCallback(() => {
let connId = '';
let db = '';
// Priority: Active Tab Context (if connection still valid) > Sidebar Selection (activeContext)
if (activeTabId) {
const currentTab = tabs.find(t => t.id === activeTabId);
if (currentTab && currentTab.connectionId && connections.some(c => c.id === currentTab.connectionId)) {
connId = currentTab.connectionId;
db = currentTab.dbName || '';
}
}
// Fallback: Sidebar selection context (only if connection still valid)
if (!connId && activeContext?.connectionId && connections.some(c => c.id === activeContext.connectionId)) {
connId = activeContext.connectionId;
db = activeContext.dbName || '';
}
addTab({
id: `query-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title: t('query.new'),
type: 'query',
connectionId: connId,
dbName: db,
query: ''
});
}, [activeTabId, tabs, connections, activeContext, addTab, t]);
const switchActiveTabByOffset = useCallback((offset: 1 | -1) => {
if (tabs.length < 2) return;
const activeIndex = tabs.findIndex(tab => tab.id === activeTabId);
const baseIndex = activeIndex >= 0 ? activeIndex : 0;
const nextIndex = (baseIndex + offset + tabs.length) % tabs.length;
setActiveTab(tabs[nextIndex].id);
}, [activeTabId, setActiveTab, tabs]);
const resetApplicationQuitRequest = useCallback(() => {
applicationQuitHandlingRef.current = false;
applicationQuitConfirmRef.current = null;
void CancelApplicationQuit();
}, []);
const forceQuitApplication = useCallback(async () => {
const res = await ForceQuitApplication();
if (res && res.success === false) {
throw new Error(res.message || t('common.unknown'));
}
}, [t]);
const handleApplicationQuitRequest = useCallback(async () => {
if (applicationQuitHandlingRef.current) {
return;
}
applicationQuitHandlingRef.current = true;
let targets;
try {
targets = await collectApplicationQuitUnsavedSQLTargets(tabs, savedQueries);
} catch (error) {
resetApplicationQuitRequest();
message.error(t('app.quit.unsaved_sql.inspect_failed', {
detail: error instanceof Error ? error.message : String(error),
}));
return;
}
if (targets.length === 0) {
try {
await forceQuitApplication();
} catch (error) {
resetApplicationQuitRequest();
message.error(t('app.quit.message.quit_failed', {
detail: error instanceof Error ? error.message : String(error),
}));
}
return;
}
const label = buildApplicationQuitUnsavedSQLLabel(targets);
let destroyConfirm: (() => void) | null = null;
const confirmRef = Modal.confirm({
title: t('app.quit.unsaved_sql.title'),
content: t(targets.length === 1
? 'app.quit.unsaved_sql.content_single'
: 'app.quit.unsaved_sql.content_multiple', { label }),
okText: t('app.quit.unsaved_sql.save_exit'),
cancelText: t('app.quit.unsaved_sql.cancel'),
closable: true,
maskClosable: false,
okButtonProps: { danger: true, type: 'primary' },
footer: (_, { OkBtn, CancelBtn }) => (
<>
<Button
onClick={() => {
destroyConfirm?.();
applicationQuitConfirmRef.current = null;
void forceQuitApplication().catch((error) => {
resetApplicationQuitRequest();
message.error(t('app.quit.message.quit_failed', {
detail: error instanceof Error ? error.message : String(error),
}));
});
}}
>
{t('app.quit.unsaved_sql.confirm_exit')}
</Button>
<CancelBtn />
<OkBtn />
</>
),
onCancel: () => {
resetApplicationQuitRequest();
},
onOk: async () => {
try {
await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery);
message.success(t('app.quit.unsaved_sql.saved'));
await forceQuitApplication();
} catch (error) {
resetApplicationQuitRequest();
message.error(t('app.quit.unsaved_sql.save_failed_cancel_exit', {
detail: error instanceof Error ? error.message : String(error),
}));
throw error;
}
},
});
destroyConfirm = confirmRef.destroy;
applicationQuitConfirmRef.current = confirmRef;
}, [forceQuitApplication, resetApplicationQuitRequest, saveQuery, savedQueries, t, tabs]);
useEffect(() => {
const offBeforeClose = EventsOn('app:before-close-request', () => {
void handleApplicationQuitRequest();
});
return () => {
offBeforeClose();
};
}, [handleApplicationQuitRequest]);
const closeConnectionPackageDialog = useCallback(() => {
setConnectionPackageDialog(createClosedConnectionPackageDialogState());
setPendingConnectionImportPayload(null);
setToolCenterBackGroupKey(null);
setActiveToolCenterPane((current) => (current?.key === 'connection-package' ? null : current));
}, []);
const refreshConnectionsAfterImport = useCallback(async (importedViews: SavedConnection[]) => {
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.GetSavedConnections === 'function') {
let latestConnections: unknown;
try {
latestConnections = await GetSavedConnections();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error ?? '').trim();
throw new Error(
detail
? t('app.connection_package.message.import_failed_with_error', { error: detail })
: t('app.connection_package.message.import_failed'),
);
}
if (!Array.isArray(latestConnections)) {
throw new Error(t('app.connection_package.error.refresh_failed_no_connections'));
}
replaceConnections(latestConnections as SavedConnection[]);
return;
}
const latestConnections = useStore.getState().connections;
replaceConnections(mergeSavedConnections(latestConnections, importedViews));
}, [replaceConnections]);
const importConnectionsPayload = useCallback(async (raw: string, password: string) => {
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.ImportConnectionsPayload !== 'function') {
throw new Error(t('app.connection_package.error.import_capability_unavailable'));
}
let importedViews: unknown;
try {
importedViews = await backendApp.ImportConnectionsPayload(raw, password);
} catch (error) {
if (isConnectionPackagePasswordRequiredError(error)) {
throw error;
}
const detail = error instanceof Error ? error.message : String(error ?? '').trim();
throw new Error(
detail
? t('app.connection_package.message.import_failed_with_error', { error: detail })
: t('app.connection_package.message.import_failed'),
);
}
if (!Array.isArray(importedViews)) {
throw new Error(t('app.connection_package.error.import_no_connections'));
}
await refreshConnectionsAfterImport(importedViews as SavedConnection[]);
return importedViews as SavedConnection[];
}, [refreshConnectionsAfterImport, t]);
const handleImportConnections = async (sourceGroup?: ToolCenterGroupKey) => {
setToolCenterBackGroupKey(sourceGroup ?? null);
const res = await (window as any).go.app.App.ImportConfigFile();
if (!res.success) {
if (res.message !== "已取消") {
void message.error(t('app.connection_package.message.import_failed_with_error', { error: res.message }));
}
return;
}
const raw = typeof res.data === 'string' ? res.data : String(res.data ?? '');
const importKind = detectConnectionImportKind(raw);
if (importKind === 'invalid') {
void message.error(t('app.connection_package.message.unsupported_file_format'));
return;
}
try {
setPendingConnectionImportPayload(null);
const importedViews = await importConnectionsPayload(raw, '');
if ((importKind === 'mysql-workbench-xml' || importKind === 'navicat-ncx') && importedViews.some(v => !v.hasPrimaryPassword)) {
void message.warning(t('app.connection_package.message.imported_with_missing_passwords', { count: importedViews.length }));
} else {
void message.success(t('app.connection_package.message.imported_connections', { count: importedViews.length }));
}
} catch (e: any) {
if (isConnectionPackagePasswordRequiredError(e)) {
if (sourceGroup) {
setToolCenterBackGroupKey(sourceGroup);
setActiveToolCenterPane({ key: 'connection-package', group: sourceGroup });
}
setPendingConnectionImportPayload(raw);
setConnectionPackageDialog({
open: true,
mode: 'import',
includeSecrets: true,
useFilePassword: false,
password: '',
error: '',
confirmLoading: false,
});
return;
}
void message.error(e?.message || t('app.connection_package.message.import_failed'));
}
};
const handleExportConnections = async (sourceGroup?: ToolCenterGroupKey) => {
if (connections.length === 0) {
void message.warning(t('app.connection_package.message.no_connections_to_export'));
return;
}
setToolCenterBackGroupKey(sourceGroup ?? null);
if (sourceGroup) {
setActiveToolCenterPane({ key: 'connection-package', group: sourceGroup });
}
setConnectionPackageDialog({
open: true,
mode: 'export',
includeSecrets: true,
useFilePassword: false,
password: '',
error: '',
confirmLoading: false,
});
};
const handleConfirmConnectionPackageDialog = async () => {
const backendApp = (window as any).go?.app?.App;
const password = normalizeConnectionPackagePassword(connectionPackageDialog.password);
if (connectionPackageDialog.mode === 'import' && !password) {
setConnectionPackageDialog((current) => ({
...current,
error: t('app.connection_package.error.restore_password_required'),
}));
return;
}
if (
connectionPackageDialog.mode === 'export'
&& connectionPackageDialog.includeSecrets
&& connectionPackageDialog.useFilePassword
&& !password
) {
setConnectionPackageDialog((current) => ({
...current,
error: t('app.connection_package.error.file_password_required'),
}));
return;
}
setConnectionPackageDialog((current) => ({
...current,
password: (
current.mode === 'export'
&& (!current.includeSecrets || !current.useFilePassword)
) ? '' : password,
error: '',
confirmLoading: true,
}));
try {
if (connectionPackageDialog.mode === 'export') {
if (typeof backendApp?.ExportConnectionsPackage !== 'function') {
throw new Error(t('app.connection_package.error.export_capability_unavailable'));
}
let res: unknown;
try {
res = await backendApp.ExportConnectionsPackage({
includeSecrets: connectionPackageDialog.includeSecrets,
filePassword: (
connectionPackageDialog.includeSecrets
&& connectionPackageDialog.useFilePassword
) ? password : '',
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error ?? '').trim();
throw new Error(
detail
? `${t('app.connection_package.message.export_failed')}: ${detail}`
: t('app.connection_package.message.export_failed'),
);
}
const exportResult = resolveConnectionPackageExportResult(connectionPackageDialog, res);
if (exportResult.kind === 'canceled') {
setConnectionPackageDialog(exportResult.nextDialog);
return;
}
if (exportResult.kind === 'failed') {
throw new Error(exportResult.error);
}
closeConnectionPackageDialog();
void message.success(t('app.connection_package.message.export_succeeded'));
return;
}
if (!pendingConnectionImportPayload) {
throw new Error(t('app.connection_package.error.missing_import_payload'));
}
const importedViews = await importConnectionsPayload(pendingConnectionImportPayload, password);
closeConnectionPackageDialog();
void message.success(t('app.connection_package.message.imported_connections', { count: importedViews.length }));
} catch (e: any) {
setConnectionPackageDialog((current) => ({
...current,
confirmLoading: false,
error: e?.message || t(
current.mode === 'export'
? 'app.connection_package.message.export_failed'
: 'app.connection_package.message.import_failed',
),
}));
}
};
const [isToolsModalOpen, setIsToolsModalOpen] = useState(false);
const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState<ToolCenterGroupKey>('config');
const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState<ToolCenterGroupKey | null>(null);
const [activeToolCenterPane, setActiveToolCenterPane] = useState<ToolCenterPaneState | null>(null);
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [activeSettingsCenterGroupKey, setActiveSettingsCenterGroupKey] = useState<SettingsCenterGroupKey>('preferences');
const [activeSettingsCenterPane, setActiveSettingsCenterPane] = useState<SettingsCenterPaneState | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
type ThemeSettingsSection = 'theme' | 'appearance' | 'workspace';
const THEME_SETTINGS_SECTION_STORAGE_KEY = 'gonavi.themeSettingsSection';
const sanitizeThemeSettingsSection = useCallback((value: unknown): ThemeSettingsSection => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'appearance' || normalized === 'workspace') {
return normalized;
}
return 'theme';
}, []);
const [themeModalSection, setThemeModalSection] = useState<ThemeSettingsSection>(() => {
try {
return sanitizeThemeSettingsSection(window.localStorage.getItem(THEME_SETTINGS_SECTION_STORAGE_KEY));
} catch {
return 'theme';
}
});
useEffect(() => {
try {
window.localStorage.setItem(THEME_SETTINGS_SECTION_STORAGE_KEY, themeModalSection);
} catch {
// ignore persistence failures
}
}, [themeModalSection]);
const [isLinuxCJKFontBannerDismissed, setIsLinuxCJKFontBannerDismissed] = useState(false);
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false);
const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false);
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
const tabDisplaySettingsPanelRef = useRef<HTMLDivElement | null>(null);
const [tabDisplaySettingsFocusRequest, setTabDisplaySettingsFocusRequest] = useState(0);
const isThemeSettingsPaneOpen = activeSettingsCenterPane?.key === 'theme';
useEffect(() => {
const shouldLoadInstalledFonts =
runtimePlatform === 'linux' || ((isThemeModalOpen || isThemeSettingsPaneOpen) && themeModalSection === 'appearance');
if (!shouldLoadInstalledFonts) {
return;
}
if (hasLoadedInstalledFontsRef.current || isFontFamiliesLoading) {
return;
}
let cancelled = false;
hasLoadedInstalledFontsRef.current = true;
setIsFontFamiliesLoading(true);
setFontFamiliesLoadError(null);
ListInstalledFontFamilies()
.then((result) => {
if (cancelled) {
return;
}
if (!result?.success) {
throw new Error(String(result?.message || t('app.theme.font_family.load_failed')));
}
const nextFonts = Array.isArray(result?.data)
? result.data
.map((item) => ({
family: sanitizeFontFamilyInput((item as InstalledFontFamily | Record<string, unknown>)?.family) || '',
path: typeof (item as InstalledFontFamily | Record<string, unknown>)?.path === 'string'
? String((item as InstalledFontFamily | Record<string, unknown>).path)
: undefined,
}))
.filter((item) => item.family)
: EMPTY_INSTALLED_FONT_FAMILIES;
setInstalledFontFamilies(nextFonts);
})
.catch((error) => {
if (cancelled) {
return;
}
hasLoadedInstalledFontsRef.current = false;
setFontFamiliesLoadError(String(error instanceof Error ? error.message : error || t('app.theme.font_family.load_failed')));
})
.finally(() => {
if (!cancelled) {
setIsFontFamiliesLoading(false);
}
});
return () => {
cancelled = true;
};
}, [isThemeModalOpen, isThemeSettingsPaneOpen, runtimePlatform, t, themeModalSection]);
useEffect(() => {
if ((!isThemeModalOpen && !isThemeSettingsPaneOpen) || themeModalSection !== 'workspace' || tabDisplaySettingsFocusRequest === 0) {
return;
}
const timer = window.setTimeout(() => {
tabDisplaySettingsPanelRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
}, 80);
return () => window.clearTimeout(timer);
}, [isThemeModalOpen, isThemeSettingsPaneOpen, themeModalSection, tabDisplaySettingsFocusRequest]);
const shortcutConflictMap = useMemo(() => {
const map: Partial<Record<ShortcutAction, ConflictInfo[]>> = {};
for (const action of SHORTCUT_ACTION_ORDER) {
const binding = resolveShortcutBinding(shortcutOptions, action, activeShortcutPlatform);
if (!binding?.enabled || !binding.combo) continue;
const conflicts = findReservedConflictsForAction(
action,
normalizeShortcutCombo(binding.combo),
activeShortcutPlatform,
);
if (conflicts.length > 0) {
map[action] = conflicts;
}
}
return map;
}, [activeShortcutPlatform, language, shortcutOptions]);
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
const [proxyDraft, setProxyDraft] = useState<GlobalProxyConfig>(() => createGlobalProxyComparableDraft(globalProxy));
const [proxyDraftClearPassword, setProxyDraftClearPassword] = useState(false);
const [proxyApplying, setProxyApplying] = useState(false);
const [proxyTestUrl, setProxyTestUrl] = useState(DEFAULT_GLOBAL_PROXY_TEST_URL);
const [proxyTesting, setProxyTesting] = useState(false);
const [proxyTestResult, setProxyTestResult] = useState<GlobalProxyTestResultState | null>(null);
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 aiEntryPlacement = resolveAIEntryPlacement();
const legacyAiEdgeHandleAttachment = resolveLegacyAIEdgeHandleAttachment(aiPanelVisible);
const aiPanelOverlayActive = aiPanelVisible && shouldOverlayAIPanel({
isV2Ui,
viewportWidth,
sidebarWidth,
panelWidth: DEFAULT_AI_PANEL_WIDTH,
});
const aiPanelRenderWidth = aiPanelOverlayActive
? resolveOverlayAIPanelWidth({
viewportWidth,
sidebarWidth,
panelWidth: DEFAULT_AI_PANEL_WIDTH,
})
: DEFAULT_AI_PANEL_WIDTH;
const appliedGlobalProxyDraft = useMemo(() => (
createGlobalProxyComparableDraft(globalProxy)
), [
globalProxy.enabled,
globalProxy.type,
globalProxy.host,
globalProxy.port,
globalProxy.user,
globalProxy.password,
globalProxy.hasPassword,
]);
const proxyDraftHost = String(proxyDraft.host || '').trim();
const proxyDraftUser = String(proxyDraft.user || '').trim();
const proxyDraftPort = Number(proxyDraft.port);
const proxyDraftPortValid = Number.isFinite(proxyDraftPort) && proxyDraftPort > 0 && proxyDraftPort <= 65535;
const proxyDraftValid = !proxyDraft.enabled || (proxyDraftHost !== '' && proxyDraftPortValid);
const proxyDraftDirty = proxyDraftClearPassword || !areGlobalProxyDraftsEqual(proxyDraft, appliedGlobalProxyDraft);
const proxyPanelOpen = isProxyModalOpen || activeSettingsCenterPane?.key === 'proxy';
const proxyPanelWasOpenRef = useRef(false);
const proxyStatusTone = proxyDraft.enabled
? (proxyDraftValid ? 'success' : 'warning')
: 'info';
const proxyStatusTitle = proxyDraft.enabled
? (proxyDraftValid ? t('app.proxy.status.enabled') : t('app.proxy.status.incomplete'))
: t('app.proxy.status.disabled');
const proxyStatusDescription = proxyDraft.enabled && proxyDraftValid
? t('app.proxy.status.enabled_description', {
type: proxyDraft.type.toUpperCase(),
endpoint: `${proxyDraftHost}:${proxyDraftPort}`,
})
: (proxyDraft.enabled
? t('app.proxy.status.incomplete_description')
: t('app.proxy.status.disabled_description'));
const proxyPresetItems = useMemo(() => ([
{ key: 'clash-mixed', label: t('app.proxy.preset.clash_mixed'), type: 'socks5' as const, host: '127.0.0.1', port: 7890 },
{ key: 'socks5-local', label: t('app.proxy.preset.socks5_local'), type: 'socks5' as const, host: '127.0.0.1', port: 1080 },
{ key: 'http-local', label: t('app.proxy.preset.http_local'), type: 'http' as const, host: '127.0.0.1', port: 8080 },
]), [t]);
const proxyTestPresetItems = useMemo(() => ([
{ key: 'github-api', label: t('app.proxy.test.preset.github_api'), url: 'https://api.github.com/' },
{ key: 'github-release', label: t('app.proxy.test.preset.github_release'), url: 'https://github.com/Syngnat/GoNavi/releases/latest' },
{ key: 'go-module-proxy', label: t('app.proxy.test.preset.go_module_proxy'), url: 'https://proxy.golang.org/' },
{ key: 'baidu', label: t('app.proxy.test.preset.baidu'), url: 'https://www.baidu.com/' },
]), [t]);
const proxyTestUrlTrimmed = String(proxyTestUrl || '').trim();
const proxyCanTest = proxyDraft.enabled && proxyDraftValid && proxyTestUrlTrimmed !== '' && !proxyTesting;
useEffect(() => {
if (!proxyPanelOpen) {
proxyPanelWasOpenRef.current = false;
return;
}
if (proxyPanelWasOpenRef.current) {
return;
}
proxyPanelWasOpenRef.current = true;
setProxyDraft(appliedGlobalProxyDraft);
setProxyDraftClearPassword(false);
}, [appliedGlobalProxyDraft, proxyPanelOpen]);
useEffect(() => {
setProxyTestResult(null);
}, [
proxyDraft.enabled,
proxyDraft.type,
proxyDraft.host,
proxyDraft.port,
proxyDraft.user,
proxyDraft.password,
proxyDraftClearPassword,
proxyTestUrlTrimmed,
]);
const resetProxyDraftToCurrent = useCallback(() => {
setProxyDraft(appliedGlobalProxyDraft);
setProxyDraftClearPassword(false);
}, [appliedGlobalProxyDraft]);
const updateProxyDraftType = useCallback((type: GlobalProxyConfig['type']) => {
setProxyDraft((current) => {
const currentPort = Number(current.port);
const previousDefault = getGlobalProxyDefaultPort(current.type);
const shouldSwitchPort = !Number.isFinite(currentPort) || currentPort === previousDefault;
return {
...current,
type,
port: shouldSwitchPort ? getGlobalProxyDefaultPort(type) : current.port,
};
});
}, []);
const applyProxyPreset = useCallback((preset: { type: GlobalProxyConfig['type']; host: string; port: number }) => {
setProxyDraft((current) => ({
...current,
enabled: true,
type: preset.type,
host: preset.host,
port: preset.port,
}));
}, []);
const handleTestGlobalProxyDraft = useCallback(async () => {
if (!proxyDraft.enabled) {
void message.warning(t('app.proxy.test.message.enable_first'));
return;
}
if (!proxyDraftValid) {
void message.warning(t('app.proxy.message.invalid_enabled'));
return;
}
if (proxyTestUrlTrimmed === '') {
void message.warning(t('app.proxy.test.message.url_required'));
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.TestGlobalProxyConnection !== 'function') {
void message.error(t('app.proxy.test.message.unavailable'));
return;
}
setProxyTesting(true);
try {
const res = await backendApp.TestGlobalProxyConnection({
proxy: toSaveGlobalProxyInput({
...proxyDraft,
host: proxyDraftHost,
user: proxyDraftUser,
port: proxyDraftPortValid ? proxyDraftPort : getGlobalProxyDefaultPort(proxyDraft.type),
clearPassword: proxyDraftClearPassword,
}),
url: proxyTestUrlTrimmed,
timeoutSeconds: 8,
});
const data = (res?.data || {}) as Partial<GlobalProxyTestResultState>;
const statusCode = Number(data.statusCode);
setProxyTestResult({
success: res?.success === true,
message: res?.message || t('common.unknown'),
url: data.url || proxyTestUrlTrimmed,
finalUrl: data.finalUrl,
statusCode: Number.isFinite(statusCode) ? statusCode : undefined,
durationMs: typeof data.durationMs === 'number' ? data.durationMs : undefined,
viaProxy: data.viaProxy === true,
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err || t('common.unknown'));
setProxyTestResult({
success: false,
message: errMsg,
url: proxyTestUrlTrimmed,
});
} finally {
setProxyTesting(false);
}
}, [
proxyDraft,
proxyDraftClearPassword,
proxyDraftHost,
proxyDraftPort,
proxyDraftPortValid,
proxyDraftUser,
proxyDraftValid,
proxyTestUrlTrimmed,
t,
]);
const handleApplyGlobalProxyDraft = useCallback(async () => {
if (!proxyDraftValid) {
void message.warning({
content: t('app.proxy.message.invalid_enabled'),
key: 'global-proxy-invalid',
});
return;
}
void message.destroy('global-proxy-invalid');
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.SaveGlobalProxy !== 'function') {
void message.error({
content: t('app.proxy.message.save_failed', { error: t('common.unknown') }),
key: 'global-proxy-sync-error',
});
return;
}
const saveInput = toSaveGlobalProxyInput({
...proxyDraft,
host: proxyDraftHost,
user: proxyDraftUser,
port: proxyDraftPortValid ? proxyDraftPort : getGlobalProxyDefaultPort(proxyDraft.type),
clearPassword: proxyDraftClearPassword,
});
setProxyApplying(true);
try {
const saved = await backendApp.SaveGlobalProxy(saveInput);
const nextDraft = createGlobalProxyComparableDraft(saved || saveInput);
replaceGlobalProxy(nextDraft);
setProxyDraft(nextDraft);
setProxyDraftClearPassword(false);
void message.success({
content: t('app.proxy.message.config_applied'),
key: 'global-proxy-applied',
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err || t('common.unknown'));
void message.error({
content: t('app.proxy.message.save_failed', { error: errMsg }),
key: 'global-proxy-sync-error',
});
} finally {
setProxyApplying(false);
}
}, [
proxyDraft,
proxyDraftClearPassword,
proxyDraftHost,
proxyDraftPort,
proxyDraftPortValid,
proxyDraftUser,
proxyDraftValid,
replaceGlobalProxy,
t,
]);
const legacyAiEdgeHandleDockStyle = useMemo(
() => resolveLegacyAIEdgeHandleDockStyle(legacyAiEdgeHandleAttachment),
[legacyAiEdgeHandleAttachment],
);
const legacyAiEdgeHandleStyle = useMemo(() => (
resolveLegacyAIEdgeHandleStyle({
darkMode,
aiPanelVisible,
effectiveUiScale,
})
), [aiPanelVisible, darkMode, effectiveUiScale]);
const handleOpenToolsModal = useCallback((group: ToolCenterGroupKey = 'config') => {
setToolCenterBackGroupKey(null);
setActiveToolCenterPane(null);
setActiveToolCenterGroupKey(group);
setIsToolsModalOpen(true);
}, []);
const handleOpenSettingsModal = useCallback((group: SettingsCenterGroupKey = 'preferences') => {
setActiveSettingsCenterGroupKey(group);
setActiveSettingsCenterPane(resolveSettingsCenterGroupInitialPane(group));
setIsSettingsModalOpen(true);
}, []);
const handleOpenSettingsCenterPane = useCallback((group: SettingsCenterGroupKey, key: SettingsCenterPaneKey) => {
setActiveSettingsCenterGroupKey(group);
setActiveSettingsCenterPane({ key, group });
setIsSettingsModalOpen(true);
}, []);
const finalizeSecurityRepairReturnFromAISettings = useCallback(() => {
const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource);
setFocusedAIProviderId(undefined);
setSecurityUpdateRepairSource(null);
if (reopenSecurityUpdateDetails) {
setIsSecurityUpdateSettingsOpen(true);
}
}, [securityUpdateRepairSource]);
const handleBackFromSettingsCenterPane = useCallback(() => {
const leavingAI = activeSettingsCenterPane?.key === 'ai';
const returnGroup = activeSettingsCenterPane?.group ?? activeSettingsCenterGroupKey;
setActiveSettingsCenterGroupKey(returnGroup);
setActiveSettingsCenterPane(null);
if (leavingAI) {
finalizeSecurityRepairReturnFromAISettings();
}
}, [
activeSettingsCenterGroupKey,
activeSettingsCenterPane?.group,
activeSettingsCenterPane?.key,
finalizeSecurityRepairReturnFromAISettings,
]);
const handleCancelSettingsCenterPane = useCallback(() => {
const leavingAI = activeSettingsCenterPane?.key === 'ai';
setActiveSettingsCenterPane(null);
setIsSettingsModalOpen(false);
if (leavingAI) {
finalizeSecurityRepairReturnFromAISettings();
}
}, [activeSettingsCenterPane?.key, finalizeSecurityRepairReturnFromAISettings]);
const isSettingsAboutPaneOpen = isSettingsModalOpen && activeSettingsCenterPane?.key === 'about-go-navi';
const isSettingsAboutPaneOpenRef = useRef(false);
useEffect(() => {
isSettingsAboutPaneOpenRef.current = isSettingsAboutPaneOpen;
}, [isSettingsAboutPaneOpen]);
useEffect(() => {
updateCenterBridgeRef.current = {
open: () => {
handleOpenSettingsCenterPane('about', 'about-go-navi');
},
close: () => {
setActiveSettingsCenterPane(null);
setIsSettingsModalOpen(false);
},
isOpen: () => isSettingsAboutPaneOpenRef.current,
};
return () => {
updateCenterBridgeRef.current = null;
};
}, [handleOpenSettingsCenterPane]);
useEffect(() => {
if (!isSettingsAboutPaneOpen) {
return;
}
prepareAboutSurface();
}, [isSettingsAboutPaneOpen, prepareAboutSurface]);
const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {
setToolCenterBackGroupKey(group);
setActiveToolCenterGroupKey(group);
setActiveToolCenterPane({ key, group });
setIsToolsModalOpen(true);
}, []);
const handleReturnToToolCenter = useCallback((closeChild?: () => void) => {
const returnGroup = toolCenterBackGroupKey ?? 'config';
closeChild?.();
setToolCenterBackGroupKey(null);
setActiveToolCenterGroupKey(returnGroup);
setActiveToolCenterPane(null);
setIsToolsModalOpen(true);
}, [toolCenterBackGroupKey]);
const sidebarUtilityItems = useMemo(() => {
const itemMap = {
tools: {
key: 'tools',
title: t('app.sidebar.tools'),
icon: <ToolOutlined />,
onClick: () => handleOpenToolsModal(),
},
settings: {
key: 'settings',
title: t('app.sidebar.settings'),
icon: <SettingOutlined />,
onClick: () => handleOpenSettingsModal(),
},
} as const;
return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]);
}, [handleOpenSettingsModal, handleOpenToolsModal, t]);
const handleFocusSidebarSearch = useCallback(() => {
window.dispatchEvent(new CustomEvent('gonavi:focus-sidebar-search'));
}, []);
const renderLegacyAIEdgeHandle = () => (
<Tooltip title={t('app.sidebar.ai_assistant')}>
<Button
type="text"
icon={<RobotOutlined />}
onClick={toggleAIPanel}
style={legacyAiEdgeHandleStyle}
data-gonavi-legacy-ai-edge-action="true"
>
AI
</Button>
</Tooltip>
);
const loadDataRootInfo = useCallback(async () => {
setDataRootLoading(true);
try {
const res = await GetDataRootDirectoryInfo();
if (!res?.success) {
throw new Error(res?.message || t('app.data_root.message.load_failed'));
}
const data = (res?.data || {}) as any;
setDataRootInfo(data);
setSelectedDataRootPath(String(data.path || ''));
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || t('common.unknown'));
void message.error(t('app.data_root.message.load_failed_with_error', { error: errMsg }));
} finally {
setDataRootLoading(false);
}
}, [t]);
useEffect(() => {
if (!isDataRootModalOpen && activeToolCenterPane?.key !== 'data-root') {
return;
}
void loadDataRootInfo();
}, [activeToolCenterPane?.key, 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 || t('app.data_root.message.select_failed'));
}
return;
}
const data = (res?.data || {}) as any;
setSelectedDataRootPath(String(data.path || ''));
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || t('common.unknown'));
void message.error(t('app.data_root.message.select_failed_with_error', { error: errMsg }));
}
}, [dataRootInfo?.path, selectedDataRootPath, t]);
const handleApplyDataRoot = useCallback(async (migrate: boolean, useDefaultPath = false) => {
const nextPath = useDefaultPath ? String(dataRootInfo?.defaultPath || '') : String(selectedDataRootPath || '').trim();
if (!nextPath) {
void message.warning(t('app.data_root.message.select_valid_first'));
return;
}
setDataRootApplying(true);
try {
const res = await ApplyDataRootDirectory(nextPath, migrate);
if (!res?.success) {
throw new Error(res?.message || t('app.data_root.message.apply_failed'));
}
const data = (res?.data || {}) as any;
setDataRootInfo(data);
setSelectedDataRootPath(String(data.path || nextPath));
void message.success(res?.message || t('app.data_root.message.updated'));
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || t('common.unknown'));
void message.error(t('app.data_root.message.apply_failed_with_error', { error: errMsg }));
} finally {
setDataRootApplying(false);
}
}, [dataRootInfo?.defaultPath, selectedDataRootPath, t]);
const handleOpenDataRoot = useCallback(async () => {
try {
const res = await OpenDataRootDirectory();
if (!res?.success) {
throw new Error(res?.message || t('app.data_root.message.open_failed'));
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error || t('common.unknown'));
void message.error(t('app.data_root.message.open_failed_with_error', { error: errMsg }));
}
}, [t]);
const {
handleCloseLogPanel: handleCloseAppLogPanel,
handleLogResizeStart,
handleToggleLogPanel: toggleAppLogPanel,
isLogPanelOpen,
logGhostRef,
logPanelHeight,
} = useAppLogPanelResize();
const handleToggleLogPanel = useCallback(() => {
if (isV2Ui) {
window.dispatchEvent(new CustomEvent('gonavi:show-sql-execution-log', { detail: { mode: 'open' } }));
return;
}
toggleAppLogPanel();
}, [isV2Ui, toggleAppLogPanel]);
const handleCloseLogPanel = useCallback(() => {
handleCloseAppLogPanel();
}, [handleCloseAppLogPanel]);
const handleCreateConnection = useCallback(() => {
setSecurityUpdateRepairSource(null);
setEditingConnection(null);
setIsConnectionModalMounted(true);
setIsModalOpen(true);
}, []);
const handleEditConnection = useCallback((conn: SavedConnection) => {
setSecurityUpdateRepairSource(null);
setIsConnectionModalMounted(true);
void (async () => {
const backendApp = (window as any).go?.app?.App;
let nextConnection = conn;
if (typeof backendApp?.GetEditableSavedConnection === 'function') {
try {
const editableConnection = await backendApp.GetEditableSavedConnection(conn.id);
if (editableConnection) {
nextConnection = editableConnection;
}
} catch (error: any) {
const errorMessage = error?.message;
const detail = (
typeof errorMessage === 'string'
? errorMessage
: (
typeof errorMessage === 'number'
|| typeof errorMessage === 'boolean'
? String(errorMessage)
: String(error ?? '')
)
).trim();
void message.warning(
detail
? t('app.connection.message.editable_load_failed_with_detail', { detail })
: t('app.connection.message.editable_load_failed')
);
}
}
setEditingConnection(nextConnection);
setIsModalOpen(true);
})();
}, [t]);
useEffect(() => {
if (connectionModalWarmupDoneRef.current) {
return;
}
connectionModalWarmupDoneRef.current = true;
const warmup = () => setIsConnectionModalMounted(true);
if (typeof window === 'undefined') {
warmup();
return;
}
if (typeof window.requestIdleCallback === 'function') {
const idleId = window.requestIdleCallback(() => warmup(), { timeout: 1200 });
return () => window.cancelIdleCallback?.(idleId);
}
const timerId = window.setTimeout(warmup, 300);
return () => window.clearTimeout(timerId);
}, []);
const handleConnectionSaved = useCallback(async (savedConnection: SavedConnection) => {
if (!shouldRetrySecurityUpdateAfterRepairSave(securityUpdateRepairSource)) {
return;
}
const backendApp = (window as any).go?.app?.App;
if (securityUpdateStatus.migrationId) {
if (typeof backendApp?.RetrySecurityUpdateCurrentRound !== 'function') {
return;
}
const rawStatus = await backendApp.RetrySecurityUpdateCurrentRound({
migrationId: securityUpdateStatus.migrationId,
});
const nextStatus = await finalizeSecurityUpdateStatus({
backend: backendApp,
replaceConnections,
replaceGlobalProxy,
t,
}, normalizeSecurityUpdateStatus(rawStatus));
applySecurityUpdateStatus(nextStatus, {
openSettings: false,
});
if (nextStatus.overallStatus === 'completed') {
setSecurityUpdateHasLegacySensitiveItems(false);
setSecurityUpdateRawPayload(null);
}
return;
}
if (!securityUpdateRawPayload || !savedConnection?.id) {
return;
}
const nextRawPayload = stripLegacyPersistedConnectionById(securityUpdateRawPayload, savedConnection.id);
if (!nextRawPayload || nextRawPayload === securityUpdateRawPayload) {
return;
}
window.localStorage.setItem(LEGACY_PERSIST_KEY, nextRawPayload);
const rawStatus = typeof backendApp?.GetSecurityUpdateStatus === 'function'
? await backendApp.GetSecurityUpdateStatus()
: securityUpdateStatus;
const nextStatus = mergeSecurityUpdateStatusWithLegacySource(rawStatus, nextRawPayload, {
previousStatus: securityUpdateStatus,
t,
});
const nextHasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(nextRawPayload);
setSecurityUpdateRawPayload(nextRawPayload);
setSecurityUpdateHasLegacySensitiveItems(nextHasLegacySensitiveItems);
applySecurityUpdateStatus(nextStatus, {
openSettings: false,
});
}, [
applySecurityUpdateStatus,
normalizeSecurityUpdateStatus,
replaceConnections,
replaceGlobalProxy,
securityUpdateRawPayload,
securityUpdateRepairSource,
securityUpdateStatus,
securityUpdateStatus.migrationId,
t,
]);
const handleCloseModal = () => {
const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource);
setIsModalOpen(false);
setEditingConnection(null);
setSecurityUpdateRepairSource(null);
if (reopenSecurityUpdateDetails) {
setIsSecurityUpdateSettingsOpen(true);
}
};
const handleOpenDriverManagerFromConnection = () => {
setIsModalOpen(false);
setEditingConnection(null);
setToolCenterBackGroupKey(null);
setIsDriverModalOpen(true);
};
const handleCloseDriverManager = useCallback(() => {
const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource);
setIsDriverModalOpen(false);
setToolCenterBackGroupKey(null);
setSecurityUpdateRepairSource(null);
if (reopenSecurityUpdateDetails) {
setIsSecurityUpdateSettingsOpen(true);
}
}, [securityUpdateRepairSource]);
const handleOpenGlobalProxySettings = useCallback(() => {
setSecurityUpdateRepairSource(null);
setIsProxyModalOpen(true);
}, []);
const handleCloseGlobalProxySettings = useCallback(() => {
const reopenSecurityUpdateDetails = shouldReopenSecurityUpdateDetails(securityUpdateRepairSource);
setIsProxyModalOpen(false);
setSecurityUpdateRepairSource(null);
if (reopenSecurityUpdateDetails) {
setIsSecurityUpdateSettingsOpen(true);
}
}, [securityUpdateRepairSource]);
/** 从聊天面板等入口打开 AI 配置:走设置中心,不再弹独立 AISettingsModal */
const handleOpenAISettings = useCallback((providerId?: string) => {
setSecurityUpdateRepairSource(null);
setFocusedAIProviderId(providerId);
setActiveSettingsCenterGroupKey('services');
setActiveSettingsCenterPane({ key: 'ai', group: 'services' });
setIsSettingsModalOpen(true);
}, []);
const handleAIPanelRenderError = useCallback((error: Error, errorInfo: React.ErrorInfo) => {
try {
(window as any).__gonaviLastAIPanelRenderError = {
message: error?.message || '',
stack: error?.stack || '',
componentStack: errorInfo?.componentStack || '',
};
} catch {
// ignore debug capture failures
}
console.error('AIChatPanel render error:', error, errorInfo);
}, []);
const handleRetryAIPanelRender = useCallback(() => {
setAiPanelRenderNonce((current) => current + 1);
}, []);
const handleWebLogout = useCallback(async () => {
try {
await fetch('/__gonavi/auth/logout', {
method: 'POST',
credentials: 'same-origin',
});
} catch (_) {
// ignore
}
window.location.assign('/login');
}, []);
const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => {
const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true;
const syncWindowStateFromRuntime = async () => {
try {
const [isFullscreen, isMaximised] = await Promise.all([
safeWindowRuntimeCall(() => WindowIsFullscreen(), false),
safeWindowRuntimeCall(() => WindowIsMaximised(), false),
]);
useStore.getState().setWindowState(isFullscreen ? 'fullscreen' : (isMaximised ? 'maximized' : 'normal'));
} catch {
// ignore
}
};
try {
void emitWindowDiagnostic('action:titlebar-toggle:before');
if (await WindowIsFullscreen()) {
await WindowUnfullscreen();
await syncWindowStateFromRuntime();
void emitWindowDiagnostic('action:titlebar-toggle:after-unfullscreen');
return;
}
if (allowMacNativeFullscreen && useNativeMacWindowControls && isMacRuntime) {
await WindowFullscreen();
await syncWindowStateFromRuntime();
void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen');
return;
}
const isMaximised = await safeWindowRuntimeCall(() => WindowIsMaximised(), false);
if (isMaximised) {
WindowUnmaximise();
} else {
WindowMaximise();
}
await new Promise((resolve) => window.setTimeout(resolve, 96));
await syncWindowStateFromRuntime();
void emitWindowDiagnostic('action:titlebar-toggle:after-set-maximise-state');
} catch (_) {
// ignore
}
};
const handleTitleBarDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-no-titlebar-toggle="true"]')) {
return;
}
void handleTitleBarWindowToggle({ allowMacNativeFullscreen: false });
};
// handleManualResetWindowZoom 由 resetWindowZoom 快捷键(默认 Ctrl+Shift+0触发
// 作为自动路径失败时的兜底入口。
//
// 优先调 backend App.ResetWebViewZoom 走 WebView2 zoom reset零动画零感知
// 失败时回退到 Unmaximise→Maximise toggle —— 用户主动按了快捷键,预期看见动画。
const handleManualResetWindowZoom = React.useCallback(async () => {
if (!isWindowsPlatform()) {
message.info(t('app.window_zoom.message.windows_only'));
return;
}
try {
const res = await (window as any).go?.app?.App?.ResetWebViewZoom?.();
if (res?.success) {
window.dispatchEvent(new Event('resize'));
message.success(t('app.window_zoom.message.reset_success'));
return;
}
console.warn('ResetWebViewZoom backend reported failure, falling back to maximise toggle:', res?.message);
} catch (e) {
console.warn('ResetWebViewZoom backend unavailable, falling back to maximise toggle', e);
}
try {
const isFullscreen = await safeWindowRuntimeCall(() => WindowIsFullscreen(), false);
if (isFullscreen) {
message.info(t('app.window_zoom.message.fullscreen_exit_first'));
return;
}
const isMaximised = await safeWindowRuntimeCall(() => WindowIsMaximised(), false);
if (isMaximised) {
WindowUnmaximise();
await new Promise((resolve) => window.setTimeout(resolve, 96));
WindowMaximise();
await new Promise((resolve) => window.setTimeout(resolve, 96));
} else {
const size = await safeWindowRuntimeCall(() => WindowGetSize(), null);
const width = Math.trunc(Number(size?.w) || 0);
const height = Math.trunc(Number(size?.h) || 0);
if (width > 0 && height > 0) {
WindowSetSize(getWindowsScaleFixNudgedWidth(width), height);
await new Promise((resolve) => window.setTimeout(resolve, 28));
WindowSetSize(width, height);
}
}
window.dispatchEvent(new Event('resize'));
message.success(t('app.window_zoom.message.reset_success_fallback'));
} catch (e) {
console.warn('Failed to reset window zoom', e);
message.error(t('app.window_zoom.message.reset_failed'));
}
}, [t]);
const {
ghostRef,
handleSidebarMouseDown,
sidebarResizeHandleWidth,
siderRef,
} = useAppSidebarResize({
effectiveUiScale,
setSidebarWidth,
sidebarWidth,
});
useEffect(() => {
document.body.style.backgroundColor = 'transparent';
document.body.style.color = darkMode ? '#ffffff' : '#000000';
document.body.setAttribute('data-theme', darkMode ? 'dark' : 'light');
document.body.setAttribute('data-ui-version', appearance.uiVersion);
document.body.setAttribute('data-platform', runtimePlatform || '');
document.body.style.fontSize = `${effectiveFontSize}px`;
document.body.style.setProperty('--gn-font-sans', resolvedUiFontFamily);
document.body.style.setProperty('--gn-font-mono', resolvedMonoFontFamily);
document.documentElement.style.setProperty('--gonavi-font-size', `${effectiveFontSize}px`);
document.documentElement.style.setProperty('--gn-font-sans', resolvedUiFontFamily);
document.documentElement.style.setProperty('--gn-font-mono', resolvedMonoFontFamily);
document.documentElement.style.setProperty('--gn-ui-scale', `${effectiveUiScale}`);
document.documentElement.style.setProperty('--gn-font-size', `${effectiveFontSize}px`);
document.documentElement.style.setProperty('--gn-font-size-sm', `${Math.max(10, Math.round(effectiveFontSize * 0.86))}px`);
document.documentElement.style.setProperty('--gn-font-size-xs', `${Math.max(9, Math.round(effectiveFontSize * 0.76))}px`);
document.documentElement.style.setProperty('--gn-font-size-mono', `${Math.max(10, Math.round(effectiveDataTableFontSize * 0.92))}px`);
document.documentElement.style.setProperty('--gn-data-table-font-size', `${effectiveDataTableFontSize}px`);
document.documentElement.style.setProperty('--gn-sidebar-tree-font-size', `${effectiveSidebarTreeFontSize}px`);
document.documentElement.style.setProperty('--gn-sidebar-rail-scale', `${effectiveSidebarRailScale}`);
document.documentElement.style.setProperty('--gn-control-height', `${tokenControlHeight}px`);
document.documentElement.style.setProperty('--gn-control-height-sm', `${tokenControlHeightSM}px`);
}, [
appearance.uiVersion,
darkMode,
effectiveDataTableFontSize,
effectiveFontSize,
resolvedMonoFontFamily,
resolvedUiFontFamily,
runtimePlatform,
effectiveSidebarRailScale,
effectiveSidebarTreeFontSize,
effectiveUiScale,
tokenControlHeight,
tokenControlHeightSM,
]);
useEffect(() => {
const handleOpenShortcutSettingsEvent = () => {
setIsShortcutModalOpen(true);
};
window.addEventListener('gonavi:open-shortcut-settings', handleOpenShortcutSettingsEvent as EventListener);
return () => {
window.removeEventListener('gonavi:open-shortcut-settings', handleOpenShortcutSettingsEvent as EventListener);
};
}, []);
useEffect(() => {
const handleOpenSnippetSettingsEvent = () => {
setIsSnippetModalOpen(false);
handleOpenToolCenterPane('workspace', 'snippet-settings');
};
window.addEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener);
return () => {
window.removeEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener);
};
}, [handleOpenToolCenterPane]);
useEffect(() => {
const handleOpenTabDisplaySettingsEvent = () => {
setIsSettingsModalOpen(false);
setThemeModalSection('workspace');
setIsThemeModalOpen(true);
setTabDisplaySettingsFocusRequest((current) => current + 1);
};
window.addEventListener('gonavi:open-tab-display-settings', handleOpenTabDisplaySettingsEvent as EventListener);
return () => {
window.removeEventListener('gonavi:open-tab-display-settings', handleOpenTabDisplaySettingsEvent as EventListener);
};
}, []);
useEffect(() => {
const handleCreateQueryTabEvent = () => {
handleNewQuery();
};
window.addEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);
return () => {
window.removeEventListener('gonavi:create-query-tab', handleCreateQueryTabEvent as EventListener);
};
}, [handleNewQuery]);
useEffect(() => {
if (!isMacRuntime || !useNativeMacWindowControls) {
return;
}
const handleMacNativeEscapeCapture = (event: KeyboardEvent) => {
if (!shouldSuppressMacNativeEscapeExit(
isMacRuntime,
useNativeMacWindowControls,
useStore.getState().windowState === 'fullscreen',
event,
{ isEditableTarget: isEditableElement(event.target) },
)) {
return;
}
event.preventDefault();
event.stopPropagation();
};
window.addEventListener('keydown', handleMacNativeEscapeCapture, true);
return () => {
window.removeEventListener('keydown', handleMacNativeEscapeCapture, true);
};
}, [isMacRuntime, useNativeMacWindowControls]);
useEffect(() => {
const handleGlobalShortcut = (event: KeyboardEvent) => {
const matchedAction = SHORTCUT_ACTION_ORDER.find((action) => {
const meta = SHORTCUT_ACTION_META[action];
if (meta.scope && meta.scope !== 'global') {
return false;
}
const binding = resolveShortcutBinding(shortcutOptions, action, activeShortcutPlatform);
if (!binding?.enabled) {
return false;
}
if (isEditableElement(event.target) && !meta.allowInEditable) {
return false;
}
return isShortcutMatch(event, binding.combo);
});
if (!matchedAction) {
return;
}
event.preventDefault();
event.stopPropagation();
switch (matchedAction) {
case 'runQuery':
window.dispatchEvent(new CustomEvent('gonavi:run-active-query'));
break;
case 'focusSidebarSearch':
window.dispatchEvent(new CustomEvent('gonavi:focus-sidebar-search'));
break;
case 'newQueryTab':
handleNewQuery();
break;
case 'switchToNextTab':
switchActiveTabByOffset(1);
break;
case 'switchToPreviousTab':
switchActiveTabByOffset(-1);
break;
case 'newConnection':
handleCreateConnection();
break;
case 'toggleAIPanel':
toggleAIPanel();
break;
case 'toggleLogPanel':
handleToggleLogPanel();
break;
case 'toggleTheme':
setThemePreference(themeMode === 'dark' ? 'light' : 'dark');
break;
case 'openShortcutManager':
setIsShortcutModalOpen(true);
break;
case 'toggleMacFullscreen':
if (isMacRuntime && useNativeMacWindowControls) {
void handleTitleBarWindowToggle({ allowMacNativeFullscreen: true });
}
break;
case 'resetWindowZoom':
void handleManualResetWindowZoom();
break;
}
};
window.addEventListener('keydown', handleGlobalShortcut, true);
return () => {
window.removeEventListener('keydown', handleGlobalShortcut, true);
};
}, [activeShortcutPlatform, handleCreateConnection, handleManualResetWindowZoom, handleNewQuery, handleTitleBarWindowToggle, handleToggleLogPanel, isMacRuntime, shortcutOptions, switchActiveTabByOffset, themeMode, setThemePreference, toggleAIPanel, useNativeMacWindowControls]);
useEffect(() => {
if (!capturingShortcutAction) {
return;
}
const handleShortcutCapture = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.key === 'Escape') {
setCapturingShortcutAction(null);
return;
}
const combo = eventToShortcut(event);
if (!combo) {
return;
}
const normalizedCombo = normalizeShortcutCombo(combo);
if (!canRecordShortcutForAction(capturingShortcutAction, normalizedCombo)) {
const meta = SHORTCUT_ACTION_META[capturingShortcutAction];
void message.warning(meta.scope === 'aiComposer'
? t('app.shortcuts.message.ai_send_limit')
: t('app.shortcuts.message.modifier_required'));
return;
}
const conflictAction = SHORTCUT_ACTION_ORDER.find((action) => {
if (action === capturingShortcutAction) {
return false;
}
const binding = resolveShortcutBinding(shortcutOptions, action, activeShortcutPlatform);
if (!binding?.enabled) {
return false;
}
return normalizeShortcutCombo(binding.combo) === normalizedCombo;
});
if (conflictAction) {
void message.warning(t('app.shortcuts.message.conflict', { action: SHORTCUT_ACTION_META[conflictAction].label }));
return;
}
const reservedConflicts = findReservedConflictsForAction(
capturingShortcutAction,
normalizedCombo,
activeShortcutPlatform,
);
if (reservedConflicts.length > 0) {
const { hasMonaco, hasOther, monacoLabels, otherLabels, otherContexts } = splitConflictsByContext(reservedConflicts);
if (hasMonaco) {
void message.info(t('app.shortcuts.message.reserved_conflict_info', { labels: monacoLabels }), 4);
}
if (hasOther) {
void message.warning(t('app.shortcuts.message.reserved_conflict_warning', { contexts: otherContexts, labels: otherLabels }), 4);
}
}
updateShortcut(capturingShortcutAction, { combo: normalizedCombo, enabled: true }, activeShortcutPlatform);
setCapturingShortcutAction(null);
};
window.addEventListener('keydown', handleShortcutCapture, true);
return () => {
window.removeEventListener('keydown', handleShortcutCapture, true);
};
}, [activeShortcutPlatform, capturingShortcutAction, shortcutOptions, t, updateShortcut]);
const linuxResizeHandleStyleBase = {
position: 'fixed',
zIndex: 12000,
background: 'transparent',
WebkitAppRegion: 'drag',
'--wails-draggable': 'drag',
userSelect: 'none'
} as any;
const showLinuxResizeHandles = isLinuxRuntime;
const resizeGuideColor = isV2Ui
? 'var(--gn-accent, #16a34a)'
: (darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)');
const v2AntPrimaryColor = darkMode ? '#22c55e' : '#16a34a';
const v2AntPrimaryHoverColor = darkMode ? '#4ade80' : '#15803d';
const v2AntPrimaryActiveColor = darkMode ? '#16a34a' : '#166534';
const v2AntPrimaryBgColor = darkMode ? 'rgba(34, 197, 94, 0.20)' : '#dcfce7';
const v2AntPrimaryBgHoverColor = darkMode ? 'rgba(34, 197, 94, 0.28)' : '#bbf7d0';
const v2AntPrimaryBorderColor = darkMode ? 'rgba(34, 197, 94, 0.42)' : '#86efac';
const v2AntPrimaryBorderHoverColor = darkMode ? 'rgba(74, 222, 128, 0.58)' : '#4ade80';
const v2AntControlActiveBg = darkMode ? 'rgba(34, 197, 94, 0.16)' : 'rgba(34, 197, 94, 0.10)';
const v2AntControlActiveHoverBg = darkMode ? 'rgba(34, 197, 94, 0.24)' : 'rgba(34, 197, 94, 0.16)';
const v2AntControlOutline = darkMode ? 'rgba(34, 197, 94, 0.42)' : 'rgba(22, 163, 74, 0.22)';
const antdTheme = useMemo(() => ({
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
fontSize: tokenFontSize,
fontSizeSM: tokenFontSizeSM,
fontSizeLG: tokenFontSizeLG,
fontFamily: resolvedUiFontFamily,
fontFamilyCode: resolvedMonoFontFamily,
controlHeight: tokenControlHeight,
controlHeightSM: tokenControlHeightSM,
controlHeightLG: tokenControlHeightLG,
colorBgLayout: 'transparent',
colorBgContainer: darkMode
? `rgba(29, 29, 29, ${effectiveOpacity})`
: `rgba(255, 255, 255, ${effectiveOpacity})`,
colorBgElevated: darkMode
? '#1f1f1f'
: '#ffffff',
colorFillAlter: darkMode
? `rgba(38, 38, 38, ${effectiveOpacity})`
: `rgba(250, 250, 250, ${effectiveOpacity})`,
colorPrimary: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff'),
colorPrimaryHover: isV2Ui ? v2AntPrimaryHoverColor : (darkMode ? '#ffd666' : '#4096ff'),
colorPrimaryActive: isV2Ui ? v2AntPrimaryActiveColor : (darkMode ? '#d8a93b' : '#0958d9'),
colorInfo: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#f6c453' : '#1677ff'),
colorLink: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff'),
colorLinkHover: isV2Ui ? v2AntPrimaryHoverColor : (darkMode ? '#ffe58f' : '#4096ff'),
colorLinkActive: isV2Ui ? v2AntPrimaryActiveColor : (darkMode ? '#d8a93b' : '#0958d9'),
colorPrimaryBg: isV2Ui ? v2AntPrimaryBgColor : (darkMode ? 'rgba(246, 196, 83, 0.22)' : '#e6f4ff'),
colorPrimaryBgHover: isV2Ui ? v2AntPrimaryBgHoverColor : (darkMode ? 'rgba(246, 196, 83, 0.30)' : '#bae0ff'),
colorPrimaryBorder: isV2Ui ? v2AntPrimaryBorderColor : (darkMode ? 'rgba(246, 196, 83, 0.45)' : '#91caff'),
colorPrimaryBorderHover: isV2Ui ? v2AntPrimaryBorderHoverColor : (darkMode ? 'rgba(246, 196, 83, 0.60)' : '#69b1ff'),
controlItemBgActive: isV2Ui ? v2AntControlActiveBg : (darkMode ? 'rgba(246, 196, 83, 0.20)' : 'rgba(22, 119, 255, 0.12)'),
controlItemBgActiveHover: isV2Ui ? v2AntControlActiveHoverBg : (darkMode ? 'rgba(246, 196, 83, 0.28)' : 'rgba(22, 119, 255, 0.18)'),
controlOutline: isV2Ui ? v2AntControlOutline : (darkMode ? 'rgba(246, 196, 83, 0.50)' : 'rgba(5, 145, 255, 0.24)'),
},
components: {
Layout: {
bodyBg: 'transparent',
headerBg: 'transparent',
siderBg: 'transparent',
triggerBg: 'transparent'
},
Table: {
headerBg: 'transparent',
rowHoverBg: darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)',
},
Tabs: {
cardBg: 'transparent',
itemActiveColor: isV2Ui ? v2AntPrimaryHoverColor : (darkMode ? '#ffd666' : '#1890ff'),
itemHoverColor: isV2Ui ? v2AntPrimaryHoverColor : (darkMode ? '#ffe58f' : '#40a9ff'),
itemSelectedColor: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff'),
inkBarColor: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff'),
}
}
}), [
darkMode,
effectiveOpacity,
isV2Ui,
v2AntControlActiveBg,
v2AntControlActiveHoverBg,
v2AntControlOutline,
v2AntPrimaryActiveColor,
v2AntPrimaryBgColor,
v2AntPrimaryBgHoverColor,
v2AntPrimaryBorderColor,
v2AntPrimaryBorderHoverColor,
v2AntPrimaryColor,
v2AntPrimaryHoverColor,
tokenControlHeight,
tokenControlHeightLG,
tokenControlHeightSM,
tokenFontSize,
tokenFontSizeLG,
tokenFontSizeSM,
resolvedMonoFontFamily,
resolvedUiFontFamily,
]);
const filterFontOption = useCallback((input: string, option?: { value?: string; label?: React.ReactNode }) => (
matchFontFamilyOption(input, {
value: String(option?.value || ''),
label: String(option?.label || ''),
})
), []);
const renderFontOptionLabel = useCallback((option: FontFamilyOption) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, lineHeight: 1.35 }}>
<span>{option.label}</span>
<span style={{ fontSize: 11, color: darkMode ? 'rgba(255,255,255,0.45)' : 'rgba(16,24,40,0.45)' }}>
{option.value}
</span>
</div>
), [darkMode]);
const showLinuxCJKFontBanner = Boolean(
linuxCJKFontInstallHint &&
hasLoadedInstalledFontsRef.current &&
!isFontFamiliesLoading &&
!fontFamiliesLoadError &&
!isLinuxCJKFontBannerDismissed,
);
const sidebarMetadataFieldItems = useMemo(() => {
const labelByField: Record<SidebarTableMetadataField, string> = {
comment: t('sidebar.v2_table_group_menu.show_table_comments'),
rows: t('sidebar.v2_table_group_menu.display_table_rows'),
size: t('sidebar.v2_table_group_menu.display_table_size'),
createdAt: t('sidebar.v2_table_group_menu.display_create_time'),
updatedAt: t('sidebar.v2_table_group_menu.display_update_time'),
};
return sidebarTableMetadataFieldOrder.map((field) => ({
field,
label: labelByField[field],
}));
}, [sidebarTableMetadataFieldOrder, t]);
const toggleSidebarMetadataFieldFromSettings = useCallback((field: SidebarTableMetadataField, selected: boolean) => {
setQueryOptions({
sidebarTableMetadataFields: setSidebarTableMetadataFieldSelected(
sidebarTableMetadataFields,
field,
selected,
sidebarTableMetadataFieldOrder,
),
});
}, [setQueryOptions, sidebarTableMetadataFieldOrder, sidebarTableMetadataFields]);
const handleSidebarMetadataDragEnd = useCallback((event: DragEndEvent) => {
const activeField = String(event.active.id || '') as SidebarTableMetadataField;
const overField = String(event.over?.id || '') as SidebarTableMetadataField;
if (!overField || activeField === overField) {
return;
}
const currentOrder = sidebarMetadataFieldItems.map((item) => item.field);
const activeIndex = currentOrder.indexOf(activeField);
const overIndex = currentOrder.indexOf(overField);
if (activeIndex < 0 || overIndex < 0) {
return;
}
const nextOrder = arrayMove(currentOrder, activeIndex, overIndex);
setQueryOptions({
sidebarTableMetadataFieldOrder: nextOrder,
sidebarTableMetadataFields: applySidebarTableMetadataFieldOrder(
sidebarTableMetadataFields,
nextOrder,
),
});
}, [setQueryOptions, sidebarMetadataFieldItems, sidebarTableMetadataFields]);
const renderProxySettingsContent = useCallback(() => {
const fieldLabelStyle: React.CSSProperties = {
marginBottom: 6,
fontSize: 12,
color: darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(16,24,40,0.58)',
};
const proxyGridColumns = viewportWidth < 760 ? '1fr' : '1fr 1fr';
const proxyCardAccent = proxyDraft.enabled
? (darkMode ? 'rgba(34,197,94,0.12)' : 'rgba(16,185,129,0.10)')
: (darkMode ? 'rgba(148,163,184,0.12)' : 'rgba(71,85,105,0.08)');
const proxyTestAlertType = proxyTestResult
? (!proxyTestResult.success ? 'error' : ((proxyTestResult.statusCode || 0) >= 400 ? 'warning' : 'success'))
: 'info';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: '12px 0' }}>
<div style={{ ...utilityPanelStyle, background: `linear-gradient(135deg, ${proxyCardAccent}, transparent 58%)` }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 16 }}>
<div>
<div style={{ fontWeight: 700, fontSize: 15 }}>{t('app.proxy.section_title')}</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>{t('app.proxy.description')}</div>
</div>
<Switch
checked={proxyDraft.enabled}
checkedChildren={t('app.proxy.switch.enabled')}
unCheckedChildren={t('app.proxy.switch.disabled')}
onChange={(checked) => setProxyDraft((current) => ({ ...current, enabled: checked }))}
/>
</div>
<Alert
showIcon
type={proxyStatusTone}
message={proxyStatusTitle}
description={proxyStatusDescription}
style={{ marginTop: 14 }}
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
{proxyPresetItems.map((preset) => (
<Button key={preset.key} size="small" onClick={() => applyProxyPreset(preset)}>
{preset.label}
</Button>
))}
</div>
{proxyDraftDirty && (
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
{t('app.proxy.unsaved_hint')}
</div>
)}
</div>
<div style={utilityPanelStyle}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>{t('app.proxy.connection_title')}</div>
<div style={{ display: 'grid', gridTemplateColumns: proxyGridColumns, gap: 12 }}>
<div>
<div style={fieldLabelStyle}>{t('app.proxy.type')}</div>
<Segmented
block
value={proxyDraft.type}
options={[
{ label: t('app.proxy.type_socks5'), value: 'socks5' },
{ label: t('app.proxy.type_http'), value: 'http' },
]}
onChange={(value) => updateProxyDraftType(value as GlobalProxyConfig['type'])}
/>
</div>
<div>
<div style={fieldLabelStyle}>{t('app.proxy.port')}</div>
<InputNumber
min={1}
max={65535}
status={proxyDraft.enabled && !proxyDraftPortValid ? 'error' : undefined}
style={{ width: '100%' }}
value={proxyDraft.port}
onChange={(value) => setProxyDraft((current) => ({
...current,
port: typeof value === 'number' ? value : getGlobalProxyDefaultPort(current.type),
}))}
/>
</div>
<div style={{ gridColumn: viewportWidth < 760 ? 'auto' : '1 / span 2' }}>
<div style={fieldLabelStyle}>{t('app.proxy.host')}</div>
<Input
placeholder={t('app.proxy.host_placeholder')}
status={proxyDraft.enabled && proxyDraftHost === '' ? 'error' : undefined}
value={proxyDraft.host}
onChange={(e) => setProxyDraft((current) => ({ ...current, host: e.target.value }))}
/>
</div>
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
{proxyDraft.enabled ? t('app.proxy.enabled_edit_hint') : t('app.proxy.disabled_hint')}
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ fontWeight: 600, marginBottom: 12 }}>{t('app.proxy.auth_title')}</div>
<div style={{ display: 'grid', gridTemplateColumns: proxyGridColumns, gap: 12 }}>
<div>
<div style={fieldLabelStyle}>{t('app.proxy.username_optional')}</div>
<Input
placeholder="proxy-user"
value={proxyDraft.user}
onChange={(e) => setProxyDraft((current) => ({ ...current, user: e.target.value }))}
/>
</div>
<div>
<div style={fieldLabelStyle}>{t('app.proxy.password_optional')}</div>
<Input.Password
placeholder="proxy-password"
value={proxyDraft.password}
onChange={(e) => {
const nextPassword = e.target.value;
setProxyDraft((current) => ({
...current,
password: nextPassword,
hasPassword: nextPassword !== '' ? true : current.hasPassword,
}));
setProxyDraftClearPassword(false);
}}
/>
</div>
</div>
{proxyDraftClearPassword ? (
<Alert
showIcon
type="warning"
message={t('app.proxy.clear_saved_password_pending')}
style={{ marginTop: 12 }}
/>
) : proxyDraft.hasPassword && proxyDraft.password === '' ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 12 }}>
<span style={utilityMutedTextStyle}>{t('app.proxy.password_saved_hint')}</span>
<Button
size="small"
onClick={() => {
setProxyDraft((current) => ({ ...current, password: '', hasPassword: false }));
setProxyDraftClearPassword(true);
}}
>
{t('app.proxy.clear_saved_password')}
</Button>
</div>
) : (
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>{t('app.proxy.no_auth_hint')}</div>
)}
</div>
<div style={utilityPanelStyle}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div>
<div style={{ fontWeight: 600 }}>{t('app.proxy.test.title')}</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>{t('app.proxy.test.description')}</div>
</div>
<Button
type="primary"
loading={proxyTesting}
disabled={!proxyCanTest}
onClick={handleTestGlobalProxyDraft}
>
{t('app.proxy.test.action')}
</Button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<div style={fieldLabelStyle}>{t('app.proxy.test.target_label')}</div>
<Select
value={proxyTestUrlTrimmed}
style={{ width: '100%' }}
options={proxyTestPresetItems.map((item) => ({
value: item.url,
label: `${item.label} - ${item.url}`,
}))}
onChange={(value) => setProxyTestUrl(value)}
/>
</div>
<Input
value={proxyTestUrl}
placeholder={t('app.proxy.test.target_placeholder')}
onChange={(event) => setProxyTestUrl(event.target.value)}
onPressEnter={() => {
if (proxyCanTest) {
void handleTestGlobalProxyDraft();
}
}}
/>
{!proxyDraft.enabled && (
<div style={utilityMutedTextStyle}>{t('app.proxy.test.disabled_hint')}</div>
)}
{proxyTestResult && (
<Alert
showIcon
type={proxyTestAlertType}
message={proxyTestResult.message}
description={[
proxyTestResult.statusCode ? t('app.proxy.test.result.status', { status: proxyTestResult.statusCode }) : '',
typeof proxyTestResult.durationMs === 'number' ? t('app.proxy.test.result.duration', { duration: proxyTestResult.durationMs }) : '',
proxyTestResult.finalUrl && proxyTestResult.finalUrl !== proxyTestResult.url ? t('app.proxy.test.result.final_url', { url: proxyTestResult.finalUrl }) : '',
].filter(Boolean).join(' ')}
/>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ ...utilityMutedTextStyle, flex: '1 1 260px' }}>{t('app.proxy.scope_hint')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={resetProxyDraftToCurrent} disabled={!proxyDraftDirty || proxyApplying}>
{t('app.proxy.reset')}
</Button>
<Button
type="primary"
loading={proxyApplying}
disabled={!proxyDraftDirty && !proxyApplying}
onClick={handleApplyGlobalProxyDraft}
>
{t('app.proxy.apply')}
</Button>
</div>
</div>
</div>
);
}, [
applyProxyPreset,
darkMode,
handleApplyGlobalProxyDraft,
proxyApplying,
proxyCanTest,
proxyDraft.enabled,
proxyDraft.hasPassword,
proxyDraft.host,
proxyDraft.password,
proxyDraft.port,
proxyDraft.type,
proxyDraft.user,
proxyDraftClearPassword,
proxyDraftDirty,
proxyDraftHost,
proxyDraftPortValid,
proxyTestPresetItems,
proxyTestResult,
proxyTesting,
proxyTestUrl,
proxyTestUrlTrimmed,
proxyPresetItems,
proxyStatusDescription,
proxyStatusTitle,
proxyStatusTone,
resetProxyDraftToCurrent,
t,
handleTestGlobalProxyDraft,
updateProxyDraftType,
utilityMutedTextStyle,
utilityPanelStyle,
viewportWidth,
]);
const renderSidebarMetadataSettingsPane = useCallback(() => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t('app.settings.sidebar_metadata.title')}</div>
<div style={{ ...utilityMutedTextStyle, marginBottom: 14 }}>
{t('app.settings.sidebar_metadata.description')}
</div>
<DndContext
sensors={sidebarMetadataDragSensors}
collisionDetection={closestCenter}
onDragEnd={handleSidebarMetadataDragEnd}
>
<SortableContext
items={sidebarMetadataFieldItems.map((item) => item.field)}
strategy={verticalListSortingStrategy}
>
<div style={{ display: 'grid', gap: 14 }}>
{sidebarMetadataFieldItems.map((item) => {
const checked = sidebarTableMetadataFields.includes(item.field);
return (
<SidebarMetadataSortableRow
key={item.field}
field={item.field}
label={item.label}
checked={checked}
dividerColor={overlayTheme.divider}
titleColor={overlayTheme.titleText}
mutedColor={utilityMutedTextStyle.color as string}
onToggle={(selected) => toggleSidebarMetadataFieldFromSettings(item.field, selected)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setQueryOptions({
sidebarTableMetadataFields: DEFAULT_SIDEBAR_TABLE_METADATA_FIELDS,
sidebarTableMetadataFieldOrder: [...SIDEBAR_TABLE_METADATA_FIELDS],
});
}}
>
{t('app.theme.action.restore_defaults')}
</Button>
</div>
</div>
), [
overlayTheme.divider,
overlayTheme.titleText,
setQueryOptions,
sidebarMetadataFieldItems,
sidebarMetadataDragSensors,
handleSidebarMetadataDragEnd,
sidebarTableMetadataFields,
t,
toggleSidebarMetadataFieldFromSettings,
utilityMutedTextStyle,
utilityPanelStyle,
]);
const renderAboutUpdateActions = (closeAction?: React.ReactNode) => [
isBackgroundProgressForLatestUpdate && !isLatestUpdateDownloaded ? (
<Button key="progress" icon={<DownloadOutlined />} onClick={showUpdateDownloadProgress}>{t('app.about.action.download_progress')}</Button>
) : null,
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? (
<Button key="mute" onClick={muteLatestUpdate}>{t('app.about.action.mute_this_version')}</Button>
) : null,
<Button key="check" icon={<CloudDownloadOutlined />} onClick={() => checkForUpdates(false)}>{t('app.about.action.check_updates')}</Button>,
closeAction ?? null,
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? (
<Button key="download" type="primary" icon={<DownloadOutlined />} onClick={() => downloadUpdate(lastUpdateInfo, false)}>{t('app.about.action.download_update')}</Button>
) : null,
isLatestUpdateDownloaded ? (
<Button key="open-install-directory" onClick={openDownloadedUpdateDirectory}>
{t('app.about.action.open_install_directory')}
</Button>
) : null,
isLatestUpdateDownloaded ? (
<Button key="restart-to-update" type="primary" icon={<DownloadOutlined />} onClick={handleInstallFromProgress}>
{t('app.about.action.restart_to_update')}
</Button>
) : null,
].filter(Boolean);
const renderAboutSettingsContent = () => (
aboutLoading ? (
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<Spin />
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={utilityPanelStyle}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
<div>
<div style={{ marginBottom: 6, fontWeight: 600 }}>{t('app.about.field.version')}</div>
<div style={utilityMutedTextStyle}>{aboutDisplayVersion}</div>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 600 }}>{t('app.about.field.author')}</div>
<div style={utilityMutedTextStyle}>{aboutInfo?.author || t('common.unknown')}</div>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ marginBottom: 6, fontWeight: 600 }}>{t('app.about.field.update_status')}</div>
<div style={utilityMutedTextStyle}>{aboutUpdateStatus || t('app.about.update_status.not_checked')}</div>
</div>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ marginBottom: 6, fontWeight: 600 }}>{t('app.about.field.update_channel')}</div>
<Select
value={updateChannel}
options={[
{ value: 'latest', label: t('app.about.update_channel.latest') },
{ value: 'dev', label: t('app.about.update_channel.dev') },
]}
onChange={(value) => {
void changeUpdateChannel(String(value));
}}
loading={isUpdateChannelLoading}
disabled={
isUpdateChannelLoading
|| isUpdateChannelSaving
|| updateDownloadProgress.status === 'start'
|| updateDownloadProgress.status === 'downloading'
}
style={{ width: 220, maxWidth: '100%' }}
/>
</div>
{aboutInfo?.communityUrl ? (
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ marginBottom: 6, fontWeight: 600 }}>{t('app.about.field.community')}</div>
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.communityUrl) BrowserOpenURL(aboutInfo.communityUrl); }} href={aboutInfo.communityUrl}>{t('app.about.community.ai_book')}</a>
</div>
) : null}
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}>{t('app.about.project_links')}</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<GithubOutlined />
{aboutInfo?.repoUrl ? (
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.repoUrl) BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}>{aboutInfo.repoUrl}</a>
) : t('common.unknown')}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<BugOutlined />
{aboutInfo?.issueUrl ? (
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.issueUrl) BrowserOpenURL(aboutInfo.issueUrl); }} href={aboutInfo.issueUrl}>{aboutInfo.issueUrl}</a>
) : t('common.unknown')}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CloudDownloadOutlined />
{aboutInfo?.releaseUrl ? (
<a onClick={(e) => { e.preventDefault(); if (aboutInfo?.releaseUrl) BrowserOpenURL(aboutInfo.releaseUrl); }} href={aboutInfo.releaseUrl}>{aboutInfo.releaseUrl}</a>
) : t('common.unknown')}
</div>
</div>
</div>
</div>
)
);
const renderSettingsCenterAboutProjectEntry = ({
icon,
title,
description,
url,
}: {
icon: React.ReactNode;
title: string;
description: string;
url?: string;
}) => (
<button
type="button"
onClick={() => {
if (url) {
BrowserOpenURL(url);
}
}}
disabled={!url}
style={{
width: '100%',
display: 'grid',
gridTemplateColumns: '44px minmax(0, 1fr) auto',
alignItems: 'center',
gap: 14,
padding: '18px 20px',
borderRadius: 12,
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.10)' : 'rgba(16,24,40,0.10)'}`,
background: darkMode ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.76)',
color: darkMode ? 'rgba(255,255,255,0.90)' : '#101828',
cursor: url ? 'pointer' : 'not-allowed',
opacity: url ? 1 : 0.58,
textAlign: 'left',
}}
>
<span style={{ fontSize: 26, display: 'grid', placeItems: 'center', color: darkMode ? '#f8fafc' : '#0f172a' }}>
{icon}
</span>
<span style={{ minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 16, fontWeight: 700, lineHeight: 1.4 }}>{title}</span>
<span style={{ ...utilityMutedTextStyle, display: 'block', marginTop: 3, lineHeight: 1.45 }}>{description}</span>
</span>
<RightOutlined style={{ color: overlayTheme.mutedText, fontSize: 18 }} />
</button>
);
const renderSettingsCenterAboutPane = () => {
if (aboutLoading) {
return (
<div style={{ padding: '16px 0', textAlign: 'center' }}>
<Spin />
</div>
);
}
const hasUpdate = Boolean(lastUpdateInfo?.hasUpdate);
const latestVersionText = lastUpdateInfo?.latestVersion || t('common.unknown');
const updateStatusText = hasUpdate
? t('app.about.hero.update_available_version', { version: latestVersionText })
: (lastUpdateInfo ? t('app.about.hero.no_update') : t('app.about.update_status.not_checked'));
const currentVersionText = lastUpdateInfo?.currentVersion || aboutDisplayVersion;
const releaseNotesText = lastUpdateInfo?.releaseName || (hasUpdate ? t('app.about.release_notes.fallback') : '-');
const releaseTimeText = formatAboutReleaseTime(lastUpdateInfo?.releasePublishedAt);
const cardBorder = darkMode ? 'rgba(255,255,255,0.10)' : 'rgba(16,24,40,0.11)';
const cardBg = darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.82)';
const mutedText = utilityMutedTextStyle.color;
const dividerColor = darkMode ? 'rgba(255,255,255,0.09)' : 'rgba(16,24,40,0.09)';
const versionRows = [
[t('app.about.version.current'), currentVersionText],
[t('app.about.version.latest'), latestVersionText],
[t('app.about.version.release_time'), releaseTimeText],
[t('app.about.version.release_notes'), releaseNotesText],
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, padding: '10px 0 20px' }}>
<div
style={{
border: `1px solid ${cardBorder}`,
borderRadius: 14,
padding: '18px 22px',
background: cardBg,
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
alignItems: 'center',
gap: 18,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 18, minWidth: 0 }}>
<div
style={{
width: 64,
height: 64,
borderRadius: 14,
background: 'linear-gradient(135deg, #19b968 0%, #058d40 100%)',
display: 'grid',
placeItems: 'center',
color: '#fff',
fontSize: 36,
boxShadow: darkMode ? '0 12px 26px rgba(0,0,0,0.26)' : '0 10px 22px rgba(16,185,129,0.22)',
flexShrink: 0,
}}
>
<SendOutlined />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 24, lineHeight: 1.1, fontWeight: 800, color: overlayTheme.titleText }}>GoNavi</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 12, flexWrap: 'wrap' }}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 7,
padding: '5px 10px',
borderRadius: 999,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(16,24,40,0.04)',
color: mutedText,
fontWeight: 600,
}}
>
<TagOutlined />
{aboutDisplayVersion}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, color: mutedText, fontWeight: 600 }}>
<UserOutlined />
{aboutInfo?.author || t('common.unknown')}
</span>
</div>
</div>
</div>
<div
style={{
minWidth: 260,
padding: '11px 18px',
borderRadius: 10,
border: `1px solid ${hasUpdate ? (darkMode ? 'rgba(74,222,128,0.46)' : 'rgba(22,163,74,0.30)') : cardBorder}`,
background: hasUpdate
? (darkMode ? 'rgba(34,197,94,0.10)' : 'rgba(22,163,74,0.055)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.025)'),
color: hasUpdate ? (darkMode ? '#86efac' : '#16a34a') : mutedText,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
fontSize: 15,
fontWeight: 700,
}}
>
<UpCircleOutlined style={{ fontSize: 21 }} />
{updateStatusText}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 22 }}>
<div style={{ border: `1px solid ${cardBorder}`, borderRadius: 12, background: cardBg, overflow: 'hidden' }}>
<div style={{ padding: '22px 24px', fontSize: 18, fontWeight: 800, color: overlayTheme.titleText }}>
{t('app.about.version_update.title')}
</div>
<div style={{ borderTop: `1px solid ${dividerColor}`, padding: '18px 24px' }}>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(220px, 260px)', alignItems: 'center', gap: 16 }}>
<div style={{ fontSize: 15, fontWeight: 600, color: overlayTheme.titleText }}>{t('app.about.field.update_channel')}</div>
<Segmented
className="gonavi-about-update-channel"
value={updateChannel}
options={[
{ value: 'latest', label: t('app.about.update_channel.latest') },
{ value: 'dev', label: t('app.about.update_channel.dev') },
]}
onChange={(value) => {
void changeUpdateChannel(String(value));
}}
disabled={
isUpdateChannelLoading
|| isUpdateChannelSaving
|| updateDownloadProgress.status === 'start'
|| updateDownloadProgress.status === 'downloading'
}
block
style={{ width: '100%' }}
/>
</div>
<div style={{ ...utilityMutedTextStyle, lineHeight: 1.55, maxWidth: 360 }}>{t('app.about.version_update.channel_hint')}</div>
</div>
<div style={{ height: 1, background: dividerColor, margin: '18px 0' }} />
<div style={{ display: 'grid', gap: 13 }}>
{versionRows.map(([label, value]) => (
<div key={label} style={{ display: 'grid', gridTemplateColumns: '150px minmax(0, 1fr)', gap: 16, alignItems: 'start' }}>
<div style={{ fontWeight: 600, color: overlayTheme.titleText, lineHeight: 1.45 }}>{label}</div>
<div style={{ color: label === t('app.about.version.latest') && hasUpdate ? (darkMode ? '#86efac' : '#16a34a') : mutedText, fontWeight: label === t('app.about.version.latest') && hasUpdate ? 700 : 500, lineHeight: 1.45, minWidth: 0, overflowWrap: 'anywhere' }}>
{value}
</div>
</div>
))}
</div>
</div>
<div style={{ borderTop: `1px solid ${dividerColor}`, padding: '16px 24px', display: 'flex', alignItems: 'center', gap: 10, color: hasUpdate ? (darkMode ? '#86efac' : '#16a34a') : mutedText, fontWeight: 700 }}>
<CheckOutlined />
{hasUpdate ? t('app.about.version_update.available') : t('app.about.version_update.up_to_date')}
</div>
</div>
<div style={{ border: `1px solid ${cardBorder}`, borderRadius: 12, background: cardBg, padding: '22px 24px' }}>
<div style={{ fontSize: 18, fontWeight: 800, color: overlayTheme.titleText, marginBottom: 18 }}>
{t('app.about.project_links')}
</div>
<div style={{ display: 'grid', gap: 14 }}>
{renderSettingsCenterAboutProjectEntry({
icon: <GithubOutlined />,
title: t('app.about.project.github.title'),
description: t('app.about.project.github.description'),
url: aboutInfo?.repoUrl,
})}
{renderSettingsCenterAboutProjectEntry({
icon: <MessageOutlined />,
title: t('app.about.project.issues.title'),
description: t('app.about.project.issues.description'),
url: aboutInfo?.issueUrl,
})}
{renderSettingsCenterAboutProjectEntry({
icon: <FileTextOutlined />,
title: t('app.about.project.releases.title'),
description: t('app.about.project.releases.description'),
url: lastUpdateInfo?.releaseNotesUrl || aboutInfo?.releaseUrl,
})}
</div>
</div>
</div>
</div>
);
};
const renderSettingsCenterAboutFooter = () => (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0, color: overlayTheme.mutedText, fontWeight: 600 }}>
<SyncOutlined style={{ color: darkMode ? '#86efac' : '#16a34a', fontSize: 22 }} />
<span>
{aboutLastCheckedAt
? t('app.about.last_checked_at', { time: aboutLastCheckedAt })
: t('app.about.last_checked_never')}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
{renderAboutUpdateActions()}
</div>
</>
);
const renderThemeSettingsSection = (title: React.ReactNode, children: React.ReactNode, hint?: React.ReactNode) => (
<section className="gonavi-settings-section">
<div className="gonavi-settings-section-title">{title}</div>
{hint ? <div className="gonavi-settings-section-hint">{hint}</div> : null}
<div>{children}</div>
</section>
);
const renderThemeSettingsRow = ({
label,
hint,
control,
stacked = false,
controlOnly = false,
}: {
label?: React.ReactNode;
hint?: React.ReactNode;
control: React.ReactNode;
stacked?: boolean;
/** 分区标题已说明用途时,只渲染控件,避免标题重复 */
controlOnly?: boolean;
}) => (
<div className={`gonavi-settings-row${stacked || controlOnly ? ' is-stacked' : ''}${controlOnly ? ' is-control-only' : ''}`}>
{!controlOnly ? (
<div>
<div className="gonavi-settings-label">{label}</div>
{hint ? <div className="gonavi-settings-label-hint">{hint}</div> : null}
</div>
) : null}
<div className="gonavi-settings-control">{control}</div>
</div>
);
const renderThemeModePreview = (preview: 'light' | 'dark' | 'system') => (
<div
aria-hidden
className={`gonavi-settings-mode-preview${preview === 'system' ? ' is-system' : ''}`}
>
{(preview === 'light' || preview === 'system') ? (
<div className="gonavi-settings-mode-preview-pane is-light">
<span className="gonavi-settings-mode-preview-line" />
<span className="gonavi-settings-mode-preview-line" />
<span className="gonavi-settings-mode-preview-line" />
</div>
) : null}
{(preview === 'dark' || preview === 'system') ? (
<div className="gonavi-settings-mode-preview-pane is-dark">
<span className="gonavi-settings-mode-preview-line" />
<span className="gonavi-settings-mode-preview-line" />
<span className="gonavi-settings-mode-preview-line" />
</div>
) : null}
</div>
);
const renderUiVersionPreview = (version: 'legacy' | 'v2') => (
<div aria-hidden className={`gonavi-settings-ui-version-preview is-${version}`}>
<div className="uv-side">
<span className="uv-dot" />
<span className="uv-dot" />
<span className="uv-dot" />
{version === 'legacy' ? <span className="uv-dot" /> : null}
</div>
<div className="uv-main">
<div className="uv-bar is-accent" />
<div className="uv-cards">
<div className="uv-card" />
<div className="uv-card" />
</div>
</div>
</div>
);
const renderThemeSettingsContentV2 = () => (
<div className="gonavi-theme-settings" style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
padding: '4px 4px 0',
height: '100%',
minHeight: 0,
overflow: 'hidden',
boxSizing: 'border-box',
}}>
<div style={{ flexShrink: 0, display: 'grid', gap: 4 }}>
{/* 自绘 Tab避开 antd Segmented CSS-in-JS 压掉选中态 */}
<div className="gonavi-settings-tabs" role="tablist" aria-label={t('app.settings.entry.theme.title')}>
{([
{ value: 'theme' as const, label: t('app.theme.nav.theme.title'), icon: <SkinOutlined /> },
{ value: 'appearance' as const, label: t('app.theme.nav.appearance.title'), icon: <BgColorsOutlined /> },
{ value: 'workspace' as const, label: t('app.theme.nav.workspace.title'), icon: <AppstoreOutlined /> },
]).map((item) => {
const active = themeModalSection === item.value;
return (
<button
key={item.value}
type="button"
role="tab"
aria-selected={active}
className={`gonavi-settings-tab${active ? ' is-active' : ''}`}
onClick={() => setThemeModalSection(item.value)}
>
<span className="gonavi-settings-tab-icon">{item.icon}</span>
<span>{item.label}</span>
</button>
);
})}
</div>
<div className="gonavi-settings-inline-meta" style={{ marginTop: 2 }}>
{t('app.theme.instant_apply_hint')}
</div>
</div>
<div
key={themeModalSection}
style={{
minWidth: 0,
minHeight: 0,
flex: 1,
overflowY: 'auto',
/* visible避免 Slider 两端手柄被横向裁切 */
overflowX: 'visible',
overscrollBehavior: 'contain',
paddingRight: 8,
paddingLeft: 2,
paddingBottom: 20,
}}
>
{themeModalSection === 'theme' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{renderThemeSettingsSection(
t('app.theme.mode_title'),
<div className="gonavi-settings-mode-grid" role="radiogroup" aria-label={t('app.theme.mode_title')}>
{([
{ key: 'light' as const, label: t('app.theme.mode.light.label'), preview: 'light' as const },
{ key: 'dark' as const, label: t('app.theme.mode.dark.label'), preview: 'dark' as const },
{ key: 'system' as const, label: t('app.theme.mode.system.label'), preview: 'system' as const },
]).map((item) => {
const active = themePreference === item.key;
return (
<button
key={item.key}
type="button"
role="radio"
aria-checked={active}
className={`gonavi-settings-mode-tile${active ? ' is-active' : ''}`}
onClick={() => setThemePreference(item.key)}
>
{renderThemeModePreview(item.preview)}
<div className="gonavi-settings-mode-meta">
<span className="gonavi-settings-mode-label">{item.label}</span>
{active ? <CheckOutlined className="gonavi-settings-mode-check" /> : null}
</div>
</button>
);
})}
</div>,
)}
{renderThemeSettingsSection(
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{t('app.theme.ui_version.title')}</span>
<span style={{
fontSize: 10,
fontWeight: 700,
padding: '1px 6px',
background: darkMode ? 'rgba(56,189,248,0.18)' : 'rgba(2,132,199,0.10)',
color: darkMode ? '#7dd3fc' : '#0284c7',
borderRadius: 4,
}}>
{t('app.theme.ui_version.badge.new')}
</span>
{appearance.uiVersion === 'v2' ? (
<span className="gonavi-settings-beta-chip">{t('app.theme.ui_version.v2.badge')}</span>
) : null}
</span>,
<>
<div
className="gonavi-settings-ui-version-grid"
role="radiogroup"
aria-label={t('app.theme.ui_version.title')}
>
{([
{
key: 'legacy' as const,
label: t('app.theme.ui_version.legacy.label'),
badge: t('app.theme.ui_version.legacy.badge'),
badgeClass: '',
},
{
key: 'v2' as const,
label: t('app.theme.ui_version.v2.label'),
badge: t('app.theme.ui_version.v2.badge'),
badgeClass: ' is-new',
},
]).map((item) => {
const active = (appearance.uiVersion ?? 'legacy') === item.key;
return (
<button
key={item.key}
type="button"
role="radio"
aria-checked={active}
className={`gonavi-settings-ui-version-tile${active ? ' is-active' : ''}`}
onClick={() => setAppearance({ uiVersion: item.key })}
>
{renderUiVersionPreview(item.key)}
<div className="gonavi-settings-ui-version-meta">
<span className="gonavi-settings-ui-version-title">
<span>{item.label}</span>
<span className={`gonavi-settings-ui-version-badge${item.badgeClass}`}>{item.badge}</span>
</span>
{active ? <CheckOutlined className="gonavi-settings-mode-check" /> : null}
</div>
</button>
);
})}
</div>
<div className="gonavi-settings-inline-meta" style={{ marginTop: 8 }}>
{t('app.theme.ui_version.platform_hint')}
</div>
{appearance.uiVersion === 'v2' ? (
<div className="gonavi-settings-inline-choice-row">
<Tooltip title={t('app.theme.ui_version.sidebar_search.hint')}>
<span className="gonavi-settings-label" style={{ cursor: 'help' }}>
{t('app.theme.ui_version.sidebar_search.title')}
</span>
</Tooltip>
<div className="gonavi-settings-pills" role="group" aria-label={t('app.theme.ui_version.sidebar_search.title')}>
{([
{ value: 'command' as const, label: t('app.theme.ui_version.sidebar_search.command') },
{ value: 'filter' as const, label: t('app.theme.ui_version.sidebar_search.filter') },
]).map((item) => {
const active = (appearance.v2SidebarSearchMode ?? 'command') === item.value;
return (
<button
key={item.value}
type="button"
className={`gonavi-settings-pill${active ? ' is-active' : ''}`}
aria-pressed={active}
onClick={() => setAppearance({ v2SidebarSearchMode: item.value })}
>
{item.label}
</button>
);
})}
</div>
</div>
) : null}
</>,
)}
</div>
) : themeModalSection === 'appearance' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{renderThemeSettingsSection(
t('app.theme.nav.appearance.title'),
<>
{renderThemeSettingsRow({
label: t('app.theme.appearance.ui_scale_title'),
hint: t('app.theme.appearance.ui_scale_hint'),
stacked: true,
control: (
<ThemeSettingsSlider
min={MIN_UI_SCALE}
max={MAX_UI_SCALE}
step={0.05}
marks={UI_SCALE_SLIDER_MARKS}
value={effectiveUiScale}
unit="percent"
onChange={(v) => setUiScale(v)}
/>
),
})}
{renderThemeSettingsRow({
label: t('app.theme.appearance.font_size_title'),
stacked: true,
control: (
<ThemeSettingsSlider
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
marks={FONT_SIZE_SLIDER_MARKS}
value={effectiveFontSize}
unit="px"
onChange={(v) => setFontSize(v)}
/>
),
})}
{appearance.uiVersion === 'v2' ? renderThemeSettingsRow({
label: t('app.theme.appearance.sidebar_rail_scale_title'),
hint: t('app.theme.appearance.sidebar_rail_scale_hint'),
stacked: true,
control: (
<ThemeSettingsSlider
min={MIN_V2_SIDEBAR_RAIL_SCALE}
max={MAX_V2_SIDEBAR_RAIL_SCALE}
step={0.05}
marks={SIDEBAR_RAIL_SCALE_SLIDER_MARKS}
value={effectiveSidebarRailScale}
unit="percent"
onChange={(value) => setAppearance({
v2SidebarRailScale: sanitizeV2SidebarRailScale(value),
})}
/>
),
}) : null}
</>,
)}
{renderThemeSettingsSection(
t('app.theme.font_family.title'),
<>
<div style={{ padding: '8px 0' }}>
<div className="gonavi-settings-label" style={{ marginBottom: 8 }}>{t('app.theme.font_family.ui_title')}</div>
<Select
allowClear
showSearch
optionFilterProp="label"
loading={isFontFamiliesLoading}
placeholder={DEFAULT_UI_FONT_FAMILY}
value={appearance.customUIFontFamily ?? undefined}
onChange={(value) => setAppearance({
customUIFontFamily: sanitizeFontFamilyInput(value),
})}
onClear={() => setAppearance({ customUIFontFamily: null })}
options={uiFontOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
filterOption={filterFontOption}
popupMatchSelectWidth
style={{ width: '100%' }}
optionRender={(option) => renderFontOptionLabel({
value: String(option.data.value),
label: String(option.data.label),
})}
/>
<div className="gonavi-settings-inline-meta">
{fontFamiliesLoadError
? t('app.theme.font_family.load_failed_fallback', { error: fontFamiliesLoadError })
: (installedFontFamilies.length > 0
? t('app.theme.font_family.loaded_ui_hint', { count: installedFontFamilies.length })
: t('app.theme.font_family.loading_ui_hint'))}
</div>
{linuxCJKFontInstallHint && hasLoadedInstalledFontsRef.current && !isFontFamiliesLoading && !fontFamiliesLoadError ? (
<div className="gonavi-settings-alert" style={{ borderColor: darkMode ? 'rgba(250,204,21,0.28)' : 'rgba(217,119,6,0.22)', background: darkMode ? 'rgba(250,204,21,0.08)' : 'rgba(251,191,36,0.12)', color: darkMode ? 'rgba(254,249,195,0.92)' : '#92400e' }}>
{t('app.theme.font_family.linux_cjk_install_prefix')}
<span style={{ fontFamily: 'var(--gn-font-mono)', marginLeft: 6 }}>{linuxCJKFontInstallHint}</span>
{t('app.theme.font_family.linux_cjk_install_suffix')}
</div>
) : null}
</div>
<div style={{ padding: '8px 0', borderTop: '1px solid var(--gn-settings-line)' }}>
<div className="gonavi-settings-label" style={{ marginBottom: 8 }}>{t('app.theme.font_family.mono_title')}</div>
<Select
allowClear
showSearch
optionFilterProp="label"
loading={isFontFamiliesLoading}
placeholder={DEFAULT_MONO_FONT_FAMILY}
value={appearance.customMonoFontFamily ?? undefined}
onChange={(value) => setAppearance({
customMonoFontFamily: sanitizeFontFamilyInput(value),
})}
onClear={() => setAppearance({ customMonoFontFamily: null })}
options={monoFontOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
filterOption={filterFontOption}
popupMatchSelectWidth
style={{ width: '100%' }}
optionRender={(option) => renderFontOptionLabel({
value: String(option.data.value),
label: String(option.data.label),
})}
/>
<div className="gonavi-settings-inline-meta">
{fontFamiliesLoadError
? t('app.theme.font_family.mono_fallback_hint')
: t('app.theme.font_family.mono_hint')}
</div>
</div>
</>,
)}
{renderThemeSettingsSection(
t('app.theme.appearance.transparency_blur_title'),
<>
{renderThemeSettingsRow({
label: t('app.theme.appearance.enable_transparency_blur'),
hint: t('app.theme.appearance.enable_transparency_blur_hint'),
control: (
<Switch
checked={appearance.enabled !== false}
onChange={(checked) => setAppearance({ enabled: checked })}
/>
),
})}
<div style={{ opacity: appearance.enabled !== false ? 1 : 0.55 }}>
{renderThemeSettingsRow({
label: t('app.theme.appearance.opacity_title'),
stacked: true,
control: (
<ThemeSettingsSlider
min={0.1}
max={1.0}
step={0.05}
marks={OPACITY_SLIDER_MARKS}
disabled={appearance.enabled === false}
value={appearance.opacity ?? 1.0}
unit="percent"
onChange={(v) => setAppearance({ opacity: v })}
/>
),
})}
{isWindowsPlatform() ? (
<div className="gonavi-settings-inline-meta">{t('app.theme.appearance.windows_acrylic_hint')}</div>
) : (
renderThemeSettingsRow({
label: t('app.theme.appearance.blur_title'),
hint: t('app.theme.appearance.blur_hint'),
stacked: true,
control: (
<ThemeSettingsSlider
min={0}
max={20}
step={1}
marks={BLUR_SLIDER_MARKS}
disabled={appearance.enabled === false}
value={appearance.blur ?? 0}
unit="px"
onChange={(v) => setAppearance({ blur: v })}
/>
),
})
)}
</div>
</>,
)}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{renderThemeSettingsSection(
t('app.theme.query_template.title'),
<>
<div className="gonavi-settings-section-hint" style={{ marginTop: 0 }}>{t('app.theme.query_template.description')}</div>
<Input.TextArea
value={newQuerySqlTemplate}
autoSize={{ minRows: 3, maxRows: 8 }}
spellCheck={false}
onChange={(event) => setAppearance({ newQuerySqlTemplate: event.target.value })}
style={{ fontFamily: 'var(--gn-font-mono)' }}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginTop: 8 }}>
<div className="gonavi-settings-inline-meta" style={{ marginTop: 0 }}>{t('app.theme.query_template.hint')}</div>
<Button
size="small"
disabled={appearance.newQuerySqlTemplate === null}
onClick={() => setAppearance({ newQuerySqlTemplate: null })}
>
{t('app.theme.query_template.reset_default')}
</Button>
</div>
</>,
)}
<section className="gonavi-settings-section" ref={tabDisplaySettingsPanelRef}>
<div className="gonavi-settings-section-title">{t('app.theme.tab_display.title')}</div>
<div className="gonavi-settings-section-hint">{t('app.theme.tab_display.description')}</div>
{renderThemeSettingsRow({
label: t('app.theme.tab_display.title'),
stacked: true,
control: (
<Segmented
className="gonavi-settings-segmented-choice"
block
options={[
{ label: t('app.theme.tab_display.layout.single'), value: 'single' },
{ label: t('app.theme.tab_display.layout.double'), value: 'double' },
]}
value={tabDisplaySettings.layout}
onChange={(value) => setTabDisplayLayout(value as TabDisplayLayout)}
/>
),
})}
<div style={{ display: 'grid', gap: 8, marginTop: 8 }}>
{tabDisplayElementOrder.map((key) => {
const checked = visibleTabDisplayElementKeys.has(key);
const row = tabDisplaySettings.secondaryElements.includes(key) ? 'secondary' : 'primary';
const currentRowElements = row === 'secondary'
? tabDisplaySettings.secondaryElements
: tabDisplaySettings.primaryElements;
const indexInRow = currentRowElements.indexOf(key);
const canMoveUp = checked && indexInRow > 0;
const canMoveDown = checked && indexInRow >= 0 && indexInRow < currentRowElements.length - 1;
const isFocused = focusedTabDisplayElementKey === key;
return (
<div
key={key}
role="button"
tabIndex={0}
onClick={() => setFocusedTabDisplayElementKey(key)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setFocusedTabDisplayElementKey(key);
}
}}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 10,
alignItems: 'center',
padding: '9px 10px',
borderRadius: 10,
border: `1px solid ${isFocused
? (isV2Ui
? v2AntPrimaryBorderHoverColor
: (darkMode ? 'rgba(255,214,102,0.54)' : 'rgba(24,144,255,0.54)'))
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
boxShadow: isFocused
? (isV2Ui
? `0 0 0 2px ${v2AntControlActiveBg}`
: (darkMode ? '0 0 0 2px rgba(255,214,102,0.14)' : '0 0 0 2px rgba(24,144,255,0.12)'))
: 'none',
background: isFocused
? (isV2Ui
? `linear-gradient(90deg, ${v2AntPrimaryBgHoverColor} 0%, rgba(255,255,255,0.045) 100%)`
: (darkMode ? 'linear-gradient(90deg, rgba(255,214,102,0.12) 0%, rgba(255,255,255,0.045) 100%)' : 'linear-gradient(90deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.78) 100%)'))
: checked
? (darkMode ? 'rgba(255,255,255,0.045)' : 'rgba(255,255,255,0.62)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(16,24,40,0.025)'),
cursor: 'pointer',
transition: 'border-color 140ms ease, box-shadow 140ms ease, background 140ms ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
width: 22,
height: 22,
borderRadius: 999,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontFamily: resolvedMonoFontFamily,
fontSize: 11,
fontWeight: 800,
background: isFocused
? (isV2Ui ? v2AntPrimaryBgHoverColor : (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.14)'))
: (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(16,24,40,0.05)'),
color: isFocused
? (isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff'))
: (darkMode ? 'rgba(255,255,255,0.56)' : 'rgba(16,24,40,0.5)'),
}}>
{checked && indexInRow >= 0 ? indexInRow + 1 : '-'}
</span>
<Switch
size="small"
checked={checked}
onClick={(_, event) => event.stopPropagation()}
onChange={(nextChecked) => updateTabDisplayElementVisibility(key, nextChecked)}
/>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ fontWeight: 600 }}>{getTabDisplayElementLabel(key)}</span>
{isFocused ? (
<span style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 6px',
borderRadius: 999,
background: isV2Ui ? v2AntPrimaryBgColor : (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.10)'),
color: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff'),
}}>
{t('app.theme.tab_display.badge.current')}
</span>
) : null}
{checked && tabDisplaySettings.layout === 'double' ? (
<span style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 6px',
borderRadius: 999,
background: row === 'secondary'
? (darkMode ? 'rgba(56,189,248,0.14)' : 'rgba(2,132,199,0.08)')
: (darkMode ? 'rgba(34,197,94,0.14)' : 'rgba(22,163,74,0.08)'),
color: row === 'secondary'
? (darkMode ? '#7dd3fc' : '#0369a1')
: (darkMode ? '#86efac' : '#15803d'),
}}>
{row === 'secondary'
? t('app.theme.tab_display.row.secondary')
: t('app.theme.tab_display.row.primary')}
</span>
) : null}
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 2 }}>{getTabDisplayElementDescription(key)}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{tabDisplaySettings.layout === 'double' && checked ? (
<Segmented
size="small"
options={[
{ label: t('app.theme.tab_display.row.primary'), value: 'primary' },
{ label: t('app.theme.tab_display.row.secondary'), value: 'secondary' },
]}
value={row}
onChange={(value) => setTabDisplayElementRow(key, value as 'primary' | 'secondary')}
onClick={(event) => event.stopPropagation()}
/>
) : null}
<Button
size="small"
disabled={!canMoveUp}
onClick={(event) => {
event.stopPropagation();
moveTabDisplayElement(key, -1);
}}
>
{t('app.theme.tab_display.action.move_up')}
</Button>
<Button
size="small"
disabled={!canMoveDown}
onClick={(event) => {
event.stopPropagation();
moveTabDisplayElement(key, 1);
}}
>
{t('app.theme.tab_display.action.move_down')}
</Button>
</div>
</div>
);
})}
</div>
<div className="gonavi-settings-inline-meta">
{t('app.theme.tab_display.preview.prefix')}
{tabDisplaySettings.layout === 'double' ? `${t('app.theme.tab_display.row.primary')} ` : ''}
{tabDisplaySettings.primaryElements.map(getTabDisplayElementLabel).join(' / ') || t('app.theme.tab_display.preview.default_label')}
{tabDisplaySettings.layout === 'double' && tabDisplaySettings.secondaryElements.length > 0
? t('app.theme.tab_display.preview.secondary', {
labels: tabDisplaySettings.secondaryElements.map(getTabDisplayElementLabel).join(' / '),
})
: ''}
{focusedTabDisplayElementKey
? t('app.theme.tab_display.preview.focused', {
label: getTabDisplayElementLabel(focusedTabDisplayElementKey),
})
: ''}
</div>
</section>
{renderThemeSettingsSection(
t('app.theme.data_table.title'),
<>
{renderThemeSettingsRow({
label: t('app.theme.data_table.vertical_borders'),
hint: t('app.theme.data_table.vertical_borders_hint'),
control: (
<Switch
checked={appearance.showDataTableVerticalBorders === true}
onChange={(checked) => setAppearance({ showDataTableVerticalBorders: checked })}
/>
),
})}
{renderThemeSettingsRow({
label: t('app.theme.data_table.table_double_click_action'),
hint: t('app.theme.data_table.table_double_click_action_hint'),
stacked: true,
control: (
<Segmented
className="gonavi-settings-segmented-choice"
block
options={[
{ label: t('app.theme.data_table.table_double_click_action.open_data'), value: 'open-data' },
{ label: t('app.theme.data_table.table_double_click_action.open_design'), value: 'open-design' },
]}
value={tableDoubleClickAction}
onChange={(value) => setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })}
/>
),
})}
{renderThemeSettingsRow({
label: t('app.theme.data_table.density'),
hint: t('app.theme.data_table.density_hint'),
stacked: true,
control: (
<Segmented
className="gonavi-settings-segmented-choice"
block
options={DENSITY_OPTIONS.map((option) => ({
...option,
label: t(`app.theme.data_table.density.${option.value}`),
}))}
value={appearance.dataTableDensity}
onChange={(value) => setAppearance({ dataTableDensity: sanitizeDataTableDensity(value) })}
/>
),
})}
{renderThemeSettingsRow({
label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{t('app.theme.data_table.font_size')}</span>
<Button
size="small"
type={dataTableFontSizeFollowsGlobal ? 'primary' : 'default'}
onClick={() => setAppearance({
dataTableFontSizeFollowGlobal: !dataTableFontSizeFollowsGlobal,
dataTableFontSize: dataTableFontSizeFollowsGlobal
? sanitizeDataTableFontSize(appearance.dataTableFontSize)
: null,
})}
>
{t('app.theme.data_table.follow_global')}
</Button>
</span>
),
stacked: true,
control: (
<ThemeSettingsSlider
min={10}
max={18}
step={1}
marks={DATA_TABLE_FONT_SLIDER_MARKS}
disabled={dataTableFontSizeFollowsGlobal}
value={effectiveDataTableFontSize}
unit="px"
onChange={(value) => setAppearance({
dataTableFontSize: sanitizeDataTableFontSize(value),
dataTableFontSizeFollowGlobal: false,
})}
/>
),
})}
{renderThemeSettingsRow({
label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{t('app.theme.data_table.sidebar_tree_font_size')}</span>
<Button
size="small"
type={sidebarTreeFontSizeFollowsGlobal ? 'primary' : 'default'}
onClick={() => setAppearance({
sidebarTreeFontSizeFollowGlobal: !sidebarTreeFontSizeFollowsGlobal,
sidebarTreeFontSize: sidebarTreeFontSizeFollowsGlobal
? sanitizeSidebarTreeFontSize(appearance.sidebarTreeFontSize)
: null,
})}
>
{t('app.theme.data_table.follow_global')}
</Button>
</span>
),
stacked: true,
control: (
<ThemeSettingsSlider
min={10}
max={18}
step={1}
marks={DATA_TABLE_FONT_SLIDER_MARKS}
disabled={sidebarTreeFontSizeFollowsGlobal}
value={effectiveSidebarTreeFontSize}
unit="px"
onChange={(value) => setAppearance({
sidebarTreeFontSize: sanitizeSidebarTreeFontSize(value),
sidebarTreeFontSizeFollowGlobal: false,
})}
/>
),
})}
</>,
)}
{isMacRuntime ? renderThemeSettingsSection(
t('app.theme.mac_window.title'),
renderThemeSettingsRow({
label: t('app.theme.mac_window.use_native_controls'),
hint: t('app.theme.mac_window.restart_hint'),
control: (
<Switch
checked={appearance.useNativeMacWindowControls === true}
onChange={(checked) => setAppearance({ useNativeMacWindowControls: checked })}
/>
),
}),
t('app.theme.mac_window.use_native_controls_hint'),
) : null}
{renderThemeSettingsSection(
t('app.theme.startup_window.title'),
renderThemeSettingsRow({
label: isWindowsRuntime
? t('app.theme.startup_window.fullscreen_windows')
: t('app.theme.startup_window.fullscreen'),
hint: isWindowsRuntime
? t('app.theme.startup_window.windows_hint')
: t('app.theme.startup_window.hint'),
control: (
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
),
}),
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 12 }}>
<Button
onClick={() => {
setUiScale(DEFAULT_UI_SCALE);
setFontSize(DEFAULT_FONT_SIZE);
setAppearance({ ...DEFAULT_APPEARANCE });
}}
>
{t('app.theme.action.restore_defaults')}
</Button>
</div>
</div>
)}
</div>
</div>
);
const renderThemeSettingsContentLegacy = () => (
<div style={{ display: 'grid', gridTemplateColumns: '180px minmax(0, 1fr)', gap: 16, padding: '12px 0', height: '100%', minHeight: 0, overflow: 'hidden', alignItems: 'stretch', boxSizing: 'border-box' }}>
<div style={{ ...utilityPanelStyle, padding: 12, height: 'fit-content' }}>
<div style={{ marginBottom: 12, fontWeight: 600 }}>{t('app.theme.navigation_title')}</div>
<div style={{ display: 'grid', gap: 10 }}>
{[
{ key: 'theme', title: t('app.theme.nav.theme.title'), description: t('app.theme.nav.theme.description'), icon: <SkinOutlined /> },
{ key: 'appearance', title: t('app.theme.nav.appearance.title'), description: t('app.theme.nav.appearance.description'), icon: <BgColorsOutlined /> },
].map((item) => {
const active = themeModalSection === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setThemeModalSection(item.key as 'theme' | 'appearance')}
style={{
textAlign: 'left',
padding: '12px 12px',
borderRadius: 12,
border: `1px solid ${active
? (isV2Ui
? v2AntPrimaryBorderColor
: (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)'))
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (isV2Ui
? `linear-gradient(180deg, ${v2AntPrimaryBgHoverColor} 0%, ${v2AntPrimaryBgColor} 100%)`
: (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)'))
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span>{item.icon}</span>
<span style={{ fontWeight: 700 }}>{item.title}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : utilityMutedTextStyle.color }}>
{item.description}
</div>
</button>
);
})}
</div>
</div>
<div style={{ minWidth: 0, minHeight: 0, height: '100%', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain', paddingRight: 8, paddingBottom: 28 }}>
{themeModalSection === 'theme' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8 }}>
<span>{t('app.theme.ui_version.title')}</span>
<span style={{
fontSize: 10,
fontWeight: 700,
padding: '1px 6px',
background: darkMode ? 'rgba(56,189,248,0.18)' : 'rgba(2,132,199,0.10)',
color: darkMode ? '#7dd3fc' : '#0284c7',
borderRadius: 4,
}}>
{t('app.theme.ui_version.badge.new')}
</span>
</div>
<div style={{ ...utilityMutedTextStyle, marginBottom: 12 }}>
{t('app.theme.ui_version.description')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }}>
{[
{ key: 'legacy', label: t('app.theme.ui_version.legacy.label'), description: t('app.theme.ui_version.legacy.description'), badge: t('app.theme.ui_version.legacy.badge') },
{ key: 'v2', label: t('app.theme.ui_version.v2.label'), description: t('app.theme.ui_version.v2.description'), badge: t('app.theme.ui_version.v2.badge') },
].map((item) => {
const active = (appearance.uiVersion ?? 'legacy') === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}
style={{
textAlign: 'left',
padding: '14px 14px',
borderRadius: 14,
border: `1px solid ${active
? (darkMode ? 'rgba(34,197,94,0.36)' : 'rgba(22,163,74,0.32)')
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (darkMode ? 'linear-gradient(180deg, rgba(34,197,94,0.14) 0%, rgba(34,197,94,0.06) 100%)' : 'linear-gradient(180deg, rgba(22,163,74,0.10) 0%, rgba(22,163,74,0.05) 100%)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.label}</span>
<span style={{
fontSize: 10,
fontWeight: 600,
padding: '1px 6px',
background: item.key === 'v2'
? (darkMode ? 'rgba(56,189,248,0.18)' : 'rgba(2,132,199,0.10)')
: (darkMode ? 'rgba(255,255,255,0.10)' : 'rgba(16,24,40,0.06)'),
color: item.key === 'v2'
? (darkMode ? '#7dd3fc' : '#0284c7')
: (darkMode ? 'rgba(255,255,255,0.7)' : 'rgba(16,24,40,0.6)'),
borderRadius: 4,
}}>
{item.badge}
</span>
</span>
{active ? <CheckOutlined style={{ color: darkMode ? '#4ade80' : '#16a34a' }} /> : null}
</div>
<div style={{
marginTop: 6,
fontSize: 12,
lineHeight: 1.6,
color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : utilityMutedTextStyle.color,
}}>
{item.description}
</div>
</button>
);
})}
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
{t('app.theme.ui_version.platform_hint')}
</div>
{appearance.uiVersion === 'v2' && (
<div style={{
marginTop: 10,
padding: '8px 10px',
background: darkMode ? 'rgba(245,158,11,0.10)' : 'rgba(245,158,11,0.08)',
border: `1px solid ${darkMode ? 'rgba(245,158,11,0.24)' : 'rgba(245,158,11,0.22)'}`,
borderRadius: 8,
fontSize: 11.5,
color: darkMode ? 'rgba(252,211,77,0.92)' : 'rgba(120,53,15,0.85)',
lineHeight: 1.55,
}}>
{t('app.theme.ui_version.beta_warning')}
</div>
)}
{appearance.uiVersion === 'v2' && (
<div style={{ marginTop: 14 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.ui_version.sidebar_search.title')}</div>
<Segmented
block
options={[
{ label: t('app.theme.ui_version.sidebar_search.command'), value: 'command' },
{ label: t('app.theme.ui_version.sidebar_search.filter'), value: 'filter' },
]}
value={appearance.v2SidebarSearchMode ?? 'command'}
onChange={(value) => setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
{t('app.theme.ui_version.sidebar_search.hint')}
</div>
</div>
)}
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}>{t('app.theme.mode_title')}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
{[
{ key: 'light', label: t('app.theme.mode.light.label'), description: t('app.theme.mode.light.description') },
{ key: 'dark', label: t('app.theme.mode.dark.label'), description: t('app.theme.mode.dark.description') },
{ key: 'system', label: t('app.theme.mode.system.label'), description: t('app.theme.mode.system.description') },
].map((item) => {
const active = themePreference === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setThemePreference(item.key as 'light' | 'dark' | 'system')}
style={{
textAlign: 'left',
padding: '14px 14px',
borderRadius: 14,
border: `1px solid ${active
? (isV2Ui
? v2AntPrimaryBorderColor
: (darkMode ? 'rgba(255,214,102,0.3)' : 'rgba(24,144,255,0.24)'))
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
background: active
? (isV2Ui
? `linear-gradient(180deg, ${v2AntPrimaryBgHoverColor} 0%, ${v2AntPrimaryBgColor} 100%)`
: (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.12) 0%, rgba(255,214,102,0.06) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.10) 0%, rgba(24,144,255,0.05) 100%)'))
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>{item.label}</span>
{active ? <CheckOutlined style={{ color: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff') }} /> : null}
</div>
<div style={{ marginTop: 6, fontSize: 12, lineHeight: 1.6, color: active ? (darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(22,32,51,0.68)') : utilityMutedTextStyle.color }}>
{item.description}
</div>
</button>
);
})}
</div>
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.appearance.ui_scale_title')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={MIN_UI_SCALE}
max={MAX_UI_SCALE}
step={0.05}
value={effectiveUiScale}
onChange={(v) => setUiScale(Number(v))}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{Math.round(effectiveUiScale * 100)}%</span>
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
{t('app.theme.appearance.ui_scale_hint')}
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.appearance.font_size_title')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step={1}
value={effectiveFontSize}
onChange={(v) => setFontSize(Number(v))}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{effectiveFontSize}px</span>
</div>
</div>
{appearance.uiVersion === 'v2' && (
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.appearance.sidebar_rail_scale_title')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={MIN_V2_SIDEBAR_RAIL_SCALE}
max={MAX_V2_SIDEBAR_RAIL_SCALE}
step={0.05}
value={effectiveSidebarRailScale}
onChange={(value) => setAppearance({
v2SidebarRailScale: sanitizeV2SidebarRailScale(value),
})}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{Math.round(effectiveSidebarRailScale * 100)}%</span>
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>
{t('app.theme.appearance.sidebar_rail_scale_hint')}
</div>
</div>
)}
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 500 }}>{t('app.theme.font_family.title')}</div>
<div style={{ display: 'grid', gap: 14 }}>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.font_family.ui_title')}</div>
<Select
allowClear
showSearch
optionFilterProp="label"
loading={isFontFamiliesLoading}
placeholder={DEFAULT_UI_FONT_FAMILY}
value={appearance.customUIFontFamily ?? undefined}
onChange={(value) => setAppearance({
customUIFontFamily: sanitizeFontFamilyInput(value),
})}
onClear={() => setAppearance({ customUIFontFamily: null })}
options={uiFontOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
filterOption={filterFontOption}
popupMatchSelectWidth
style={{ width: '100%' }}
optionRender={(option) => renderFontOptionLabel({
value: String(option.data.value),
label: String(option.data.label),
})}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 6 }}>
{fontFamiliesLoadError
? t('app.theme.font_family.load_failed_fallback', { error: fontFamiliesLoadError })
: (installedFontFamilies.length > 0
? t('app.theme.font_family.loaded_ui_hint', { count: installedFontFamilies.length })
: t('app.theme.font_family.loading_ui_hint'))}
</div>
{linuxCJKFontInstallHint && hasLoadedInstalledFontsRef.current && !isFontFamiliesLoading && !fontFamiliesLoadError && (
<div
style={{
marginTop: 8,
padding: '9px 10px',
borderRadius: 8,
border: darkMode ? '1px solid rgba(250,204,21,0.28)' : '1px solid rgba(217,119,6,0.22)',
background: darkMode ? 'rgba(250,204,21,0.08)' : 'rgba(251,191,36,0.12)',
color: darkMode ? 'rgba(254,249,195,0.92)' : '#92400e',
fontSize: 12,
lineHeight: 1.7,
}}
>
{t('app.theme.font_family.linux_cjk_install_prefix')}
<span style={{ fontFamily: 'var(--gn-font-mono)', marginLeft: 6 }}>{linuxCJKFontInstallHint}</span>
{t('app.theme.font_family.linux_cjk_install_suffix')}
</div>
)}
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.font_family.mono_title')}</div>
<Select
allowClear
showSearch
optionFilterProp="label"
loading={isFontFamiliesLoading}
placeholder={DEFAULT_MONO_FONT_FAMILY}
value={appearance.customMonoFontFamily ?? undefined}
onChange={(value) => setAppearance({
customMonoFontFamily: sanitizeFontFamilyInput(value),
})}
onClear={() => setAppearance({ customMonoFontFamily: null })}
options={monoFontOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
filterOption={filterFontOption}
popupMatchSelectWidth
style={{ width: '100%' }}
optionRender={(option) => renderFontOptionLabel({
value: String(option.data.value),
label: String(option.data.label),
})}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 6 }}>
{fontFamiliesLoadError
? t('app.theme.font_family.mono_fallback_hint')
: t('app.theme.font_family.mono_hint')}
</div>
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 500 }}>{t('app.theme.query_template.title')}</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={utilityMutedTextStyle}>
{t('app.theme.query_template.description')}
</div>
<Input.TextArea
value={newQuerySqlTemplate}
autoSize={{ minRows: 3, maxRows: 8 }}
spellCheck={false}
onChange={(event) => setAppearance({ newQuerySqlTemplate: event.target.value })}
style={{ fontFamily: 'var(--gn-font-mono)' }}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={utilityMutedTextStyle}>
{t('app.theme.query_template.hint')}
</div>
<Button
size="small"
disabled={appearance.newQuerySqlTemplate === null}
onClick={() => setAppearance({ newQuerySqlTemplate: null })}
>
{t('app.theme.query_template.reset_default')}
</Button>
</div>
</div>
</div>
<div ref={tabDisplaySettingsPanelRef} style={utilityPanelStyle}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{t('app.theme.tab_display.title')}</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>
{t('app.theme.tab_display.description')}
</div>
</div>
<Segmented
size="small"
options={[
{ label: t('app.theme.tab_display.layout.single'), value: 'single' },
{ label: t('app.theme.tab_display.layout.double'), value: 'double' },
]}
value={tabDisplaySettings.layout}
onChange={(value) => setTabDisplayLayout(value as TabDisplayLayout)}
/>
</div>
<div style={{ display: 'grid', gap: 8 }}>
{tabDisplayElementOrder.map((key) => {
const checked = visibleTabDisplayElementKeys.has(key);
const row = tabDisplaySettings.secondaryElements.includes(key) ? 'secondary' : 'primary';
const currentRowElements = row === 'secondary'
? tabDisplaySettings.secondaryElements
: tabDisplaySettings.primaryElements;
const indexInRow = currentRowElements.indexOf(key);
const canMoveUp = checked && indexInRow > 0;
const canMoveDown = checked && indexInRow >= 0 && indexInRow < currentRowElements.length - 1;
const isFocused = focusedTabDisplayElementKey === key;
return (
<div
key={key}
role="button"
tabIndex={0}
onClick={() => setFocusedTabDisplayElementKey(key)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setFocusedTabDisplayElementKey(key);
}
}}
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) auto',
gap: 10,
alignItems: 'center',
padding: '9px 10px',
borderRadius: 10,
border: `1px solid ${isFocused
? (isV2Ui
? v2AntPrimaryBorderHoverColor
: (darkMode ? 'rgba(255,214,102,0.54)' : 'rgba(24,144,255,0.54)'))
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)')}`,
boxShadow: isFocused
? (isV2Ui
? `0 0 0 2px ${v2AntControlActiveBg}`
: (darkMode ? '0 0 0 2px rgba(255,214,102,0.14)' : '0 0 0 2px rgba(24,144,255,0.12)'))
: 'none',
background: isFocused
? (isV2Ui
? `linear-gradient(90deg, ${v2AntPrimaryBgHoverColor} 0%, rgba(255,255,255,0.045) 100%)`
: (darkMode ? 'linear-gradient(90deg, rgba(255,214,102,0.12) 0%, rgba(255,255,255,0.045) 100%)' : 'linear-gradient(90deg, rgba(24,144,255,0.10) 0%, rgba(255,255,255,0.78) 100%)'))
: checked
? (darkMode ? 'rgba(255,255,255,0.045)' : 'rgba(255,255,255,0.62)')
: (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(16,24,40,0.025)'),
cursor: 'pointer',
transition: 'border-color 140ms ease, box-shadow 140ms ease, background 140ms ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
width: 22,
height: 22,
borderRadius: 999,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontFamily: resolvedMonoFontFamily,
fontSize: 11,
fontWeight: 800,
background: isFocused
? (isV2Ui ? v2AntPrimaryBgHoverColor : (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.14)'))
: (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(16,24,40,0.05)'),
color: isFocused
? (isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff'))
: (darkMode ? 'rgba(255,255,255,0.56)' : 'rgba(16,24,40,0.5)'),
}}>
{checked && indexInRow >= 0 ? indexInRow + 1 : '-'}
</span>
<Switch
size="small"
checked={checked}
onClick={(_, event) => event.stopPropagation()}
onChange={(nextChecked) => updateTabDisplayElementVisibility(key, nextChecked)}
/>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ fontWeight: 600 }}>{getTabDisplayElementLabel(key)}</span>
{isFocused ? (
<span style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 6px',
borderRadius: 999,
background: isV2Ui ? v2AntPrimaryBgColor : (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.10)'),
color: isV2Ui ? v2AntPrimaryColor : (darkMode ? '#ffd666' : '#1677ff'),
}}>
{t('app.theme.tab_display.badge.current')}
</span>
) : null}
{checked && tabDisplaySettings.layout === 'double' ? (
<span style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 6px',
borderRadius: 999,
background: row === 'secondary'
? (darkMode ? 'rgba(56,189,248,0.14)' : 'rgba(2,132,199,0.08)')
: (darkMode ? 'rgba(34,197,94,0.14)' : 'rgba(22,163,74,0.08)'),
color: row === 'secondary'
? (darkMode ? '#7dd3fc' : '#0369a1')
: (darkMode ? '#86efac' : '#15803d'),
}}>
{row === 'secondary'
? t('app.theme.tab_display.row.secondary')
: t('app.theme.tab_display.row.primary')}
</span>
) : null}
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 2 }}>{getTabDisplayElementDescription(key)}</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{tabDisplaySettings.layout === 'double' && checked ? (
<Segmented
size="small"
options={[
{ label: t('app.theme.tab_display.row.primary'), value: 'primary' },
{ label: t('app.theme.tab_display.row.secondary'), value: 'secondary' },
]}
value={row}
onChange={(value) => setTabDisplayElementRow(key, value as 'primary' | 'secondary')}
onClick={(event) => event.stopPropagation()}
/>
) : null}
<Button
size="small"
disabled={!canMoveUp}
onClick={(event) => {
event.stopPropagation();
moveTabDisplayElement(key, -1);
}}
>
{t('app.theme.tab_display.action.move_up')}
</Button>
<Button
size="small"
disabled={!canMoveDown}
onClick={(event) => {
event.stopPropagation();
moveTabDisplayElement(key, 1);
}}
>
{t('app.theme.tab_display.action.move_down')}
</Button>
</div>
</div>
);
})}
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
{t('app.theme.tab_display.preview.prefix')}
{tabDisplaySettings.layout === 'double' ? `${t('app.theme.tab_display.row.primary')} ` : ''}
{tabDisplaySettings.primaryElements.map(getTabDisplayElementLabel).join(' / ') || t('app.theme.tab_display.preview.default_label')}
{tabDisplaySettings.layout === 'double' && tabDisplaySettings.secondaryElements.length > 0
? t('app.theme.tab_display.preview.secondary', {
labels: tabDisplaySettings.secondaryElements.map(getTabDisplayElementLabel).join(' / '),
})
: ''}
{focusedTabDisplayElementKey
? t('app.theme.tab_display.preview.focused', {
label: getTabDisplayElementLabel(focusedTabDisplayElementKey),
})
: ''}
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 500 }}>{t('app.theme.appearance.transparency_blur_title')}</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div>
<div style={{ fontWeight: 500 }}>{t('app.theme.appearance.enable_transparency_blur')}</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>{t('app.theme.appearance.enable_transparency_blur_hint')}</div>
</div>
<Switch checked={appearance.enabled !== false} onChange={(checked) => setAppearance({ enabled: checked })} />
</div>
<div style={{ display: 'grid', gap: 14, opacity: appearance.enabled !== false ? 1 : 0.6 }}>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.appearance.opacity_title')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={0.1}
max={1.0}
step={0.05}
disabled={appearance.enabled === false}
value={appearance.opacity ?? 1.0}
onChange={(v) => setAppearance({ opacity: v })}
style={{ flex: 1 }}
/>
<span style={{ width: 40 }}>{Math.round((appearance.opacity ?? 1.0) * 100)}%</span>
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.appearance.blur_title')}</div>
{isWindowsPlatform() ? (
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
{t('app.theme.appearance.windows_acrylic_hint')}
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={0}
max={20}
disabled={appearance.enabled === false}
value={appearance.blur ?? 0}
onChange={(v) => setAppearance({ blur: v })}
style={{ flex: 1 }}
/>
<span style={{ width: 40 }}>{appearance.blur}px</span>
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
{t('app.theme.appearance.blur_hint')}
</div>
</>
)}
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 500 }}>{t('app.theme.data_table.title')}</div>
<div style={{ display: 'grid', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontWeight: 500 }}>{t('app.theme.data_table.vertical_borders')}</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>{t('app.theme.data_table.vertical_borders_hint')}</div>
</div>
<Switch
checked={appearance.showDataTableVerticalBorders === true}
onChange={(checked) => setAppearance({ showDataTableVerticalBorders: checked })}
/>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.data_table.table_double_click_action')}</div>
<Segmented
block
options={[
{ label: t('app.theme.data_table.table_double_click_action.open_data'), value: 'open-data' },
{ label: t('app.theme.data_table.table_double_click_action.open_design'), value: 'open-design' },
]}
value={tableDoubleClickAction}
onChange={(value) => setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
{t('app.theme.data_table.table_double_click_action_hint')}
</div>
</div>
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.data_table.density')}</div>
<Segmented
block
options={DENSITY_OPTIONS.map((option) => ({
...option,
label: t(`app.theme.data_table.density.${option.value}`),
}))}
value={appearance.dataTableDensity}
onChange={(value) => setAppearance({ dataTableDensity: sanitizeDataTableDensity(value) })}
/>
<div style={{ ...utilityMutedTextStyle, marginTop: 8 }}>
{t('app.theme.data_table.density_hint')}
</div>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ fontWeight: 500 }}>{t('app.theme.data_table.font_size')}</div>
<Button
size="small"
type={dataTableFontSizeFollowsGlobal ? 'primary' : 'default'}
onClick={() => setAppearance({
dataTableFontSizeFollowGlobal: !dataTableFontSizeFollowsGlobal,
dataTableFontSize: dataTableFontSizeFollowsGlobal
? sanitizeDataTableFontSize(appearance.dataTableFontSize)
: null,
})}
>
{t('app.theme.data_table.follow_global')}
</Button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={10}
max={18}
step={1}
disabled={dataTableFontSizeFollowsGlobal}
value={effectiveDataTableFontSize}
onChange={(value) => setAppearance({
dataTableFontSize: sanitizeDataTableFontSize(value),
dataTableFontSizeFollowGlobal: false,
})}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{effectiveDataTableFontSize}px</span>
</div>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
<div style={{ fontWeight: 500 }}>{t('app.theme.data_table.sidebar_tree_font_size')}</div>
<Button
size="small"
type={sidebarTreeFontSizeFollowsGlobal ? 'primary' : 'default'}
onClick={() => setAppearance({
sidebarTreeFontSizeFollowGlobal: !sidebarTreeFontSizeFollowsGlobal,
sidebarTreeFontSize: sidebarTreeFontSizeFollowsGlobal
? sanitizeSidebarTreeFontSize(appearance.sidebarTreeFontSize)
: null,
})}
>
{t('app.theme.data_table.follow_global')}
</Button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Slider
min={10}
max={18}
step={1}
disabled={sidebarTreeFontSizeFollowsGlobal}
value={effectiveSidebarTreeFontSize}
onChange={(value) => setAppearance({
sidebarTreeFontSize: sanitizeSidebarTreeFontSize(value),
sidebarTreeFontSizeFollowGlobal: false,
})}
style={{ flex: 1 }}
/>
<span style={{ width: 56 }}>{effectiveSidebarTreeFontSize}px</span>
</div>
</div>
</div>
</div>
{isMacRuntime ? (
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.mac_window.title')}</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontWeight: 500 }}>{t('app.theme.mac_window.use_native_controls')}</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>{t('app.theme.mac_window.use_native_controls_hint')}</div>
</div>
<Switch
checked={appearance.useNativeMacWindowControls === true}
onChange={(checked) => setAppearance({ useNativeMacWindowControls: checked })}
/>
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 8 }}>
{t('app.theme.mac_window.restart_hint')}
</div>
</div>
) : null}
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('app.theme.startup_window.title')}</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span>{isWindowsRuntime ? t('app.theme.startup_window.fullscreen_windows') : t('app.theme.startup_window.fullscreen')}</span>
<Switch checked={startupFullscreen} onChange={(checked) => setStartupFullscreen(checked)} />
</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', marginTop: 4 }}>
{isWindowsRuntime ? t('app.theme.startup_window.windows_hint') : t('app.theme.startup_window.hint')}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, paddingTop: 8, paddingBottom: 12 }}>
<Button
onClick={() => {
setUiScale(DEFAULT_UI_SCALE);
setFontSize(DEFAULT_FONT_SIZE);
setAppearance({ ...DEFAULT_APPEARANCE });
}}
>
{t('app.theme.action.restore_defaults')}
</Button>
</div>
</div>
)}
</div>
</div>
);
const renderThemeSettingsContent = () => (
isV2Ui ? renderThemeSettingsContentV2() : renderThemeSettingsContentLegacy()
);
const settingsCenterGroups = [
{
key: 'preferences' as const,
icon: <SettingOutlined />,
title: t('app.settings.group.preferences.title'),
description: t('app.settings.group.preferences.description'),
items: [
{
key: 'language',
icon: <GlobalOutlined />,
title: t('settings.language.title'),
description: t('settings.language.description'),
onClick: () => handleOpenSettingsCenterPane('preferences', 'language'),
},
{
key: 'theme',
icon: <SkinOutlined />,
title: t('app.settings.entry.theme.title'),
description: t('app.settings.entry.theme.description'),
onClick: () => {
setThemeModalSection('theme');
handleOpenSettingsCenterPane('preferences', 'theme');
},
},
{
key: 'sidebar-metadata',
icon: <TableOutlined />,
title: t('app.settings.sidebar_metadata.title'),
description: t('app.settings.sidebar_metadata.description'),
onClick: () => handleOpenSettingsCenterPane('preferences', 'sidebar-metadata'),
},
],
},
{
key: 'services' as const,
icon: <GlobalOutlined />,
title: t('app.settings.group.services.title'),
description: t('app.settings.group.services.description'),
items: [
{
key: 'proxy',
icon: <GlobalOutlined />,
title: t('app.settings.entry.proxy.title'),
description: t('app.settings.entry.proxy.description'),
onClick: () => handleOpenSettingsCenterPane('services', 'proxy'),
},
...(isWebRuntime ? [{
key: 'web-auth' as const,
icon: <SafetyCertificateOutlined />,
title: t('app.settings.entry.web_auth.title'),
description: t('app.settings.entry.web_auth.description'),
onClick: () => handleOpenSettingsCenterPane('services', 'web-auth'),
}] : []),
{
key: 'ai',
icon: <RobotOutlined />,
title: t('app.settings.entry.ai.title'),
description: t('app.settings.entry.ai.description'),
onClick: () => {
setSecurityUpdateRepairSource(null);
setFocusedAIProviderId(undefined);
handleOpenSettingsCenterPane('services', 'ai');
},
},
],
},
{
key: 'about' as const,
icon: <InfoCircleOutlined />,
title: t('app.settings.group.about.title'),
description: t('app.settings.group.about.description'),
items: [
{
key: 'about-go-navi',
icon: <InfoCircleOutlined />,
title: t('app.settings.entry.about.title'),
description: t('app.settings.entry.about.description'),
onClick: () => handleOpenSettingsCenterPane('about', 'about-go-navi'),
},
],
},
];
const activeSettingsCenterGroup = settingsCenterGroups.find((group) => group.key === activeSettingsCenterGroupKey) ?? settingsCenterGroups[0];
const activeSettingsCenterPaneItem = activeSettingsCenterPane
? settingsCenterGroups
.find((group) => group.key === activeSettingsCenterPane.group)
?.items.find((item) => item.key === activeSettingsCenterPane.key)
: null;
const isSettingsCenterContainedScrollPane =
activeSettingsCenterPane?.key === 'theme' || activeSettingsCenterPane?.key === 'ai';
const isV2ThemeSettingsPane = isV2Ui && activeSettingsCenterPane?.key === 'theme';
const settingsCenterDetailBodyStyle: React.CSSProperties = isSettingsCenterContainedScrollPane
? {
...toolCenterDetailBodyStyle,
overflowY: 'hidden',
// v2 主题设置页含 Slider 手柄横向伸出hidden 会裁切贴边圆点
overflowX: isV2ThemeSettingsPane ? 'visible' : 'hidden',
paddingRight: isV2ThemeSettingsPane ? 8 : 0,
paddingLeft: isV2ThemeSettingsPane ? 4 : undefined,
}
: toolCenterDetailBodyStyle;
const renderSettingsCenterPane = () => {
if (!activeSettingsCenterPane) {
return null;
}
if (activeSettingsCenterPane.key === 'language') {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: '12px 0' }}>
<div style={utilityPanelStyle}>
<LanguageSettingsPanel />
</div>
</div>
);
}
if (activeSettingsCenterPane.key === 'theme') {
return (
<div style={{ height: '100%', minHeight: 0 }}>
{renderThemeSettingsContent()}
</div>
);
}
if (activeSettingsCenterPane.key === 'sidebar-metadata') {
return renderSidebarMetadataSettingsPane();
}
if (activeSettingsCenterPane.key === 'proxy') {
return renderProxySettingsContent();
}
if (activeSettingsCenterPane.key === 'web-auth') {
return (
<WebAuthSettingsPanel
darkMode={darkMode}
dividerColor={overlayTheme.divider}
mutedColor={String(utilityMutedTextStyle.color || overlayTheme.mutedText)}
titleColor={overlayTheme.titleText}
/>
);
}
if (activeSettingsCenterPane.key === 'ai') {
return (
<div style={{ height: '100%', minHeight: 0 }}>
<AISettingsContent
active={isSettingsModalOpen && activeSettingsCenterPane.key === 'ai'}
darkMode={darkMode}
overlayTheme={overlayTheme}
focusProviderId={focusedAIProviderId}
onBeforeExternalMCPUse={handlePrepareExternalMCPUse}
/>
</div>
);
}
if (activeSettingsCenterPane.key === 'about-go-navi') {
return (
renderSettingsCenterAboutPane()
);
}
return null;
};
return (
<ConfigProvider
locale={getAntdLocale(language)}
componentSize={appComponentSize}
theme={antdTheme}
>
<Layout style={{
height: '100vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
borderRadius: showLinuxResizeHandles ? 0 : 'var(--gonavi-border-radius)',
clipPath: showLinuxResizeHandles ? 'none' : 'inset(0 round var(--gonavi-border-radius))',
backdropFilter: blurFilter,
WebkitBackdropFilter: blurFilter,
}}>
{/* Custom Title Bar */}
<div
onDoubleClick={handleTitleBarDoubleClick}
style={{
height: titleBarHeight,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: bgMain,
borderBottom: 'none',
userSelect: 'none',
WebkitAppRegion: isWebRuntime ? 'no-drag' : 'drag',
'--wails-draggable': isWebRuntime ? 'no-drag' : 'drag',
paddingLeft: getMacNativeTitlebarPaddingLeft(effectiveUiScale, useNativeMacWindowControls),
paddingRight: getMacNativeTitlebarPaddingRight(effectiveUiScale, useNativeMacWindowControls),
fontSize: tokenFontSize
} as any}
>
<div style={{ display: 'flex', alignItems: 'center', gap: Math.max(6, Math.round(8 * effectiveUiScale)), fontWeight: 600, minWidth: 0 }}>
{/* Logo can be added here if available */}
GoNavi
</div>
{isWebRuntime ? (
<div
onDoubleClick={(e) => e.stopPropagation()}
style={{ display: 'flex', alignItems: 'center', gap: 8, WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
>
<Tooltip title="退出当前 Web 会话">
<Button
type="text"
icon={<PoweroffOutlined />}
style={{ height: '100%', borderRadius: 8, width: titleBarButtonWidth }}
onClick={() => { void handleWebLogout(); }}
/>
</Tooltip>
</div>
) : useNativeMacWindowControls ? (
<div style={{ minWidth: Math.max(40, Math.round(48 * effectiveUiScale)) }} />
) : (
<div
data-no-titlebar-toggle="true"
onDoubleClick={(e) => e.stopPropagation()}
style={{ display: 'flex', height: '100%', WebkitAppRegion: 'no-drag', '--wails-draggable': 'no-drag' } as any}
>
<Button
type="text"
icon={<MinusOutlined />}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={WindowMinimise}
/>
<Button
type="text"
icon={titleBarToggleIconKey === 'restore' ? <SwitcherOutlined /> : <BorderOutlined />}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={() => { void handleTitleBarWindowToggle(); }}
/>
<Button
type="text"
icon={<CloseOutlined />}
danger
className="titlebar-close-btn"
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={() => { void handleApplicationQuitRequest(); }}
/>
</div>
)}
</div>
{showLinuxCJKFontBanner && (
<LinuxCJKFontBanner
darkMode={darkMode}
installHint={linuxCJKFontInstallHint || ''}
onOpenFontSettings={() => {
setThemeModalSection('appearance');
setIsThemeModalOpen(true);
}}
onDismiss={() => setIsLinuxCJKFontBannerDismissed(true)}
/>
)}
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
<Sider
ref={siderRef}
width={sidebarWidth}
className={isV2Ui ? 'gn-v2-app-sider' : undefined}
style={{
borderRight: isV2Ui ? 'none' : '1px solid rgba(128,128,128,0.2)',
position: 'relative',
background: isV2Ui ? 'var(--gn-bg-panel-2)' : bgMain
}}
>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{!isV2Ui && (
<>
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${sidebarUtilityItems.length}, minmax(0, 1fr))`, gap: 8, width: '100%' }}>
{sidebarUtilityItems.map((item) => (
<Tooltip key={item.key} title={item.title}>
<Button type="text" icon={item.icon} style={utilityButtonStyle} onClick={item.onClick} />
</Tooltip>
))}
</div>
</div>
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: isSidebarCompact ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
<Button icon={<PlusOutlined />} onClick={handleCreateConnection} title={t('connection.new')} style={sidebarCreateConnectionActionStyle}>
{t('connection.new')}
</Button>
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title={t('query.new')} style={sidebarQueryActionStyle}>
{t('query.new')}
</Button>
</div>
</div>
</>
)}
<div style={{ flex: 1, overflow: 'hidden', paddingBottom: isV2Ui ? 0 : 58, paddingRight: sidebarResizeHandleWidth, position: 'relative' }}>
<div style={{ height: '100%', opacity: connectionWorkbenchState.ready ? 1 : 0.72, pointerEvents: connectionWorkbenchState.ready ? 'auto' : 'none' }}>
<Sidebar
onCreateConnection={handleCreateConnection}
onEditConnection={handleEditConnection}
onOpenTools={handleOpenToolsModal}
onOpenSettings={handleOpenSettingsModal}
onToggleAI={toggleAIPanel}
onToggleLogPanel={handleToggleLogPanel}
uiVersion={appearance.uiVersion}
onFocusCommandSearch={handleFocusSidebarSearch}
/>
</div>
{!connectionWorkbenchState.ready && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
background: darkMode ? 'rgba(7, 12, 20, 0.42)' : 'rgba(255, 255, 255, 0.58)',
backdropFilter: 'blur(4px)',
zIndex: 1,
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
borderRadius: 999,
background: darkMode ? 'rgba(15, 23, 36, 0.86)' : 'rgba(255, 255, 255, 0.94)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(22,32,51,0.08)',
boxShadow: darkMode ? '0 12px 24px rgba(0,0,0,0.26)' : '0 12px 24px rgba(15,23,42,0.08)',
color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
fontSize: 12,
fontWeight: 500,
}}
>
<Spin size="small" />
<span>{connectionWorkbenchState.message}</span>
</div>
</div>
)}
<div
onMouseDown={handleSidebarMouseDown}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
role="separator"
aria-orientation="vertical"
title={t('app.sidebar.resize_width')}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: sidebarResizeHandleWidth,
cursor: 'col-resize',
zIndex: 3,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
background: 'transparent',
}}
/>
</div>
{/* Floating SQL Log Toggle */}
{!isV2Ui && (
<div
style={{
position: 'absolute',
left: 10,
right: 14,
bottom: 10,
zIndex: 20,
pointerEvents: 'none'
}}
>
<Button
type={isLogPanelOpen ? "primary" : "text"}
icon={<BugOutlined />}
onClick={handleToggleLogPanel}
style={isLogPanelOpen ? {
width: '100%',
height: floatingLogButtonHeight,
borderRadius: 999,
boxShadow: floatingLogButtonShadow,
pointerEvents: 'auto'
} : {
width: '100%',
height: floatingLogButtonHeight,
borderRadius: 999,
border: `1px solid ${floatingLogButtonBorderColor}`,
color: floatingLogButtonTextColor,
background: floatingLogButtonBgColor,
boxShadow: floatingLogButtonShadow,
backdropFilter: blurFilter,
pointerEvents: 'auto'
}}
>
{t('app.sidebar.sql_execution_log')}
</Button>
</div>
)}
</div>
</Sider>
<Content style={{ background: bgContent, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
{securityUpdateEntryVisibility.showBanner && !isSecurityUpdateBannerDismissed && (
<SecurityUpdateBanner
status={securityUpdateStatus}
darkMode={darkMode}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
onStart={handleStartSecurityUpdate}
onRetry={handleRetrySecurityUpdate}
onRestart={handleRestartSecurityUpdate}
onOpenDetails={() => handleOpenSecurityUpdateSettings(
hasSecurityUpdateRecentResult(securityUpdateStatus) ? 'recent_result' : null,
)}
onDismiss={() => setIsSecurityUpdateBannerDismissed(true)}
/>
)}
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row', position: 'relative' }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? 'var(--gonavi-border-radius)' : 0, clipPath: isLogPanelOpen ? 'inset(0 round var(--gonavi-border-radius))' : 'none' }}>
<TabManager />
<FloatingWorkbenchWindows />
<FloatingQueryResultWindows />
</div>
{!isV2Ui && !aiPanelVisible && (
<>
{aiEntryPlacement === 'content-edge' && legacyAiEdgeHandleAttachment === 'content-shell' && (
<div style={legacyAiEdgeHandleDockStyle}>
{renderLegacyAIEdgeHandle()}
</div>
)}
</>
)}
{aiPanelVisible && !aiChatDetached && (
<div
className={aiPanelOverlayActive ? 'gn-v2-ai-panel-overlay' : undefined}
style={aiPanelOverlayActive
? { position: 'absolute', inset: 0, display: 'flex', justifyContent: 'flex-end', pointerEvents: 'none', zIndex: 14 }
: { position: 'relative', display: 'flex', flexShrink: 0, overflow: 'visible' }}
>
{aiPanelOverlayActive && (
<button
type="button"
className="gn-v2-ai-panel-backdrop"
aria-label={t('app.ai_panel.aria.close')}
onClick={() => setAIPanelVisible(false)}
style={{
position: 'absolute',
inset: 0,
border: 0,
padding: 0,
background: darkMode ? 'rgba(3, 7, 18, 0.26)' : 'rgba(248, 250, 252, 0.38)',
backdropFilter: 'blur(2px)',
pointerEvents: 'auto',
}}
/>
)}
<div
className={`gn-v2-ai-panel-dock${aiPanelOverlayActive ? ' is-overlay' : ''}`}
style={aiPanelOverlayActive
? {
position: 'relative',
display: 'flex',
height: '100%',
pointerEvents: 'auto',
zIndex: 1,
boxShadow: '0 18px 48px rgba(15, 23, 42, 0.18)',
}
: undefined}
>
{!isV2Ui && (
<>
{aiEntryPlacement === 'content-edge' && legacyAiEdgeHandleAttachment === 'panel-shell' && (
<div style={legacyAiEdgeHandleDockStyle}>
{renderLegacyAIEdgeHandle()}
</div>
)}
</>
)}
<AIPanelErrorBoundary
key={aiPanelRenderNonce}
onError={handleAIPanelRenderError}
fallback={(error) => (
<div
style={{
width: aiPanelRenderWidth,
minWidth: 0,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
background: bgContent,
color: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
}}
>
<div
style={{
width: '100%',
maxWidth: 360,
display: 'grid',
gap: 12,
padding: 18,
borderRadius: 16,
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(15,23,42,0.08)',
background: darkMode ? 'rgba(15,23,42,0.72)' : 'rgba(255,255,255,0.94)',
boxShadow: darkMode ? '0 16px 36px rgba(0,0,0,0.32)' : '0 16px 36px rgba(15,23,42,0.12)',
}}
>
<div style={{ fontSize: 15, fontWeight: 600 }}>{t('app.ai_panel.error.title')}</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: darkMode ? 'rgba(255,255,255,0.68)' : '#526075' }}>
{t('app.ai_panel.error.description')}
</div>
{error?.message && (
<div
style={{
fontSize: 12,
lineHeight: 1.5,
wordBreak: 'break-word',
padding: '10px 12px',
borderRadius: 10,
background: darkMode ? 'rgba(2,6,23,0.7)' : 'rgba(248,250,252,0.92)',
border: darkMode ? '1px solid rgba(148,163,184,0.18)' : '1px solid rgba(148,163,184,0.22)',
}}
>
{error.message}
</div>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<Button aria-label={t('app.ai_panel.aria.close')} onClick={() => setAIPanelVisible(false)}>{t('app.ai_panel.action.close')}</Button>
<Button type="primary" onClick={handleRetryAIPanelRender}>{t('app.ai_panel.action.reload')}</Button>
</div>
</div>
</div>
)}
>
<AIChatPanel
width={aiPanelRenderWidth}
darkMode={darkMode}
bgColor={bgContent}
presentation="dock"
onClose={() => setAIPanelVisible(false)}
onDetach={() => detachAIChatPanel()}
onOpenSettings={() => {
handleOpenAISettings();
}}
overlayTheme={overlayTheme}
/>
</AIPanelErrorBoundary>
</div>
</div>
)}
{aiPanelVisible && aiChatDetached && (
<FloatingAIChatWindow
darkMode={darkMode}
bgColor={bgContent}
overlayTheme={overlayTheme}
renderNonce={aiPanelRenderNonce}
onOpenSettings={() => handleOpenAISettings()}
onRenderError={handleAIPanelRenderError}
onRetryRender={handleRetryAIPanelRender}
/>
)}
</div>
{!isV2Ui && isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={handleCloseLogPanel}
onResizeStart={handleLogResizeStart}
/>
)}
</Content>
</Layout>
{isConnectionModalMounted && (
<ConnectionModal
open={isModalOpen}
onClose={handleCloseModal}
initialValues={editingConnection}
onOpenDriverManager={handleOpenDriverManagerFromConnection}
onSaved={handleConnectionSaved}
/>
)}
{isToolsModalOpen && (() => {
const toolCenterGroups = [
{
key: 'config',
icon: <SettingOutlined />,
title: t('app.tools.group.config.title'),
description: t('app.tools.group.config.description'),
items: [
{
key: 'import',
icon: <UploadOutlined />,
title: t('app.tools.entry.import.title'),
description: t('app.tools.entry.import.description'),
onClick: () => {
void handleImportConnections('config');
},
},
{
key: 'export',
icon: <DownloadOutlined />,
title: t('app.tools.entry.export.title'),
description: t('app.tools.entry.export.description'),
onClick: () => {
void handleExportConnections('config');
},
},
{
key: 'data-root',
icon: <HddOutlined />,
title: t('app.tools.entry.data_root.title'),
description: t('app.tools.entry.data_root.description'),
onClick: () => {
handleOpenToolCenterPane('config', 'data-root');
},
},
{
key: 'security-update',
icon: <SafetyCertificateOutlined />,
title: t('app.tools.entry.security_update.title'),
description: securityUpdateEntryVisibility.showDetailEntry || securityUpdateHasLegacySensitiveItems
? t('app.tools.entry.security_update.status_description', { status: securityUpdateStatusMeta.label })
: t('app.tools.entry.security_update.description'),
onClick: () => {
handleOpenToolCenterPane('config', 'security-update');
},
},
],
},
{
key: 'workflow',
icon: <SwitcherOutlined />,
title: t('app.tools.group.workflow.title'),
description: t('app.tools.group.workflow.description'),
items: [
{
key: 'schema-compare',
icon: <AppstoreOutlined />,
title: t('app.tools.entry.schema_compare.title'),
description: t('app.tools.entry.schema_compare.description'),
onClick: () => {
setSyncModalEntryMode('schemaCompare');
handleOpenToolCenterPane('workflow', 'schema-compare');
},
},
{
key: 'data-compare',
icon: <SwitcherOutlined />,
title: t('app.tools.entry.data_compare.title'),
description: t('app.tools.entry.data_compare.description'),
onClick: () => {
setSyncModalEntryMode('dataCompare');
handleOpenToolCenterPane('workflow', 'data-compare');
},
},
{
key: 'sync',
icon: <UploadOutlined rotate={90} />,
title: t('app.tools.entry.sync.title'),
description: t('app.tools.entry.sync.description'),
onClick: () => {
setSyncModalEntryMode('sync');
handleOpenToolCenterPane('workflow', 'sync');
},
},
],
},
{
key: 'workspace',
icon: <CodeOutlined />,
title: t('app.tools.group.workspace.title'),
description: t('app.tools.group.workspace.description'),
items: [
{
key: 'drivers',
icon: <SettingOutlined />,
title: t('app.tools.entry.drivers.title'),
description: t('app.tools.entry.drivers.description'),
onClick: () => {
handleOpenToolCenterPane('workspace', 'drivers');
},
},
{
key: 'snippet-settings',
icon: <CodeOutlined />,
title: t('app.tools.entry.snippets.title'),
description: t('app.tools.entry.snippets.description'),
onClick: () => {
handleOpenToolCenterPane('workspace', 'snippet-settings');
},
},
{
key: 'shortcut-settings',
icon: <LinkOutlined />,
title: t('app.tools.entry.shortcuts.title'),
description: t('app.tools.entry.shortcuts.description'),
onClick: () => {
handleOpenToolCenterPane('workspace', 'shortcut-settings');
},
},
],
},
] as const;
const filteredToolCenterGroups = toolCenterGroups.map((group) => ({
...group,
items: group.items.filter((item) => {
if (!isWebRuntime) {
return true;
}
return !['import', 'export', 'data-root'].includes(String(item.key || ''));
}),
})).filter((group) => group.items.length > 0);
const activeToolCenterGroup = filteredToolCenterGroups.find((group) => group.key === activeToolCenterGroupKey) ?? filteredToolCenterGroups[0];
const activeToolCenterPaneItem = activeToolCenterPane
? filteredToolCenterGroups
.find((group) => group.key === activeToolCenterPane.group)
?.items.find((item) => item.key === activeToolCenterPane.key)
: null;
if (!activeToolCenterGroup) {
return null;
}
const closeToolCenterPane = () => {
if (activeToolCenterPane?.key === 'connection-package') {
closeConnectionPackageDialog();
return;
}
setToolCenterBackGroupKey(null);
setActiveToolCenterPane(null);
};
const renderToolCenterPane = () => {
if (!activeToolCenterPane) {
return null;
}
if (activeToolCenterPane.key === 'connection-package') {
return (
<ConnectionPackagePasswordModal
embedded
open={connectionPackageDialog.open}
title={connectionPackageDialog.mode === 'export'
? t('app.connection_package.dialog.export_title')
: t('app.connection_package.dialog.import_password_title')}
mode={connectionPackageDialog.mode}
includeSecrets={connectionPackageDialog.includeSecrets}
useFilePassword={connectionPackageDialog.useFilePassword}
password={connectionPackageDialog.password}
error={connectionPackageDialog.error}
confirmLoading={connectionPackageDialog.confirmLoading}
confirmText={connectionPackageDialog.mode === 'export'
? t('app.connection_package.action.start_export')
: t('app.connection_package.action.start_import')}
cancelText={t('common.close')}
onBack={closeToolCenterPane}
onIncludeSecretsChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
includeSecrets: value,
useFilePassword: value ? current.useFilePassword : false,
password: value ? current.password : '',
error: '',
}));
}}
onUseFilePasswordChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
useFilePassword: value,
password: value ? current.password : '',
error: '',
}));
}}
onPasswordChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
password: value,
error: '',
}));
}}
onConfirm={() => {
void handleConfirmConnectionPackageDialog();
}}
onCancel={closeConnectionPackageDialog}
/>
);
}
if (activeToolCenterPane.key === 'data-root') {
return (
<Modal
embedded
open
title={null}
closable={false}
onCancel={closeToolCenterPane}
footer={[
<Button key="close" onClick={closeToolCenterPane}>
{t('common.close')}
</Button>,
<Button key="back" onClick={closeToolCenterPane}>
{t('common.back_to_previous')}
</Button>,
]}
styles={{
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 }}>{t('app.data_root.current_directory')}</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 }}>{t('app.data_root.default_directory')}</div>
<div style={utilityMutedTextStyle}>{dataRootInfo?.defaultPath || '-'}</div>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}>{t('app.data_root.driver_directory')}</div>
<div style={utilityMutedTextStyle}>{dataRootInfo?.driverPath || '-'}</div>
</div>
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}>{t('app.data_root.switch_target')}</div>
<div style={{ display: 'grid', gap: 10 }}>
<Input
readOnly
value={selectedDataRootPath}
placeholder={t('app.data_root.placeholder.select_new_directory')}
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<Button icon={<FolderOpenOutlined />} onClick={() => void handleSelectDataRoot()}>
{t('app.data_root.action.select')}
</Button>
<Button onClick={() => void handleOpenDataRoot()}>
{t('app.data_root.action.open_current')}
</Button>
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false, true)}>
{t('app.data_root.action.restore_default_directory')}
</Button>
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}>{t('app.data_root.apply_method')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false)}>
{t('app.data_root.action.switch_only')}
</Button>
<Button type="primary" loading={dataRootApplying} onClick={() => void handleApplyDataRoot(true)}>
{t('app.data_root.action.migrate_and_switch')}
</Button>
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
{t('app.data_root.restart_hint')}
</div>
</div>
</div>
)}
</Modal>
);
}
if (activeToolCenterPane.key === 'security-update') {
return (
<SecurityUpdateSettingsModal
embedded
open
darkMode={darkMode}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
status={securityUpdateStatus}
focusTarget={securityUpdateSettingsFocusTarget}
focusRequest={securityUpdateSettingsFocusRequest}
onClose={closeToolCenterPane}
onBack={closeToolCenterPane}
onStart={handleStartSecurityUpdate}
onRetry={handleRetrySecurityUpdate}
onRestart={handleRestartSecurityUpdate}
onIssueAction={handleSecurityUpdateIssueAction}
/>
);
}
if (
activeToolCenterPane.key === 'schema-compare'
|| activeToolCenterPane.key === 'data-compare'
|| activeToolCenterPane.key === 'sync'
) {
return (
<DataSyncModal
embedded
open
onClose={closeToolCenterPane}
onBack={closeToolCenterPane}
entryMode={syncModalEntryMode}
/>
);
}
if (activeToolCenterPane.key === 'drivers') {
return (
<DriverManagerModal
embedded
open
onClose={closeToolCenterPane}
onBack={closeToolCenterPane}
onOpenGlobalProxySettings={handleOpenGlobalProxySettings}
/>
);
}
if (activeToolCenterPane.key === 'snippet-settings') {
return (
<SnippetSettingsModal
embedded
open
onClose={closeToolCenterPane}
onBack={closeToolCenterPane}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
);
}
if (activeToolCenterPane.key === 'shortcut-settings') {
return (
<Modal
embedded
open
title={null}
closable={false}
onCancel={() => {
setCapturingShortcutAction(null);
closeToolCenterPane();
}}
footer={[
<Button
key="reset"
onClick={() => {
resetShortcutOptions();
setCapturingShortcutAction(null);
void message.success(t('app.shortcuts.message.restored_defaults'));
}}
>
{t('app.shortcuts.action.restore_defaults')}
</Button>,
<Button
key="close"
type="primary"
onClick={() => {
setCapturingShortcutAction(null);
closeToolCenterPane();
}}
>
{t('common.close')}
</Button>,
<Button
key="back"
onClick={() => {
setCapturingShortcutAction(null);
closeToolCenterPane();
}}
>
{t('common.back_to_previous')}
</Button>,
]}
styles={{
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, overflow: 'hidden', flex: 1, minHeight: 0 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
>
<div data-gonavi-shortcut-modal-scroll="true" style={{ height: '100%', overflowY: 'auto', overflowX: 'hidden', display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8, paddingRight: 8 }}>
<div style={utilityPanelStyle}>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
{t('app.shortcuts.capture_hint')}
</div>
</div>
{SHORTCUT_ACTION_ORDER.map((action) => {
const meta = SHORTCUT_ACTION_META[action];
if (meta.platformOnly === 'mac' && !isMacRuntime) {
return null;
}
const binding = resolveShortcutBinding(shortcutOptions, action, activeShortcutPlatform);
const isCapturing = capturingShortcutAction === action;
const conflicts = shortcutConflictMap[action];
const conflictInfo = conflicts?.length ? splitConflictsByContext(conflicts) : null;
return (
<div
key={action}
style={{
...utilityPanelStyle,
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 12,
alignItems: 'center',
padding: '10px 12px',
}}
>
<div>
<div style={{ fontWeight: 500 }}>{meta.label}</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{meta.description}</div>
{conflictInfo && (
<div style={{ fontSize: 11, color: darkMode ? '#faad14' : '#d48806', marginTop: 2 }}>
{conflictInfo.hasMonaco && (
<> {t('app.shortcuts.message.reserved_conflict_info', { labels: conflictInfo.monacoLabels })}</>
)}
{conflictInfo.hasOther && (
<> {t('app.shortcuts.message.reserved_conflict_warning', { contexts: conflictInfo.otherContexts, labels: conflictInfo.otherLabels })}</>
)}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Input
readOnly
value={isCapturing ? t('app.shortcuts.capture_waiting') : getShortcutDisplayLabel(binding.combo, activeShortcutPlatform)}
style={{ width: 180, fontFamily: resolvedMonoFontFamily }}
/>
<Button
size="small"
onClick={() => setCapturingShortcutAction((prev) => (prev === action ? null : action))}
>
{isCapturing ? t('common.cancel') : t('app.shortcuts.action.record')}
</Button>
<Switch
checked={binding.enabled}
onChange={(checked) => updateShortcut(action, { enabled: checked }, activeShortcutPlatform)}
/>
</div>
</div>
);
})}
</div>
</Modal>
);
}
return null;
};
return (
<Modal
title={renderUtilityModalTitle(<ToolOutlined />, t('app.tools.title'), t('app.tools.description'))}
open={isToolsModalOpen}
onCancel={() => {
if (activeToolCenterPane?.key === 'connection-package') {
closeConnectionPackageDialog();
}
setActiveToolCenterPane(null);
setToolCenterBackGroupKey(null);
setIsToolsModalOpen(false);
}}
footer={null}
centered
width={1080}
styles={{
content: toolCenterModalContentStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
>
<div style={toolCenterModalWorkspaceStyle}>
<div style={toolCenterModalSplitStyle}>
<div style={toolCenterNavPanelStyle}>
<div style={toolCenterNavScrollStyle} role="tablist" aria-orientation="vertical">
{toolCenterGroups.map((group) => {
const active = group.key === activeToolCenterGroup.key;
return (
<button
key={group.key}
type="button"
role="tab"
aria-selected={active}
title={`${group.title} - ${group.description}`}
onClick={() => {
setActiveToolCenterGroupKey(group.key);
setActiveToolCenterPane(null);
}}
style={{
position: 'relative',
textAlign: 'left',
width: '100%',
padding: '11px 10px 11px 14px',
borderRadius: 8,
border: 'none',
background: active
? overlayTheme.selectedBg
: 'transparent',
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 10,
bottom: 10,
width: 3,
borderRadius: 999,
background: active
? overlayTheme.selectedText
: 'transparent',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, minWidth: 0 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span
style={{
width: 28,
height: 28,
borderRadius: 8,
display: 'grid',
placeItems: 'center',
fontSize: 15,
flexShrink: 0,
background: active
? overlayTheme.iconBg
: (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'),
color: active
? overlayTheme.iconColor
: overlayTheme.mutedText,
}}
>
{group.icon}
</span>
<span
style={{
fontSize: 13,
fontWeight: active ? 700 : 600,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{group.title}
</span>
</span>
<span
style={{
minWidth: 20,
height: 20,
paddingInline: 6,
borderRadius: 999,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: active
? overlayTheme.selectedBg
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'),
color: active ? (darkMode ? '#f8fafc' : '#0f172a') : overlayTheme.mutedText,
fontSize: 10,
fontWeight: 700,
flexShrink: 0,
}}
>
{group.items.length}
</span>
</div>
</button>
);
})}
</div>
</div>
<div style={toolCenterContentPanelStyle}>
{activeToolCenterPane ? (
<div style={toolCenterDetailPanelStyle}>
<div style={{ paddingBottom: 10, borderBottom: `1px solid ${overlayTheme.divider}` }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
{activeToolCenterPaneItem?.title ?? activeToolCenterGroup.title}
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>
{activeToolCenterPaneItem?.description ?? activeToolCenterGroup.description}
</div>
</div>
</div>
<div style={toolCenterDetailBodyStyle}>
{renderToolCenterPane()}
</div>
</div>
) : (
<>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{activeToolCenterGroup.title}</div>
<div style={utilityMutedTextStyle}>{activeToolCenterGroup.description}</div>
</div>
<div style={toolCenterScrollableListStyle}>
{activeToolCenterGroup.items.map((item, index) => (
<Button
key={item.key}
type="text"
style={{
...toolCenterRowStyle,
borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none',
borderBottom: `1px solid ${overlayTheme.divider}`,
}}
onClick={item.onClick}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
<span style={{ width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{item.icon}
</span>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', minWidth: 0 }}>
<span>{item.title}</span>
<span style={toolCenterRowDescriptionStyle}>{item.description}</span>
</span>
</span>
<RightOutlined style={{ color: overlayTheme.mutedText, fontSize: 12, flexShrink: 0 }} />
</Button>
))}
</div>
</>
)}
</div>
</div>
</div>
</Modal>
);
})()}
{isSettingsModalOpen && (
<Modal
title={renderUtilityModalTitle(<SettingOutlined />, t('app.settings.title'), t('app.settings.description'))}
open={isSettingsModalOpen}
onCancel={handleCancelSettingsCenterPane}
footer={null}
centered
width={1080}
styles={{
content: toolCenterModalContentStyle,
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 },
}}
>
<div style={toolCenterModalWorkspaceStyle}>
<div style={toolCenterModalSplitStyle}>
<div style={toolCenterNavPanelStyle}>
<div style={toolCenterNavScrollStyle} role="tablist" aria-orientation="vertical">
{settingsCenterGroups.map((group) => {
const active = group.key === activeSettingsCenterGroup.key;
return (
<button
key={group.key}
type="button"
role="tab"
aria-selected={active}
title={`${group.title} - ${group.description}`}
onClick={() => {
setActiveSettingsCenterGroupKey(group.key);
setActiveSettingsCenterPane(resolveSettingsCenterGroupInitialPane(group.key));
}}
style={{
position: 'relative',
textAlign: 'left',
width: '100%',
padding: '11px 10px 11px 14px',
borderRadius: 8,
border: 'none',
background: active ? overlayTheme.selectedBg : 'transparent',
color: active ? (darkMode ? '#f5f7ff' : '#162033') : (darkMode ? 'rgba(255,255,255,0.82)' : '#3f4b5e'),
cursor: 'pointer',
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 10,
bottom: 10,
width: 3,
borderRadius: 999,
background: active ? overlayTheme.selectedText : 'transparent',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, minWidth: 0 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span
style={{
width: 28,
height: 28,
borderRadius: 8,
display: 'grid',
placeItems: 'center',
fontSize: 15,
flexShrink: 0,
background: active
? overlayTheme.iconBg
: (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'),
color: active ? overlayTheme.iconColor : overlayTheme.mutedText,
}}
>
{group.icon}
</span>
<span
style={{
fontSize: 13,
fontWeight: active ? 700 : 600,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{group.title}
</span>
</span>
<span
style={{
minWidth: 20,
height: 20,
paddingInline: 6,
borderRadius: 999,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: active
? overlayTheme.selectedBg
: (darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)'),
color: active ? (darkMode ? '#f8fafc' : '#0f172a') : overlayTheme.mutedText,
fontSize: 10,
fontWeight: 700,
flexShrink: 0,
}}
>
{group.items.length}
</span>
</div>
</button>
);
})}
</div>
</div>
<div style={toolCenterContentPanelStyle}>
{activeSettingsCenterPane ? (
<div style={toolCenterDetailPanelStyle}>
<div style={{ paddingBottom: 10, borderBottom: `1px solid ${overlayTheme.divider}` }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>
{activeSettingsCenterPaneItem?.title ?? activeSettingsCenterGroup.title}
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 4 }}>
{activeSettingsCenterPaneItem?.description ?? activeSettingsCenterGroup.description}
</div>
</div>
</div>
<div style={settingsCenterDetailBodyStyle}>
{renderSettingsCenterPane()}
</div>
<div
style={{
display: 'flex',
justifyContent: activeSettingsCenterPane.key === 'about-go-navi' ? 'space-between' : 'flex-end',
alignItems: 'center',
gap: activeSettingsCenterPane.key === 'about-go-navi' ? 16 : 8,
paddingTop: 10,
marginTop: 10,
borderTop: `1px solid ${overlayTheme.divider}`,
flexShrink: 0,
}}
>
{activeSettingsCenterPane.key === 'about-go-navi' ? (
renderSettingsCenterAboutFooter()
) : isV2Ui && activeSettingsCenterPane.key === 'theme' ? (
<>
<Button onClick={handleBackFromSettingsCenterPane}>
{t('common.back_to_previous')}
</Button>
<Button type="primary" onClick={handleCancelSettingsCenterPane}>
{t('common.close')}
</Button>
</>
) : (
<>
<Button onClick={handleCancelSettingsCenterPane}>
{t('common.cancel')}
</Button>
<Button type="primary" onClick={handleBackFromSettingsCenterPane}>
{t('common.back_to_previous')}
</Button>
</>
)}
</div>
</div>
) : (
<>
<div style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{activeSettingsCenterGroup.title}</div>
<div style={utilityMutedTextStyle}>{activeSettingsCenterGroup.description}</div>
</div>
<div style={toolCenterScrollableListStyle}>
{activeSettingsCenterGroup.items.map((item, index) => (
<Button
key={item.key}
type="text"
style={{
...toolCenterRowStyle,
borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none',
borderBottom: `1px solid ${overlayTheme.divider}`,
}}
onClick={item.onClick}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
<span style={{ width: 36, height: 36, borderRadius: 10, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{item.icon}
</span>
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', minWidth: 0 }}>
<span>{item.title}</span>
<span style={toolCenterRowDescriptionStyle}>{item.description}</span>
</span>
</span>
<RightOutlined style={{ color: overlayTheme.mutedText, fontSize: 12, flexShrink: 0 }} />
</Button>
))}
</div>
</>
)}
</div>
</div>
</div>
</Modal>
)}
{isDataRootModalOpen && (
<Modal
title={renderUtilityModalTitle(
<HddOutlined />,
t('app.data_root.title'),
t('app.data_root.description'),
)}
open={isDataRootModalOpen}
onCancel={() => {
setIsDataRootModalOpen(false);
setToolCenterBackGroupKey(null);
}}
footer={[
<Button
key="close"
onClick={() => {
setIsDataRootModalOpen(false);
setToolCenterBackGroupKey(null);
}}
>
{t('common.close')}
</Button>,
toolCenterBackGroupKey === 'config' ? (
<Button
key="back"
onClick={() => handleReturnToToolCenter(() => setIsDataRootModalOpen(false))}
>
{t('common.back_to_previous')}
</Button>
) : 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 }}>{t('app.data_root.current_directory')}</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 }}>{t('app.data_root.default_directory')}</div>
<div style={utilityMutedTextStyle}>{dataRootInfo?.defaultPath || '-'}</div>
</div>
<div>
<div style={{ marginBottom: 6, fontWeight: 500 }}>{t('app.data_root.driver_directory')}</div>
<div style={utilityMutedTextStyle}>{dataRootInfo?.driverPath || '-'}</div>
</div>
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}>{t('app.data_root.switch_target')}</div>
<div style={{ display: 'grid', gap: 10 }}>
<Input
readOnly
value={selectedDataRootPath}
placeholder={t('app.data_root.placeholder.select_new_directory')}
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<Button icon={<FolderOpenOutlined />} onClick={() => void handleSelectDataRoot()}>
{t('app.data_root.action.select')}
</Button>
<Button onClick={() => void handleOpenDataRoot()}>
{t('app.data_root.action.open_current')}
</Button>
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false, true)}>
{t('app.data_root.action.restore_default_directory')}
</Button>
</div>
</div>
</div>
<div style={utilityPanelStyle}>
<div style={{ marginBottom: 10, fontWeight: 600 }}>{t('app.data_root.apply_method')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
<Button loading={dataRootApplying} onClick={() => void handleApplyDataRoot(false)}>
{t('app.data_root.action.switch_only')}
</Button>
<Button type="primary" loading={dataRootApplying} onClick={() => void handleApplyDataRoot(true)}>
{t('app.data_root.action.migrate_and_switch')}
</Button>
</div>
<div style={{ ...utilityMutedTextStyle, marginTop: 10 }}>
{t('app.data_root.restart_hint')}
</div>
</div>
</div>
)}
</Modal>
)}
{isSyncModalOpen && (
<DataSyncModal
open={isSyncModalOpen}
onClose={() => {
setIsSyncModalOpen(false);
setToolCenterBackGroupKey(null);
}}
onBack={toolCenterBackGroupKey === 'workflow' ? () => handleReturnToToolCenter(() => setIsSyncModalOpen(false)) : undefined}
entryMode={syncModalEntryMode}
/>
)}
{isDriverModalOpen && (
<DriverManagerModal
open={isDriverModalOpen}
onClose={handleCloseDriverManager}
onBack={toolCenterBackGroupKey === 'workspace' ? () => handleReturnToToolCenter(() => setIsDriverModalOpen(false)) : undefined}
onOpenGlobalProxySettings={handleOpenGlobalProxySettings}
/>
)}
<SecurityUpdateIntroModal
open={isSecurityUpdateIntroOpen}
loading={isSecurityUpdateProgressOpen}
darkMode={darkMode}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
onStart={handleStartSecurityUpdate}
onPostpone={handlePostponeSecurityUpdate}
onViewDetails={() => handleOpenSecurityUpdateSettings()}
/>
<SecurityUpdateSettingsModal
open={isSecurityUpdateSettingsOpen}
darkMode={darkMode}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
status={securityUpdateStatus}
focusTarget={securityUpdateSettingsFocusTarget}
focusRequest={securityUpdateSettingsFocusRequest}
onClose={() => {
setIsSecurityUpdateSettingsOpen(false);
setToolCenterBackGroupKey(null);
}}
onBack={toolCenterBackGroupKey === 'config' ? () => handleReturnToToolCenter(() => setIsSecurityUpdateSettingsOpen(false)) : undefined}
onStart={handleStartSecurityUpdate}
onRetry={handleRetrySecurityUpdate}
onRestart={handleRestartSecurityUpdate}
onIssueAction={handleSecurityUpdateIssueAction}
/>
<SecurityUpdateProgressModal
open={isSecurityUpdateProgressOpen}
stageText={securityUpdateProgressStage}
overlayTheme={overlayTheme}
surfaceOpacity={effectiveOpacity}
/>
<ConnectionPackagePasswordModal
open={connectionPackageDialog.open && !(isToolsModalOpen && activeToolCenterPane?.key === 'connection-package')}
title={connectionPackageDialog.mode === 'export'
? t('app.connection_package.dialog.export_title')
: t('app.connection_package.dialog.import_password_title')}
mode={connectionPackageDialog.mode}
includeSecrets={connectionPackageDialog.includeSecrets}
useFilePassword={connectionPackageDialog.useFilePassword}
password={connectionPackageDialog.password}
error={connectionPackageDialog.error}
confirmLoading={connectionPackageDialog.confirmLoading}
confirmText={connectionPackageDialog.mode === 'export'
? t('app.connection_package.action.start_export')
: t('app.connection_package.action.start_import')}
onBack={toolCenterBackGroupKey === 'config' ? () => handleReturnToToolCenter(closeConnectionPackageDialog) : undefined}
onIncludeSecretsChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
includeSecrets: value,
useFilePassword: value ? current.useFilePassword : false,
password: value ? current.password : '',
error: '',
}));
}}
onUseFilePasswordChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
useFilePassword: value,
password: value ? current.password : '',
error: '',
}));
}}
onPasswordChange={(value) => {
setConnectionPackageDialog((current) => ({
...current,
password: value,
error: '',
}));
}}
onConfirm={() => {
void handleConfirmConnectionPackageDialog();
}}
onCancel={closeConnectionPackageDialog}
/>
<Modal
title={renderUtilityModalTitle(<InfoCircleOutlined />, t('app.about.title'), t('app.about.description'))}
open={isAboutOpen}
onCancel={() => setIsAboutOpen(false)}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10, justifyContent: 'flex-end' } }}
footer={renderAboutUpdateActions(
<Button key="close" onClick={() => setIsAboutOpen(false)}>{t('common.close')}</Button>,
)}
>
{renderAboutSettingsContent()}
</Modal>
{isThemeModalOpen && (
<Modal
title={renderUtilityModalTitle(
themeModalSection === 'theme'
? <SkinOutlined />
: themeModalSection === 'appearance'
? <BgColorsOutlined />
: <AppstoreOutlined />,
themeModalSection === 'theme'
? t('app.theme.theme_settings_title')
: themeModalSection === 'appearance'
? t('app.theme.appearance_settings_title')
: t('app.theme.workspace_settings_title'),
themeModalSection === 'theme'
? t('app.theme.theme_settings_description')
: themeModalSection === 'appearance'
? t('app.theme.appearance_settings_description')
: t('app.theme.workspace_settings_description')
)}
open={isThemeModalOpen}
onCancel={() => { setIsThemeModalOpen(false); }}
footer={null}
width={820}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8, height: 620, overflow: 'hidden' }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
>
{renderThemeSettingsContent()}
</Modal>
)}
{isShortcutModalOpen && (
<Modal
title={renderUtilityModalTitle(
<LinkOutlined />,
t('app.shortcuts.title'),
t('app.shortcuts.description'),
)}
open={isShortcutModalOpen}
onCancel={() => {
setIsShortcutModalOpen(false);
setCapturingShortcutAction(null);
setToolCenterBackGroupKey(null);
}}
width={760}
centered
style={{ top: 0, maxHeight: 'calc(100vh - 80px)' }}
styles={{
content: {
...utilityModalShellStyle,
height: 'min(760px, calc(100vh - 80px))',
display: 'flex',
flexDirection: 'column',
},
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
body: { paddingTop: 8, overflow: 'hidden', flex: 1, minHeight: 0 },
footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 }
}}
footer={[
<Button
key="reset"
onClick={() => {
resetShortcutOptions();
setCapturingShortcutAction(null);
void message.success(t('app.shortcuts.message.restored_defaults'));
}}
>
{t('app.shortcuts.action.restore_defaults')}
</Button>,
<Button
key="close"
type="primary"
onClick={() => {
setIsShortcutModalOpen(false);
setCapturingShortcutAction(null);
}}
>
{t('common.close')}
</Button>,
toolCenterBackGroupKey === 'workspace' ? (
<Button
key="back"
onClick={() => handleReturnToToolCenter(() => {
setIsShortcutModalOpen(false);
setCapturingShortcutAction(null);
})}
>
{t('common.back_to_previous')}
</Button>
) : null,
]}
>
<div data-gonavi-shortcut-modal-scroll="true" style={{ height: '100%', overflowY: 'auto', overflowX: 'hidden', display: 'flex', flexDirection: 'column', gap: 16, paddingTop: 8, paddingRight: 8 }}>
<div style={utilityPanelStyle}>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
{t('app.shortcuts.capture_hint')}
</div>
</div>
{SHORTCUT_ACTION_ORDER.map((action) => {
const meta = SHORTCUT_ACTION_META[action];
if (meta.platformOnly === 'mac' && !isMacRuntime) {
return null;
}
const binding = resolveShortcutBinding(shortcutOptions, action, activeShortcutPlatform);
const isCapturing = capturingShortcutAction === action;
const conflicts = shortcutConflictMap[action];
const conflictInfo = conflicts?.length ? splitConflictsByContext(conflicts) : null;
return (
<div
key={action}
style={{
...utilityPanelStyle,
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 12,
alignItems: 'center',
padding: '10px 12px',
}}
>
<div>
<div style={{ fontWeight: 500 }}>{meta.label}</div>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>{meta.description}</div>
{conflictInfo && (
<div style={{ fontSize: 11, color: darkMode ? '#faad14' : '#d48806', marginTop: 2 }}>
{conflictInfo.hasMonaco && (
<> {t('app.shortcuts.message.reserved_conflict_info', { labels: conflictInfo.monacoLabels })}</>
)}
{conflictInfo.hasOther && (
<> {t('app.shortcuts.message.reserved_conflict_warning', { contexts: conflictInfo.otherContexts, labels: conflictInfo.otherLabels })}</>
)}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Input
readOnly
value={isCapturing ? t('app.shortcuts.capture_waiting') : getShortcutDisplayLabel(binding.combo, activeShortcutPlatform)}
style={{ width: 180, fontFamily: resolvedMonoFontFamily }}
/>
<Button
size="small"
onClick={() => setCapturingShortcutAction((prev) => (prev === action ? null : action))}
>
{isCapturing ? t('common.cancel') : t('app.shortcuts.action.record')}
</Button>
<Switch
checked={binding.enabled}
onChange={(checked) => updateShortcut(action, { enabled: checked }, activeShortcutPlatform)}
/>
</div>
</div>
);
})}
</div>
</Modal>
)}
{isSnippetModalOpen && (
<SnippetSettingsModal
open={isSnippetModalOpen}
onClose={() => {
setIsSnippetModalOpen(false);
setToolCenterBackGroupKey(null);
}}
onBack={toolCenterBackGroupKey === 'workspace' ? () => handleReturnToToolCenter(() => setIsSnippetModalOpen(false)) : undefined}
darkMode={darkMode}
overlayTheme={overlayTheme}
/>
)}
{isProxyModalOpen && (
<Modal
title={renderUtilityModalTitle(<GlobalOutlined />, t('app.proxy.title'), t('app.proxy.description'))}
open={isProxyModalOpen}
onCancel={handleCloseGlobalProxySettings}
footer={null}
width={680}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
>
{renderProxySettingsContent()}
</Modal>
)}
<Modal
title={updateDownloadProgress.version
? t('app.about.download_progress.title_with_version', { version: updateDownloadProgress.version })
: t('app.about.download_progress.title')}
open={updateDownloadProgress.open}
closable
maskClosable
keyboard
onCancel={hideUpdateDownloadProgress}
footer={updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading' ? [
<Button
key="background"
onClick={() => {
markUpdateProgressDismissed();
hideUpdateDownloadProgress();
}}
>
{t('app.about.action.hide_to_background')}
</Button>
] : (updateDownloadProgress.status === 'done' ? [
<Button key="close" onClick={hideUpdateDownloadProgress}>{t('common.close')}</Button>,
<Button key="open-install-directory" onClick={openDownloadedUpdateDirectory}>
{t('app.about.action.open_install_directory')}
</Button>,
<Button key="restart" type="primary" onClick={handleInstallFromProgress}>
{t('app.about.action.restart_to_update')}
</Button>
] : (updateDownloadProgress.status === 'error' ? [
<Button key="close" onClick={hideUpdateDownloadProgress}>{t('common.close')}</Button>
] : null))}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Progress
percent={Math.round(updateDownloadProgress.percent)}
status={updateDownloadProgress.status === 'error' ? 'exception' : (updateDownloadProgress.status === 'done' ? 'success' : 'active')}
/>
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
{updateDownloadProgress.status === 'done'
? t('app.about.download_progress.complete_hint')
: `${formatBytes(updateDownloadProgress.downloaded)} / ${formatBytes(updateDownloadProgress.total)}`}
</div>
{updateDownloadProgress.message ? (
<div style={{
fontSize: 12,
color: updateDownloadProgress.status === 'error'
? '#ff4d4f'
: (darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(16,24,40,0.65)'),
}}
>
{updateDownloadProgress.message}
</div>
) : null}
</div>
</Modal>
{showLinuxResizeHandles && (
<>
{/* Linux Mint 下 frameless 仅局部可缩放:补四边四角命中层 */}
<div style={{ ...linuxResizeHandleStyleBase, top: 0, left: 14, right: 14, height: 6, cursor: 'ns-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, left: 14, right: 14, height: 6, cursor: 'ns-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 14, bottom: 14, left: 0, width: 6, cursor: 'ew-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 14, bottom: 14, right: 0, width: 6, cursor: 'ew-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 0, left: 0, width: 14, height: 14, cursor: 'nwse-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, top: 0, right: 0, width: 14, height: 14, cursor: 'nesw-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, left: 0, width: 14, height: 14, cursor: 'nesw-resize' }} />
<div style={{ ...linuxResizeHandleStyleBase, bottom: 0, right: 0, width: 14, height: 14, cursor: 'nwse-resize' }} />
</>
)}
{/* Ghost Resize Line for Sidebar */}
<div
ref={ghostRef}
style={{
position: 'fixed',
top: 0,
bottom: 0,
left: 0,
width: '4px',
background: resizeGuideColor,
zIndex: 9999,
pointerEvents: 'none',
display: 'none'
}}
/>
{/* Ghost Resize Line for Log Panel */}
<div
ref={logGhostRef}
style={{
position: 'fixed',
left: sidebarWidth, // Start from sidebar edge
right: 0,
height: '4px',
background: resizeGuideColor,
zIndex: 9999,
pointerEvents: 'none',
display: 'none',
cursor: 'row-resize'
}}
/>
</Layout>
</ConfigProvider>
);
}
export default App;