fix(saved-sql): 修复 SQL 库连接关联丢失与历史文件不可见

* fix: 持久化并显示 SQL 库连接关联

* fix: 补充 SQL 库关联状态翻译

* chore: 修复数据标签页策略格式

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-08-05 23:58:32 +08:00
1 parent 580b70e1d4
commit 445b6473f4
18 files changed
+336 -115

No files matched your search

+12 -16
View File
@@ -56,7 +56,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
import type { ConnectionConfig, ObjectSourceKind, QueryTab, SavedSqlFile } from "@/types/database"; import type { ConnectionConfig, ObjectSourceKind, QueryTab } from "@/types/database";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import { import {
isBrowserReloadShortcut, isBrowserReloadShortcut,
@@ -848,16 +848,13 @@ async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: bo
} }
} }
function savedSqlTargetForSave(tab: QueryTab, existing?: SavedSqlFile) { function savedSqlTargetForSave(tab: QueryTab) {
return savedSqlDefaultTargetForWrite( return savedSqlDefaultTargetForWrite({
{ connectionId: tab.connectionId,
connectionId: tab.connectionId, database: tab.database,
database: tab.database, schema: tab.schema,
schema: tab.schema, catalog: tab.catalog,
catalog: tab.catalog, });
},
existing,
);
} }
async function saveTabForCloseAll(tabId: string): Promise<boolean> { async function saveTabForCloseAll(tabId: string): Promise<boolean> {
@@ -876,7 +873,7 @@ async function saveTabForCloseAll(tabId: string): Promise<boolean> {
if (await saveExternalSqlPath(tab)) return !queryStore.isTabDirty(tab); if (await saveExternalSqlPath(tab)) return !queryStore.isTabDirty(tab);
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
const target = savedSqlTargetForSave(tab, existing); const target = savedSqlTargetForSave(tab);
try { try {
const saved = await savedSqlStore.saveFile({ const saved = await savedSqlStore.saveFile({
id: existing?.id, id: existing?.id,
@@ -943,7 +940,7 @@ async function handleSaveTab(tabId: string) {
} }
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
if (existing) { if (existing) {
const target = savedSqlTargetForSave(tab, existing); const target = savedSqlTargetForSave(tab);
const updated = await savedSqlStore.saveFile({ const updated = await savedSqlStore.saveFile({
id: existing.id, id: existing.id,
connectionId: target.connectionId, connectionId: target.connectionId,
@@ -979,7 +976,7 @@ async function openSaveSqlDialog() {
if (await saveExternalSqlPath(tab)) return; if (await saveExternalSqlPath(tab)) return;
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined;
if (existing) { if (existing) {
const target = savedSqlTargetForSave(tab, existing); const target = savedSqlTargetForSave(tab);
const updated = await savedSqlStore.saveFile({ const updated = await savedSqlStore.saveFile({
id: existing.id, id: existing.id,
connectionId: target.connectionId, connectionId: target.connectionId,
@@ -1073,8 +1070,7 @@ async function confirmSaveSqlToLibrary() {
const name = saveSqlName.value.trim(); const name = saveSqlName.value.trim();
if (!tab || !tab.sql.trim() || !name) return; if (!tab || !tab.sql.trim() || !name) return;
try { try {
const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; const target = savedSqlTargetForSave(tab);
const target = savedSqlTargetForSave(tab, existing);
const saved = await savedSqlStore.saveFile({ const saved = await savedSqlStore.saveFile({
id: tab.savedSqlId, id: tab.savedSqlId,
connectionId: target.connectionId, connectionId: target.connectionId,
@@ -41,18 +41,14 @@ type DropPosition = "before" | "after" | "inside";
const activeConnectionIds = computed(() => new Set(connectionStore.connections.map((c) => c.id))); const activeConnectionIds = computed(() => new Set(connectionStore.connections.map((c) => c.id)));
const searchText = ref(""); const searchText = ref("");
const searchQuery = computed(() => searchText.value.trim().toLowerCase()); const searchQuery = computed(() => searchText.value.trim().toLowerCase());
const orphanedIds = computed(() => savedSqlStore.orphanedFileIds(activeConnectionIds.value));
// Sort mode: "folder" (default tree structure) or "date" (flat list by update date) // Sort mode: "folder" (default tree structure) or "date" (flat list by update date)
const sortMode = ref<"folder" | "date">("folder"); const sortMode = ref<"folder" | "date">("folder");
function isConnectionVisible(connectionId: string) {
return activeConnectionIds.value.has(connectionId);
}
function getConnectionLabel(connectionId: string) { function getConnectionLabel(connectionId: string) {
if (!connectionId) return t("sqlLibrary.unassociated");
const conn = connectionStore.connections.find((c) => c.id === connectionId); const conn = connectionStore.connections.find((c) => c.id === connectionId);
return conn?.name || connectionId; return conn?.name || t("sqlLibrary.deletedConnection");
} }
function folderPath(folder: SavedSqlFolder) { function folderPath(folder: SavedSqlFolder) {
@@ -180,13 +176,13 @@ async function exportFolderContents(folder?: SavedSqlFolder) {
if (folder) { if (folder) {
await writeFolder(folder, rootDir); await writeFolder(folder, rootDir);
} else { } else {
for (const libraryFolder of savedSqlStore.allFolders.filter((item) => isConnectionVisible(item.connectionId) && !item.parentFolderId)) { for (const libraryFolder of savedSqlStore.allFolders.filter((item) => !item.parentFolderId)) {
const folderDir = await join(rootDir, sanitizeFileSystemSegment(libraryFolder.name)); const folderDir = await join(rootDir, sanitizeFileSystemSegment(libraryFolder.name));
await mkdir(folderDir, { recursive: true }); await mkdir(folderDir, { recursive: true });
await writeFolder(libraryFolder, folderDir); await writeFolder(libraryFolder, folderDir);
} }
const unfiled = savedSqlStore.filesWithoutFolder().filter((file) => !orphanedIds.value.has(file.id)); const unfiled = savedSqlStore.filesWithoutFolder();
if (unfiled.length > 0) { if (unfiled.length > 0) {
const unfiledDir = await join(rootDir, sanitizeFileSystemSegment(t("sqlLibrary.unfiled"))); const unfiledDir = await join(rootDir, sanitizeFileSystemSegment(t("sqlLibrary.unfiled")));
await mkdir(unfiledDir, { recursive: true }); await mkdir(unfiledDir, { recursive: true });
@@ -239,7 +235,7 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
return; return;
} }
const takenNames = new Set((targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : savedSqlStore.filesWithoutFolder()).filter((file) => !orphanedIds.value.has(file.id)).map((file) => file.name)); const takenNames = new Set((targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : savedSqlStore.filesWithoutFolder()).map((file) => file.name));
for (const path of sqlPaths) { for (const path of sqlPaths) {
const content = await api.readExternalSqlFile(path); const content = await api.readExternalSqlFile(path);
@@ -320,11 +316,11 @@ function folderMatchesQuery(folder: SavedSqlFolder) {
const q = searchQuery.value; const q = searchQuery.value;
if (!q) return true; if (!q) return true;
if (folder.name.toLowerCase().includes(q)) return true; if (folder.name.toLowerCase().includes(q)) return true;
return savedSqlStore.filesInFolder(folder.id).some((file) => !orphanedIds.value.has(file.id) && fileMatchesQuery(file)); return savedSqlStore.filesInFolder(folder.id).some((file) => fileMatchesQuery(file));
} }
function childFolders(parentFolderId?: string) { function childFolders(parentFolderId?: string) {
return savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId) && (folder.parentFolderId || "") === (parentFolderId || "")); return savedSqlStore.allFolders.filter((folder) => (folder.parentFolderId || "") === (parentFolderId || ""));
} }
function descendantFolders(parentFolderId: string): SavedSqlFolder[] { function descendantFolders(parentFolderId: string): SavedSqlFolder[] {
@@ -340,15 +336,11 @@ function folderBranchMatchesQuery(folder: SavedSqlFolder) {
function filesInFolder(folderId: string) { function filesInFolder(folderId: string) {
const folder = savedSqlStore.allFolders.find((item) => item.id === folderId); const folder = savedSqlStore.allFolders.find((item) => item.id === folderId);
const includeAllFilesForMatchedFolder = !!folder && !!searchQuery.value && folder.name.toLowerCase().includes(searchQuery.value); const includeAllFilesForMatchedFolder = !!folder && !!searchQuery.value && folder.name.toLowerCase().includes(searchQuery.value);
return savedSqlStore return savedSqlStore.filesInFolder(folderId).filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file));
.filesInFolder(folderId)
.filter((file) => !orphanedIds.value.has(file.id))
.filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file));
} }
function folderFileCount(folderId: string) { function folderFileCount(folderId: string) {
const visibleFolders = savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId)); return savedSqlFolderBranchFileCount(folderId, savedSqlStore.allFolders, filesInFolder);
return savedSqlFolderBranchFileCount(folderId, visibleFolders, filesInFolder);
} }
type SqlLibraryRow = { type: "folder"; folder: SavedSqlFolder; depth: number; folderIndex: number } | { type: "file"; file: SavedSqlFile; depth: number }; type SqlLibraryRow = { type: "folder"; folder: SavedSqlFolder; depth: number; folderIndex: number } | { type: "file"; file: SavedSqlFile; depth: number };
@@ -373,19 +365,11 @@ const visibleFolderRows = computed<SqlLibraryRow[]>(() => {
return rows; return rows;
}); });
const visibleFiles = computed(() => const visibleFiles = computed(() => savedSqlStore.filesWithoutFolder().filter((file) => fileMatchesQuery(file)));
savedSqlStore
.filesWithoutFolder()
.filter((file) => !orphanedIds.value.has(file.id))
.filter((file) => fileMatchesQuery(file)),
);
// Flat list sorted by updatedAt (descending) - combines all folders and files // Flat list sorted by updatedAt (descending) - combines all folders and files
const itemsByDate = computed(() => { const itemsByDate = computed(() => {
const allFolders = savedSqlStore.allFolders const allFolders = savedSqlStore.allFolders.filter((folder) => folderBranchMatchesQuery(folder)).map((folder) => ({ type: "folder" as const, item: folder, updatedAt: folder.updatedAt }));
.filter((folder) => isConnectionVisible(folder.connectionId))
.filter((folder) => folderBranchMatchesQuery(folder))
.map((folder) => ({ type: "folder" as const, item: folder, updatedAt: folder.updatedAt }));
const allFiles = [...savedSqlStore.allFolders.flatMap((folder) => filesInFolder(folder.id)), ...visibleFiles.value].map((file) => ({ type: "file" as const, item: file, updatedAt: file.updatedAt })); const allFiles = [...savedSqlStore.allFolders.flatMap((folder) => filesInFolder(folder.id)), ...visibleFiles.value].map((file) => ({ type: "file" as const, item: file, updatedAt: file.updatedAt }));
@@ -429,14 +413,7 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) {
const connectionId = folder?.connectionId || connectionStore.activeConnectionId || connectionStore.connections[0]?.id; const connectionId = folder?.connectionId || connectionStore.activeConnectionId || connectionStore.connections[0]?.id;
if (!connectionId) return; if (!connectionId) return;
const takenNames = folder const takenNames = folder ? new Set(savedSqlStore.filesInFolder(folder.id).map((f) => f.name)) : new Set(savedSqlStore.filesWithoutFolder().map((f) => f.name));
? new Set(savedSqlStore.filesInFolder(folder.id).map((f) => f.name))
: new Set(
savedSqlStore
.filesWithoutFolder()
.filter((file) => !orphanedIds.value.has(file.id))
.map((f) => f.name),
);
const name = uniqueImportedName("new_query.sql", takenNames); const name = uniqueImportedName("new_query.sql", takenNames);
const file = await savedSqlStore.saveFile({ const file = await savedSqlStore.saveFile({
connectionId, connectionId,
@@ -805,18 +782,16 @@ const contextTarget = ref<SavedSqlFolder | SavedSqlFile | "panel" | null>(null);
function folderMoveMenuItems(fileIds: string[]): CtxMenuItem[] { function folderMoveMenuItems(fileIds: string[]): CtxMenuItem[] {
const files = [...new Set(fileIds)].map((id) => savedSqlStore.getFile(id)).filter((file): file is SavedSqlFile => Boolean(file)); const files = [...new Set(fileIds)].map((id) => savedSqlStore.getFile(id)).filter((file): file is SavedSqlFile => Boolean(file));
const allInUnfiled = files.length > 0 && files.every((file) => !file.folderId); const allInUnfiled = files.length > 0 && files.every((file) => !file.folderId);
const folderItems = savedSqlStore.allFoldersTreeOrder const folderItems = savedSqlStore.allFoldersTreeOrder.map((folder) => ({
.filter((folder) => isConnectionVisible(folder.connectionId)) label: folderPath(folder),
.map((folder) => ({ action: () =>
label: folderPath(folder), moveFilesToFolder(
action: () => files.map((file) => file.id),
moveFilesToFolder( folder.id,
files.map((file) => file.id), ),
folder.id, disabled: files.every((file) => file.folderId === folder.id),
), icon: FolderClosed,
disabled: files.every((file) => file.folderId === folder.id), }));
icon: FolderClosed,
}));
return [ return [
{ {
+14 -8
View File
@@ -30,7 +30,6 @@ vi.mock("@/lib/sqlFile/sqlFileFolders", async () => {
function emptySavedSqlStore() { function emptySavedSqlStore() {
return { return {
allFiles: [] as any[], allFiles: [] as any[],
orphanedFileIds: vi.fn().mockReturnValue(new Set<string>()),
getFile: vi.fn().mockReturnValue(undefined), getFile: vi.fn().mockReturnValue(undefined),
}; };
} }
@@ -661,7 +660,6 @@ describe("useQuickOpen", () => {
const fileMap = new Map(files.map((f) => [f.id, f])); const fileMap = new Map(files.map((f) => [f.id, f]));
return { return {
allFiles: files, allFiles: files,
orphanedFileIds: vi.fn().mockReturnValue(new Set<string>()),
getFile: vi.fn().mockImplementation((id: string) => fileMap.get(id)), getFile: vi.fn().mockImplementation((id: string) => fileMap.get(id)),
}; };
} }
@@ -713,7 +711,7 @@ describe("useQuickOpen", () => {
expect(sqlItems[0].sqlFileId).toBe("f1"); expect(sqlItems[0].sqlFileId).toBe("f1");
}); });
it("excludes orphaned SQL library files", () => { it("includes SQL library files whose connection was deleted", () => {
const mockConnStore = { const mockConnStore = {
connections: [{ id: "conn1", name: "Active", type: "mssql" }], connections: [{ id: "conn1", name: "Active", type: "mssql" }],
treeNodes: [], treeNodes: [],
@@ -724,16 +722,24 @@ describe("useQuickOpen", () => {
{ id: "f1", name: "active_query.sql", connectionId: "conn1", updatedAt: "2024-01-01T00:00:00.000Z" }, { id: "f1", name: "active_query.sql", connectionId: "conn1", updatedAt: "2024-01-01T00:00:00.000Z" },
{ id: "f2", name: "orphaned_query.sql", connectionId: "deleted_conn", updatedAt: "2024-01-02T00:00:00.000Z" }, { id: "f2", name: "orphaned_query.sql", connectionId: "deleted_conn", updatedAt: "2024-01-02T00:00:00.000Z" },
]; ];
const store = savedSqlStoreWithFiles(files); vi.mocked(useSavedSqlStore).mockReturnValue(savedSqlStoreWithFiles(files) as any);
store.orphanedFileIds = vi.fn().mockReturnValue(new Set(["f2"]));
vi.mocked(useSavedSqlStore).mockReturnValue(store as any);
const { filteredItems, setQuery } = useQuickOpen(); const { filteredItems, setQuery } = useQuickOpen();
setQuery("query"); setQuery("query");
const sqlItems = filteredItems.value.filter((item) => item.type === "sql_library_file"); const sqlItems = filteredItems.value.filter((item) => item.type === "sql_library_file");
expect(sqlItems).toHaveLength(1); expect(sqlItems).toHaveLength(2);
expect(sqlItems[0].label).toBe("active_query.sql"); expect(sqlItems.find((item) => item.label === "orphaned_query.sql")?.description).toBe("Connection deleted");
});
it("labels SQL library files without a connection as unassociated", () => {
vi.mocked(useConnectionStore).mockReturnValue({ connections: [], treeNodes: [] } as any);
vi.mocked(useSavedSqlStore).mockReturnValue(savedSqlStoreWithFiles([{ id: "f1", name: "draft.sql", connectionId: "", updatedAt: "2024-01-01T00:00:00.000Z" }]) as any);
const { filteredItems, setQuery } = useQuickOpen();
setQuery("draft");
expect(filteredItems.value.find((item) => item.type === "sql_library_file")?.description).toBe("Unassociated");
}); });
}); });
+13 -15
View File
@@ -7,6 +7,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import * as api from "@/lib/backend/api"; import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api"; import type { SqlFileEntry } from "@/lib/backend/api";
import { getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders"; import { getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
import i18n from "@/i18n";
const REMOTE_SEARCH_DEBOUNCE_MS = 180; const REMOTE_SEARCH_DEBOUNCE_MS = 180;
const REMOTE_SEARCH_MIN_QUERY_LENGTH = 2; const REMOTE_SEARCH_MIN_QUERY_LENGTH = 2;
@@ -118,25 +119,22 @@ export function useQuickOpen() {
let sqlFilesLoadGeneration = 0; let sqlFilesLoadGeneration = 0;
function getConnectionLabel(connectionId: string): string { function getConnectionLabel(connectionId: string): string {
if (!connectionId) return i18n.global.t("sqlLibrary.unassociated");
const conn = connectionStore.connections.find((c) => c.id === connectionId); const conn = connectionStore.connections.find((c) => c.id === connectionId);
return conn?.name || connectionId; return conn?.name || i18n.global.t("sqlLibrary.deletedConnection");
} }
const sqlLibraryAllItems = computed<QuickOpenItem[]>(() => { const sqlLibraryAllItems = computed<QuickOpenItem[]>(() => {
const activeConnectionIds = new Set(connectionStore.connections.map((c) => c.id)); return savedSqlStore.allFiles.map((file) => ({
const orphanedIds = savedSqlStore.orphanedFileIds(activeConnectionIds); id: `sqllib-${file.id}`,
return savedSqlStore.allFiles type: "sql_library_file" as const,
.filter((file) => !orphanedIds.has(file.id)) label: file.name,
.map((file) => ({ description: getConnectionLabel(file.connectionId),
id: `sqllib-${file.id}`, connectionId: file.connectionId,
type: "sql_library_file" as const, connectionName: getConnectionLabel(file.connectionId),
label: file.name, sqlFileId: file.id,
description: getConnectionLabel(file.connectionId), searchText: `${file.name} ${getConnectionLabel(file.connectionId)}`,
connectionId: file.connectionId, }));
connectionName: getConnectionLabel(file.connectionId),
sqlFileId: file.id,
searchText: `${file.name} ${getConnectionLabel(file.connectionId)}`,
}));
}); });
const sqlLibraryRecentItems = computed<QuickOpenItem[]>(() => { const sqlLibraryRecentItems = computed<QuickOpenItem[]>(() => {
+2
View File
@@ -151,6 +151,8 @@ export default {
}, },
sqlLibrary: { sqlLibrary: {
title: "SQL Library", title: "SQL Library",
unassociated: "Unassociated",
deletedConnection: "Connection deleted",
openInCurrentDatabase: "Open in Current Database", openInCurrentDatabase: "Open in Current Database",
empty: "No saved queries yet", empty: "No saved queries yet",
emptyFolder: "Empty folder", emptyFolder: "Empty folder",
+2
View File
@@ -153,6 +153,8 @@ export default withEnglishFallback({
}, },
sqlLibrary: { sqlLibrary: {
title: "Biblioteca SQL", title: "Biblioteca SQL",
unassociated: "Sin asociación",
deletedConnection: "Conexión eliminada",
openInCurrentDatabase: "Abrir en la base de datos actual", openInCurrentDatabase: "Abrir en la base de datos actual",
empty: "Sin consultas guardadas", empty: "Sin consultas guardadas",
emptyFolder: "Carpeta vacía", emptyFolder: "Carpeta vacía",
+2
View File
@@ -152,6 +152,8 @@ export default withEnglishFallback({
}, },
sqlLibrary: { sqlLibrary: {
title: "Libreria SQL", title: "Libreria SQL",
unassociated: "Non associato",
deletedConnection: "Connessione eliminata",
openInCurrentDatabase: "Apri nel database corrente", openInCurrentDatabase: "Apri nel database corrente",
empty: "Nessuna query salvata", empty: "Nessuna query salvata",
emptyFolder: "Cartella vuota", emptyFolder: "Cartella vuota",
+2
View File
@@ -153,6 +153,8 @@ export default withEnglishFallback({
}, },
sqlLibrary: { sqlLibrary: {
title: "SQLライブラリ", title: "SQLライブラリ",
unassociated: "未関連付け",
deletedConnection: "削除された接続",
openInCurrentDatabase: "現在のデータベースで開く", openInCurrentDatabase: "現在のデータベースで開く",
empty: "保存されたクエリはまだありません", empty: "保存されたクエリはまだありません",
emptyFolder: "空のフォルダ", emptyFolder: "空のフォルダ",
+2
View File
@@ -148,6 +148,8 @@ export default withEnglishFallback({
}, },
sqlLibrary: { sqlLibrary: {
title: "SQL 라이브러리", title: "SQL 라이브러리",
unassociated: "연결되지 않음",
deletedConnection: "삭제된 연결",
openInCurrentDatabase: "현재 데이터베이스에서 열기", openInCurrentDatabase: "현재 데이터베이스에서 열기",
empty: "아직 저장된 쿼리가 없습니다", empty: "아직 저장된 쿼리가 없습니다",
emptyFolder: "빈 폴더", emptyFolder: "빈 폴더",
+2
View File
@@ -153,6 +153,8 @@ export default withEnglishFallback({
}, },
sqlLibrary: { sqlLibrary: {
title: "Biblioteca SQL", title: "Biblioteca SQL",
unassociated: "Sem associação",
deletedConnection: "Conexão excluída",
openInCurrentDatabase: "Abrir no banco de dados atual", openInCurrentDatabase: "Abrir no banco de dados atual",
empty: "Nenhuma consulta salva", empty: "Nenhuma consulta salva",
emptyFolder: "Pasta vazia", emptyFolder: "Pasta vazia",
+2
View File
@@ -153,6 +153,8 @@ export default withEnglishFallback({
}, },
sqlLibrary: { sqlLibrary: {
title: "SQL 库", title: "SQL 库",
unassociated: "未关联",
deletedConnection: "连接已删除",
openInCurrentDatabase: "在当前库中打开", openInCurrentDatabase: "在当前库中打开",
empty: "暂无已保存的查询", empty: "暂无已保存的查询",
emptyFolder: "空文件夹", emptyFolder: "空文件夹",
+2
View File
@@ -153,6 +153,8 @@ export default withEnglishFallback({
}, },
sqlLibrary: { sqlLibrary: {
title: "SQL 庫", title: "SQL 庫",
unassociated: "未關聯",
deletedConnection: "連線已刪除",
openInCurrentDatabase: "在目前資料庫中開啟", openInCurrentDatabase: "在目前資料庫中開啟",
empty: "暫無已儲存的查詢", empty: "暫無已儲存的查詢",
emptyFolder: "空資料夾", emptyFolder: "空資料夾",
@@ -40,16 +40,17 @@ describe("saved SQL execution targets", () => {
expect(savedSqlExecutionTargetFromTab(undefined)).toBeUndefined(); expect(savedSqlExecutionTargetFromTab(undefined)).toBeUndefined();
}); });
it("preserves the saved default target when an existing file is updated", () => { it("uses the current execution target when a file is saved", () => {
expect( expect(
savedSqlDefaultTargetForWrite( savedSqlDefaultTargetForWrite({
{ connectionId: "runtime-connection",
connectionId: "runtime-connection", database: "runtime_database",
database: "runtime_database", schema: "runtime_schema",
schema: "runtime_schema", }),
}, ).toEqual({
savedTarget, connectionId: "runtime-connection",
), database: "runtime_database",
).toEqual(savedTarget); schema: "runtime_schema",
});
}); });
}); });
@@ -35,8 +35,7 @@ export function resolveSavedSqlExecutionTarget(file: SavedSqlFileTarget, mode: S
return savedSqlExecutionTargetFromFile(file); return savedSqlExecutionTargetFromFile(file);
} }
export function savedSqlDefaultTargetForWrite(currentTarget: SavedSqlExecutionTarget, existingFile?: SavedSqlFileTarget): SavedSqlFileTarget { export function savedSqlDefaultTargetForWrite(currentTarget: SavedSqlExecutionTarget): SavedSqlFileTarget {
if (existingFile) return savedSqlExecutionTargetFromFile(existingFile);
return { return {
connectionId: currentTarget.connectionId, connectionId: currentTarget.connectionId,
database: currentTarget.database, database: currentTarget.database,
+29 -8
View File
@@ -103,6 +103,10 @@ interface OpenSavedSqlOptions {
targetMode?: SavedSqlOpenTargetMode; targetMode?: SavedSqlOpenTargetMode;
} }
interface UpdateExecutionTargetOptions {
persistSavedSqlTarget?: boolean;
}
type DroppedTableObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW"; type DroppedTableObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW";
interface DroppedTableObjectTarget { interface DroppedTableObjectTarget {
@@ -2568,12 +2572,13 @@ export const useQueryStore = defineStore("query", () => {
} }
function applySavedSqlExecutionTarget(tab: QueryTab, target: SavedSqlExecutionTarget) { function applySavedSqlExecutionTarget(tab: QueryTab, target: SavedSqlExecutionTarget) {
updateConnection(tab.id, target.connectionId, target.database); const options = { persistSavedSqlTarget: false };
updateConnection(tab.id, target.connectionId, target.database, options);
if (tab.catalog !== target.catalog || tab.database !== target.database) { if (tab.catalog !== target.catalog || tab.database !== target.database) {
if (tab.catalog !== undefined || target.catalog !== undefined) updateCatalog(tab.id, target.catalog, target.database); if (tab.catalog !== undefined || target.catalog !== undefined) updateCatalog(tab.id, target.catalog, target.database, options);
else updateDatabase(tab.id, target.database); else updateDatabase(tab.id, target.database, options);
} }
updateSchema(tab.id, target.schema); updateSchema(tab.id, target.schema, options);
} }
function openSavedSql(file: SavedSqlFile, options: OpenSavedSqlOptions = {}) { function openSavedSql(file: SavedSqlFile, options: OpenSavedSqlOptions = {}) {
@@ -2653,7 +2658,19 @@ export const useQueryStore = defineStore("query", () => {
tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned); tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned);
} }
function updateDatabase(id: string, database: string) { function persistSavedSqlExecutionTarget(tab: QueryTab, options: UpdateExecutionTargetOptions) {
if (options.persistSavedSqlTarget === false || tab.mode !== "query" || !tab.savedSqlId) return;
const savedSqlStore = useSavedSqlStore();
void savedSqlStore
.updateFileExecutionTarget(tab.savedSqlId, {
connectionId: tab.connectionId,
database: tab.database,
schema: tab.schema,
})
.catch((error) => console.warn("[DBX][saved-sql:target:error]", error));
}
function updateDatabase(id: string, database: string, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id); const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.database === database) return; if (!tab || tab.database === database) return;
rollbackTabTransaction(tab); rollbackTabTransaction(tab);
@@ -2668,9 +2685,10 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortedSql = undefined; tab.resultSortedSql = undefined;
clearExplain(tab); clearExplain(tab);
tab.tableMeta = undefined; tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
} }
function updateCatalog(id: string, catalog: string | undefined, database: string) { function updateCatalog(id: string, catalog: string | undefined, database: string, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((candidate) => candidate.id === id); const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab || (tab.catalog === catalog && tab.database === database)) return; if (!tab || (tab.catalog === catalog && tab.database === database)) return;
rollbackTabTransaction(tab); rollbackTabTransaction(tab);
@@ -2686,9 +2704,10 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortedSql = undefined; tab.resultSortedSql = undefined;
clearExplain(tab); clearExplain(tab);
tab.tableMeta = undefined; tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
} }
function updateSchema(id: string, schema: string | undefined) { function updateSchema(id: string, schema: string | undefined, options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id); const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.schema === schema) return; if (!tab || tab.schema === schema) return;
rollbackTabTransaction(tab); rollbackTabTransaction(tab);
@@ -2703,9 +2722,10 @@ export const useQueryStore = defineStore("query", () => {
} }
tab.schema = schema; tab.schema = schema;
if (tab.mode === "objects") tab.objectBrowser = { ...tab.objectBrowser, schema, viewport: undefined }; if (tab.mode === "objects") tab.objectBrowser = { ...tab.objectBrowser, schema, viewport: undefined };
persistSavedSqlExecutionTarget(tab, options);
} }
function updateConnection(id: string, connectionId: string, database = "") { function updateConnection(id: string, connectionId: string, database = "", options: UpdateExecutionTargetOptions = {}) {
const tab = tabs.value.find((t) => t.id === id); const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.connectionId === connectionId) return; if (!tab || tab.connectionId === connectionId) return;
rollbackTabTransaction(tab, { resetAutoCommit: true }); rollbackTabTransaction(tab, { resetAutoCommit: true });
@@ -2723,6 +2743,7 @@ export const useQueryStore = defineStore("query", () => {
tab.resultSortedSql = undefined; tab.resultSortedSql = undefined;
clearExplain(tab); clearExplain(tab);
tab.tableMeta = undefined; tab.tableMeta = undefined;
persistSavedSqlExecutionTarget(tab, options);
} }
function clearInvalidDataTabSortState(tab: QueryTab, columns: NonNullable<QueryTab["tableMeta"]>["columns"]): boolean { function clearInvalidDataTabSortState(tab: QueryTab, columns: NonNullable<QueryTab["tableMeta"]>["columns"]): boolean {
+83 -5
View File
@@ -25,6 +25,12 @@ interface SaveFileInput {
sql: string; sql: string;
} }
interface SavedSqlExecutionTargetInput {
connectionId: string;
database: string;
schema?: string;
}
function nowIso() { function nowIso() {
return new Date().toISOString(); return new Date().toISOString();
} }
@@ -95,6 +101,9 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
let pendingSync: Promise<void> | null = null; let pendingSync: Promise<void> | null = null;
let initFromStoragePromise: Promise<void> | null = null; let initFromStoragePromise: Promise<void> | null = null;
const pendingFolderCreates = new Map<string, Promise<SavedSqlFolder>>(); const pendingFolderCreates = new Map<string, Promise<SavedSqlFolder>>();
const fileTargetRevisions = new Map<string, number>();
const pendingFileTargetSaves = new Map<string, Promise<SavedSqlFile | undefined>>();
const persistedFileTargets = new Map<string, SavedSqlExecutionTargetInput & { updatedAt: string }>();
const version = ref(0); const version = ref(0);
function bumpVersion() { function bumpVersion() {
@@ -256,6 +265,79 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
return saved; return saved;
} }
function updateFileExecutionTarget(id: string, target: SavedSqlExecutionTargetInput): Promise<SavedSqlFile | undefined> {
const existing = getFile(id);
if (!existing) return Promise.resolve(undefined);
if (existing.connectionId === target.connectionId && existing.database === target.database && existing.schema === target.schema) {
return Promise.resolve(existing);
}
if (!persistedFileTargets.has(id)) {
persistedFileTargets.set(id, {
connectionId: existing.connectionId,
database: existing.database,
schema: existing.schema,
updatedAt: existing.updatedAt,
});
}
const revision = (fileTargetRevisions.get(id) ?? 0) + 1;
fileTargetRevisions.set(id, revision);
files.value = files.value.map((file) => (file.id === id ? { ...file, ...target, updatedAt: nowIso() } : file));
bumpVersion();
const previousSave = pendingFileTargetSaves.get(id) ?? Promise.resolve(undefined);
const save = previousSave
.catch(() => undefined)
.then(async () => {
if (fileTargetRevisions.get(id) !== revision) return getFile(id);
const loaded = await ensureFileContent(id);
if (!loaded || fileTargetRevisions.get(id) !== revision) return getFile(id);
const candidate: SavedSqlFile = {
...loaded,
...target,
sqlLoaded: true,
updatedAt: nowIso(),
};
files.value = files.value.map((file) => (file.id === id ? candidate : file));
bumpVersion();
try {
const saved = await api.saveSavedSqlFile(candidate);
persistedFileTargets.set(id, {
connectionId: saved.connectionId,
database: saved.database,
schema: saved.schema,
updatedAt: saved.updatedAt,
});
if (fileTargetRevisions.get(id) !== revision) return getFile(id);
const current = getFile(id);
const persisted = { ...saved, sql: current?.sql ?? saved.sql, sqlLoaded: current?.sqlLoaded ?? true };
files.value = files.value.map((file) => (file.id === id ? persisted : file));
bumpVersion();
await syncToLocalDirectory();
return persisted;
} catch (error) {
if (fileTargetRevisions.get(id) === revision) {
const persistedTarget = persistedFileTargets.get(id);
if (persistedTarget) files.value = files.value.map((file) => (file.id === id ? { ...file, ...persistedTarget } : file));
bumpVersion();
}
throw error;
}
});
pendingFileTargetSaves.set(id, save);
const cleanup = () => {
if (pendingFileTargetSaves.get(id) !== save) return;
pendingFileTargetSaves.delete(id);
persistedFileTargets.delete(id);
};
void save.then(cleanup, cleanup);
return save;
}
async function renameFile(id: string, name: string) { async function renameFile(id: string, name: string) {
const existing = getFile(id); const existing = getFile(id);
if (!existing) return; if (!existing) return;
@@ -555,10 +637,6 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
return allFiles.value.filter((f) => !f.folderId); return allFiles.value.filter((f) => !f.folderId);
} }
function orphanedFileIds(activeConnectionIds: Set<string>) {
return new Set(files.value.filter((f) => !activeConnectionIds.has(f.connectionId)).map((f) => f.id));
}
return { return {
folders, folders,
files, files,
@@ -574,6 +652,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
renameFolder, renameFolder,
deleteFolder, deleteFolder,
saveFile, saveFile,
updateFileExecutionTarget,
renameFile, renameFile,
recordFileUsage, recordFileUsage,
deleteFile, deleteFile,
@@ -588,6 +667,5 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
allFiles, allFiles,
filesInFolder, filesInFolder,
filesWithoutFolder, filesWithoutFolder,
orphanedFileIds,
}; };
}); });
+6 -4
View File
@@ -441,7 +441,7 @@ test("hydrating saved SQL content preserves its restored runtime target", async
} }
}); });
test("changing a saved SQL tab target does not rebind its saved default", async () => { test("changing a saved SQL tab target updates its saved default", async () => {
const restoreStorage = installMemoryStorage(); const restoreStorage = installMemoryStorage();
try { try {
setActivePinia(createPinia()); setActivePinia(createPinia());
@@ -466,9 +466,11 @@ test("changing a saved SQL tab target does not rebind its saved default", async
store.updateConnection(tabId, "runtime-connection", "runtime_database"); store.updateConnection(tabId, "runtime-connection", "runtime_database");
store.updateSchema(tabId, "runtime_schema"); store.updateSchema(tabId, "runtime_schema");
assert.equal(savedSqlStore.getFile(file.id)?.connectionId, "saved-connection"); await waitFor(() => savedSqlStore.getFile(file.id)?.schema === "runtime_schema");
assert.equal(savedSqlStore.getFile(file.id)?.database, "saved_database");
assert.equal(savedSqlStore.getFile(file.id)?.schema, "saved_schema"); assert.equal(savedSqlStore.getFile(file.id)?.connectionId, "runtime-connection");
assert.equal(savedSqlStore.getFile(file.id)?.database, "runtime_database");
assert.equal(savedSqlStore.getFile(file.id)?.schema, "runtime_schema");
} finally { } finally {
await nextTick(); await nextTick();
restoreStorage(); restoreStorage();
+129
View File
@@ -136,6 +136,135 @@ test("saved SQL summaries load file content on demand", async () => {
assert.equal(apiMock.loadSavedSqlFile.mock.calls.length, 1); assert.equal(apiMock.loadSavedSqlFile.mock.calls.length, 1);
}); });
test("changing a saved SQL execution target persists the latest target", async () => {
const file: SavedSqlFile = {
id: "sql-target",
connectionId: "conn-1",
name: "query.sql",
database: "db-1",
schema: "public",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
};
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] });
const store = useSavedSqlStore();
await store.initFromStorage();
await store.updateFileExecutionTarget("sql-target", {
connectionId: "conn-2",
database: "db-2",
schema: "app",
});
const saved = apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0];
assert.equal(saved?.connectionId, "conn-2");
assert.equal(saved?.database, "db-2");
assert.equal(saved?.schema, "app");
assert.equal(saved?.sql, file.sql);
assert.equal(saved?.name, file.name);
assert.deepEqual(
{
connectionId: store.getFile("sql-target")?.connectionId,
database: store.getFile("sql-target")?.database,
schema: store.getFile("sql-target")?.schema,
},
{ connectionId: "conn-2", database: "db-2", schema: "app" },
);
});
test("rapid saved SQL target changes persist only the latest target", async () => {
const file: SavedSqlFile = {
id: "sql-target",
connectionId: "conn-1",
name: "query.sql",
database: "db-1",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
};
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] });
apiMock.saveSavedSqlFile.mockImplementation(async (value) => value);
const store = useSavedSqlStore();
await store.initFromStorage();
const first = store.updateFileExecutionTarget("sql-target", { connectionId: "conn-2", database: "db-2" });
const second = store.updateFileExecutionTarget("sql-target", { connectionId: "conn-3", database: "db-3" });
await Promise.all([first, second]);
assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1);
assert.equal(apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0].connectionId, "conn-3");
assert.equal(apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0].database, "db-3");
assert.equal(store.getFile("sql-target")?.connectionId, "conn-3");
});
test("saved SQL target changes roll back when persistence fails", async () => {
const file: SavedSqlFile = {
id: "sql-target",
connectionId: "conn-1",
name: "query.sql",
database: "db-1",
schema: "public",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
};
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] });
apiMock.saveSavedSqlFile.mockRejectedValueOnce(new Error("disk full"));
const store = useSavedSqlStore();
await store.initFromStorage();
await assert.rejects(store.updateFileExecutionTarget("sql-target", { connectionId: "conn-2", database: "db-2", schema: "app" }), /disk full/);
assert.deepEqual(
{
connectionId: store.getFile("sql-target")?.connectionId,
database: store.getFile("sql-target")?.database,
schema: store.getFile("sql-target")?.schema,
},
{ connectionId: "conn-1", database: "db-1", schema: "public" },
);
});
test("empty and deleted-connection SQL files remain in the library", async () => {
const files: SavedSqlFile[] = [
{
id: "unassociated",
connectionId: "",
name: "unassociated.sql",
database: "",
sql: "SELECT 1;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
},
{
id: "deleted",
connectionId: "deleted-connection",
name: "deleted.sql",
database: "",
sql: "SELECT 2;",
sqlLoaded: true,
createdAt: "2026-06-27T00:00:00.000Z",
updatedAt: "2026-06-27T00:00:00.000Z",
},
];
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files });
const store = useSavedSqlStore();
await store.initFromStorage();
assert.deepEqual(
store.allFiles.map((item) => item.id),
["deleted", "unassociated"],
);
});
test("saving an existing SQL file without folderId keeps its folder", async () => { test("saving an existing SQL file without folderId keeps its folder", async () => {
const file: SavedSqlFile = { const file: SavedSqlFile = {
id: "sql-1", id: "sql-1",