mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-23 01:11:06 +08:00
- 新增变更预览能力,支持在提交前查看删除、更新和新增对应的 SQL 语句 - 增加表格密度配置,统一控制默认列宽、行高、字号与单元格内边距 - 优化 DataGrid 编辑状态展示,区分新增、修改和删除行列的视觉反馈 - 调整导出入口与 Wails 前端绑定,补齐变更预览相关调用与测试覆盖
2331 lines
75 KiB
TypeScript
2331 lines
75 KiB
TypeScript
import { create } from "zustand";
|
||
import { persist } from "zustand/middleware";
|
||
import {
|
||
ConnectionConfig,
|
||
ProxyConfig,
|
||
SavedConnection,
|
||
TabData,
|
||
SavedQuery,
|
||
ConnectionTag,
|
||
AIChatMessage,
|
||
AIContextItem,
|
||
GlobalProxyConfig,
|
||
ExternalSQLDirectory,
|
||
JVMDiagnosticCommandDraft,
|
||
JVMDiagnosticEventChunk,
|
||
SqlSnippet,
|
||
} from "./types";
|
||
import {
|
||
ShortcutAction,
|
||
ShortcutBinding,
|
||
ShortcutOptions,
|
||
DEFAULT_SHORTCUT_OPTIONS,
|
||
cloneShortcutOptions,
|
||
sanitizeShortcutOptions,
|
||
} from "./utils/shortcuts";
|
||
import { buildExternalSQLDirectoryId } from "./utils/externalSqlTree";
|
||
import {
|
||
DEFAULT_SQL_SNIPPETS,
|
||
BUILTIN_SNIPPET_MAP,
|
||
} from "./utils/sqlSnippetDefaults";
|
||
import { toPersistedGlobalProxy } from "./utils/globalProxyDraft";
|
||
import {
|
||
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||
sanitizeDataGridDisplaySettings,
|
||
type DataGridDisplaySettings,
|
||
} from "./utils/dataGridDisplay";
|
||
|
||
export interface AppearanceSettings extends DataGridDisplaySettings {
|
||
enabled: boolean;
|
||
opacity: number;
|
||
blur: number;
|
||
useNativeMacWindowControls: boolean;
|
||
}
|
||
|
||
export const DEFAULT_APPEARANCE: AppearanceSettings = {
|
||
enabled: true,
|
||
opacity: 1.0,
|
||
blur: 0,
|
||
useNativeMacWindowControls: false,
|
||
...DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
|
||
};
|
||
const DEFAULT_UI_SCALE = 1.0;
|
||
const MIN_UI_SCALE = 0.8;
|
||
const MAX_UI_SCALE = 1.25;
|
||
const DEFAULT_FONT_SIZE = 14;
|
||
const MIN_FONT_SIZE = 12;
|
||
const MAX_FONT_SIZE = 20;
|
||
const DEFAULT_STARTUP_FULLSCREEN = false;
|
||
const LEGACY_DEFAULT_OPACITY = 0.95;
|
||
const OPACITY_EPSILON = 1e-6;
|
||
const MAX_URI_LENGTH = 4096;
|
||
const MAX_HOST_ENTRY_LENGTH = 512;
|
||
const MAX_HOST_ENTRIES = 64;
|
||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||
const MAX_TIMEOUT_SECONDS = 3600;
|
||
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
|
||
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
|
||
const PERSIST_VERSION = 9;
|
||
const PERSIST_STORAGE_KEY = "lite-db-storage";
|
||
const DEFAULT_CONNECTION_TYPE = "mysql";
|
||
const DEFAULT_JVM_PORT = 9010;
|
||
const DEFAULT_GLOBAL_PROXY: GlobalProxyConfig = {
|
||
enabled: false,
|
||
type: "socks5",
|
||
host: "",
|
||
port: 1080,
|
||
user: "",
|
||
password: "",
|
||
hasPassword: false,
|
||
};
|
||
const OCEANBASE_PROTOCOL_PARAM_KEYS = [
|
||
"protocol",
|
||
"oceanBaseProtocol",
|
||
"oceanbaseProtocol",
|
||
"tenantMode",
|
||
"compatMode",
|
||
"mode",
|
||
];
|
||
const normalizeOceanBaseProtocol = (
|
||
value: unknown,
|
||
): "mysql" | "oracle" | undefined => {
|
||
const normalized = String(value ?? "").trim().toLowerCase();
|
||
if (!normalized) {
|
||
return undefined;
|
||
}
|
||
return normalized === "oracle" ||
|
||
normalized === "oracle-mode" ||
|
||
normalized === "oracle_mode" ||
|
||
normalized === "oboracle"
|
||
? "oracle"
|
||
: "mysql";
|
||
};
|
||
const resolveOceanBaseProtocolFromQueryText = (
|
||
value: unknown,
|
||
): "mysql" | "oracle" | undefined => {
|
||
let text = String(value ?? "").trim();
|
||
if (!text) {
|
||
return undefined;
|
||
}
|
||
const queryIndex = text.indexOf("?");
|
||
if (queryIndex >= 0) {
|
||
text = text.slice(queryIndex + 1);
|
||
}
|
||
const hashIndex = text.indexOf("#");
|
||
if (hashIndex >= 0) {
|
||
text = text.slice(0, hashIndex);
|
||
}
|
||
const params = new URLSearchParams(text.replace(/^[?&]+/, ""));
|
||
for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) {
|
||
const protocol = normalizeOceanBaseProtocol(params.get(key));
|
||
if (protocol) {
|
||
return protocol;
|
||
}
|
||
}
|
||
return undefined;
|
||
};
|
||
const resolveOceanBaseProtocol = (
|
||
raw: Record<string, unknown>,
|
||
normalizedConnectionParams: string,
|
||
normalizedUri: string,
|
||
): "mysql" | "oracle" => {
|
||
if (Object.prototype.hasOwnProperty.call(raw, "oceanBaseProtocol")) {
|
||
const explicitProtocol = normalizeOceanBaseProtocol(raw.oceanBaseProtocol);
|
||
if (explicitProtocol) {
|
||
return explicitProtocol;
|
||
}
|
||
}
|
||
return (
|
||
resolveOceanBaseProtocolFromQueryText(normalizedConnectionParams) ||
|
||
resolveOceanBaseProtocolFromQueryText(normalizedUri) ||
|
||
"mysql"
|
||
);
|
||
};
|
||
const SUPPORTED_CONNECTION_TYPES = new Set([
|
||
"mysql",
|
||
"mariadb",
|
||
"oceanbase",
|
||
"doris",
|
||
"diros",
|
||
"sphinx",
|
||
"clickhouse",
|
||
"postgres",
|
||
"redis",
|
||
"tdengine",
|
||
"oracle",
|
||
"dameng",
|
||
"kingbase",
|
||
"sqlserver",
|
||
"mongodb",
|
||
"highgo",
|
||
"vastbase",
|
||
"opengauss",
|
||
"jvm",
|
||
"sqlite",
|
||
"duckdb",
|
||
"custom",
|
||
]);
|
||
const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
|
||
"mysql",
|
||
"mariadb",
|
||
"oceanbase",
|
||
"diros",
|
||
"sphinx",
|
||
"dameng",
|
||
"clickhouse",
|
||
"postgres",
|
||
"sqlserver",
|
||
"oracle",
|
||
"kingbase",
|
||
"highgo",
|
||
"vastbase",
|
||
"opengauss",
|
||
"mongodb",
|
||
"redis",
|
||
"tdengine",
|
||
]);
|
||
|
||
const getDefaultPortByType = (type: string): number => {
|
||
switch (type) {
|
||
case "jvm":
|
||
return DEFAULT_JVM_PORT;
|
||
case "mysql":
|
||
case "mariadb":
|
||
return 3306;
|
||
case "oceanbase":
|
||
return 2881;
|
||
case "doris":
|
||
case "diros":
|
||
return 9030;
|
||
case "duckdb":
|
||
return 0;
|
||
case "sphinx":
|
||
return 9306;
|
||
case "clickhouse":
|
||
return 9000;
|
||
case "postgres":
|
||
case "vastbase":
|
||
case "opengauss":
|
||
return 5432;
|
||
case "redis":
|
||
return 6379;
|
||
case "tdengine":
|
||
return 6041;
|
||
case "oracle":
|
||
return 1521;
|
||
case "dameng":
|
||
return 5236;
|
||
case "kingbase":
|
||
return 54321;
|
||
case "sqlserver":
|
||
return 1433;
|
||
case "mongodb":
|
||
return 27017;
|
||
case "highgo":
|
||
return 5866;
|
||
default:
|
||
return 3306;
|
||
}
|
||
};
|
||
|
||
const toTrimmedString = (value: unknown, fallback = ""): string => {
|
||
if (typeof value === "string") {
|
||
return value.trim();
|
||
}
|
||
if (typeof value === "number" || typeof value === "boolean") {
|
||
return String(value).trim();
|
||
}
|
||
return fallback;
|
||
};
|
||
|
||
const normalizeClickHouseProtocol = (
|
||
value: unknown,
|
||
): "auto" | "http" | "native" => {
|
||
const text = toTrimmedString(value).toLowerCase();
|
||
if (text === "http" || text === "https") return "http";
|
||
if (text === "native" || text === "tcp") return "native";
|
||
return "auto";
|
||
};
|
||
|
||
const normalizePort = (value: unknown, fallbackPort: number): number => {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallbackPort;
|
||
const port = Math.trunc(parsed);
|
||
if (port <= 0 || port > 65535) return fallbackPort;
|
||
return port;
|
||
};
|
||
|
||
const normalizeIntegerInRange = (
|
||
value: unknown,
|
||
fallbackValue: number,
|
||
min: number,
|
||
max: number,
|
||
): number => {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallbackValue;
|
||
const normalized = Math.trunc(parsed);
|
||
if (normalized < min || normalized > max) return fallbackValue;
|
||
return normalized;
|
||
};
|
||
|
||
const normalizeFloatInRange = (
|
||
value: unknown,
|
||
fallbackValue: number,
|
||
min: number,
|
||
max: number,
|
||
): number => {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallbackValue;
|
||
if (parsed < min || parsed > max) return fallbackValue;
|
||
return parsed;
|
||
};
|
||
|
||
const isValidHostEntry = (entry: string): boolean => {
|
||
if (!entry) return false;
|
||
if (entry.length > MAX_HOST_ENTRY_LENGTH) return false;
|
||
if (/[()\\/\s]/.test(entry)) return false;
|
||
return true;
|
||
};
|
||
|
||
const sanitizeStringArray = (value: unknown, maxLength = 256): string[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
const seen = new Set<string>();
|
||
const result: string[] = [];
|
||
value.forEach((entry) => {
|
||
const normalized = toTrimmedString(entry);
|
||
if (!normalized || normalized.length > maxLength) return;
|
||
if (seen.has(normalized)) return;
|
||
seen.add(normalized);
|
||
result.push(normalized);
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const sanitizeNumberArray = (
|
||
value: unknown,
|
||
min: number,
|
||
max: number,
|
||
): number[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
const seen = new Set<number>();
|
||
const result: number[] = [];
|
||
value.forEach((entry) => {
|
||
const parsed = Number(entry);
|
||
if (!Number.isFinite(parsed)) return;
|
||
const num = Math.trunc(parsed);
|
||
if (num < min || num > max) return;
|
||
if (seen.has(num)) return;
|
||
seen.add(num);
|
||
result.push(num);
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const sanitizeAddressList = (value: unknown): string[] => {
|
||
const all = sanitizeStringArray(value, MAX_HOST_ENTRY_LENGTH).filter(
|
||
(entry) => isValidHostEntry(entry),
|
||
);
|
||
return all.slice(0, MAX_HOST_ENTRIES);
|
||
};
|
||
|
||
const sanitizeConnectionIconType = (value: unknown): string | undefined => {
|
||
const iconType = toTrimmedString(value).toLowerCase();
|
||
return iconType || undefined;
|
||
};
|
||
|
||
const sanitizeConnectionIconColor = (value: unknown): string | undefined => {
|
||
const color = toTrimmedString(value);
|
||
return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)
|
||
? color
|
||
: undefined;
|
||
};
|
||
|
||
const normalizeConnectionType = (value: unknown): string => {
|
||
const type = toTrimmedString(value).toLowerCase();
|
||
if (type === "doris") {
|
||
return "diros";
|
||
}
|
||
if (
|
||
type === "open_gauss" ||
|
||
type === "open-gauss" ||
|
||
type === "opengauss"
|
||
) {
|
||
return "opengauss";
|
||
}
|
||
return SUPPORTED_CONNECTION_TYPES.has(type) ? type : DEFAULT_CONNECTION_TYPE;
|
||
};
|
||
|
||
const sanitizeJVMModes = (
|
||
value: unknown,
|
||
): Array<"jmx" | "endpoint" | "agent"> => {
|
||
if (!Array.isArray(value)) return ["jmx"];
|
||
const result: Array<"jmx" | "endpoint" | "agent"> = [];
|
||
const seen = new Set<"jmx" | "endpoint" | "agent">();
|
||
value.forEach((entry) => {
|
||
const normalized = toTrimmedString(entry).toLowerCase();
|
||
if (
|
||
normalized !== "jmx" &&
|
||
normalized !== "endpoint" &&
|
||
normalized !== "agent"
|
||
)
|
||
return;
|
||
if (seen.has(normalized)) return;
|
||
seen.add(normalized);
|
||
result.push(normalized);
|
||
});
|
||
return result.length > 0 ? result : ["jmx"];
|
||
};
|
||
|
||
const sanitizeJVMConfig = (
|
||
value: unknown,
|
||
options: {
|
||
host: string;
|
||
port: number;
|
||
timeout: number;
|
||
persistSecrets: boolean;
|
||
},
|
||
): ConnectionConfig["jvm"] => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const allowedModes = sanitizeJVMModes(raw.allowedModes);
|
||
const preferredModeRaw = toTrimmedString(raw.preferredMode).toLowerCase();
|
||
const preferredMode = allowedModes.includes(
|
||
preferredModeRaw as "jmx" | "endpoint" | "agent",
|
||
)
|
||
? (preferredModeRaw as "jmx" | "endpoint" | "agent")
|
||
: allowedModes[0];
|
||
const environmentRaw = toTrimmedString(raw.environment, "dev").toLowerCase();
|
||
const environment: "dev" | "uat" | "prod" =
|
||
environmentRaw === "uat"
|
||
? "uat"
|
||
: environmentRaw === "prod"
|
||
? "prod"
|
||
: "dev";
|
||
const jmxRaw =
|
||
raw.jmx && typeof raw.jmx === "object"
|
||
? (raw.jmx as Record<string, unknown>)
|
||
: {};
|
||
const endpointRaw =
|
||
raw.endpoint && typeof raw.endpoint === "object"
|
||
? (raw.endpoint as Record<string, unknown>)
|
||
: {};
|
||
const agentRaw =
|
||
raw.agent && typeof raw.agent === "object"
|
||
? (raw.agent as Record<string, unknown>)
|
||
: {};
|
||
const diagnosticRaw =
|
||
raw.diagnostic && typeof raw.diagnostic === "object"
|
||
? (raw.diagnostic as Record<string, unknown>)
|
||
: {};
|
||
const diagnosticTransportRaw = toTrimmedString(
|
||
diagnosticRaw.transport,
|
||
"agent-bridge",
|
||
).toLowerCase();
|
||
const diagnosticTransport =
|
||
diagnosticTransportRaw === "arthas-tunnel"
|
||
? "arthas-tunnel"
|
||
: "agent-bridge";
|
||
const fallbackPort = options.port > 0 ? options.port : DEFAULT_JVM_PORT;
|
||
const fallbackTimeout =
|
||
options.timeout > 0 ? options.timeout : DEFAULT_TIMEOUT_SECONDS;
|
||
|
||
return {
|
||
environment,
|
||
readOnly: typeof raw.readOnly === "boolean" ? raw.readOnly : true,
|
||
allowedModes,
|
||
preferredMode,
|
||
jmx: {
|
||
enabled: jmxRaw.enabled === true || allowedModes.includes("jmx"),
|
||
host: toTrimmedString(jmxRaw.host, options.host) || options.host,
|
||
port: normalizePort(jmxRaw.port, fallbackPort),
|
||
username: toTrimmedString(jmxRaw.username),
|
||
password: options.persistSecrets ? toTrimmedString(jmxRaw.password) : "",
|
||
domainAllowlist: sanitizeStringArray(jmxRaw.domainAllowlist, 256),
|
||
},
|
||
endpoint: {
|
||
enabled: endpointRaw.enabled === true,
|
||
baseUrl: toTrimmedString(endpointRaw.baseUrl),
|
||
apiKey: options.persistSecrets ? toTrimmedString(endpointRaw.apiKey) : "",
|
||
timeoutSeconds: normalizeIntegerInRange(
|
||
endpointRaw.timeoutSeconds,
|
||
fallbackTimeout,
|
||
1,
|
||
MAX_TIMEOUT_SECONDS,
|
||
),
|
||
},
|
||
agent: {
|
||
enabled: agentRaw.enabled === true,
|
||
baseUrl: toTrimmedString(agentRaw.baseUrl),
|
||
apiKey: options.persistSecrets ? toTrimmedString(agentRaw.apiKey) : "",
|
||
timeoutSeconds: normalizeIntegerInRange(
|
||
agentRaw.timeoutSeconds,
|
||
fallbackTimeout,
|
||
1,
|
||
MAX_TIMEOUT_SECONDS,
|
||
),
|
||
},
|
||
diagnostic: {
|
||
enabled: diagnosticRaw.enabled === true,
|
||
transport: diagnosticTransport,
|
||
baseUrl: toTrimmedString(diagnosticRaw.baseUrl),
|
||
targetId: toTrimmedString(diagnosticRaw.targetId),
|
||
apiKey: options.persistSecrets
|
||
? toTrimmedString(diagnosticRaw.apiKey)
|
||
: "",
|
||
allowObserveCommands: diagnosticRaw.allowObserveCommands !== false,
|
||
allowTraceCommands: diagnosticRaw.allowTraceCommands === true,
|
||
allowMutatingCommands: diagnosticRaw.allowMutatingCommands === true,
|
||
timeoutSeconds: normalizeIntegerInRange(
|
||
diagnosticRaw.timeoutSeconds,
|
||
DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
|
||
1,
|
||
MAX_DIAGNOSTIC_TIMEOUT_SECONDS,
|
||
),
|
||
},
|
||
};
|
||
};
|
||
|
||
const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const type = normalizeConnectionType(raw.type);
|
||
const defaultPort = getDefaultPortByType(type);
|
||
const savePassword =
|
||
typeof raw.savePassword === "boolean" ? raw.savePassword : true;
|
||
const mongoSrv = !!raw.mongoSrv;
|
||
const sslCapable = SSL_SUPPORTED_CONNECTION_TYPES.has(type);
|
||
const sslModeRaw = toTrimmedString(raw.sslMode, "preferred").toLowerCase();
|
||
const sslMode: "preferred" | "required" | "skip-verify" | "disable" =
|
||
sslModeRaw === "required"
|
||
? "required"
|
||
: sslModeRaw === "skip-verify"
|
||
? "skip-verify"
|
||
: sslModeRaw === "disable"
|
||
? "disable"
|
||
: "preferred";
|
||
|
||
const sshRaw =
|
||
raw.ssh && typeof raw.ssh === "object"
|
||
? (raw.ssh as Record<string, unknown>)
|
||
: {};
|
||
const ssh = {
|
||
host: toTrimmedString(sshRaw.host),
|
||
port: normalizePort(sshRaw.port, 22),
|
||
user: toTrimmedString(sshRaw.user),
|
||
password: toTrimmedString(sshRaw.password),
|
||
keyPath: toTrimmedString(sshRaw.keyPath),
|
||
};
|
||
const proxyRaw =
|
||
raw.proxy && typeof raw.proxy === "object"
|
||
? (raw.proxy as Record<string, unknown>)
|
||
: {};
|
||
const proxyTypeRaw = toTrimmedString(proxyRaw.type, "socks5").toLowerCase();
|
||
const proxyType: "socks5" | "http" =
|
||
proxyTypeRaw === "http" ? "http" : "socks5";
|
||
const proxy = {
|
||
type: proxyType,
|
||
host: toTrimmedString(proxyRaw.host),
|
||
port: normalizePort(proxyRaw.port, proxyTypeRaw === "http" ? 8080 : 1080),
|
||
user: toTrimmedString(proxyRaw.user),
|
||
password: toTrimmedString(proxyRaw.password),
|
||
};
|
||
const httpTunnelRaw =
|
||
raw.httpTunnel && typeof raw.httpTunnel === "object"
|
||
? (raw.httpTunnel as Record<string, unknown>)
|
||
: raw.HTTPTunnel && typeof raw.HTTPTunnel === "object"
|
||
? (raw.HTTPTunnel as Record<string, unknown>)
|
||
: {};
|
||
const httpTunnel = {
|
||
host: toTrimmedString(httpTunnelRaw.host ?? raw.httpTunnelHost),
|
||
port: normalizePort(httpTunnelRaw.port ?? raw.httpTunnelPort, 8080),
|
||
user: toTrimmedString(httpTunnelRaw.user ?? raw.httpTunnelUser),
|
||
password: toTrimmedString(httpTunnelRaw.password ?? raw.httpTunnelPassword),
|
||
};
|
||
const supportsNetworkTunnel = type !== "sqlite" && type !== "duckdb";
|
||
const useHttpTunnel =
|
||
supportsNetworkTunnel &&
|
||
(raw.useHttpTunnel === true || raw.UseHTTPTunnel === true);
|
||
const useProxy = supportsNetworkTunnel && !!raw.useProxy && !useHttpTunnel;
|
||
|
||
const safeConfig: ConnectionConfig & Record<string, unknown> = {
|
||
...raw,
|
||
id: toTrimmedString(raw.id ?? raw.ID),
|
||
type,
|
||
host: toTrimmedString(raw.host, "localhost") || "localhost",
|
||
port: normalizePort(raw.port, defaultPort),
|
||
user: toTrimmedString(raw.user),
|
||
password: savePassword ? toTrimmedString(raw.password) : "",
|
||
savePassword,
|
||
database: toTrimmedString(raw.database),
|
||
useSSL: sslCapable ? !!raw.useSSL : false,
|
||
sslMode: sslCapable ? sslMode : "disable",
|
||
sslCertPath: sslCapable ? toTrimmedString(raw.sslCertPath) : "",
|
||
sslKeyPath: sslCapable ? toTrimmedString(raw.sslKeyPath) : "",
|
||
useSSH: !!raw.useSSH,
|
||
ssh,
|
||
useProxy,
|
||
proxy,
|
||
useHttpTunnel,
|
||
httpTunnel,
|
||
uri: toTrimmedString(raw.uri).slice(0, MAX_URI_LENGTH),
|
||
connectionParams: toTrimmedString(raw.connectionParams).slice(
|
||
0,
|
||
MAX_URI_LENGTH,
|
||
),
|
||
hosts: sanitizeAddressList(raw.hosts),
|
||
topology:
|
||
raw.topology === "replica"
|
||
? "replica"
|
||
: raw.topology === "cluster"
|
||
? "cluster"
|
||
: "single",
|
||
mysqlReplicaUser: toTrimmedString(raw.mysqlReplicaUser),
|
||
mysqlReplicaPassword: savePassword
|
||
? toTrimmedString(raw.mysqlReplicaPassword)
|
||
: "",
|
||
replicaSet: toTrimmedString(raw.replicaSet),
|
||
authSource: toTrimmedString(raw.authSource),
|
||
readPreference: toTrimmedString(raw.readPreference),
|
||
mongoSrv,
|
||
mongoAuthMechanism: toTrimmedString(raw.mongoAuthMechanism),
|
||
mongoReplicaUser: toTrimmedString(raw.mongoReplicaUser),
|
||
mongoReplicaPassword: savePassword
|
||
? toTrimmedString(raw.mongoReplicaPassword)
|
||
: "",
|
||
timeout: normalizeIntegerInRange(
|
||
raw.timeout,
|
||
DEFAULT_TIMEOUT_SECONDS,
|
||
1,
|
||
MAX_TIMEOUT_SECONDS,
|
||
),
|
||
};
|
||
|
||
if (type === "redis") {
|
||
safeConfig.redisDB = normalizeIntegerInRange(raw.redisDB, 0, 0, 15);
|
||
}
|
||
|
||
if (type === "clickhouse") {
|
||
safeConfig.clickHouseProtocol = normalizeClickHouseProtocol(
|
||
raw.clickHouseProtocol,
|
||
);
|
||
}
|
||
|
||
if (type === "oceanbase") {
|
||
safeConfig.oceanBaseProtocol = resolveOceanBaseProtocol(
|
||
raw,
|
||
safeConfig.connectionParams || "",
|
||
safeConfig.uri || "",
|
||
);
|
||
}
|
||
|
||
if (type === "custom") {
|
||
safeConfig.driver = toTrimmedString(raw.driver);
|
||
safeConfig.dsn = toTrimmedString(raw.dsn).slice(0, MAX_URI_LENGTH);
|
||
}
|
||
|
||
if (type === "jvm") {
|
||
safeConfig.jvm = sanitizeJVMConfig(raw.jvm, {
|
||
host: safeConfig.host,
|
||
port: safeConfig.port,
|
||
timeout: safeConfig.timeout || DEFAULT_TIMEOUT_SECONDS,
|
||
persistSecrets: savePassword,
|
||
});
|
||
}
|
||
|
||
return safeConfig;
|
||
};
|
||
|
||
const resolveConnectionConfigPayload = (
|
||
raw: Record<string, unknown>,
|
||
): unknown => {
|
||
if (raw.config && typeof raw.config === "object") {
|
||
return raw.config;
|
||
}
|
||
// 兼容历史/导入场景:连接对象可能是扁平结构(无 config 包装)。
|
||
const hasLegacyFlatConfig =
|
||
raw.type !== undefined ||
|
||
raw.host !== undefined ||
|
||
raw.port !== undefined ||
|
||
raw.user !== undefined ||
|
||
raw.database !== undefined;
|
||
if (hasLegacyFlatConfig) {
|
||
return raw;
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
const sanitizeSavedConnection = (
|
||
value: unknown,
|
||
index: number,
|
||
): SavedConnection | null => {
|
||
if (!value || typeof value !== "object") return null;
|
||
const raw = value as Record<string, unknown>;
|
||
const config = sanitizeConnectionConfig(resolveConnectionConfigPayload(raw));
|
||
const id =
|
||
toTrimmedString(raw.id, `conn-${index + 1}`) || `conn-${index + 1}`;
|
||
const displayType = config.type === "diros" ? "doris" : config.type;
|
||
const fallbackName = config.host
|
||
? `${displayType}-${config.host}`
|
||
: `连接-${index + 1}`;
|
||
const name = toTrimmedString(raw.name, fallbackName) || fallbackName;
|
||
const includeDatabases = sanitizeStringArray(raw.includeDatabases, 256);
|
||
const includeRedisDatabases = sanitizeNumberArray(
|
||
raw.includeRedisDatabases,
|
||
0,
|
||
15,
|
||
);
|
||
|
||
return {
|
||
id,
|
||
name,
|
||
config: { ...config, id: config.id || id },
|
||
secretRef: toTrimmedString(raw.secretRef) || undefined,
|
||
hasPrimaryPassword: raw.hasPrimaryPassword === true,
|
||
hasSSHPassword: raw.hasSSHPassword === true,
|
||
hasProxyPassword: raw.hasProxyPassword === true,
|
||
hasHttpTunnelPassword: raw.hasHttpTunnelPassword === true,
|
||
hasMySQLReplicaPassword: raw.hasMySQLReplicaPassword === true,
|
||
hasMongoReplicaPassword: raw.hasMongoReplicaPassword === true,
|
||
hasOpaqueURI: raw.hasOpaqueURI === true,
|
||
hasOpaqueDSN: raw.hasOpaqueDSN === true,
|
||
includeDatabases:
|
||
includeDatabases.length > 0 ? includeDatabases : undefined,
|
||
includeRedisDatabases:
|
||
includeRedisDatabases.length > 0 ? includeRedisDatabases : undefined,
|
||
iconType: sanitizeConnectionIconType(raw.iconType),
|
||
iconColor: sanitizeConnectionIconColor(raw.iconColor),
|
||
};
|
||
};
|
||
|
||
const sanitizeConnections = (value: unknown): SavedConnection[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
const result: SavedConnection[] = [];
|
||
const idSet = new Set<string>();
|
||
|
||
value.forEach((entry, index) => {
|
||
const conn = sanitizeSavedConnection(entry, index);
|
||
if (!conn) return;
|
||
let nextId = conn.id;
|
||
if (idSet.has(nextId)) {
|
||
nextId = `${nextId}-${index + 1}`;
|
||
}
|
||
idSet.add(nextId);
|
||
result.push({ ...conn, id: nextId });
|
||
});
|
||
|
||
return result;
|
||
};
|
||
|
||
const sanitizeConnectionTags = (value: unknown): ConnectionTag[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
const result: ConnectionTag[] = [];
|
||
const idSet = new Set<string>();
|
||
|
||
value.forEach((entry, index) => {
|
||
if (!entry || typeof entry !== "object") return;
|
||
const raw = entry as Record<string, unknown>;
|
||
const id =
|
||
toTrimmedString(raw.id, `tag-${index + 1}`) || `tag-${index + 1}`;
|
||
if (idSet.has(id)) return;
|
||
idSet.add(id);
|
||
|
||
const name =
|
||
toTrimmedString(raw.name, `标签-${index + 1}`) || `标签-${index + 1}`;
|
||
const connectionIds = sanitizeStringArray(raw.connectionIds, 256);
|
||
|
||
result.push({ id, name, connectionIds });
|
||
});
|
||
|
||
return result;
|
||
};
|
||
|
||
const isLegacyDefaultAppearance = (
|
||
appearance: Partial<{ opacity: number; blur: number }> | undefined,
|
||
): boolean => {
|
||
if (!appearance) {
|
||
return true;
|
||
}
|
||
const opacity =
|
||
typeof appearance.opacity === "number"
|
||
? appearance.opacity
|
||
: LEGACY_DEFAULT_OPACITY;
|
||
const blur = typeof appearance.blur === "number" ? appearance.blur : 0;
|
||
return (
|
||
Math.abs(opacity - LEGACY_DEFAULT_OPACITY) < OPACITY_EPSILON && blur === 0
|
||
);
|
||
};
|
||
|
||
export interface SqlLog {
|
||
id: string;
|
||
timestamp: number;
|
||
sql: string;
|
||
status: "success" | "error";
|
||
duration: number;
|
||
message?: string;
|
||
dbName?: string;
|
||
affectedRows?: number;
|
||
}
|
||
|
||
export interface QueryOptions {
|
||
maxRows: number;
|
||
showColumnComment: boolean;
|
||
showColumnType: boolean;
|
||
}
|
||
|
||
interface AppState {
|
||
connections: SavedConnection[];
|
||
connectionTags: ConnectionTag[];
|
||
tabs: TabData[];
|
||
activeTabId: string | null;
|
||
activeContext: { connectionId: string; dbName: string } | null;
|
||
savedQueries: SavedQuery[];
|
||
externalSQLDirectories: ExternalSQLDirectory[];
|
||
theme: "light" | "dark";
|
||
appearance: AppearanceSettings;
|
||
uiScale: number;
|
||
fontSize: number;
|
||
startupFullscreen: boolean;
|
||
globalProxy: GlobalProxyConfig;
|
||
sqlFormatOptions: { keywordCase: "upper" | "lower" };
|
||
queryOptions: QueryOptions;
|
||
shortcutOptions: ShortcutOptions;
|
||
sqlSnippets: SqlSnippet[];
|
||
sqlLogs: SqlLog[];
|
||
tableAccessCount: Record<string, number>;
|
||
tableSortPreference: Record<string, "name" | "frequency">;
|
||
tableColumnOrders: Record<string, string[]>;
|
||
enableColumnOrderMemory: boolean;
|
||
tableHiddenColumns: Record<string, string[]>;
|
||
enableHiddenColumnMemory: boolean;
|
||
windowBounds: { width: number; height: number; x: number; y: number } | null;
|
||
windowState: "normal" | "fullscreen" | "maximized";
|
||
sidebarWidth: number;
|
||
|
||
// AI 运行时与持久化状态
|
||
aiPanelVisible: boolean;
|
||
aiChatHistory: Record<string, AIChatMessage[]>; // sessionId -> messages
|
||
replaceAIChatHistory: (sessionId: string, messages: AIChatMessage[]) => void;
|
||
aiChatSessions: { id: string; title: string; updatedAt: number }[]; // 历史会话列表
|
||
aiActiveSessionId: string | null;
|
||
updateAISessionTitle: (sessionId: string, title: string) => void;
|
||
|
||
aiContexts: Record<string, AIContextItem[]>;
|
||
addAIContext: (connectionKey: string, context: AIContextItem) => void;
|
||
removeAIContext: (
|
||
connectionKey: string,
|
||
dbName: string,
|
||
tableName: string,
|
||
) => void;
|
||
clearAIContexts: (connectionKey: string) => void;
|
||
|
||
jvmDiagnosticDrafts: Record<string, JVMDiagnosticCommandDraft>;
|
||
jvmDiagnosticOutputs: Record<string, JVMDiagnosticEventChunk[]>;
|
||
setJVMDiagnosticDraft: (
|
||
tabId: string,
|
||
draft: Partial<JVMDiagnosticCommandDraft>,
|
||
) => void;
|
||
appendJVMDiagnosticOutput: (
|
||
tabId: string,
|
||
chunks: JVMDiagnosticEventChunk[],
|
||
) => void;
|
||
clearJVMDiagnosticOutput: (tabId: string) => void;
|
||
|
||
addConnection: (conn: SavedConnection) => void;
|
||
updateConnection: (conn: SavedConnection) => void;
|
||
removeConnection: (id: string) => void;
|
||
replaceConnections: (connections: SavedConnection[]) => void;
|
||
|
||
addConnectionTag: (tag: ConnectionTag) => void;
|
||
updateConnectionTag: (tag: ConnectionTag) => void;
|
||
removeConnectionTag: (id: string) => void;
|
||
moveConnectionToTag: (
|
||
connectionId: string,
|
||
targetTagId: string | null,
|
||
) => void;
|
||
reorderTags: (tagIds: string[]) => void;
|
||
|
||
addTab: (tab: TabData) => void;
|
||
closeTab: (id: string) => void;
|
||
closeOtherTabs: (id: string) => void;
|
||
closeTabsToLeft: (id: string) => void;
|
||
closeTabsToRight: (id: string) => void;
|
||
closeTabsByConnection: (connectionId: string) => void;
|
||
closeTabsByDatabase: (connectionId: string, dbName: string) => void;
|
||
moveTab: (sourceId: string, targetId: string) => void;
|
||
closeAllTabs: () => void;
|
||
setActiveTab: (id: string) => void;
|
||
setActiveContext: (
|
||
context: { connectionId: string; dbName: string } | null,
|
||
) => void;
|
||
|
||
saveQuery: (query: SavedQuery) => void;
|
||
deleteQuery: (id: string) => void;
|
||
saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void;
|
||
deleteExternalSQLDirectory: (id: string) => void;
|
||
|
||
setTheme: (theme: "light" | "dark") => void;
|
||
setAppearance: (appearance: Partial<AppearanceSettings>) => void;
|
||
setUiScale: (scale: number) => void;
|
||
setFontSize: (size: number) => void;
|
||
setStartupFullscreen: (enabled: boolean) => void;
|
||
setGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||
replaceGlobalProxy: (proxy: Partial<GlobalProxyConfig>) => void;
|
||
setSqlFormatOptions: (options: { keywordCase: "upper" | "lower" }) => void;
|
||
setQueryOptions: (options: Partial<QueryOptions>) => void;
|
||
updateShortcut: (
|
||
action: ShortcutAction,
|
||
binding: Partial<ShortcutBinding>,
|
||
) => void;
|
||
resetShortcutOptions: () => void;
|
||
saveSqlSnippet: (snippet: SqlSnippet) => void;
|
||
deleteSqlSnippet: (id: string) => void;
|
||
resetBuiltinSqlSnippet: (id: string) => void;
|
||
|
||
addSqlLog: (log: SqlLog) => void;
|
||
clearSqlLogs: () => void;
|
||
|
||
recordTableAccess: (
|
||
connectionId: string,
|
||
dbName: string,
|
||
tableName: string,
|
||
) => void;
|
||
setTableSortPreference: (
|
||
connectionId: string,
|
||
dbName: string,
|
||
sortBy: "name" | "frequency",
|
||
) => void;
|
||
setTableColumnOrder: (
|
||
connectionId: string,
|
||
dbName: string,
|
||
tableName: string,
|
||
order: string[],
|
||
) => void;
|
||
setEnableColumnOrderMemory: (enabled: boolean) => void;
|
||
clearTableColumnOrder: (
|
||
connectionId: string,
|
||
dbName: string,
|
||
tableName: string,
|
||
) => void;
|
||
|
||
setTableHiddenColumns: (
|
||
connectionId: string,
|
||
dbName: string,
|
||
tableName: string,
|
||
hiddenColumns: string[],
|
||
) => void;
|
||
setEnableHiddenColumnMemory: (enabled: boolean) => void;
|
||
clearTableHiddenColumns: (
|
||
connectionId: string,
|
||
dbName: string,
|
||
tableName: string,
|
||
) => void;
|
||
setWindowBounds: (bounds: {
|
||
width: number;
|
||
height: number;
|
||
x: number;
|
||
y: number;
|
||
}) => void;
|
||
setWindowState: (state: "normal" | "fullscreen" | "maximized") => void;
|
||
setSidebarWidth: (width: number) => void;
|
||
|
||
// AI actions
|
||
toggleAIPanel: () => void;
|
||
setAIPanelVisible: (visible: boolean) => void;
|
||
addAIChatMessage: (sessionId: string, message: AIChatMessage) => void;
|
||
updateAIChatMessage: (
|
||
sessionId: string,
|
||
messageId: string,
|
||
updates: Partial<AIChatMessage>,
|
||
) => void;
|
||
deleteAIChatMessage: (sessionId: string, messageId: string) => void;
|
||
truncateAIChatMessages: (sessionId: string, upToMessageId: string) => void;
|
||
clearAIChatHistory: (sessionId: string) => void;
|
||
deleteAISession: (sessionId: string) => void;
|
||
createNewAISession: () => void;
|
||
setAIActiveSessionId: (sessionId: string | null) => void;
|
||
}
|
||
|
||
const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
const result: SavedQuery[] = [];
|
||
value.forEach((entry, index) => {
|
||
if (!entry || typeof entry !== "object") return;
|
||
const raw = entry as Record<string, unknown>;
|
||
const id =
|
||
toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`;
|
||
const sql = toTrimmedString(raw.sql);
|
||
const connectionId = toTrimmedString(raw.connectionId);
|
||
const dbName = toTrimmedString(raw.dbName);
|
||
if (!sql || !connectionId || !dbName) return;
|
||
result.push({
|
||
id,
|
||
name:
|
||
toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`,
|
||
sql,
|
||
connectionId,
|
||
dbName,
|
||
createdAt: Number.isFinite(Number(raw.createdAt))
|
||
? Number(raw.createdAt)
|
||
: Date.now(),
|
||
});
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => {
|
||
if (!Array.isArray(value)) return DEFAULT_SQL_SNIPPETS;
|
||
const result: SqlSnippet[] = [];
|
||
const seenIds = new Set<string>();
|
||
value.forEach((entry, index) => {
|
||
if (!entry || typeof entry !== "object") return;
|
||
const raw = entry as Record<string, unknown>;
|
||
const prefix = toTrimmedString(raw.prefix)
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9_]/g, "")
|
||
.slice(0, 20);
|
||
const body = toTrimmedString(raw.body);
|
||
if (!prefix || !body) return;
|
||
const id = toTrimmedString(raw.id, `snippet-${index + 1}`) || `snippet-${index + 1}`;
|
||
if (seenIds.has(id)) return;
|
||
seenIds.add(id);
|
||
result.push({
|
||
id,
|
||
prefix,
|
||
name: toTrimmedString(raw.name, `片段-${index + 1}`) || `片段-${index + 1}`,
|
||
description: toTrimmedString(raw.description) || undefined,
|
||
body,
|
||
isBuiltin: raw.isBuiltin === true,
|
||
createdAt: Number.isFinite(Number(raw.createdAt))
|
||
? Number(raw.createdAt)
|
||
: Date.now(),
|
||
});
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const sanitizeExternalSQLDirectories = (
|
||
value: unknown,
|
||
): ExternalSQLDirectory[] => {
|
||
if (!Array.isArray(value)) return [];
|
||
const result: ExternalSQLDirectory[] = [];
|
||
value.forEach((entry, index) => {
|
||
if (!entry || typeof entry !== "object") return;
|
||
const raw = entry as Record<string, unknown>;
|
||
const path = toTrimmedString(raw.path);
|
||
const connectionId = toTrimmedString(raw.connectionId);
|
||
const dbName = toTrimmedString(raw.dbName);
|
||
if (!path || !connectionId || !dbName) return;
|
||
const fallbackName =
|
||
path.split(/[\\/]/).filter(Boolean).pop() || `SQL目录-${index + 1}`;
|
||
result.push({
|
||
id:
|
||
toTrimmedString(
|
||
raw.id,
|
||
buildExternalSQLDirectoryId(connectionId, dbName, path),
|
||
) || buildExternalSQLDirectoryId(connectionId, dbName, path),
|
||
name: toTrimmedString(raw.name, fallbackName) || fallbackName,
|
||
path,
|
||
connectionId,
|
||
dbName,
|
||
createdAt: Number.isFinite(Number(raw.createdAt))
|
||
? Number(raw.createdAt)
|
||
: Date.now(),
|
||
});
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const hasLegacyConnectionSecrets = (
|
||
connections: SavedConnection[],
|
||
): boolean => {
|
||
return connections.some((connection) => {
|
||
const config =
|
||
connection?.config && typeof connection.config === "object"
|
||
? (connection.config as unknown as Record<string, unknown>)
|
||
: {};
|
||
const ssh =
|
||
config.ssh && typeof config.ssh === "object"
|
||
? (config.ssh as Record<string, unknown>)
|
||
: {};
|
||
const proxy =
|
||
config.proxy && typeof config.proxy === "object"
|
||
? (config.proxy as Record<string, unknown>)
|
||
: {};
|
||
const httpTunnel =
|
||
config.httpTunnel && typeof config.httpTunnel === "object"
|
||
? (config.httpTunnel as Record<string, unknown>)
|
||
: {};
|
||
|
||
return (
|
||
toTrimmedString(config.password) !== "" ||
|
||
toTrimmedString(ssh.password) !== "" ||
|
||
toTrimmedString(proxy.password) !== "" ||
|
||
toTrimmedString(httpTunnel.password) !== "" ||
|
||
toTrimmedString(config.mysqlReplicaPassword) !== "" ||
|
||
toTrimmedString(config.mongoReplicaPassword) !== "" ||
|
||
toTrimmedString(config.uri) !== "" ||
|
||
toTrimmedString(config.dsn) !== ""
|
||
);
|
||
});
|
||
};
|
||
|
||
const sanitizeTheme = (value: unknown): "light" | "dark" =>
|
||
value === "dark" ? "dark" : "light";
|
||
|
||
const sanitizeSqlFormatOptions = (
|
||
value: unknown,
|
||
): { keywordCase: "upper" | "lower" } => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
return { keywordCase: raw.keywordCase === "lower" ? "lower" : "upper" };
|
||
};
|
||
|
||
const sanitizeQueryOptions = (value: unknown): QueryOptions => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const maxRows = Number(raw.maxRows);
|
||
const showColumnComment =
|
||
typeof raw.showColumnComment === "boolean" ? raw.showColumnComment : true;
|
||
const showColumnType =
|
||
typeof raw.showColumnType === "boolean" ? raw.showColumnType : true;
|
||
if (!Number.isFinite(maxRows) || maxRows <= 0) {
|
||
return { maxRows: 5000, showColumnComment, showColumnType };
|
||
}
|
||
return {
|
||
maxRows: Math.min(50000, Math.trunc(maxRows)),
|
||
showColumnComment,
|
||
showColumnType,
|
||
};
|
||
};
|
||
|
||
const sanitizeTableAccessCount = (value: unknown): Record<string, number> => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const result: Record<string, number> = {};
|
||
Object.entries(raw).forEach(([key, count]) => {
|
||
const parsed = Number(count);
|
||
if (!Number.isFinite(parsed) || parsed < 0) return;
|
||
result[key] = Math.trunc(parsed);
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const sanitizeTableSortPreference = (
|
||
value: unknown,
|
||
): Record<string, "name" | "frequency"> => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const result: Record<string, "name" | "frequency"> = {};
|
||
Object.entries(raw).forEach(([key, preference]) => {
|
||
result[key] = preference === "frequency" ? "frequency" : "name";
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const sanitizeTableColumnOrders = (
|
||
value: unknown,
|
||
): Record<string, string[]> => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const result: Record<string, string[]> = {};
|
||
Object.entries(raw).forEach(([key, orderArray]) => {
|
||
if (Array.isArray(orderArray)) {
|
||
result[key] = orderArray.map((col) => String(col));
|
||
}
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const sanitizeTableHiddenColumns = (
|
||
value: unknown,
|
||
): Record<string, string[]> => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const result: Record<string, string[]> = {};
|
||
Object.entries(raw).forEach(([key, hiddenArray]) => {
|
||
if (Array.isArray(hiddenArray)) {
|
||
result[key] = hiddenArray.map((col) => String(col));
|
||
}
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const sanitizeAppearance = (
|
||
appearance: Partial<AppearanceSettings> | undefined,
|
||
version: number,
|
||
): AppearanceSettings => {
|
||
if (!appearance || typeof appearance !== "object") {
|
||
return { ...DEFAULT_APPEARANCE };
|
||
}
|
||
const dataGridDisplaySettings = sanitizeDataGridDisplaySettings(appearance);
|
||
const nextAppearance = {
|
||
enabled:
|
||
typeof appearance.enabled === "boolean"
|
||
? appearance.enabled
|
||
: DEFAULT_APPEARANCE.enabled,
|
||
opacity:
|
||
typeof appearance.opacity === "number"
|
||
? appearance.opacity
|
||
: DEFAULT_APPEARANCE.opacity,
|
||
blur:
|
||
typeof appearance.blur === "number"
|
||
? appearance.blur
|
||
: DEFAULT_APPEARANCE.blur,
|
||
useNativeMacWindowControls:
|
||
typeof appearance.useNativeMacWindowControls === "boolean"
|
||
? appearance.useNativeMacWindowControls
|
||
: DEFAULT_APPEARANCE.useNativeMacWindowControls,
|
||
showDataTableVerticalBorders:
|
||
dataGridDisplaySettings.showDataTableVerticalBorders,
|
||
dataTableDensity: dataGridDisplaySettings.dataTableDensity,
|
||
};
|
||
if (version < 2 && isLegacyDefaultAppearance(appearance)) {
|
||
return { ...DEFAULT_APPEARANCE };
|
||
}
|
||
return nextAppearance;
|
||
};
|
||
|
||
const sanitizeStartupFullscreen = (value: unknown): boolean => {
|
||
return value === true;
|
||
};
|
||
|
||
const sanitizeUiScale = (value: unknown): number => {
|
||
return normalizeFloatInRange(
|
||
value,
|
||
DEFAULT_UI_SCALE,
|
||
MIN_UI_SCALE,
|
||
MAX_UI_SCALE,
|
||
);
|
||
};
|
||
|
||
const sanitizeFontSize = (value: unknown): number => {
|
||
return normalizeIntegerInRange(
|
||
value,
|
||
DEFAULT_FONT_SIZE,
|
||
MIN_FONT_SIZE,
|
||
MAX_FONT_SIZE,
|
||
);
|
||
};
|
||
|
||
const sanitizeGlobalProxy = (
|
||
value: unknown,
|
||
options: { allowPassword?: boolean } = {},
|
||
): GlobalProxyConfig => {
|
||
const raw =
|
||
value && typeof value === "object"
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
const typeRaw = toTrimmedString(
|
||
raw.type,
|
||
DEFAULT_GLOBAL_PROXY.type,
|
||
).toLowerCase();
|
||
const type: "socks5" | "http" = typeRaw === "http" ? "http" : "socks5";
|
||
const fallbackPort = type === "http" ? 8080 : 1080;
|
||
const password = toTrimmedString(raw.password);
|
||
return {
|
||
enabled: raw.enabled === true,
|
||
type,
|
||
host: toTrimmedString(raw.host),
|
||
port: normalizePort(raw.port, fallbackPort),
|
||
user: toTrimmedString(raw.user),
|
||
password: options.allowPassword === false ? "" : password,
|
||
hasPassword: raw.hasPassword === true || password !== "",
|
||
secretRef: toTrimmedString(raw.secretRef) || undefined,
|
||
};
|
||
};
|
||
|
||
const sanitizeWindowState = (
|
||
value: unknown,
|
||
): "normal" | "fullscreen" | "maximized" => {
|
||
if (value === "fullscreen" || value === "maximized") return value;
|
||
return "normal";
|
||
};
|
||
|
||
const sanitizeSidebarWidth = (value: unknown): number => {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return 330;
|
||
return Math.max(200, Math.min(600, Math.trunc(parsed)));
|
||
};
|
||
|
||
const sanitizeWindowBounds = (
|
||
value: unknown,
|
||
): { width: number; height: number; x: number; y: number } | null => {
|
||
if (!value || typeof value !== "object") return null;
|
||
const raw = value as Record<string, unknown>;
|
||
const width = Number(raw.width);
|
||
const height = Number(raw.height);
|
||
const x = Number(raw.x);
|
||
const y = Number(raw.y);
|
||
if (
|
||
!Number.isFinite(width) ||
|
||
!Number.isFinite(height) ||
|
||
!Number.isFinite(x) ||
|
||
!Number.isFinite(y)
|
||
)
|
||
return null;
|
||
if (width < 400 || height < 300) return null;
|
||
return {
|
||
width: Math.trunc(width),
|
||
height: Math.trunc(height),
|
||
x: Math.trunc(x),
|
||
y: Math.trunc(y),
|
||
};
|
||
};
|
||
|
||
const unwrapPersistedAppState = (
|
||
persistedState: unknown,
|
||
): Record<string, unknown> => {
|
||
if (!persistedState || typeof persistedState !== "object") {
|
||
return {};
|
||
}
|
||
const raw = persistedState as Record<string, unknown>;
|
||
if (raw.state && typeof raw.state === "object") {
|
||
return raw.state as Record<string, unknown>;
|
||
}
|
||
return raw;
|
||
};
|
||
|
||
let shortcutOptionsExplicitlySet = false;
|
||
|
||
const readPersistedShortcutOptions = (): ShortcutOptions | null => {
|
||
if (typeof localStorage === "undefined") {
|
||
return null;
|
||
}
|
||
try {
|
||
const payload = localStorage.getItem(PERSIST_STORAGE_KEY);
|
||
if (!payload) {
|
||
return null;
|
||
}
|
||
const state = unwrapPersistedAppState(JSON.parse(payload));
|
||
if (state.shortcutOptions === undefined) {
|
||
return null;
|
||
}
|
||
return sanitizeShortcutOptions(state.shortcutOptions);
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const resolveShortcutOptionsForPersistence = (
|
||
shortcutOptions: ShortcutOptions,
|
||
): ShortcutOptions => {
|
||
const safeOptions = sanitizeShortcutOptions(shortcutOptions);
|
||
if (shortcutOptionsExplicitlySet) {
|
||
return safeOptions;
|
||
}
|
||
return readPersistedShortcutOptions() ?? safeOptions;
|
||
};
|
||
|
||
const runWithExplicitShortcutPersistence = (callback: () => void): void => {
|
||
shortcutOptionsExplicitlySet = true;
|
||
try {
|
||
callback();
|
||
} finally {
|
||
shortcutOptionsExplicitlySet = false;
|
||
}
|
||
};
|
||
|
||
// --- AI 会话文件持久化辅助函数 ---
|
||
|
||
/** 每个 session 独立防抖定时器(2秒) */
|
||
const _persistTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||
|
||
function _debouncedPersistSession(sessionId: string) {
|
||
if (_persistTimers[sessionId]) clearTimeout(_persistTimers[sessionId]);
|
||
_persistTimers[sessionId] = setTimeout(() => {
|
||
delete _persistTimers[sessionId];
|
||
const state = useStore.getState();
|
||
const messages = state.aiChatHistory[sessionId];
|
||
const sessionMeta = state.aiChatSessions.find((s) => s.id === sessionId);
|
||
if (!messages && !sessionMeta) return; // session 已被删除,跳过
|
||
const title = sessionMeta?.title || "新的对话";
|
||
const updatedAt = sessionMeta?.updatedAt || Date.now();
|
||
const messagesJSON = JSON.stringify(messages || []);
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
Service?.AISaveSession?.(sessionId, title, updatedAt, messagesJSON).catch(
|
||
(e: any) => {
|
||
console.error("[AI Session Persist] 持久化失败:", sessionId, e);
|
||
},
|
||
);
|
||
}, 2000);
|
||
}
|
||
|
||
/** 从后端加载会话列表(仅元数据,不含消息体) */
|
||
export async function loadAISessionsFromBackend(): Promise<
|
||
{ id: string; title: string; updatedAt: number }[]
|
||
> {
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
if (!Service?.AIGetSessions) return [];
|
||
try {
|
||
const sessions = await Service.AIGetSessions();
|
||
if (Array.isArray(sessions)) {
|
||
useStore.setState({ aiChatSessions: sessions });
|
||
return sessions;
|
||
}
|
||
} catch (e) {
|
||
console.error("[AI Session] 加载会话列表失败:", e);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
/** 从后端加载指定会话的消息数据到内存 */
|
||
export async function loadAISessionFromBackend(
|
||
sessionId: string,
|
||
): Promise<boolean> {
|
||
const state = useStore.getState();
|
||
// 如果内存中已有消息,跳过重复加载
|
||
if (state.aiChatHistory[sessionId]?.length > 0) return true;
|
||
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
if (!Service?.AILoadSession) return false;
|
||
try {
|
||
const result = await Service.AILoadSession(sessionId);
|
||
if (result?.success) {
|
||
let messages = result.messages;
|
||
// messages 可能是 JSON string 或已解析的数组
|
||
if (typeof messages === "string") {
|
||
try {
|
||
messages = JSON.parse(messages);
|
||
} catch {
|
||
messages = [];
|
||
}
|
||
}
|
||
if (Array.isArray(messages)) {
|
||
useStore.setState((prev) => ({
|
||
aiChatHistory: { ...prev.aiChatHistory, [sessionId]: messages },
|
||
}));
|
||
return true;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("[AI Session] 加载会话消息失败:", sessionId, e);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export const useStore = create<AppState>()(
|
||
persist(
|
||
(set) => ({
|
||
connections: [],
|
||
connectionTags: [],
|
||
tabs: [],
|
||
activeTabId: null,
|
||
activeContext: null,
|
||
savedQueries: [],
|
||
externalSQLDirectories: [],
|
||
theme: "light",
|
||
appearance: { ...DEFAULT_APPEARANCE },
|
||
uiScale: DEFAULT_UI_SCALE,
|
||
fontSize: DEFAULT_FONT_SIZE,
|
||
startupFullscreen: DEFAULT_STARTUP_FULLSCREEN,
|
||
globalProxy: { ...DEFAULT_GLOBAL_PROXY },
|
||
sqlFormatOptions: { keywordCase: "upper" },
|
||
queryOptions: {
|
||
maxRows: 5000,
|
||
showColumnComment: true,
|
||
showColumnType: true,
|
||
},
|
||
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
|
||
sqlSnippets: DEFAULT_SQL_SNIPPETS,
|
||
sqlLogs: [],
|
||
tableAccessCount: {},
|
||
tableSortPreference: {},
|
||
tableColumnOrders: {},
|
||
enableColumnOrderMemory: true,
|
||
tableHiddenColumns: {},
|
||
enableHiddenColumnMemory: true,
|
||
windowBounds: null,
|
||
windowState: "normal" as const,
|
||
sidebarWidth: 330,
|
||
|
||
// AI 运行状态
|
||
aiPanelVisible: false,
|
||
aiChatHistory: {},
|
||
aiChatSessions: [],
|
||
aiActiveSessionId: null,
|
||
aiContexts: {},
|
||
jvmDiagnosticDrafts: {},
|
||
jvmDiagnosticOutputs: {},
|
||
|
||
addConnection: (conn) =>
|
||
set((state) => {
|
||
const sanitized = sanitizeSavedConnection(
|
||
conn,
|
||
state.connections.length,
|
||
);
|
||
if (!sanitized) {
|
||
return { connections: state.connections };
|
||
}
|
||
return { connections: [...state.connections, sanitized] };
|
||
}),
|
||
updateConnection: (conn) =>
|
||
set((state) => {
|
||
const sanitized = sanitizeSavedConnection(
|
||
conn,
|
||
state.connections.length,
|
||
);
|
||
if (!sanitized) {
|
||
return { connections: state.connections };
|
||
}
|
||
return {
|
||
connections: state.connections.map((c) =>
|
||
c.id === conn.id ? sanitized : c,
|
||
),
|
||
};
|
||
}),
|
||
removeConnection: (id) =>
|
||
set((state) => ({
|
||
connections: state.connections.filter((c) => c.id !== id),
|
||
connectionTags: state.connectionTags.map((tag) => ({
|
||
...tag,
|
||
connectionIds: tag.connectionIds.filter((cid) => cid !== id),
|
||
})),
|
||
})),
|
||
replaceConnections: (connections) =>
|
||
set((state) => ({
|
||
connections: sanitizeConnections(connections),
|
||
shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
|
||
})),
|
||
|
||
addConnectionTag: (tag) =>
|
||
set((state) => ({ connectionTags: [...state.connectionTags, tag] })),
|
||
updateConnectionTag: (tag) =>
|
||
set((state) => ({
|
||
connectionTags: state.connectionTags.map((t) =>
|
||
t.id === tag.id ? tag : t,
|
||
),
|
||
})),
|
||
removeConnectionTag: (id) =>
|
||
set((state) => ({
|
||
connectionTags: state.connectionTags.filter((t) => t.id !== id),
|
||
})),
|
||
moveConnectionToTag: (connectionId, targetTagId) =>
|
||
set((state) => {
|
||
const newTags = state.connectionTags.map((tag) => {
|
||
//先从所有tag中移除该connection
|
||
const filteredIds = tag.connectionIds.filter(
|
||
(id) => id !== connectionId,
|
||
);
|
||
if (tag.id === targetTagId) {
|
||
return { ...tag, connectionIds: [...filteredIds, connectionId] };
|
||
}
|
||
return { ...tag, connectionIds: filteredIds };
|
||
});
|
||
return { connectionTags: newTags };
|
||
}),
|
||
reorderTags: (tagIds) =>
|
||
set((state) => {
|
||
const tagMap = new Map(state.connectionTags.map((t) => [t.id, t]));
|
||
const newTags: ConnectionTag[] = [];
|
||
tagIds.forEach((id) => {
|
||
const tag = tagMap.get(id);
|
||
if (tag) {
|
||
newTags.push(tag);
|
||
tagMap.delete(id);
|
||
}
|
||
});
|
||
// 追加未指定的tag(如果有的话)
|
||
newTags.push(...Array.from(tagMap.values()));
|
||
return { connectionTags: newTags };
|
||
}),
|
||
|
||
addTab: (tab) =>
|
||
set((state) => {
|
||
const index = state.tabs.findIndex((t) => t.id === tab.id);
|
||
if (index !== -1) {
|
||
// Update existing tab with new data (e.g. switch initialTab)
|
||
const newTabs = [...state.tabs];
|
||
newTabs[index] = { ...newTabs[index], ...tab };
|
||
return { tabs: newTabs, activeTabId: tab.id };
|
||
}
|
||
// 语义去重:对 table/design 类型按 connectionId+dbName+tableName 匹配已有 Tab
|
||
if (
|
||
(tab.type === "table" || tab.type === "design") &&
|
||
tab.tableName &&
|
||
tab.connectionId &&
|
||
tab.dbName
|
||
) {
|
||
const semanticIndex = state.tabs.findIndex(
|
||
(t) =>
|
||
t.type === tab.type &&
|
||
t.connectionId === tab.connectionId &&
|
||
t.dbName === tab.dbName &&
|
||
t.tableName === tab.tableName,
|
||
);
|
||
if (semanticIndex !== -1) {
|
||
const existingTab = state.tabs[semanticIndex];
|
||
const newTabs = [...state.tabs];
|
||
newTabs[semanticIndex] = {
|
||
...existingTab,
|
||
...tab,
|
||
id: existingTab.id,
|
||
};
|
||
return { tabs: newTabs, activeTabId: existingTab.id };
|
||
}
|
||
}
|
||
// 语义去重:对 query 类型按 savedQueryId 匹配已有 Tab(避免保存后重复打开)
|
||
if (tab.type === "query" && tab.savedQueryId) {
|
||
const savedQueryIndex = state.tabs.findIndex(
|
||
(t) =>
|
||
t.type === "query" &&
|
||
(t.savedQueryId === tab.savedQueryId ||
|
||
t.id === tab.savedQueryId),
|
||
);
|
||
if (savedQueryIndex !== -1) {
|
||
const existingTab = state.tabs[savedQueryIndex];
|
||
const newTabs = [...state.tabs];
|
||
newTabs[savedQueryIndex] = {
|
||
...existingTab,
|
||
...tab,
|
||
id: existingTab.id,
|
||
};
|
||
return { tabs: newTabs, activeTabId: existingTab.id };
|
||
}
|
||
}
|
||
return { tabs: [...state.tabs, tab], activeTabId: tab.id };
|
||
}),
|
||
|
||
closeTab: (id) =>
|
||
set((state) => {
|
||
const newTabs = state.tabs.filter((t) => t.id !== id);
|
||
let newActiveId = state.activeTabId;
|
||
if (state.activeTabId === id) {
|
||
newActiveId =
|
||
newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null;
|
||
}
|
||
return { tabs: newTabs, activeTabId: newActiveId };
|
||
}),
|
||
|
||
closeOtherTabs: (id) =>
|
||
set((state) => {
|
||
const keep = state.tabs.find((t) => t.id === id);
|
||
if (!keep) return state;
|
||
return { tabs: [keep], activeTabId: id };
|
||
}),
|
||
|
||
closeTabsToLeft: (id) =>
|
||
set((state) => {
|
||
const index = state.tabs.findIndex((t) => t.id === id);
|
||
if (index === -1) return state;
|
||
const newTabs = state.tabs.slice(index);
|
||
const activeStillExists = state.activeTabId
|
||
? newTabs.some((t) => t.id === state.activeTabId)
|
||
: false;
|
||
return {
|
||
tabs: newTabs,
|
||
activeTabId: activeStillExists ? state.activeTabId : id,
|
||
};
|
||
}),
|
||
|
||
closeTabsToRight: (id) =>
|
||
set((state) => {
|
||
const index = state.tabs.findIndex((t) => t.id === id);
|
||
if (index === -1) return state;
|
||
const newTabs = state.tabs.slice(0, index + 1);
|
||
const activeStillExists = state.activeTabId
|
||
? newTabs.some((t) => t.id === state.activeTabId)
|
||
: false;
|
||
return {
|
||
tabs: newTabs,
|
||
activeTabId: activeStillExists ? state.activeTabId : id,
|
||
};
|
||
}),
|
||
|
||
closeTabsByConnection: (connectionId) =>
|
||
set((state) => {
|
||
const targetConnectionId = String(connectionId || "").trim();
|
||
if (!targetConnectionId) return state;
|
||
const newTabs = state.tabs.filter(
|
||
(t) => String(t.connectionId || "").trim() !== targetConnectionId,
|
||
);
|
||
const activeStillExists = state.activeTabId
|
||
? newTabs.some((t) => t.id === state.activeTabId)
|
||
: false;
|
||
const nextActiveTabId = activeStillExists
|
||
? state.activeTabId
|
||
: newTabs.length > 0
|
||
? newTabs[newTabs.length - 1].id
|
||
: null;
|
||
const nextActiveContext =
|
||
state.activeContext?.connectionId === targetConnectionId
|
||
? null
|
||
: state.activeContext;
|
||
return {
|
||
tabs: newTabs,
|
||
activeTabId: nextActiveTabId,
|
||
activeContext: nextActiveContext,
|
||
};
|
||
}),
|
||
|
||
closeTabsByDatabase: (connectionId, dbName) =>
|
||
set((state) => {
|
||
const targetConnectionId = String(connectionId || "").trim();
|
||
const targetDbName = String(dbName || "").trim();
|
||
if (!targetConnectionId || !targetDbName) return state;
|
||
const newTabs = state.tabs.filter((tab) => {
|
||
const sameConnection =
|
||
String(tab.connectionId || "").trim() === targetConnectionId;
|
||
const sameDb = String(tab.dbName || "").trim() === targetDbName;
|
||
return !(sameConnection && sameDb);
|
||
});
|
||
const activeStillExists = state.activeTabId
|
||
? newTabs.some((t) => t.id === state.activeTabId)
|
||
: false;
|
||
const nextActiveTabId = activeStillExists
|
||
? state.activeTabId
|
||
: newTabs.length > 0
|
||
? newTabs[newTabs.length - 1].id
|
||
: null;
|
||
const sameActiveContext =
|
||
state.activeContext &&
|
||
state.activeContext.connectionId === targetConnectionId &&
|
||
state.activeContext.dbName === targetDbName;
|
||
return {
|
||
tabs: newTabs,
|
||
activeTabId: nextActiveTabId,
|
||
activeContext: sameActiveContext ? null : state.activeContext,
|
||
};
|
||
}),
|
||
|
||
moveTab: (sourceId, targetId) =>
|
||
set((state) => {
|
||
const fromId = String(sourceId || "").trim();
|
||
const toId = String(targetId || "").trim();
|
||
if (!fromId || !toId || fromId === toId) {
|
||
return state;
|
||
}
|
||
const fromIndex = state.tabs.findIndex((tab) => tab.id === fromId);
|
||
const toIndex = state.tabs.findIndex((tab) => tab.id === toId);
|
||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
||
return state;
|
||
}
|
||
const nextTabs = [...state.tabs];
|
||
const [movingTab] = nextTabs.splice(fromIndex, 1);
|
||
nextTabs.splice(toIndex, 0, movingTab);
|
||
return { tabs: nextTabs };
|
||
}),
|
||
|
||
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
|
||
|
||
setActiveTab: (id) => set({ activeTabId: id }),
|
||
setActiveContext: (context) => set({ activeContext: context }),
|
||
|
||
saveQuery: (query) =>
|
||
set((state) => {
|
||
// If query with same ID exists, update it
|
||
const existing = state.savedQueries.find((q) => q.id === query.id);
|
||
if (existing) {
|
||
return {
|
||
savedQueries: state.savedQueries.map((q) =>
|
||
q.id === query.id ? query : q,
|
||
),
|
||
};
|
||
}
|
||
return { savedQueries: [...state.savedQueries, query] };
|
||
}),
|
||
|
||
deleteQuery: (id) =>
|
||
set((state) => ({
|
||
savedQueries: state.savedQueries.filter((q) => q.id !== id),
|
||
})),
|
||
|
||
saveExternalSQLDirectory: (directory) =>
|
||
set((state) => {
|
||
const path = toTrimmedString(directory.path);
|
||
const connectionId = toTrimmedString(directory.connectionId);
|
||
const dbName = toTrimmedString(directory.dbName);
|
||
if (!path || !connectionId || !dbName) {
|
||
return state;
|
||
}
|
||
const nextDirectory: ExternalSQLDirectory = {
|
||
id:
|
||
toTrimmedString(
|
||
directory.id,
|
||
buildExternalSQLDirectoryId(connectionId, dbName, path),
|
||
) || buildExternalSQLDirectoryId(connectionId, dbName, path),
|
||
name:
|
||
toTrimmedString(
|
||
directory.name,
|
||
path.split(/[\\/]/).filter(Boolean).pop() || "SQL目录",
|
||
) || "SQL目录",
|
||
path,
|
||
connectionId,
|
||
dbName,
|
||
createdAt: Number.isFinite(Number(directory.createdAt))
|
||
? Number(directory.createdAt)
|
||
: Date.now(),
|
||
};
|
||
const existingIndex = state.externalSQLDirectories.findIndex(
|
||
(item) =>
|
||
item.id === nextDirectory.id ||
|
||
(item.connectionId === nextDirectory.connectionId &&
|
||
item.dbName === nextDirectory.dbName &&
|
||
item.path === nextDirectory.path),
|
||
);
|
||
if (existingIndex === -1) {
|
||
return {
|
||
externalSQLDirectories: [
|
||
...state.externalSQLDirectories,
|
||
nextDirectory,
|
||
],
|
||
};
|
||
}
|
||
return {
|
||
externalSQLDirectories: state.externalSQLDirectories.map(
|
||
(item, index) => (index === existingIndex ? nextDirectory : item),
|
||
),
|
||
};
|
||
}),
|
||
|
||
deleteExternalSQLDirectory: (id) =>
|
||
set((state) => ({
|
||
externalSQLDirectories: state.externalSQLDirectories.filter(
|
||
(item) => item.id !== id,
|
||
),
|
||
})),
|
||
|
||
setTheme: (theme) => set({ theme }),
|
||
setAppearance: (appearance) =>
|
||
set((state) => ({
|
||
appearance: { ...state.appearance, ...appearance },
|
||
})),
|
||
setUiScale: (scale) => set({ uiScale: sanitizeUiScale(scale) }),
|
||
setFontSize: (size) => set({ fontSize: sanitizeFontSize(size) }),
|
||
setStartupFullscreen: (enabled) => set({ startupFullscreen: !!enabled }),
|
||
setGlobalProxy: (proxy) =>
|
||
set((state) => ({
|
||
globalProxy: sanitizeGlobalProxy({ ...state.globalProxy, ...proxy }),
|
||
})),
|
||
replaceGlobalProxy: (proxy) =>
|
||
set((state) => ({
|
||
globalProxy: sanitizeGlobalProxy({
|
||
...DEFAULT_GLOBAL_PROXY,
|
||
...proxy,
|
||
}),
|
||
shortcutOptions: readPersistedShortcutOptions() ?? state.shortcutOptions,
|
||
})),
|
||
setSqlFormatOptions: (options) => set({ sqlFormatOptions: options }),
|
||
setQueryOptions: (options) =>
|
||
set((state) => ({
|
||
queryOptions: { ...state.queryOptions, ...options },
|
||
})),
|
||
updateShortcut: (action, binding) => {
|
||
runWithExplicitShortcutPersistence(() => {
|
||
set((state) => ({
|
||
shortcutOptions: {
|
||
...state.shortcutOptions,
|
||
[action]: {
|
||
...state.shortcutOptions[action],
|
||
...binding,
|
||
},
|
||
},
|
||
}));
|
||
});
|
||
},
|
||
resetShortcutOptions: () => {
|
||
runWithExplicitShortcutPersistence(() => {
|
||
set({
|
||
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
|
||
});
|
||
});
|
||
},
|
||
|
||
saveSqlSnippet: (snippet) =>
|
||
set((state) => {
|
||
const existing = state.sqlSnippets.findIndex((s) => s.id === snippet.id);
|
||
if (existing >= 0) {
|
||
const updated = [...state.sqlSnippets];
|
||
updated[existing] = snippet;
|
||
return { sqlSnippets: updated };
|
||
}
|
||
return { sqlSnippets: [...state.sqlSnippets, snippet] };
|
||
}),
|
||
deleteSqlSnippet: (id) =>
|
||
set((state) => ({
|
||
sqlSnippets: state.sqlSnippets.filter(
|
||
(s) => s.id !== id || s.isBuiltin,
|
||
),
|
||
})),
|
||
resetBuiltinSqlSnippet: (id) =>
|
||
set((state) => {
|
||
const original = BUILTIN_SNIPPET_MAP[id];
|
||
if (!original) return state;
|
||
return {
|
||
sqlSnippets: state.sqlSnippets.map((s) =>
|
||
s.id === id ? { ...original } : s,
|
||
),
|
||
};
|
||
}),
|
||
|
||
addSqlLog: (log) =>
|
||
set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs
|
||
clearSqlLogs: () => set({ sqlLogs: [] }),
|
||
|
||
recordTableAccess: (connectionId, dbName, tableName) =>
|
||
set((state) => {
|
||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||
const currentCount = state.tableAccessCount[key] || 0;
|
||
return {
|
||
tableAccessCount: {
|
||
...state.tableAccessCount,
|
||
[key]: currentCount + 1,
|
||
},
|
||
};
|
||
}),
|
||
|
||
setTableSortPreference: (connectionId, dbName, sortBy) =>
|
||
set((state) => {
|
||
const key = `${connectionId}-${dbName}`;
|
||
return {
|
||
tableSortPreference: {
|
||
...state.tableSortPreference,
|
||
[key]: sortBy,
|
||
},
|
||
};
|
||
}),
|
||
|
||
setTableColumnOrder: (connectionId, dbName, tableName, order) =>
|
||
set((state) => {
|
||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||
return {
|
||
tableColumnOrders: {
|
||
...state.tableColumnOrders,
|
||
[key]: order,
|
||
},
|
||
};
|
||
}),
|
||
|
||
clearTableColumnOrder: (connectionId, dbName, tableName) =>
|
||
set((state) => {
|
||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||
const newOrders = { ...state.tableColumnOrders };
|
||
delete newOrders[key];
|
||
return { tableColumnOrders: newOrders };
|
||
}),
|
||
|
||
setEnableColumnOrderMemory: (enabled) =>
|
||
set({ enableColumnOrderMemory: !!enabled }),
|
||
|
||
setTableHiddenColumns: (connectionId, dbName, tableName, hiddenColumns) =>
|
||
set((state) => {
|
||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||
return {
|
||
tableHiddenColumns: {
|
||
...state.tableHiddenColumns,
|
||
[key]: hiddenColumns,
|
||
},
|
||
};
|
||
}),
|
||
|
||
clearTableHiddenColumns: (connectionId, dbName, tableName) =>
|
||
set((state) => {
|
||
const key = `${connectionId}-${dbName}-${tableName}`;
|
||
const newHidden = { ...state.tableHiddenColumns };
|
||
delete newHidden[key];
|
||
return { tableHiddenColumns: newHidden };
|
||
}),
|
||
|
||
setEnableHiddenColumnMemory: (enabled) =>
|
||
set({ enableHiddenColumnMemory: !!enabled }),
|
||
|
||
setWindowBounds: (bounds) =>
|
||
set({
|
||
windowBounds: {
|
||
width: Math.max(400, Math.trunc(bounds.width)),
|
||
height: Math.max(300, Math.trunc(bounds.height)),
|
||
x: Math.trunc(bounds.x),
|
||
y: Math.trunc(bounds.y),
|
||
},
|
||
}),
|
||
|
||
setWindowState: (state) => set({ windowState: state }),
|
||
|
||
setSidebarWidth: (width) =>
|
||
set({ sidebarWidth: Math.max(200, Math.min(600, Math.trunc(width))) }),
|
||
|
||
// AI actions
|
||
toggleAIPanel: () =>
|
||
set((state) => ({ aiPanelVisible: !state.aiPanelVisible })),
|
||
setAIPanelVisible: (visible) => set({ aiPanelVisible: visible }),
|
||
addAIChatMessage: (sessionId, message) => {
|
||
set((state) => {
|
||
const history = { ...state.aiChatHistory };
|
||
const messages = history[sessionId] || [];
|
||
history[sessionId] = [...messages, message];
|
||
|
||
let newSessions = [...state.aiChatSessions];
|
||
const existingSession = newSessions.find((s) => s.id === sessionId);
|
||
|
||
if (!existingSession) {
|
||
let title = message.role === "user" ? message.content : "新的对话";
|
||
if (title.length > 20) {
|
||
title = title.substring(0, 20) + "...";
|
||
}
|
||
newSessions.unshift({
|
||
id: sessionId,
|
||
title,
|
||
updatedAt: Date.now(),
|
||
});
|
||
} else {
|
||
newSessions = newSessions.filter((s) => s.id !== sessionId);
|
||
newSessions.unshift({ ...existingSession, updatedAt: Date.now() });
|
||
}
|
||
|
||
return { aiChatHistory: history, aiChatSessions: newSessions };
|
||
});
|
||
// 异步持久化到文件(fire-and-forget,防抖由外层控制)
|
||
_debouncedPersistSession(sessionId);
|
||
},
|
||
updateAIChatMessage: (sessionId, messageId, updates) => {
|
||
set((state) => {
|
||
const messages = state.aiChatHistory[sessionId];
|
||
if (!messages) return state;
|
||
const idx = messages.findIndex((m) => m.id === messageId);
|
||
if (idx < 0) return state;
|
||
const newMessages = [...messages];
|
||
newMessages[idx] = { ...newMessages[idx], ...updates };
|
||
const history = { ...state.aiChatHistory, [sessionId]: newMessages };
|
||
const isContentOnlyUpdate =
|
||
Object.keys(updates).length === 1 && "content" in updates;
|
||
if (!isContentOnlyUpdate) {
|
||
let newSessions = [...state.aiChatSessions];
|
||
const existingSession = newSessions.find((s) => s.id === sessionId);
|
||
if (existingSession) {
|
||
newSessions = newSessions.filter((s) => s.id !== sessionId);
|
||
newSessions.unshift({
|
||
...existingSession,
|
||
updatedAt: Date.now(),
|
||
});
|
||
}
|
||
return { aiChatHistory: history, aiChatSessions: newSessions };
|
||
}
|
||
return { aiChatHistory: history };
|
||
});
|
||
// 流式打字高频调用,防抖 2 秒后才写磁盘
|
||
_debouncedPersistSession(sessionId);
|
||
},
|
||
deleteAIChatMessage: (sessionId, messageId) => {
|
||
set((state) => {
|
||
const history = { ...state.aiChatHistory };
|
||
if (history[sessionId]) {
|
||
history[sessionId] = history[sessionId].filter(
|
||
(m) => m.id !== messageId,
|
||
);
|
||
}
|
||
return { aiChatHistory: history };
|
||
});
|
||
_debouncedPersistSession(sessionId);
|
||
},
|
||
truncateAIChatMessages: (sessionId, upToMessageId) => {
|
||
set((state) => {
|
||
const history = { ...state.aiChatHistory };
|
||
const messages = history[sessionId];
|
||
if (messages) {
|
||
const idx = messages.findIndex((m) => m.id === upToMessageId);
|
||
if (idx >= 0) {
|
||
history[sessionId] = messages.slice(0, idx + 1);
|
||
}
|
||
}
|
||
return { aiChatHistory: history };
|
||
});
|
||
_debouncedPersistSession(sessionId);
|
||
},
|
||
clearAIChatHistory: (sessionId) => {
|
||
set((state) => {
|
||
const history = { ...state.aiChatHistory };
|
||
delete history[sessionId];
|
||
return { aiChatHistory: history };
|
||
});
|
||
_debouncedPersistSession(sessionId);
|
||
},
|
||
replaceAIChatHistory: (sessionId, messages) => {
|
||
set((state) => {
|
||
const history = { ...state.aiChatHistory };
|
||
history[sessionId] = messages;
|
||
return { aiChatHistory: history };
|
||
});
|
||
_debouncedPersistSession(sessionId);
|
||
},
|
||
deleteAISession: (sessionId) => {
|
||
set((state) => {
|
||
const history = { ...state.aiChatHistory };
|
||
delete history[sessionId];
|
||
const newSessions = state.aiChatSessions.filter(
|
||
(s) => s.id !== sessionId,
|
||
);
|
||
const newActive =
|
||
state.aiActiveSessionId === sessionId
|
||
? null
|
||
: state.aiActiveSessionId;
|
||
return {
|
||
aiChatHistory: history,
|
||
aiChatSessions: newSessions,
|
||
aiActiveSessionId: newActive,
|
||
};
|
||
});
|
||
// 删除文件
|
||
const Service = (window as any).go?.aiservice?.Service;
|
||
Service?.AIDeleteSession?.(sessionId).catch(() => {});
|
||
},
|
||
createNewAISession: () =>
|
||
set(() => {
|
||
const newId = `session-${Date.now()}`;
|
||
return { aiActiveSessionId: newId };
|
||
}),
|
||
setAIActiveSessionId: (sessionId) =>
|
||
set({ aiActiveSessionId: sessionId }),
|
||
updateAISessionTitle: (sessionId, title) => {
|
||
set((state) => {
|
||
const newSessions = [...state.aiChatSessions];
|
||
const session = newSessions.find((s) => s.id === sessionId);
|
||
if (session) {
|
||
session.title = title;
|
||
}
|
||
return { aiChatSessions: newSessions };
|
||
});
|
||
_debouncedPersistSession(sessionId);
|
||
},
|
||
addAIContext: (connectionKey, context) =>
|
||
set((state) => {
|
||
const contexts = state.aiContexts[connectionKey] || [];
|
||
if (
|
||
contexts.find(
|
||
(c) =>
|
||
c.dbName === context.dbName &&
|
||
c.tableName === context.tableName,
|
||
)
|
||
) {
|
||
return state;
|
||
}
|
||
return {
|
||
aiContexts: {
|
||
...state.aiContexts,
|
||
[connectionKey]: [...contexts, context],
|
||
},
|
||
};
|
||
}),
|
||
removeAIContext: (connectionKey, dbName, tableName) =>
|
||
set((state) => {
|
||
const contexts = state.aiContexts[connectionKey] || [];
|
||
return {
|
||
aiContexts: {
|
||
...state.aiContexts,
|
||
[connectionKey]: contexts.filter(
|
||
(c) => !(c.dbName === dbName && c.tableName === tableName),
|
||
),
|
||
},
|
||
};
|
||
}),
|
||
clearAIContexts: (connectionKey) =>
|
||
set((state) => {
|
||
const { [connectionKey]: _, ...rest } = state.aiContexts;
|
||
return { aiContexts: rest };
|
||
}),
|
||
setJVMDiagnosticDraft: (tabId, draft) =>
|
||
set((state) => ({
|
||
jvmDiagnosticDrafts: {
|
||
...state.jvmDiagnosticDrafts,
|
||
[tabId]: {
|
||
command:
|
||
draft.command ??
|
||
state.jvmDiagnosticDrafts[tabId]?.command ??
|
||
"",
|
||
sessionId:
|
||
draft.sessionId ?? state.jvmDiagnosticDrafts[tabId]?.sessionId,
|
||
source: draft.source ?? state.jvmDiagnosticDrafts[tabId]?.source,
|
||
reason: draft.reason ?? state.jvmDiagnosticDrafts[tabId]?.reason,
|
||
},
|
||
},
|
||
})),
|
||
appendJVMDiagnosticOutput: (tabId, chunks) =>
|
||
set((state) => ({
|
||
jvmDiagnosticOutputs: {
|
||
...state.jvmDiagnosticOutputs,
|
||
[tabId]: [
|
||
...(state.jvmDiagnosticOutputs[tabId] || []),
|
||
...chunks,
|
||
],
|
||
},
|
||
})),
|
||
clearJVMDiagnosticOutput: (tabId) =>
|
||
set((state) => ({
|
||
jvmDiagnosticOutputs: {
|
||
...state.jvmDiagnosticOutputs,
|
||
[tabId]: [],
|
||
},
|
||
})),
|
||
}),
|
||
{
|
||
name: PERSIST_STORAGE_KEY, // name of the item in the storage (must be unique)
|
||
version: PERSIST_VERSION,
|
||
migrate: (persistedState: unknown, version: number) => {
|
||
const state = unwrapPersistedAppState(
|
||
persistedState,
|
||
) as Partial<AppState>;
|
||
const nextState: Partial<AppState> = { ...state };
|
||
nextState.connections = sanitizeConnections(state.connections);
|
||
if (version < 5) {
|
||
nextState.connectionTags = sanitizeConnectionTags(
|
||
state.connectionTags,
|
||
);
|
||
} else {
|
||
nextState.connectionTags = sanitizeConnectionTags(
|
||
state.connectionTags,
|
||
);
|
||
}
|
||
nextState.savedQueries = sanitizeSavedQueries(state.savedQueries);
|
||
nextState.externalSQLDirectories = sanitizeExternalSQLDirectories(
|
||
state.externalSQLDirectories,
|
||
);
|
||
nextState.theme = sanitizeTheme(state.theme);
|
||
nextState.appearance = sanitizeAppearance(state.appearance, version);
|
||
nextState.uiScale = sanitizeUiScale(state.uiScale);
|
||
nextState.fontSize = sanitizeFontSize(state.fontSize);
|
||
nextState.startupFullscreen = sanitizeStartupFullscreen(
|
||
state.startupFullscreen,
|
||
);
|
||
nextState.globalProxy = sanitizeGlobalProxy(state.globalProxy);
|
||
nextState.sqlFormatOptions = sanitizeSqlFormatOptions(
|
||
state.sqlFormatOptions,
|
||
);
|
||
nextState.queryOptions = sanitizeQueryOptions(state.queryOptions);
|
||
nextState.shortcutOptions = sanitizeShortcutOptions(
|
||
state.shortcutOptions,
|
||
);
|
||
const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets);
|
||
const existingSnippetIds = new Set(existingSnippets.map((s) => s.id));
|
||
const missingSnippets = DEFAULT_SQL_SNIPPETS.filter(
|
||
(d) => !existingSnippetIds.has(d.id),
|
||
);
|
||
nextState.sqlSnippets =
|
||
missingSnippets.length > 0
|
||
? [...existingSnippets, ...missingSnippets]
|
||
: existingSnippets;
|
||
nextState.tableAccessCount = sanitizeTableAccessCount(
|
||
state.tableAccessCount,
|
||
);
|
||
nextState.tableSortPreference = sanitizeTableSortPreference(
|
||
state.tableSortPreference,
|
||
);
|
||
// 新增的列排序记忆状态不需要做版本特殊兼容,直接做基本的类型保护
|
||
const safeOrders = sanitizeTableColumnOrders(state.tableColumnOrders);
|
||
nextState.tableColumnOrders = safeOrders;
|
||
nextState.enableColumnOrderMemory =
|
||
state.enableColumnOrderMemory !== false;
|
||
const safeHidden = sanitizeTableHiddenColumns(state.tableHiddenColumns);
|
||
nextState.tableHiddenColumns = safeHidden;
|
||
nextState.enableHiddenColumnMemory =
|
||
state.enableHiddenColumnMemory !== false;
|
||
nextState.windowBounds = sanitizeWindowBounds(state.windowBounds);
|
||
nextState.windowState = sanitizeWindowState(state.windowState);
|
||
nextState.sidebarWidth = sanitizeSidebarWidth(state.sidebarWidth);
|
||
|
||
// 保留原有的 AI 持久化记录,或者为空(版本兼容)
|
||
nextState.aiChatHistory =
|
||
state.aiChatHistory && typeof state.aiChatHistory === "object"
|
||
? state.aiChatHistory
|
||
: {};
|
||
nextState.aiChatSessions = Array.isArray(state.aiChatSessions)
|
||
? state.aiChatSessions
|
||
: [];
|
||
return nextState as AppState;
|
||
},
|
||
merge: (persistedState, currentState) => {
|
||
const state = unwrapPersistedAppState(
|
||
persistedState,
|
||
) as Partial<AppState>;
|
||
return {
|
||
...currentState,
|
||
...state,
|
||
connections: sanitizeConnections(state.connections),
|
||
connectionTags: sanitizeConnectionTags(state.connectionTags),
|
||
savedQueries: sanitizeSavedQueries(state.savedQueries),
|
||
externalSQLDirectories: sanitizeExternalSQLDirectories(
|
||
state.externalSQLDirectories,
|
||
),
|
||
theme: sanitizeTheme(state.theme),
|
||
appearance: sanitizeAppearance(state.appearance, PERSIST_VERSION),
|
||
uiScale: sanitizeUiScale(state.uiScale),
|
||
fontSize: sanitizeFontSize(state.fontSize),
|
||
startupFullscreen: sanitizeStartupFullscreen(state.startupFullscreen),
|
||
globalProxy: sanitizeGlobalProxy(state.globalProxy),
|
||
tableSortPreference: sanitizeTableSortPreference(
|
||
state.tableSortPreference,
|
||
),
|
||
tableColumnOrders: sanitizeTableColumnOrders(state.tableColumnOrders),
|
||
enableColumnOrderMemory: state.enableColumnOrderMemory !== false,
|
||
tableHiddenColumns: sanitizeTableHiddenColumns(
|
||
state.tableHiddenColumns,
|
||
),
|
||
enableHiddenColumnMemory: state.enableHiddenColumnMemory !== false,
|
||
windowBounds: sanitizeWindowBounds(state.windowBounds),
|
||
windowState: sanitizeWindowState(state.windowState),
|
||
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
|
||
|
||
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
|
||
queryOptions: sanitizeQueryOptions(state.queryOptions),
|
||
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
|
||
sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets),
|
||
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
|
||
|
||
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
|
||
aiChatHistory: {},
|
||
aiChatSessions: [],
|
||
};
|
||
},
|
||
partialize: (state) => {
|
||
const partialState: Partial<AppState> = {
|
||
connectionTags: state.connectionTags,
|
||
savedQueries: state.savedQueries,
|
||
externalSQLDirectories: state.externalSQLDirectories,
|
||
theme: state.theme,
|
||
appearance: state.appearance,
|
||
uiScale: state.uiScale,
|
||
fontSize: state.fontSize,
|
||
startupFullscreen: state.startupFullscreen,
|
||
globalProxy:
|
||
toTrimmedString(state.globalProxy.password) !== ""
|
||
? { ...state.globalProxy }
|
||
: toPersistedGlobalProxy(state.globalProxy),
|
||
sqlFormatOptions: state.sqlFormatOptions,
|
||
queryOptions: state.queryOptions,
|
||
shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions),
|
||
sqlSnippets: state.sqlSnippets,
|
||
tableAccessCount: state.tableAccessCount,
|
||
tableSortPreference: state.tableSortPreference,
|
||
tableColumnOrders: state.tableColumnOrders,
|
||
enableColumnOrderMemory: state.enableColumnOrderMemory,
|
||
tableHiddenColumns: state.tableHiddenColumns,
|
||
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
|
||
windowBounds: state.windowBounds,
|
||
windowState: state.windowState,
|
||
sidebarWidth: state.sidebarWidth,
|
||
};
|
||
|
||
if (hasLegacyConnectionSecrets(state.connections)) {
|
||
partialState.connections = state.connections;
|
||
}
|
||
|
||
// AI 会话数据已迁移到后端文件持久化(~/.gonavi/sessions/),不再写入 localStorage
|
||
return partialState as AppState;
|
||
}, // Don't persist logs
|
||
},
|
||
),
|
||
);
|