chore: 恢复旧布局基线(迁移改动丢失后重建,apps/packages 布局暂存)

This commit is contained in:
logan committed 2026-10-14 20:31:17 +08:00
1 parent a9510235bb
commit 456fa6954b
203 files changed
+18787 -48

No files matched your search

+950
View File
@@ -0,0 +1,950 @@
import { create } from "zustand";
import type { DriverId } from "./catalog";
import { driverById } from "./catalog";
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
import { decodeSecret, encodeSecret } from "./secrets";
import { dropSchemaCache } from "./api";
export type SavedConnection = {
id: string;
name: string;
driver: DriverId;
host: string;
port: number;
user: string;
password: string;
database: string;
lastUsedAt?: number;
/** 侧栏分组 id,缺省 default */
groupId?: string;
/** 组内排序,越小越靠前 */
sortOrder?: number;
ssl?: boolean;
/** disable|require|skip-verify|verify-ca;关 SSL 时视为 disable */
sslMode?: "disable" | "require" | "skip-verify" | "verify-ca";
sslCA?: string;
sslCert?: string;
sslKey?: string;
service?: string;
color?: string;
/** 连库页默认连接 */
isDefault?: boolean;
/** 是否持久化密码,默认 true */
savePassword?: boolean;
/** Redis key 分隔符,默认 : */
keySeparator?: string;
redisMode?: "standalone" | "sentinel" | "cluster";
/** Sentinel/Cluster 节点列表 host:port(多行) */
redisNodes?: string;
/** Sentinel 主节点名 */
masterName?: string;
/** Sentinel 密码(可空) */
sentinelPassword?: string;
/** Oracle */
oracleConnType?: "basic";
oracleIdKind?: "service" | "sid";
oracleRole?: string;
/** SQL Server 验证方式 */
authMode?: "sql" | "windows";
/** SSH 隧道 */
sshEnabled?: boolean;
sshHost?: string;
sshPort?: number;
sshUser?: string;
sshAuth?: "password" | "privateKey";
sshPassword?: string;
sshPrivateKey?: string;
sshPassphrase?: string;
};
export const DEFAULT_CONN_GROUP = "default";
export type ConnectionGroup = {
id: string;
name: string;
};
export type QueryTab = {
id: string;
title: string;
sql: string;
connectionId?: string;
database?: string;
/** 相对打开/保存基线有改动 */
dirty?: boolean;
savedSql?: string;
};
export type QueryResult = {
columns: string[];
rows: Record<string, unknown>[];
rowCount: number;
timeMs: number;
error?: string;
truncated?: boolean;
limit?: number;
/** C5:服务端回传的分页偏移 */
offset?: number;
/** Redis 值游标分页:>0 还有更多(hash/set=SCAN 游标;list/zset=偏移) */
nextCursor?: number;
/** C1:命中危险语句,需确认后带 confirmDanger 重发 */
needConfirm?: boolean;
};
export type SchemaView = {
database: string;
table: string;
columns: ColumnInfo[];
loading?: boolean;
error?: string;
};
export type ResultMeta = {
database?: string;
table?: string;
pkCols?: string[];
/** Redis 键详情:打开键时带类型/TTL,命令结果不带 */
redisType?: string;
redisTtl?: number;
};
export type ConnState = "ok" | "down" | "reconnecting";
export type ResultSetItem = {
id: string;
label: string;
sql: string;
result: QueryResult;
/** C5:分页状态(仅单条 SELECT 启用);page 从 0 起 */
page?: number;
pageSize?: number;
/** 执行时使用的库(翻页重发需要) */
database?: string;
/** 列宽/选中态等视图持久化(修 G3,resultSet 级) */
viewState?: {
colWidths?: Record<string, number>;
selectedIdx?: number | null;
selectedCol?: string | null;
};
};
export type Bookmark = {
id: string;
name: string;
sql: string;
at: number;
};
export type ColumnInfo = {
name: string;
type: string;
nullable: boolean;
key?: string;
default?: unknown;
comment?: string;
};
export type TreeNode = {
name: string;
kind: "database" | "group" | "table" | "view" | "procedure" | "function" | "column" | "key" | "folder";
/** group id: tables|views|procedures|functions|keys|folder */
groupId?: string;
count?: number;
children?: TreeNode[];
loaded?: boolean;
loading?: boolean;
type?: string;
/** Redis TTL seconds; -1 permanent; -2 missing */
ttl?: number;
/** Redis 文件夹前缀(含分隔符),如 CEX:FOLLOW: */
fullName?: string;
nullable?: boolean;
key?: string;
};
export type QueryHistoryItem = {
id: string;
sql: string;
at: number;
connectionId?: string;
rowCount?: number;
timeMs?: number;
error?: string;
};
export type ConfirmRequest = {
title: string;
message: string;
okLabel?: string;
danger?: boolean;
/** C6:将执行的语句清单(危险确认二段流展示,前 3 条 + 折叠其余) */
items?: string[];
};
export type AiMessage = {
id: string;
role: "user" | "assistant" | "system";
content: string;
at: string;
};
export type ViewMode = "workbench" | "picker";
export type PickerMode = "list" | "drivers";
type State = {
theme: "dark" | "light";
view: ViewMode;
connections: SavedConnection[];
connGroups: ConnectionGroup[];
expandedConnGroups: string[];
activeConnectionId: string | null;
tree: TreeNode[];
treeLoading: boolean;
tabs: QueryTab[];
activeTabId: string;
result: QueryResult | null;
resultSets: ResultSetItem[];
activeResultSetId: string | null;
resultMeta: ResultMeta | null;
schemaView: SchemaView | null;
resultTab: "data" | "schema" | "explain";
explainText: string | null;
bookmarks: Bookmark[];
showBookmarks: boolean;
connState: ConnState;
/** 左栏底部统一搜索:非 Redis=过滤库/表;Redis=过滤键(回车服务端模糊扫描) */
sidebarFilter: string;
/** Redis 类型筛选 */
sidebarTypeFilter: string;
running: boolean;
pickerSearch: string;
pickerCategory: string;
pickerDriver: DriverId | null;
pickerMode: PickerMode;
form: Partial<SavedConnection>;
aiMessages: AiMessage[];
aiAsk: string;
aiModel: string;
aiBusy: boolean;
status: string;
notifyModal: NotifyPayload | null;
notifyToasts: NotifyPayload[];
sessions: Record<string, string>;
queryHistory: QueryHistoryItem[];
leftWidth: number;
aiWidth: number;
resultRatio: number;
showAi: boolean;
showHelp: boolean;
showHistory: boolean;
confirm: ConfirmRequest | null;
/** R2-F1:多语句执行遇错即停(默认 true);false 时全部执行完再汇总 */
stopOnError: boolean;
/** R2-F8:语句执行超时(ms),0 = 不超时;持久化到 prefs */
execTimeoutMs: number;
/** 对象树最近聚焦的表/视图(编辑器右键「查看数据/DDL」用) */
focusedRelation: { database: string; table: string } | null;
/** 请求 ObjectTree 打开 DDL 弹层 */
ddlRequest: { database: string; table: string; token: number } | null;
/** 请求 ObjectTree 打开表数据预览 */
viewDataRequest: { database: string; table: string; token: number } | null;
set: <K extends keyof State>(key: K, value: State[K]) => void;
notify: (input: {
kind?: NotifyKind;
title?: string;
message: string;
detail?: string;
tips?: string[];
mode?: "modal" | "toast";
durationMs?: number;
raw?: unknown;
}) => void;
notifyError: (raw: unknown, mode?: "modal" | "toast") => void;
dismissNotify: (id: string) => void;
loadConnections: () => void;
saveConnections: (list: SavedConnection[]) => void;
upsertConnection: (c: SavedConnection) => void;
removeConnection: (id: string) => void;
setDefaultConnection: (id: string) => void;
addConnGroup: (name: string) => string;
/** R2-F5:导入连接时并入分组(冲突/缺失 id 重新生成);默认分组不重复创建 */
importConnGroups: (groups: ConnectionGroup[]) => void;
renameConnGroup: (id: string, name: string) => void;
removeConnGroup: (id: string) => void;
moveConnection: (id: string, groupId: string, index: number) => void;
toggleConnGroup: (id: string) => void;
openPicker: (driver?: DriverId | null) => void;
openConnectionList: () => void;
editConnection: (c: SavedConnection) => void;
newQueryTab: () => void;
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
setActiveTabTitle: (title: string) => void;
/** 设置当前 Tab 的库上下文(Redis 编辑器 SELECT/USE 切库用) */
setTabDatabase: (database: string) => void;
closeTab: (id: string) => void;
/** R2-F6:Tab 右键菜单批量关闭(脏确认由调用侧负责) */
closeOtherTabs: (id: string) => void;
closeRightTabs: (id: string) => void;
closeAllTabs: () => void;
setStopOnError: (v: boolean) => void;
setExecTimeoutMs: (v: number) => void;
updateActiveSql: (sql: string) => void;
pushAi: (role: AiMessage["role"], content: string) => void;
setSession: (connectionId: string, sessionId: string | null) => void;
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
clearHistory: () => void;
setResultSets: (items: ResultSetItem[]) => void;
selectResultSet: (id: string) => void;
/** C5/G3:局部更新某个结果集(翻页结果、视图持久化);若为激活项同步 result */
updateResultSet: (id: string, patch: Partial<Omit<ResultSetItem, "id">>) => void;
loadBookmarks: () => void;
saveBookmark: (name: string, sql: string) => void;
renameBookmark: (id: string, name: string) => void;
removeBookmark: (id: string) => void;
askConfirm: (req: ConfirmRequest) => Promise<boolean>;
resolveConfirm: (ok: boolean) => void;
persistPrefs: () => void;
};
const LS_KEY = "opsxdb.connections.v1";
const LS_GROUPS = "opsxdb.conn-groups.v1";
const LS_EXPANDED = "opsxdb.conn-groups-expanded.v1";
const LS_PREFS = "opsxdb.prefs.v1";
const LS_TABS = "opsxdb.tabs.v1";
const LS_HISTORY = "opsxdb.history.v1";
const LS_BOOKMARKS = "opsxdb.bookmarks.v1";
let confirmResolver: ((ok: boolean) => void) | null = null;
function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "resultRatio" | "showAi" | "stopOnError" | "execTimeoutMs">> {
try {
const raw = localStorage.getItem(LS_PREFS);
if (!raw) return {};
const p = JSON.parse(raw) as Record<string, unknown>;
return {
theme: p.theme === "light" ? "light" : "dark",
leftWidth: typeof p.leftWidth === "number" ? Math.min(360, Math.max(200, p.leftWidth)) : undefined,
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
showAi: typeof p.showAi === "boolean" ? p.showAi : undefined,
stopOnError: typeof p.stopOnError === "boolean" ? p.stopOnError : undefined,
execTimeoutMs: typeof p.execTimeoutMs === "number" ? Math.min(3_600_000, Math.max(0, p.execTimeoutMs)) : undefined,
};
} catch {
return {};
}
}
function loadTabs(): { tabs: QueryTab[]; activeTabId: string } | null {
try {
const raw = localStorage.getItem(LS_TABS);
if (!raw) return null;
const p = JSON.parse(raw) as { tabs?: QueryTab[]; activeTabId?: string };
if (!Array.isArray(p.tabs) || !p.tabs.length) return null;
const tabs = p.tabs.map((tab) => {
const savedSql = tab.savedSql ?? tab.sql ?? "";
const sql = tab.sql ?? "";
return { ...tab, sql, savedSql, dirty: sql !== savedSql };
});
return { tabs, activeTabId: p.activeTabId || tabs[0].id };
} catch {
return null;
}
}
function loadHistory(): QueryHistoryItem[] {
try {
const raw = localStorage.getItem(LS_HISTORY);
if (!raw) return [];
const p = JSON.parse(raw);
return Array.isArray(p) ? p.slice(0, 50) : [];
} catch {
return [];
}
}
function loadGroups(): ConnectionGroup[] {
try {
const raw = localStorage.getItem(LS_GROUPS);
if (!raw) return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || !parsed.length) return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
const list = parsed.filter((g: ConnectionGroup) => g && g.id && g.name);
if (!list.some((g: ConnectionGroup) => g.id === DEFAULT_CONN_GROUP)) {
list.unshift({ id: DEFAULT_CONN_GROUP, name: "默认" });
}
return list;
} catch {
return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
}
}
function saveGroups(list: ConnectionGroup[]) {
localStorage.setItem(LS_GROUPS, JSON.stringify(list));
}
function loadExpanded(): string[] {
try {
const raw = localStorage.getItem(LS_EXPANDED);
if (!raw) return [DEFAULT_CONN_GROUP];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [DEFAULT_CONN_GROUP];
} catch {
return [DEFAULT_CONN_GROUP];
}
}
function loadLs(): SavedConnection[] {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function defaultTab(): QueryTab {
// 新 Tab 默认空白(修 T3),不再预置 SELECT 1 AS ok;
return { id: crypto.randomUUID(), title: "查询 1", sql: "", savedSql: "", dirty: false };
}
export const useWorkspaceStore = create<State>((set, get) => ({
theme: "dark",
view: "workbench",
connections: [],
connGroups: [{ id: DEFAULT_CONN_GROUP, name: "默认" }],
expandedConnGroups: [DEFAULT_CONN_GROUP],
activeConnectionId: null,
tree: [],
treeLoading: false,
tabs: [defaultTab()],
activeTabId: "",
result: null,
resultSets: [],
activeResultSetId: null,
resultMeta: null,
schemaView: null,
resultTab: "data",
explainText: null,
bookmarks: [],
showBookmarks: false,
connState: "ok",
sidebarFilter: "",
sidebarTypeFilter: "all",
running: false,
pickerSearch: "",
pickerCategory: "全部",
pickerDriver: null,
pickerMode: "list",
form: {},
aiMessages: [],
aiAsk: "",
aiModel: "opsxdb-stub",
aiBusy: false,
status: "就绪",
notifyModal: null,
notifyToasts: [],
sessions: {},
queryHistory: [],
leftWidth: 260,
aiWidth: 280,
resultRatio: 0.38,
showAi: true,
showHelp: false,
showHistory: false,
confirm: null,
stopOnError: true,
execTimeoutMs: 0,
focusedRelation: null,
ddlRequest: null,
viewDataRequest: null,
set: (key, value) => {
set({ [key]: value } as Partial<State>);
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi" || key === "stopOnError" || key === "execTimeoutMs") {
get().persistPrefs();
}
if (key === "tabs" || key === "activeTabId") {
const s = get();
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: s.tabs, activeTabId: s.activeTabId }));
}
},
notify: (input) => {
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
const kind = input.kind ?? parsed?.kind ?? "info";
const mode = input.mode ?? (kind === "error" ? "modal" : "toast");
const payload: NotifyPayload = {
id: notifyId(),
kind,
title: input.title ?? parsed?.title ?? (kind === "error" ? "错误" : "提示"),
message: input.message || parsed?.message || "",
detail: input.detail ?? parsed?.detail,
tips: input.tips ?? parsed?.tips,
mode,
durationMs: input.durationMs,
};
if (mode === "modal") {
set({ notifyModal: payload });
} else {
set((s) => ({ notifyToasts: [...s.notifyToasts.slice(-4), payload] }));
}
},
notifyError: (raw, mode = "modal") => {
const parsed = formatDbError(raw);
const payload: NotifyPayload = {
id: notifyId(),
kind: parsed.kind,
title: parsed.title,
message: parsed.message,
detail: parsed.detail,
tips: parsed.tips,
mode,
};
if (mode === "modal") set({ notifyModal: payload });
else set((s) => ({ notifyToasts: [...s.notifyToasts.slice(-4), payload] }));
},
dismissNotify: (id) => {
const s = get();
if (s.notifyModal?.id === id) set({ notifyModal: null });
set({ notifyToasts: get().notifyToasts.filter((t) => t.id !== id) });
},
loadConnections: () => {
const list = loadLs().map((c) => ({
...c,
groupId: c.groupId || DEFAULT_CONN_GROUP,
sortOrder: c.sortOrder ?? 0,
}));
const prefs = loadPrefs();
const savedTabs = loadTabs();
const tabs = savedTabs?.tabs ?? get().tabs;
const activeTabId = savedTabs?.activeTabId || get().activeTabId || tabs[0]?.id || "";
set({
connections: list,
connGroups: loadGroups(),
expandedConnGroups: loadExpanded(),
tabs,
activeTabId,
queryHistory: loadHistory(),
...prefs,
});
},
saveConnections: (list) => {
const encoded = list.map((c) => ({
...c,
password: encodeSecret(c.password || ""),
sshPassword: c.sshPassword ? encodeSecret(c.sshPassword) : c.sshPassword,
sshPassphrase: c.sshPassphrase ? encodeSecret(c.sshPassphrase) : c.sshPassphrase,
}));
localStorage.setItem(LS_KEY, JSON.stringify(encoded));
set({ connections: encoded });
},
upsertConnection: (c) => {
const next = {
...c,
groupId: c.groupId || DEFAULT_CONN_GROUP,
sortOrder: c.sortOrder ?? Date.now(),
password: c.savePassword === false ? "" : c.password,
sshPassword: c.savePassword === false ? "" : c.sshPassword,
};
let list = [...get().connections];
// 同名防护:不同 id 同名时自动加序号后缀,避免侧栏出现重名条目
const nameTaken = (n: string) => list.some((x) => x.id !== next.id && x.name === n);
if (nameTaken(next.name)) {
let k = 2;
while (nameTaken(`${next.name} ${k}`)) k++;
next.name = `${next.name} ${k}`;
}
if (next.isDefault) {
list = list.map((x) => ({ ...x, isDefault: x.id === next.id ? true : false }));
}
const i = list.findIndex((x) => x.id === next.id);
if (i >= 0) list[i] = { ...list[i], ...next };
else list.unshift(next);
get().saveConnections(list);
},
removeConnection: (id) => {
get().saveConnections(get().connections.filter((c) => c.id !== id));
dropSchemaCache(id);
const sessions = { ...get().sessions };
delete sessions[id];
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
else set({ sessions });
},
setDefaultConnection: (id) => {
const list = get().connections.map((c) => ({ ...c, isDefault: c.id === id }));
get().saveConnections(list);
},
addConnGroup: (name) => {
const n = name.trim() || "新分组";
const id = crypto.randomUUID();
const list = [...get().connGroups, { id, name: n }];
saveGroups(list);
const expanded = Array.from(new Set([...get().expandedConnGroups, id]));
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
set({ connGroups: list, expandedConnGroups: expanded });
return id;
},
/** R2-F5:导入连接时并入分组;与现有 id 冲突或缺失时重新生成,避免串组 */
importConnGroups: (groups) => {
if (!Array.isArray(groups) || !groups.length) return;
const cur = get().connGroups;
const ids = new Set(cur.map((g) => g.id));
const merged = [...cur];
for (const g of groups) {
if (!g || typeof g.name !== "string" || !g.name.trim()) continue;
if (g.id === DEFAULT_CONN_GROUP) continue;
const item = { id: g.id && !ids.has(g.id) ? g.id : crypto.randomUUID(), name: g.name.trim() };
ids.add(item.id);
merged.push(item);
}
saveGroups(merged);
set({ connGroups: merged });
},
renameConnGroup: (id, name) => {
if (id === DEFAULT_CONN_GROUP) return;
const n = name.trim();
if (!n) return;
const list = get().connGroups.map((g) => (g.id === id ? { ...g, name: n } : g));
saveGroups(list);
set({ connGroups: list });
},
removeConnGroup: (id) => {
if (id === DEFAULT_CONN_GROUP) return;
const list = get().connGroups.filter((g) => g.id !== id);
saveGroups(list);
const connections = get().connections.map((c) =>
(c.groupId || DEFAULT_CONN_GROUP) === id ? { ...c, groupId: DEFAULT_CONN_GROUP } : c,
);
get().saveConnections(connections);
const expanded = get().expandedConnGroups.filter((x) => x !== id);
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
set({ connGroups: list, expandedConnGroups: expanded });
},
moveConnection: (id, groupId, index) => {
const gid = groupId || DEFAULT_CONN_GROUP;
const all = [...get().connections];
const moving = all.find((c) => c.id === id);
if (!moving) return;
const othersInGroup = all
.filter((c) => c.id !== id && (c.groupId || DEFAULT_CONN_GROUP) === gid)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const clamped = Math.max(0, Math.min(index, othersInGroup.length));
othersInGroup.splice(clamped, 0, { ...moving, groupId: gid });
othersInGroup.forEach((c, i) => {
c.sortOrder = i;
});
const rest = all.filter((c) => c.id !== id && (c.groupId || DEFAULT_CONN_GROUP) !== gid);
get().saveConnections([...rest, ...othersInGroup]);
if (!get().expandedConnGroups.includes(gid)) {
const expanded = [...get().expandedConnGroups, gid];
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
set({ expandedConnGroups: expanded });
}
},
toggleConnGroup: (id) => {
const cur = get().expandedConnGroups;
const expanded = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
set({ expandedConnGroups: expanded });
},
openPicker: (driver = null) => {
const d = driver ? driverById(driver) : null;
set({
view: "picker",
pickerMode: d ? "drivers" : "drivers",
pickerDriver: driver,
form: d
? {
id: crypto.randomUUID(),
name: `${d.name} 连接`,
driver: d.id,
host: d.hints?.host ?? "127.0.0.1",
port: d.defaultPort,
user: d.hints?.user ?? (d.id === "redis" ? "" : "root"),
password: "",
database: d.hints?.database ?? "",
ssl: false,
sslMode: "disable",
sslCA: "",
sslCert: "",
sslKey: "",
service: d.id === "oracle" ? (d.hints?.database ?? "ORCL") : "",
savePassword: true,
keySeparator: d.id === "redis" ? ":" : undefined,
redisMode: d.id === "redis" ? "standalone" : undefined,
oracleConnType: d.id === "oracle" ? "basic" : undefined,
oracleIdKind: d.id === "oracle" ? "service" : undefined,
oracleRole: d.id === "oracle" ? "default" : undefined,
authMode: d.id === "sqlserver" ? "sql" : undefined,
sshEnabled: false,
sshPort: 22,
sshAuth: "password",
}
: {},
});
},
openConnectionList: () => {
set({ view: "picker", pickerMode: "list", pickerDriver: null, form: {} });
},
editConnection: (c) => {
set({
view: "picker",
pickerMode: "drivers",
pickerDriver: c.driver,
form: {
...c,
password: decodeSecret(c.password),
sshPassword: c.sshPassword ? decodeSecret(c.sshPassword) : "",
sshPassphrase: c.sshPassphrase ? decodeSecret(c.sshPassphrase) : "",
},
});
},
newQueryTab: () => {
const n = get().tabs.length + 1;
const tab: QueryTab = {
id: crypto.randomUUID(),
title: `查询 ${n}`,
sql: "",
savedSql: "",
dirty: false,
connectionId: get().activeConnectionId ?? undefined,
};
const tabs = [...get().tabs, tab];
set({ tabs, activeTabId: tab.id });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
},
setActiveTabTitle: (title) => {
const s = get();
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, title } : t));
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
setTabDatabase: (database) => {
const s = get();
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, database } : t));
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
},
closeTab: (id) => {
const tabs = get().tabs.filter((t) => t.id !== id);
const next = tabs.length ? tabs : [defaultTab()];
const activeTabId = get().activeTabId === id ? next[0].id : get().activeTabId;
set({ tabs: next, activeTabId });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
},
/** R2-F6:右键菜单「关闭其他」——只保留指定 Tab */
closeOtherTabs: (id) => {
const tabs = get().tabs.filter((t) => t.id === id);
const next = tabs.length ? tabs : [defaultTab()];
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[0].id;
set({ tabs: next, activeTabId });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
},
/** R2-F6:右键菜单「关闭右侧」——保留指定 Tab 及其左侧全部 */
closeRightTabs: (id) => {
const idx = get().tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
const next = get().tabs.slice(0, idx + 1);
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[next.length - 1].id;
set({ tabs: next, activeTabId });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
},
/** R2-F6:右键菜单「关闭全部」——回落一个空白 Tab(与 closeTab 语义一致) */
closeAllTabs: () => {
const next = [defaultTab()];
set({ tabs: next, activeTabId: next[0].id });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId: next[0].id }));
},
setStopOnError: (v) => {
set({ stopOnError: v });
get().persistPrefs();
},
setExecTimeoutMs: (v) => {
set({ execTimeoutMs: Math.min(3_600_000, Math.max(0, v || 0)) });
get().persistPrefs();
},
updateActiveSql: (sql) => {
const id = get().activeTabId;
const tabs = get().tabs.map((t) => {
if (t.id !== id) return t;
const saved = t.savedSql ?? "";
return { ...t, sql, dirty: sql !== saved };
});
set({ tabs });
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: id }));
},
pushAi: (role, content) =>
set((s) => ({
aiMessages: [
...s.aiMessages,
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString() },
],
})),
setSession: (connectionId, sessionId) => {
const sessions = { ...get().sessions };
if (sessionId) sessions[connectionId] = sessionId;
else delete sessions[connectionId];
set({ sessions });
},
pushHistory: (item) => {
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
const key = norm(item.sql);
const entry: QueryHistoryItem = {
id: crypto.randomUUID(),
at: item.at ?? Date.now(),
sql: item.sql,
connectionId: item.connectionId,
rowCount: item.rowCount,
timeMs: item.timeMs,
error: item.error,
};
// 相同语句去重置顶:重复执行只留最新一次,历史即「最近用过的不同语句」
const next = [entry, ...get().queryHistory.filter((h) => norm(h.sql) !== key)].slice(0, 200);
localStorage.setItem(LS_HISTORY, JSON.stringify(next));
set({ queryHistory: next });
},
clearHistory: () => {
set({ queryHistory: [] });
localStorage.setItem(LS_HISTORY, JSON.stringify([]));
},
setResultSets: (items) => {
const active = items[0]?.id ?? null;
set({
resultSets: items,
activeResultSetId: active,
result: items[0]?.result ?? null,
resultTab: "data",
});
},
selectResultSet: (id) => {
const item = get().resultSets.find((x) => x.id === id);
if (!item) return;
set({ activeResultSetId: id, result: item.result, resultTab: "data" });
},
updateResultSet: (id, patch) => {
const items = get().resultSets.map((x) => (x.id === id ? { ...x, ...patch } : x));
const next: Partial<State> = { resultSets: items };
if (get().activeResultSetId === id) {
const item = items.find((x) => x.id === id);
if (item) next.result = item.result;
}
set(next);
},
loadBookmarks: () => {
try {
const raw = localStorage.getItem(LS_BOOKMARKS);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) set({ bookmarks: parsed });
} catch {
/* ignore */
}
},
saveBookmark: (name, sql) => {
const n = name.trim();
if (!n) return;
const list = [{ id: crypto.randomUUID(), name: n, sql, at: Date.now() }, ...get().bookmarks].slice(0, 100);
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
set({ bookmarks: list });
},
renameBookmark: (id, name) => {
const n = name.trim();
if (!n) return;
const list = get().bookmarks.map((b) => (b.id === id ? { ...b, name: n } : b));
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
set({ bookmarks: list });
},
removeBookmark: (id) => {
const list = get().bookmarks.filter((b) => b.id !== id);
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
set({ bookmarks: list });
},
askConfirm: (req) =>
new Promise<boolean>((resolve) => {
confirmResolver?.(false);
confirmResolver = resolve;
set({ confirm: req });
}),
resolveConfirm: (ok) => {
const r = confirmResolver;
confirmResolver = null;
set({ confirm: null });
r?.(ok);
},
persistPrefs: () => {
const s = get();
localStorage.setItem(
LS_PREFS,
JSON.stringify({
theme: s.theme,
leftWidth: s.leftWidth,
aiWidth: s.aiWidth,
resultRatio: s.resultRatio,
showAi: s.showAi,
stopOnError: s.stopOnError,
execTimeoutMs: s.execTimeoutMs,
}),
);
},
}));
// init bookmarks
try {
const _bm = localStorage.getItem(LS_BOOKMARKS);
if (_bm) {
const parsed = JSON.parse(_bm);
if (Array.isArray(parsed)) useWorkspaceStore.setState({ bookmarks: parsed });
}
} catch { /* ignore */ }
// init activeTabId after create
const boot = useWorkspaceStore.getState();
if (!boot.activeTabId && boot.tabs[0]) {
useWorkspaceStore.setState({ activeTabId: boot.tabs[0].id });
}