Files
MyGoNavi/frontend/src/store.ts
Syngnat 255e484dcf feat(sidebar): 同步标签上下文并补充对象树统计信息
- 切换和关闭标签时同步 activeContext,避免新建查询误用 host 或数据库
- 侧边栏表节点展示行数统计,数据库节点展示表数量
- 旧版 sidebar 工具栏改为稳定五列布局,v1 不再混入 v2 置顶分组
- 补充 sidebar 与 store 回归测试
2026-05-31 13:34:21 +08:00

3193 lines
102 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 { create } from "zustand";
import {
createJSONStorage,
persist,
type PersistStorage,
type StateStorage,
} from "zustand/middleware";
import {
ConnectionConfig,
ProxyConfig,
SavedConnection,
TabData,
SavedQuery,
ConnectionTag,
AIChatMessage,
AIContextItem,
GlobalProxyConfig,
ExternalSQLDirectory,
JVMDiagnosticCommandDraft,
JVMDiagnosticEventChunk,
SqlSnippet,
} from "./types";
import {
ShortcutAction,
ShortcutOptions,
DEFAULT_SHORTCUT_OPTIONS,
cloneShortcutOptions,
getShortcutPlatform,
sanitizeShortcutOptions,
type ShortcutPlatformBinding,
type ShortcutPlatform,
} 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";
import {
normalizeOceanBaseProtocol,
resolveOceanBaseProtocolFromConfig,
resolveOceanBaseProtocolFromQueryText,
} from "./utils/oceanBaseProtocol";
import { sanitizeFontFamilyInput } from "./utils/fontFamilies";
export interface AppearanceSettings extends DataGridDisplaySettings {
uiVersion: "legacy" | "v2";
enabled: boolean;
opacity: number;
blur: number;
useNativeMacWindowControls: boolean;
customUIFontFamily: string | null;
customMonoFontFamily: string | null;
}
export const DEFAULT_APPEARANCE: AppearanceSettings = {
uiVersion: "legacy",
enabled: true,
opacity: 1.0,
blur: 0,
useNativeMacWindowControls: false,
customUIFontFamily: null,
customMonoFontFamily: null,
...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 = 10;
const PERSIST_STORAGE_KEY = "lite-db-storage";
const PERSIST_WRITE_DEBOUNCE_MS = 160;
const MAX_PERSISTED_QUERY_TABS = 20;
const MAX_PERSISTED_QUERY_LENGTH = 1024 * 1024;
const MAX_SQL_LOGS = 1000;
const MAX_PERSISTED_SQL_LOGS = 200;
const MAX_PERSISTED_SQL_LOG_LENGTH = 100 * 1024;
const MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH = 2 * 1024;
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 isFrontendTestRuntime = (): boolean => {
const env = (import.meta as unknown as { env?: Record<string, unknown> }).env || {};
return env.MODE === "test" || env.VITEST === true || env.VITEST === "true";
};
const createDebouncedPersistStorage = <S>(
getStorage: () => StateStorage,
debounceMs = PERSIST_WRITE_DEBOUNCE_MS,
): PersistStorage<S> | undefined => {
const baseStorage = createJSONStorage<S>(getStorage);
if (!baseStorage || isFrontendTestRuntime()) {
return baseStorage;
}
type PersistedValue = Parameters<PersistStorage<S>["setItem"]>[1];
let pendingWrite: { name: string; value: PersistedValue } | null = null;
let pendingTimer: number | null = null;
let listenersBound = false;
let pendingResolves: Array<() => void> = [];
let pendingRejects: Array<(error: unknown) => void> = [];
const settlePending = (error?: unknown) => {
const resolves = pendingResolves;
const rejects = pendingRejects;
pendingResolves = [];
pendingRejects = [];
if (error !== undefined) {
rejects.forEach((reject) => reject(error));
return;
}
resolves.forEach((resolve) => resolve());
};
const flushPendingWrite = async (): Promise<void> => {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
pendingTimer = null;
}
const nextWrite = pendingWrite;
pendingWrite = null;
if (!nextWrite) {
settlePending();
return;
}
try {
await baseStorage.setItem(nextWrite.name, nextWrite.value);
settlePending();
} catch (error) {
settlePending(error);
throw error;
}
};
const bindFlushListeners = () => {
if (listenersBound || typeof window === "undefined") {
return;
}
listenersBound = true;
const handleFlush = () => {
void flushPendingWrite();
};
window.addEventListener("pagehide", handleFlush, { capture: true });
window.addEventListener("beforeunload", handleFlush, { capture: true });
};
return {
getItem: baseStorage.getItem,
setItem: (name, value) => {
bindFlushListeners();
pendingWrite = { name, value };
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
}
return new Promise<void>((resolve, reject) => {
pendingResolves.push(resolve);
pendingRejects.push(reject);
pendingTimer = window.setTimeout(() => {
void flushPendingWrite();
}, debounceMs);
});
},
removeItem: async (name) => {
pendingWrite = null;
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
pendingTimer = null;
}
settlePending();
await baseStorage.removeItem(name);
},
};
};
const resolveOceanBaseProtocol = (
raw: Record<string, unknown>,
normalizedConnectionParams: string,
normalizedUri: string,
): "mysql" | "oracle" => {
const normalizedConfig = {
...raw,
connectionParams: normalizedConnectionParams,
uri: normalizedUri,
};
try {
return resolveOceanBaseProtocolFromConfig(normalizedConfig);
} catch {
return (
normalizeOceanBaseProtocol(raw.oceanBaseProtocol) ||
resolveOceanBaseProtocolFromQueryText(normalizedConnectionParams).protocol ||
resolveOceanBaseProtocolFromQueryText(normalizedUri).protocol ||
"mysql"
);
}
};
const SUPPORTED_CONNECTION_TYPES = new Set([
"mysql",
"mariadb",
"oceanbase",
"doris",
"diros",
"starrocks",
"sphinx",
"clickhouse",
"postgres",
"redis",
"tdengine",
"oracle",
"dameng",
"kingbase",
"sqlserver",
"iris",
"mongodb",
"highgo",
"vastbase",
"opengauss",
"jvm",
"sqlite",
"duckdb",
"custom",
]);
const SSL_SUPPORTED_CONNECTION_TYPES = new Set([
"mysql",
"mariadb",
"oceanbase",
"diros",
"starrocks",
"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":
case "starrocks":
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 "iris":
return 1972;
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 === "postgresql") {
return "postgres";
}
if (type === "mssql" || type === "sql_server" || type === "sql-server") {
return "sqlserver";
}
if (type === "kingbase8" || type === "kingbasees" || type === "kingbasev8") {
return "kingbase";
}
if (type === "dm" || type === "dm8") {
return "dameng";
}
if (type === "sqlite3") {
return "sqlite";
}
if (type === "sphinxql") {
return "sphinx";
}
if (
type === "open_gauss" ||
type === "open-gauss" ||
type === "opengauss"
) {
return "opengauss";
}
if (
type === "inter-systems" ||
type === "inter-systems-iris" ||
type === "intersystems" ||
type === "intersystems iris" ||
type === "intersystemsiris" ||
type.includes("iris")
) {
return "iris";
}
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",
sslCAPath: sslCapable ? toTrimmedString(raw.sslCAPath) : "",
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 SIDEBAR_ROOT_TAG_TOKEN_PREFIX = "tag:";
const SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX = "connection:";
export const buildSidebarRootTagToken = (tagId: string): string =>
`${SIDEBAR_ROOT_TAG_TOKEN_PREFIX}${toTrimmedString(tagId)}`;
export const buildSidebarRootConnectionToken = (
connectionId: string,
): string => `${SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX}${toTrimmedString(connectionId)}`;
const isSidebarRootTagToken = (token: string): boolean =>
token.startsWith(SIDEBAR_ROOT_TAG_TOKEN_PREFIX) &&
token.length > SIDEBAR_ROOT_TAG_TOKEN_PREFIX.length;
const isSidebarRootConnectionToken = (token: string): boolean =>
token.startsWith(SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX) &&
token.length > SIDEBAR_ROOT_CONNECTION_TOKEN_PREFIX.length;
const sanitizeSidebarRootOrder = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const result: string[] = [];
value.forEach((entry) => {
const token = toTrimmedString(entry);
if (!token) return;
if (!isSidebarRootTagToken(token) && !isSidebarRootConnectionToken(token)) {
return;
}
if (seen.has(token)) return;
seen.add(token);
result.push(token);
});
return result;
};
const buildDefaultSidebarRootOrderTokens = (
connectionTags: ConnectionTag[],
connections: SavedConnection[],
): string[] => {
const groupedConnectionIds = new Set<string>();
connectionTags.forEach((tag) => {
tag.connectionIds.forEach((connectionId) => {
if (connectionId) groupedConnectionIds.add(connectionId);
});
});
return [
...connectionTags.map((tag) => buildSidebarRootTagToken(tag.id)),
...connections
.filter((connection) => !groupedConnectionIds.has(connection.id))
.map((connection) => buildSidebarRootConnectionToken(connection.id)),
];
};
export const resolveSidebarRootOrderTokens = (
sidebarRootOrder: unknown,
connectionTags: ConnectionTag[],
connections: SavedConnection[],
): string[] => {
const defaultOrder = buildDefaultSidebarRootOrderTokens(
connectionTags,
connections,
);
if (defaultOrder.length === 0) {
return [];
}
const validTokens = new Set(defaultOrder);
const seen = new Set<string>();
const result: string[] = [];
sanitizeSidebarRootOrder(sidebarRootOrder).forEach((token) => {
if (!validTokens.has(token) || seen.has(token)) return;
seen.add(token);
result.push(token);
});
defaultOrder.forEach((token) => {
if (seen.has(token)) return;
seen.add(token);
result.push(token);
});
return result;
};
const insertSidebarRootTokenBeforeUngrouped = (
sidebarRootOrder: string[],
token: string,
): string[] => {
if (!token || sidebarRootOrder.includes(token)) {
return [...sidebarRootOrder];
}
const firstConnectionIndex = sidebarRootOrder.findIndex(
isSidebarRootConnectionToken,
);
if (firstConnectionIndex === -1) {
return [...sidebarRootOrder, token];
}
const nextOrder = [...sidebarRootOrder];
nextOrder.splice(firstConnectionIndex, 0, token);
return nextOrder;
};
const insertSidebarRootTokenAfter = (
sidebarRootOrder: string[],
token: string,
anchorToken: string,
): string[] => {
if (!token) return [...sidebarRootOrder];
const nextOrder = sidebarRootOrder.filter((item) => item !== token);
const anchorIndex = nextOrder.indexOf(anchorToken);
if (anchorIndex === -1) {
nextOrder.push(token);
return nextOrder;
}
nextOrder.splice(anchorIndex + 1, 0, token);
return nextOrder;
};
const moveSidebarRootToken = (
sidebarRootOrder: string[],
sourceToken: string,
targetToken: string,
insertBefore: boolean,
): string[] => {
if (!sourceToken || !targetToken || sourceToken === targetToken) {
return [...sidebarRootOrder];
}
const filtered = sidebarRootOrder.filter((token) => token !== sourceToken);
const targetIndex = filtered.indexOf(targetToken);
const insertIndex =
targetIndex === -1
? filtered.length
: Math.max(
0,
Math.min(
filtered.length,
insertBefore ? targetIndex : targetIndex + 1,
),
);
filtered.splice(insertIndex, 0, sourceToken);
return filtered;
};
const orderConnectionTagsBySidebarRootOrder = (
connectionTags: ConnectionTag[],
sidebarRootOrder: string[],
): ConnectionTag[] => {
const tagMap = new Map(connectionTags.map((tag) => [tag.id, tag]));
const orderedTags: ConnectionTag[] = [];
sidebarRootOrder.forEach((token) => {
if (!isSidebarRootTagToken(token)) return;
const tagId = token.slice(SIDEBAR_ROOT_TAG_TOKEN_PREFIX.length);
const tag = tagMap.get(tagId);
if (!tag) return;
orderedTags.push(tag);
tagMap.delete(tagId);
});
orderedTags.push(...Array.from(tagMap.values()));
return orderedTags;
};
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[];
sidebarRootOrder: string[];
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;
pinnedSidebarTables: string[];
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;
reorderConnections: (
connectionId: string,
targetConnectionId: string,
targetTagId: string | null,
insertBefore?: boolean,
) => void;
reorderTags: (tagIds: string[]) => void;
reorderSidebarRoot: (
sourceToken: string,
targetToken: string,
insertBefore: boolean,
) => void;
addTab: (tab: TabData) => void;
updateQueryTabDraft: (
id: string,
draft: Partial<Pick<TabData, "query" | "connectionId" | "dbName" | "title">>,
) => 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<ShortcutPlatformBinding>,
platform?: ShortcutPlatform,
) => 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;
setSidebarTablePinned: (
connectionId: string,
dbName: string,
tableName: string,
schemaName: string | undefined,
pinned: boolean,
) => 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 sanitizeQueryTabs = (value: unknown): TabData[] => {
if (!Array.isArray(value)) return [];
const result: TabData[] = [];
const seenIds = new Set<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
if (raw.type !== "query") return;
const query = typeof raw.query === "string" ? raw.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) : "";
const filePath = toTrimmedString(raw.filePath);
const savedQueryId = toTrimmedString(raw.savedQueryId);
if (!query.trim() && !filePath && !savedQueryId) return;
let id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`;
if (seenIds.has(id)) {
id = `${id}-${index + 1}`;
}
seenIds.add(id);
result.push({
id,
title: toTrimmedString(raw.title, "新建查询") || "新建查询",
type: "query",
connectionId: toTrimmedString(raw.connectionId),
dbName: toTrimmedString(raw.dbName),
query,
filePath: filePath || undefined,
savedQueryId: savedQueryId || undefined,
readOnly: raw.readOnly === true,
});
});
return result.slice(0, MAX_PERSISTED_QUERY_TABS);
};
const sanitizeActiveTabId = (activeTabId: unknown, tabs: TabData[]): string | null => {
const id = toTrimmedString(activeTabId);
if (id && tabs.some((tab) => tab.id === id)) {
return id;
}
return tabs[0]?.id || null;
};
const resolveActiveContextFromTab = (
tab: TabData | null | undefined,
): { connectionId: string; dbName: string } | null => {
if (!tab) return null;
const connectionId = toTrimmedString(tab.connectionId);
if (!connectionId) return null;
return {
connectionId,
dbName: toTrimmedString(tab.dbName),
};
};
const resolveActiveContextForTabId = (
tabs: TabData[],
activeTabId: string | null | undefined,
fallbackContext: { connectionId: string; dbName: string } | null,
): { connectionId: string; dbName: string } | null => {
const normalizedActiveTabId = toTrimmedString(activeTabId);
if (normalizedActiveTabId) {
const activeTab = tabs.find((tab) => tab.id === normalizedActiveTabId);
const contextFromTab = resolveActiveContextFromTab(activeTab);
if (contextFromTab) {
return contextFromTab;
}
}
return fallbackContext;
};
const sanitizeSqlLogs = (value: unknown, limit = MAX_PERSISTED_SQL_LOGS): SqlLog[] => {
if (!Array.isArray(value)) return [];
const result: SqlLog[] = [];
const seenIds = new Set<string>();
value.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
const sql = typeof raw.sql === "string" ? raw.sql.slice(0, MAX_PERSISTED_SQL_LOG_LENGTH) : "";
if (!sql.trim()) return;
let id = toTrimmedString(raw.id, `log-${index + 1}`) || `log-${index + 1}`;
if (seenIds.has(id)) {
id = `${id}-${index + 1}`;
}
seenIds.add(id);
const status = raw.status === "error" ? "error" : "success";
const timestamp = Number(raw.timestamp);
const duration = Number(raw.duration);
const affectedRows = Number(raw.affectedRows);
const log: SqlLog = {
id,
timestamp: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Date.now(),
sql,
status,
duration: Number.isFinite(duration) && duration >= 0 ? duration : 0,
dbName: toTrimmedString(raw.dbName) || undefined,
};
const message = typeof raw.message === "string"
? raw.message.slice(0, MAX_PERSISTED_SQL_LOG_MESSAGE_LENGTH)
: "";
if (message) {
log.message = message;
}
if (Number.isFinite(affectedRows)) {
log.affectedRows = affectedRows;
}
result.push(log);
});
return result.slice(0, limit);
};
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 sanitizePinnedSidebarTables = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return Array.from(
new Set(
value
.map((entry) => toTrimmedString(entry))
.filter(Boolean),
),
);
};
const sanitizeAppearance = (
appearance: Partial<AppearanceSettings> | undefined,
version: number,
): AppearanceSettings => {
if (!appearance || typeof appearance !== "object") {
return { ...DEFAULT_APPEARANCE };
}
const dataGridDisplaySettings = sanitizeDataGridDisplaySettings(appearance);
const nextAppearance = {
uiVersion:
appearance.uiVersion === "v2" || appearance.uiVersion === "legacy"
? appearance.uiVersion
: DEFAULT_APPEARANCE.uiVersion,
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,
customUIFontFamily: sanitizeFontFamilyInput(appearance.customUIFontFamily),
customMonoFontFamily: sanitizeFontFamilyInput(appearance.customMonoFontFamily),
showDataTableVerticalBorders:
dataGridDisplaySettings.showDataTableVerticalBorders,
dataTableDensity: dataGridDisplaySettings.dataTableDensity,
dataTableFontSize: dataGridDisplaySettings.dataTableFontSize,
dataTableFontSizeFollowGlobal:
dataGridDisplaySettings.dataTableFontSizeFollowGlobal,
sidebarTreeFontSize: dataGridDisplaySettings.sidebarTreeFontSize,
sidebarTreeFontSizeFollowGlobal:
dataGridDisplaySettings.sidebarTreeFontSizeFollowGlobal,
};
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;
}
};
export const buildSidebarTablePinKey = (
connectionId: string,
dbName: string,
tableName: string,
schemaName = "",
): string => {
const parts = [
toTrimmedString(connectionId),
toTrimmedString(dbName),
toTrimmedString(schemaName),
toTrimmedString(tableName),
];
return parts[0] && parts[1] && parts[3] ? JSON.stringify(parts) : "";
};
// --- 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: [],
sidebarRootOrder: [],
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,
pinnedSidebarTables: [],
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) => {
const nextConnections = state.connections.filter((c) => c.id !== id);
const nextTags = state.connectionTags.map((tag) => ({
...tag,
connectionIds: tag.connectionIds.filter((cid) => cid !== id),
}));
return {
connections: nextConnections,
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder.filter(
(token) => token !== buildSidebarRootConnectionToken(id),
),
nextTags,
nextConnections,
),
};
}),
replaceConnections: (connections) =>
set((state) => {
const nextConnections = sanitizeConnections(connections);
return {
connections: nextConnections,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
state.connectionTags,
nextConnections,
),
shortcutOptions:
readPersistedShortcutOptions() ?? state.shortcutOptions,
};
}),
addConnectionTag: (tag) =>
set((state) => {
const nextTags = [...state.connectionTags, tag];
const nextRootOrder = insertSidebarRootTokenBeforeUngrouped(
resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
state.connectionTags,
state.connections,
),
buildSidebarRootTagToken(tag.id),
);
return {
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
nextRootOrder,
nextTags,
state.connections,
),
};
}),
updateConnectionTag: (tag) =>
set((state) => {
const nextTags = state.connectionTags.map((t) =>
t.id === tag.id ? tag : t,
);
return {
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
nextTags,
state.connections,
),
};
}),
removeConnectionTag: (id) =>
set((state) => {
const nextTags = state.connectionTags.filter((t) => t.id !== id);
return {
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder.filter(
(token) => token !== buildSidebarRootTagToken(id),
),
nextTags,
state.connections,
),
};
}),
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 };
});
const nextRootOrder = resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
newTags,
state.connections,
);
const connectionToken = buildSidebarRootConnectionToken(connectionId);
if (targetTagId) {
return {
connectionTags: newTags,
sidebarRootOrder: nextRootOrder.filter(
(token) => token !== connectionToken,
),
};
}
const sourceToken = buildSidebarRootTagToken(
state.connectionTags.find((tag) =>
tag.connectionIds.includes(connectionId),
)?.id || "",
);
const insertedRootOrder = sourceToken
? insertSidebarRootTokenAfter(nextRootOrder, connectionToken, sourceToken)
: insertSidebarRootTokenBeforeUngrouped(nextRootOrder, connectionToken);
return {
connectionTags: newTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
insertedRootOrder,
newTags,
state.connections,
),
};
}),
reorderConnections: (
connectionId,
targetConnectionId,
targetTagId,
insertBefore = false,
) =>
set((state) => {
if (
!connectionId ||
!targetConnectionId ||
connectionId === targetConnectionId
) {
return {
connections: state.connections,
connectionTags: state.connectionTags,
};
}
const normalizeInsertIndex = (
length: number,
index: number,
): number => Math.max(0, Math.min(length, index));
const nextTags = state.connectionTags.map((tag) => ({
...tag,
connectionIds: tag.connectionIds.filter((id) => id !== connectionId),
}));
if (targetTagId) {
const updatedTags = nextTags.map((tag) => {
if (tag.id !== targetTagId) {
return tag;
}
const targetIndex = tag.connectionIds.indexOf(targetConnectionId);
if (targetIndex === -1) {
return {
...tag,
connectionIds: [...tag.connectionIds, connectionId],
};
}
const insertIndex = normalizeInsertIndex(
tag.connectionIds.length,
insertBefore ? targetIndex : targetIndex + 1,
);
const nextIds = [...tag.connectionIds];
nextIds.splice(insertIndex, 0, connectionId);
return { ...tag, connectionIds: nextIds };
});
return {
connections: state.connections,
connectionTags: updatedTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
updatedTags,
state.connections,
),
};
}
const ungroupedIds = state.connections
.map((conn) => conn.id)
.filter((id) => id !== connectionId)
.filter((id) => !nextTags.some((tag) => tag.connectionIds.includes(id)));
const targetIndex = ungroupedIds.indexOf(targetConnectionId);
const insertIndex =
targetIndex === -1
? ungroupedIds.length
: normalizeInsertIndex(
ungroupedIds.length,
insertBefore ? targetIndex : targetIndex + 1,
);
const nextUngroupedIds = [...ungroupedIds];
nextUngroupedIds.splice(insertIndex, 0, connectionId);
const ungroupedOrderMap = new Map(
nextUngroupedIds.map((id, index) => [id, index]),
);
const nextConnections = [...state.connections].sort((a, b) => {
const indexA = ungroupedOrderMap.get(a.id);
const indexB = ungroupedOrderMap.get(b.id);
if (typeof indexA === 'number' && typeof indexB === 'number') {
return indexA - indexB;
}
if (typeof indexA === 'number') {
return -1;
}
if (typeof indexB === 'number') {
return 1;
}
return 0;
});
return {
connections: nextConnections,
connectionTags: nextTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
nextTags,
nextConnections,
),
};
}),
reorderTags: (tagIds) =>
set((state) => {
const nextRootOrder = resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
state.connectionTags,
state.connections,
);
const orderedRootOrder = [
...tagIds.map((id) => buildSidebarRootTagToken(id)),
...nextRootOrder.filter((token) => !isSidebarRootTagToken(token)),
];
const newTags = orderConnectionTagsBySidebarRootOrder(
state.connectionTags,
orderedRootOrder,
);
return {
connectionTags: newTags,
sidebarRootOrder: resolveSidebarRootOrderTokens(
orderedRootOrder,
newTags,
state.connections,
),
};
}),
reorderSidebarRoot: (sourceToken, targetToken, insertBefore) =>
set((state) => {
const nextRootOrder = moveSidebarRootToken(
resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
state.connectionTags,
state.connections,
),
sourceToken,
targetToken,
insertBefore,
);
return {
sidebarRootOrder: resolveSidebarRootOrderTokens(
nextRootOrder,
state.connectionTags,
state.connections,
),
connectionTags: orderConnectionTagsBySidebarRootOrder(
state.connectionTags,
nextRootOrder,
),
};
}),
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,
activeContext: resolveActiveContextForTabId(
newTabs,
tab.id,
state.activeContext,
),
};
}
// 语义去重:对 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,
activeContext: resolveActiveContextForTabId(
newTabs,
existingTab.id,
state.activeContext,
),
};
}
}
// 语义去重:对 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,
activeContext: resolveActiveContextForTabId(
newTabs,
existingTab.id,
state.activeContext,
),
};
}
}
const nextTabs = [...state.tabs, tab];
return {
tabs: nextTabs,
activeTabId: tab.id,
activeContext: resolveActiveContextForTabId(
nextTabs,
tab.id,
state.activeContext,
),
};
}),
updateQueryTabDraft: (id, draft) =>
set((state) => {
const tabId = toTrimmedString(id);
if (!tabId) return state;
let changed = false;
const nextTabs = state.tabs.map((tab) => {
if (tab.id !== tabId || tab.type !== "query") return tab;
const nextTab: TabData = { ...tab };
if (draft.query !== undefined) {
const nextQuery = typeof draft.query === "string" ? draft.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) : "";
if (nextTab.query !== nextQuery) {
nextTab.query = nextQuery;
changed = true;
}
}
if (draft.connectionId !== undefined) {
const nextConnectionId = toTrimmedString(draft.connectionId);
if (nextTab.connectionId !== nextConnectionId) {
nextTab.connectionId = nextConnectionId;
changed = true;
}
}
if (draft.dbName !== undefined) {
const nextDbName = toTrimmedString(draft.dbName);
if ((nextTab.dbName || "") !== nextDbName) {
nextTab.dbName = nextDbName;
changed = true;
}
}
if (draft.title !== undefined) {
const nextTitle = toTrimmedString(draft.title, nextTab.title) || nextTab.title;
if (nextTab.title !== nextTitle) {
nextTab.title = nextTitle;
changed = true;
}
}
return nextTab;
});
return changed ? { tabs: nextTabs } : state;
}),
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,
activeContext: resolveActiveContextForTabId(
newTabs,
newActiveId,
state.activeContext,
),
};
}),
closeOtherTabs: (id) =>
set((state) => {
const keep = state.tabs.find((t) => t.id === id);
if (!keep) return state;
return {
tabs: [keep],
activeTabId: id,
activeContext: resolveActiveContextFromTab(keep),
};
}),
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,
activeContext: resolveActiveContextForTabId(
newTabs,
activeStillExists ? state.activeTabId : id,
state.activeContext,
),
};
}),
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,
activeContext: resolveActiveContextForTabId(
newTabs,
activeStillExists ? state.activeTabId : id,
state.activeContext,
),
};
}),
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 nextFallbackContext =
state.activeContext?.connectionId === targetConnectionId
? null
: state.activeContext;
return {
tabs: newTabs,
activeTabId: nextActiveTabId,
activeContext: resolveActiveContextForTabId(
newTabs,
nextActiveTabId,
nextFallbackContext,
),
};
}),
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;
const nextFallbackContext = sameActiveContext
? null
: state.activeContext;
return {
tabs: newTabs,
activeTabId: nextActiveTabId,
activeContext: resolveActiveContextForTabId(
newTabs,
nextActiveTabId,
nextFallbackContext,
),
};
}),
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, activeContext: null })),
setActiveTab: (id) =>
set((state) => ({
activeTabId: id,
activeContext: resolveActiveContextForTabId(
state.tabs,
id,
state.activeContext,
),
})),
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: sanitizeAppearance(
{ ...state.appearance, ...appearance },
PERSIST_VERSION,
),
})),
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, platform) => {
runWithExplicitShortcutPersistence(() => {
const targetPlatform = platform ?? getShortcutPlatform();
set((state) => ({
shortcutOptions: {
...state.shortcutOptions,
[action]: {
...state.shortcutOptions[action],
[targetPlatform]: {
...state.shortcutOptions[action][targetPlatform],
...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: sanitizeSqlLogs([log, ...state.sqlLogs], MAX_SQL_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,
},
};
}),
setSidebarTablePinned: (connectionId, dbName, tableName, schemaName, pinned) =>
set((state) => {
const key = buildSidebarTablePinKey(connectionId, dbName, tableName, schemaName);
if (!key) return state;
const current = new Set(state.pinnedSidebarTables);
if (pinned) {
current.add(key);
} else {
current.delete(key);
}
return { pinnedSidebarTables: Array.from(current) };
}),
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)
storage: createDebouncedPersistStorage(() => localStorage),
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);
const safeTabs = sanitizeQueryTabs(state.tabs);
nextState.tabs = safeTabs;
nextState.activeTabId = sanitizeActiveTabId(state.activeTabId, safeTabs);
if (version < 5) {
nextState.connectionTags = sanitizeConnectionTags(
state.connectionTags,
);
} else {
nextState.connectionTags = sanitizeConnectionTags(
state.connectionTags,
);
}
nextState.sidebarRootOrder = resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
nextState.connectionTags,
nextState.connections,
);
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,
);
nextState.sqlLogs = sanitizeSqlLogs(state.sqlLogs);
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.pinnedSidebarTables = sanitizePinnedSidebarTables(
state.pinnedSidebarTables,
);
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>;
const safeTabs = sanitizeQueryTabs(state.tabs);
const persistedConnections =
state.connections === undefined
? currentState.connections
: sanitizeConnections(state.connections);
const persistedConnectionTags =
state.connectionTags === undefined
? currentState.connectionTags
: sanitizeConnectionTags(state.connectionTags);
const persistedSidebarRootOrder =
state.sidebarRootOrder === undefined
? currentState.sidebarRootOrder
: resolveSidebarRootOrderTokens(
state.sidebarRootOrder,
persistedConnectionTags,
persistedConnections,
);
return {
...currentState,
...state,
connections: persistedConnections,
connectionTags: persistedConnectionTags,
sidebarRootOrder: persistedSidebarRootOrder,
tabs: safeTabs,
activeTabId: sanitizeActiveTabId(state.activeTabId, safeTabs),
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,
pinnedSidebarTables: sanitizePinnedSidebarTables(
state.pinnedSidebarTables,
),
windowBounds: sanitizeWindowBounds(state.windowBounds),
windowState: sanitizeWindowState(state.windowState),
sidebarWidth: sanitizeSidebarWidth(state.sidebarWidth),
sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions),
queryOptions: sanitizeQueryOptions(state.queryOptions),
shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions),
sqlLogs: sanitizeSqlLogs(state.sqlLogs),
sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets),
tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount),
// AI 会话数据不再从 localStorage 恢复,改为从后端文件加载
aiChatHistory: {},
aiChatSessions: [],
};
},
partialize: (state) => {
const tabs = sanitizeQueryTabs(state.tabs);
const partialState: Partial<AppState> = {
tabs,
activeTabId: sanitizeActiveTabId(state.activeTabId, tabs),
connectionTags: state.connectionTags,
sidebarRootOrder: state.sidebarRootOrder,
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),
sqlLogs: sanitizeSqlLogs(state.sqlLogs),
sqlSnippets: state.sqlSnippets,
tableAccessCount: state.tableAccessCount,
tableSortPreference: state.tableSortPreference,
tableColumnOrders: state.tableColumnOrders,
enableColumnOrderMemory: state.enableColumnOrderMemory,
tableHiddenColumns: state.tableHiddenColumns,
enableHiddenColumnMemory: state.enableHiddenColumnMemory,
pinnedSidebarTables: state.pinnedSidebarTables,
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
},
),
);