diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index ce64706ce..dd2a297d5 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -56,7 +56,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; 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 { isBrowserReloadShortcut, @@ -848,16 +848,13 @@ async function saveExternalSqlPath(tab: QueryTab, options: { closeAfterSave?: bo } } -function savedSqlTargetForSave(tab: QueryTab, existing?: SavedSqlFile) { - return savedSqlDefaultTargetForWrite( - { - connectionId: tab.connectionId, - database: tab.database, - schema: tab.schema, - catalog: tab.catalog, - }, - existing, - ); +function savedSqlTargetForSave(tab: QueryTab) { + return savedSqlDefaultTargetForWrite({ + connectionId: tab.connectionId, + database: tab.database, + schema: tab.schema, + catalog: tab.catalog, + }); } async function saveTabForCloseAll(tabId: string): Promise { @@ -876,7 +873,7 @@ async function saveTabForCloseAll(tabId: string): Promise { if (await saveExternalSqlPath(tab)) return !queryStore.isTabDirty(tab); const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; - const target = savedSqlTargetForSave(tab, existing); + const target = savedSqlTargetForSave(tab); try { const saved = await savedSqlStore.saveFile({ id: existing?.id, @@ -943,7 +940,7 @@ async function handleSaveTab(tabId: string) { } const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; if (existing) { - const target = savedSqlTargetForSave(tab, existing); + const target = savedSqlTargetForSave(tab); const updated = await savedSqlStore.saveFile({ id: existing.id, connectionId: target.connectionId, @@ -979,7 +976,7 @@ async function openSaveSqlDialog() { if (await saveExternalSqlPath(tab)) return; const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; if (existing) { - const target = savedSqlTargetForSave(tab, existing); + const target = savedSqlTargetForSave(tab); const updated = await savedSqlStore.saveFile({ id: existing.id, connectionId: target.connectionId, @@ -1073,8 +1070,7 @@ async function confirmSaveSqlToLibrary() { const name = saveSqlName.value.trim(); if (!tab || !tab.sql.trim() || !name) return; try { - const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; - const target = savedSqlTargetForSave(tab, existing); + const target = savedSqlTargetForSave(tab); const saved = await savedSqlStore.saveFile({ id: tab.savedSqlId, connectionId: target.connectionId, diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 734f71e40..241ab6325 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -41,18 +41,14 @@ type DropPosition = "before" | "after" | "inside"; const activeConnectionIds = computed(() => new Set(connectionStore.connections.map((c) => c.id))); const searchText = ref(""); 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) const sortMode = ref<"folder" | "date">("folder"); -function isConnectionVisible(connectionId: string) { - return activeConnectionIds.value.has(connectionId); -} - function getConnectionLabel(connectionId: string) { + if (!connectionId) return t("sqlLibrary.unassociated"); const conn = connectionStore.connections.find((c) => c.id === connectionId); - return conn?.name || connectionId; + return conn?.name || t("sqlLibrary.deletedConnection"); } function folderPath(folder: SavedSqlFolder) { @@ -180,13 +176,13 @@ async function exportFolderContents(folder?: SavedSqlFolder) { if (folder) { await writeFolder(folder, rootDir); } 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)); await mkdir(folderDir, { recursive: true }); await writeFolder(libraryFolder, folderDir); } - const unfiled = savedSqlStore.filesWithoutFolder().filter((file) => !orphanedIds.value.has(file.id)); + const unfiled = savedSqlStore.filesWithoutFolder(); if (unfiled.length > 0) { const unfiledDir = await join(rootDir, sanitizeFileSystemSegment(t("sqlLibrary.unfiled"))); await mkdir(unfiledDir, { recursive: true }); @@ -239,7 +235,7 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) { 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) { const content = await api.readExternalSqlFile(path); @@ -320,11 +316,11 @@ function folderMatchesQuery(folder: SavedSqlFolder) { const q = searchQuery.value; if (!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) { - return savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId) && (folder.parentFolderId || "") === (parentFolderId || "")); + return savedSqlStore.allFolders.filter((folder) => (folder.parentFolderId || "") === (parentFolderId || "")); } function descendantFolders(parentFolderId: string): SavedSqlFolder[] { @@ -340,15 +336,11 @@ function folderBranchMatchesQuery(folder: SavedSqlFolder) { function filesInFolder(folderId: string) { const folder = savedSqlStore.allFolders.find((item) => item.id === folderId); const includeAllFilesForMatchedFolder = !!folder && !!searchQuery.value && folder.name.toLowerCase().includes(searchQuery.value); - return savedSqlStore - .filesInFolder(folderId) - .filter((file) => !orphanedIds.value.has(file.id)) - .filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file)); + return savedSqlStore.filesInFolder(folderId).filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file)); } function folderFileCount(folderId: string) { - const visibleFolders = savedSqlStore.allFolders.filter((folder) => isConnectionVisible(folder.connectionId)); - return savedSqlFolderBranchFileCount(folderId, visibleFolders, filesInFolder); + return savedSqlFolderBranchFileCount(folderId, savedSqlStore.allFolders, filesInFolder); } type SqlLibraryRow = { type: "folder"; folder: SavedSqlFolder; depth: number; folderIndex: number } | { type: "file"; file: SavedSqlFile; depth: number }; @@ -373,19 +365,11 @@ const visibleFolderRows = computed(() => { return rows; }); -const visibleFiles = computed(() => - savedSqlStore - .filesWithoutFolder() - .filter((file) => !orphanedIds.value.has(file.id)) - .filter((file) => fileMatchesQuery(file)), -); +const visibleFiles = computed(() => savedSqlStore.filesWithoutFolder().filter((file) => fileMatchesQuery(file))); // Flat list sorted by updatedAt (descending) - combines all folders and files const itemsByDate = computed(() => { - const allFolders = savedSqlStore.allFolders - .filter((folder) => isConnectionVisible(folder.connectionId)) - .filter((folder) => folderBranchMatchesQuery(folder)) - .map((folder) => ({ type: "folder" as const, item: folder, updatedAt: folder.updatedAt })); + const allFolders = savedSqlStore.allFolders.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 })); @@ -429,14 +413,7 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) { const connectionId = folder?.connectionId || connectionStore.activeConnectionId || connectionStore.connections[0]?.id; if (!connectionId) return; - const takenNames = folder - ? 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 takenNames = folder ? new Set(savedSqlStore.filesInFolder(folder.id).map((f) => f.name)) : new Set(savedSqlStore.filesWithoutFolder().map((f) => f.name)); const name = uniqueImportedName("new_query.sql", takenNames); const file = await savedSqlStore.saveFile({ connectionId, @@ -805,18 +782,16 @@ const contextTarget = ref(null); function folderMoveMenuItems(fileIds: string[]): CtxMenuItem[] { 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 folderItems = savedSqlStore.allFoldersTreeOrder - .filter((folder) => isConnectionVisible(folder.connectionId)) - .map((folder) => ({ - label: folderPath(folder), - action: () => - moveFilesToFolder( - files.map((file) => file.id), - folder.id, - ), - disabled: files.every((file) => file.folderId === folder.id), - icon: FolderClosed, - })); + const folderItems = savedSqlStore.allFoldersTreeOrder.map((folder) => ({ + label: folderPath(folder), + action: () => + moveFilesToFolder( + files.map((file) => file.id), + folder.id, + ), + disabled: files.every((file) => file.folderId === folder.id), + icon: FolderClosed, + })); return [ { diff --git a/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts b/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts index d0f168db9..071f70f58 100644 --- a/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts +++ b/apps/desktop/src/composables/__tests__/useQuickOpen.spec.ts @@ -30,7 +30,6 @@ vi.mock("@/lib/sqlFile/sqlFileFolders", async () => { function emptySavedSqlStore() { return { allFiles: [] as any[], - orphanedFileIds: vi.fn().mockReturnValue(new Set()), getFile: vi.fn().mockReturnValue(undefined), }; } @@ -661,7 +660,6 @@ describe("useQuickOpen", () => { const fileMap = new Map(files.map((f) => [f.id, f])); return { allFiles: files, - orphanedFileIds: vi.fn().mockReturnValue(new Set()), getFile: vi.fn().mockImplementation((id: string) => fileMap.get(id)), }; } @@ -713,7 +711,7 @@ describe("useQuickOpen", () => { expect(sqlItems[0].sqlFileId).toBe("f1"); }); - it("excludes orphaned SQL library files", () => { + it("includes SQL library files whose connection was deleted", () => { const mockConnStore = { connections: [{ id: "conn1", name: "Active", type: "mssql" }], treeNodes: [], @@ -724,16 +722,24 @@ describe("useQuickOpen", () => { { 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" }, ]; - const store = savedSqlStoreWithFiles(files); - store.orphanedFileIds = vi.fn().mockReturnValue(new Set(["f2"])); - vi.mocked(useSavedSqlStore).mockReturnValue(store as any); + vi.mocked(useSavedSqlStore).mockReturnValue(savedSqlStoreWithFiles(files) as any); const { filteredItems, setQuery } = useQuickOpen(); setQuery("query"); const sqlItems = filteredItems.value.filter((item) => item.type === "sql_library_file"); - expect(sqlItems).toHaveLength(1); - expect(sqlItems[0].label).toBe("active_query.sql"); + expect(sqlItems).toHaveLength(2); + 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"); }); }); diff --git a/apps/desktop/src/composables/useQuickOpen.ts b/apps/desktop/src/composables/useQuickOpen.ts index ab155ae8c..1cf9d5e97 100644 --- a/apps/desktop/src/composables/useQuickOpen.ts +++ b/apps/desktop/src/composables/useQuickOpen.ts @@ -7,6 +7,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore"; import * as api from "@/lib/backend/api"; import type { SqlFileEntry } from "@/lib/backend/api"; import { getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders"; +import i18n from "@/i18n"; const REMOTE_SEARCH_DEBOUNCE_MS = 180; const REMOTE_SEARCH_MIN_QUERY_LENGTH = 2; @@ -118,25 +119,22 @@ export function useQuickOpen() { let sqlFilesLoadGeneration = 0; function getConnectionLabel(connectionId: string): string { + if (!connectionId) return i18n.global.t("sqlLibrary.unassociated"); const conn = connectionStore.connections.find((c) => c.id === connectionId); - return conn?.name || connectionId; + return conn?.name || i18n.global.t("sqlLibrary.deletedConnection"); } const sqlLibraryAllItems = computed(() => { - const activeConnectionIds = new Set(connectionStore.connections.map((c) => c.id)); - const orphanedIds = savedSqlStore.orphanedFileIds(activeConnectionIds); - return savedSqlStore.allFiles - .filter((file) => !orphanedIds.has(file.id)) - .map((file) => ({ - id: `sqllib-${file.id}`, - type: "sql_library_file" as const, - label: file.name, - description: getConnectionLabel(file.connectionId), - connectionId: file.connectionId, - connectionName: getConnectionLabel(file.connectionId), - sqlFileId: file.id, - searchText: `${file.name} ${getConnectionLabel(file.connectionId)}`, - })); + return savedSqlStore.allFiles.map((file) => ({ + id: `sqllib-${file.id}`, + type: "sql_library_file" as const, + label: file.name, + description: getConnectionLabel(file.connectionId), + connectionId: file.connectionId, + connectionName: getConnectionLabel(file.connectionId), + sqlFileId: file.id, + searchText: `${file.name} ${getConnectionLabel(file.connectionId)}`, + })); }); const sqlLibraryRecentItems = computed(() => { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index caa5772df..df4f6d350 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -151,6 +151,8 @@ export default { }, sqlLibrary: { title: "SQL Library", + unassociated: "Unassociated", + deletedConnection: "Connection deleted", openInCurrentDatabase: "Open in Current Database", empty: "No saved queries yet", emptyFolder: "Empty folder", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a08c1e8bf..663bddb57 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -153,6 +153,8 @@ export default withEnglishFallback({ }, sqlLibrary: { title: "Biblioteca SQL", + unassociated: "Sin asociación", + deletedConnection: "Conexión eliminada", openInCurrentDatabase: "Abrir en la base de datos actual", empty: "Sin consultas guardadas", emptyFolder: "Carpeta vacía", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 89ccee7e5..0411bad65 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -152,6 +152,8 @@ export default withEnglishFallback({ }, sqlLibrary: { title: "Libreria SQL", + unassociated: "Non associato", + deletedConnection: "Connessione eliminata", openInCurrentDatabase: "Apri nel database corrente", empty: "Nessuna query salvata", emptyFolder: "Cartella vuota", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e87d7566c..ad25c5cbb 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -153,6 +153,8 @@ export default withEnglishFallback({ }, sqlLibrary: { title: "SQLライブラリ", + unassociated: "未関連付け", + deletedConnection: "削除された接続", openInCurrentDatabase: "現在のデータベースで開く", empty: "保存されたクエリはまだありません", emptyFolder: "空のフォルダ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 6a02e265e..81a92fc7a 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -148,6 +148,8 @@ export default withEnglishFallback({ }, sqlLibrary: { title: "SQL 라이브러리", + unassociated: "연결되지 않음", + deletedConnection: "삭제된 연결", openInCurrentDatabase: "현재 데이터베이스에서 열기", empty: "아직 저장된 쿼리가 없습니다", emptyFolder: "빈 폴더", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index c388f7397..156495073 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -153,6 +153,8 @@ export default withEnglishFallback({ }, sqlLibrary: { title: "Biblioteca SQL", + unassociated: "Sem associação", + deletedConnection: "Conexão excluída", openInCurrentDatabase: "Abrir no banco de dados atual", empty: "Nenhuma consulta salva", emptyFolder: "Pasta vazia", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 736c1823e..95a625103 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -153,6 +153,8 @@ export default withEnglishFallback({ }, sqlLibrary: { title: "SQL 库", + unassociated: "未关联", + deletedConnection: "连接已删除", openInCurrentDatabase: "在当前库中打开", empty: "暂无已保存的查询", emptyFolder: "空文件夹", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 3cde24c15..a0cf9bfce 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -153,6 +153,8 @@ export default withEnglishFallback({ }, sqlLibrary: { title: "SQL 庫", + unassociated: "未關聯", + deletedConnection: "連線已刪除", openInCurrentDatabase: "在目前資料庫中開啟", empty: "暫無已儲存的查詢", emptyFolder: "空資料夾", diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlExecutionTarget.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlExecutionTarget.spec.ts index 9cb0c3945..6e22abbea 100644 --- a/apps/desktop/src/lib/__tests__/savedSql/savedSqlExecutionTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlExecutionTarget.spec.ts @@ -40,16 +40,17 @@ describe("saved SQL execution targets", () => { 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( - savedSqlDefaultTargetForWrite( - { - connectionId: "runtime-connection", - database: "runtime_database", - schema: "runtime_schema", - }, - savedTarget, - ), - ).toEqual(savedTarget); + savedSqlDefaultTargetForWrite({ + connectionId: "runtime-connection", + database: "runtime_database", + schema: "runtime_schema", + }), + ).toEqual({ + connectionId: "runtime-connection", + database: "runtime_database", + schema: "runtime_schema", + }); }); }); diff --git a/apps/desktop/src/lib/savedSql/savedSqlExecutionTarget.ts b/apps/desktop/src/lib/savedSql/savedSqlExecutionTarget.ts index cd1131f6c..b21ce0cd4 100644 --- a/apps/desktop/src/lib/savedSql/savedSqlExecutionTarget.ts +++ b/apps/desktop/src/lib/savedSql/savedSqlExecutionTarget.ts @@ -35,8 +35,7 @@ export function resolveSavedSqlExecutionTarget(file: SavedSqlFileTarget, mode: S return savedSqlExecutionTargetFromFile(file); } -export function savedSqlDefaultTargetForWrite(currentTarget: SavedSqlExecutionTarget, existingFile?: SavedSqlFileTarget): SavedSqlFileTarget { - if (existingFile) return savedSqlExecutionTargetFromFile(existingFile); +export function savedSqlDefaultTargetForWrite(currentTarget: SavedSqlExecutionTarget): SavedSqlFileTarget { return { connectionId: currentTarget.connectionId, database: currentTarget.database, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index a906bb6df..075e39cd3 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -103,6 +103,10 @@ interface OpenSavedSqlOptions { targetMode?: SavedSqlOpenTargetMode; } +interface UpdateExecutionTargetOptions { + persistSavedSqlTarget?: boolean; +} + type DroppedTableObjectType = "TABLE" | "VIEW" | "MATERIALIZED_VIEW"; interface DroppedTableObjectTarget { @@ -2568,12 +2572,13 @@ export const useQueryStore = defineStore("query", () => { } 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 !== undefined || target.catalog !== undefined) updateCatalog(tab.id, target.catalog, target.database); - else updateDatabase(tab.id, target.database); + if (tab.catalog !== undefined || target.catalog !== undefined) updateCatalog(tab.id, target.catalog, target.database, options); + else updateDatabase(tab.id, target.database, options); } - updateSchema(tab.id, target.schema); + updateSchema(tab.id, target.schema, options); } function openSavedSql(file: SavedSqlFile, options: OpenSavedSqlOptions = {}) { @@ -2653,7 +2658,19 @@ export const useQueryStore = defineStore("query", () => { 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); if (!tab || tab.database === database) return; rollbackTabTransaction(tab); @@ -2668,9 +2685,10 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortedSql = undefined; clearExplain(tab); 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); if (!tab || (tab.catalog === catalog && tab.database === database)) return; rollbackTabTransaction(tab); @@ -2686,9 +2704,10 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortedSql = undefined; clearExplain(tab); 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); if (!tab || tab.schema === schema) return; rollbackTabTransaction(tab); @@ -2703,9 +2722,10 @@ export const useQueryStore = defineStore("query", () => { } tab.schema = schema; 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); if (!tab || tab.connectionId === connectionId) return; rollbackTabTransaction(tab, { resetAutoCommit: true }); @@ -2723,6 +2743,7 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortedSql = undefined; clearExplain(tab); tab.tableMeta = undefined; + persistSavedSqlExecutionTarget(tab, options); } function clearInvalidDataTabSortState(tab: QueryTab, columns: NonNullable["columns"]): boolean { diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index 417e72a5a..3147b06ec 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -25,6 +25,12 @@ interface SaveFileInput { sql: string; } +interface SavedSqlExecutionTargetInput { + connectionId: string; + database: string; + schema?: string; +} + function nowIso() { return new Date().toISOString(); } @@ -95,6 +101,9 @@ export const useSavedSqlStore = defineStore("savedSql", () => { let pendingSync: Promise | null = null; let initFromStoragePromise: Promise | null = null; const pendingFolderCreates = new Map>(); + const fileTargetRevisions = new Map(); + const pendingFileTargetSaves = new Map>(); + const persistedFileTargets = new Map(); const version = ref(0); function bumpVersion() { @@ -256,6 +265,79 @@ export const useSavedSqlStore = defineStore("savedSql", () => { return saved; } + function updateFileExecutionTarget(id: string, target: SavedSqlExecutionTargetInput): Promise { + 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) { const existing = getFile(id); if (!existing) return; @@ -555,10 +637,6 @@ export const useSavedSqlStore = defineStore("savedSql", () => { return allFiles.value.filter((f) => !f.folderId); } - function orphanedFileIds(activeConnectionIds: Set) { - return new Set(files.value.filter((f) => !activeConnectionIds.has(f.connectionId)).map((f) => f.id)); - } - return { folders, files, @@ -574,6 +652,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { renameFolder, deleteFolder, saveFile, + updateFileExecutionTarget, renameFile, recordFileUsage, deleteFile, @@ -588,6 +667,5 @@ export const useSavedSqlStore = defineStore("savedSql", () => { allFiles, filesInFolder, filesWithoutFolder, - orphanedFileIds, }; }); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 17e6dde77..bfb7ed41c 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -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(); try { 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.updateSchema(tabId, "runtime_schema"); - assert.equal(savedSqlStore.getFile(file.id)?.connectionId, "saved-connection"); - assert.equal(savedSqlStore.getFile(file.id)?.database, "saved_database"); - assert.equal(savedSqlStore.getFile(file.id)?.schema, "saved_schema"); + await waitFor(() => savedSqlStore.getFile(file.id)?.schema === "runtime_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 { await nextTick(); restoreStorage(); diff --git a/packages/app-tests/savedSqlStore.test.ts b/packages/app-tests/savedSqlStore.test.ts index bf9b854cc..9213fcda4 100644 --- a/packages/app-tests/savedSqlStore.test.ts +++ b/packages/app-tests/savedSqlStore.test.ts @@ -136,6 +136,135 @@ test("saved SQL summaries load file content on demand", async () => { 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 () => { const file: SavedSqlFile = { id: "sql-1",