diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 7e751f1d5..5b4187f2f 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -867,8 +867,8 @@ async function openConnectionQuery(connectionId: string) { } } -function openSavedSqlFromWelcome(fileId: string) { - const file = savedSqlStore.getFile(fileId); +async function openSavedSqlFromWelcome(fileId: string) { + const file = await savedSqlStore.ensureFileContent(fileId); if (!file) return; queryStore.openSavedSql(file); connectionStore.activeConnectionId = file.connectionId; @@ -1273,9 +1273,16 @@ function initApp() { settingsStore .initDesktopSettings() .catch(() => {}) - .then(() => savedSqlStore.initFromStorage()) .then(() => { - console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`); + void savedSqlStore + .initFromStorage() + .then(() => { + console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`); + void queryStore.hydrateSavedSqlTabs(); + }) + .catch((e: any) => { + toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000); + }); return connectionStore.initFromDisk(); }) .then(() => { diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index ac1447892..162fa99c1 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -109,6 +109,8 @@ async function downloadText(content: string, fileName: string) { async function exportSingleFile(file: SavedSqlFile) { try { + const loadedFile = await savedSqlStore.ensureFileContent(file.id); + if (!loadedFile) return; const defaultFileName = sanitizeFileSystemSegment(ensureSqlExtension(file.name)); if (isTauriRuntime()) { const { save } = await import("@tauri-apps/plugin-dialog"); @@ -118,9 +120,9 @@ async function exportSingleFile(file: SavedSqlFile) { filters: [{ name: "SQL", extensions: ["sql"] }], }); if (!path) return; - await writeTextFile(path, file.sql); + await writeTextFile(path, loadedFile.sql); } else { - await downloadText(file.sql, defaultFileName); + await downloadText(loadedFile.sql, defaultFileName); } toast(t("sqlLibrary.exported"), 2000); } catch (e: any) { @@ -158,8 +160,10 @@ async function exportFolderContents(folder?: SavedSqlFolder) { await writeFolder(child, childDir); } for (const file of savedSqlStore.filesInFolder(libraryFolder.id)) { + const loadedFile = await savedSqlStore.ensureFileContent(file.id); + if (!loadedFile) continue; const filePath = await join(dir, sanitizeFileSystemSegment(ensureSqlExtension(file.name))); - await writeTextFile(filePath, file.sql); + await writeTextFile(filePath, loadedFile.sql); } }; @@ -177,8 +181,10 @@ async function exportFolderContents(folder?: SavedSqlFolder) { const unfiledDir = await join(rootDir, sanitizeFileSystemSegment(t("sqlLibrary.unfiled"))); await mkdir(unfiledDir, { recursive: true }); for (const file of unfiled) { + const loadedFile = await savedSqlStore.ensureFileContent(file.id); + if (!loadedFile) continue; const filePath = await join(unfiledDir, sanitizeFileSystemSegment(ensureSqlExtension(file.name))); - await writeTextFile(filePath, file.sql); + await writeTextFile(filePath, loadedFile.sql); } } } @@ -579,11 +585,13 @@ async function executeBatchDelete() { toast(t("sqlLibrary.batchDeleteSuccess", { count: fileIds.length + folderIds.length }), 2000); } -function openFile(file: SavedSqlFile) { +async function openFile(file: SavedSqlFile) { if (suppressNextRowClick.value) return; - queryStore.openSavedSql(file); - connectionStore.activeConnectionId = file.connectionId; - void savedSqlStore.recordFileUsage(file.id); + const loadedFile = await savedSqlStore.ensureFileContent(file.id); + if (!loadedFile) return; + queryStore.openSavedSql(loadedFile); + connectionStore.activeConnectionId = loadedFile.connectionId; + void savedSqlStore.recordFileUsage(loadedFile.id); } function handleFileClick(file: SavedSqlFile, event: MouseEvent) { diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 41353a042..f03ff4593 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -836,10 +836,10 @@ function openMongoCollectionData(node: TreeNode) { queryStore.updateSql(tab, node.label); } -function openSavedSqlFile() { +async function openSavedSqlFile() { const node = props.node; if (node.type !== "saved-sql-file" || !node.savedSqlId) return; - const file = savedSqlStore.getFile(node.savedSqlId); + const file = await savedSqlStore.ensureFileContent(node.savedSqlId); if (!file) return; queryStore.openSavedSql(file); connectionStore.activeConnectionId = file.connectionId; @@ -3479,8 +3479,8 @@ function savedSqlHistoryScopeForNode(node: TreeNode): SavedSqlHistoryScope | nul return null; } -function openSavedSqlHistoryFile(fileId: string) { - const file = savedSqlStore.getFile(fileId); +async function openSavedSqlHistoryFile(fileId: string) { + const file = await savedSqlStore.ensureFileContent(fileId); if (!file) return; queryStore.openSavedSql(file); connectionStore.activeConnectionId = file.connectionId; diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 87e56c3d2..a74fdc923 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -92,6 +92,7 @@ export const reinstallJre = forward("reinstallJre"); export const uninstallJre = forward("uninstallJre"); export const listenAgentInstallProgress = forward("listenAgentInstallProgress"); export const loadSavedSqlLibrary = forward("loadSavedSqlLibrary"); +export const loadSavedSqlFile = forward("loadSavedSqlFile"); export const saveSavedSqlFolder = forward("saveSavedSqlFolder"); export const deleteSavedSqlFolder = forward("deleteSavedSqlFolder"); export const saveSavedSqlFile = forward("saveSavedSqlFile"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 9c4aa01e1..6a6643acb 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -405,6 +405,10 @@ export async function loadSavedSqlLibrary(): Promise { return get("/api/saved-sql"); } +export async function loadSavedSqlFile(id: string): Promise { + return get(`/api/saved-sql/${encodeURIComponent(id)}`); +} + export async function saveSavedSqlFolder(folder: SavedSqlFolder): Promise { return post("/api/saved-sql/folders", folder); } diff --git a/apps/desktop/src/lib/openTabsPersistence.ts b/apps/desktop/src/lib/openTabsPersistence.ts index aef8da2e6..7b89f5200 100644 --- a/apps/desktop/src/lib/openTabsPersistence.ts +++ b/apps/desktop/src/lib/openTabsPersistence.ts @@ -53,6 +53,11 @@ export interface RestoredOpenTabs { activeTabId: string | null; } +function shouldPersistTabSql(tab: QueryTab) { + if (!tab.savedSqlId) return true; + return tab.originalSql !== undefined && tab.sql !== tab.originalSql; +} + export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { return tabs.map((tab) => ({ id: tab.id, @@ -61,7 +66,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { connectionId: tab.connectionId, database: tab.database, schema: tab.schema, - sql: tab.sql, + sql: shouldPersistTabSql(tab) ? tab.sql : "", savedSqlId: tab.savedSqlId, externalSqlPath: tab.externalSqlPath, ...(tab.lastExecutedSql !== undefined ? { lastExecutedSql: tab.lastExecutedSql } : {}), @@ -108,7 +113,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { function isSavedOpenTab(value: unknown): value is SavedOpenTab { if (!value || typeof value !== "object") return false; const tab = value as Record; - return typeof tab.id === "string" && typeof tab.title === "string" && typeof tab.connectionId === "string" && typeof tab.database === "string" && typeof tab.sql === "string"; + return typeof tab.id === "string" && typeof tab.title === "string" && typeof tab.connectionId === "string" && typeof tab.database === "string" && (typeof tab.sql === "string" || typeof tab.savedSqlId === "string"); } export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: string | null, options: { queryOnly?: boolean } = {}): RestoredOpenTabs { @@ -134,13 +139,14 @@ export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: str return { ...tab, mode, + sql: typeof tab.sql === "string" ? tab.sql : "", isExecuting: false, isCancelling: false, queryExecutionStartedAt: undefined, editorViewport: undefined, editorSelection: undefined, isExplaining: false, - originalSql: mode === "query" && tab.externalSqlPath ? tab.sql : undefined, + originalSql: mode === "query" && tab.externalSqlPath ? tab.sql : mode === "query" && tab.savedSqlId && tab.sql ? "" : undefined, resultEvicted: mode === "data" ? undefined : tab.resultEvicted, resultCacheKey: mode === "data" ? undefined : tab.resultCacheKey, resultCacheState: mode !== "data" && tab.resultCacheKey ? "disk" : undefined, diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 779f2f225..a1acc79e9 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1044,6 +1044,10 @@ export async function loadSavedSqlLibrary(): Promise { return invoke("load_saved_sql_library"); } +export async function loadSavedSqlFile(id: string): Promise { + return invoke("load_saved_sql_file", { id }); +} + export async function saveSavedSqlFolder(folder: SavedSqlFolder): Promise { return invoke("save_saved_sql_folder", { folder }); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 342d458c7..948d60725 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1001,6 +1001,10 @@ export const useQueryStore = defineStore("query", () => { function openSavedSql(file: SavedSqlFile) { const existing = tabs.value.find((tab) => tab.savedSqlId === file.id); if (existing) { + if (!existing.sql && file.sql) { + existing.sql = file.sql; + existing.originalSql = file.sql; + } activeTabId.value = existing.id; return existing.id; } @@ -1026,6 +1030,21 @@ export const useQueryStore = defineStore("query", () => { return id; } + async function hydrateSavedSqlTabs() { + const savedSqlStore = useSavedSqlStore(); + const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === ""); + for (const tab of linkedTabs) { + const file = await savedSqlStore.ensureFileContent(tab.savedSqlId!); + if (!file) continue; + tab.title = tab.customTitle ? tab.title : file.name; + tab.connectionId = file.connectionId; + tab.database = file.database; + tab.schema = file.schema; + tab.sql = file.sql; + tab.originalSql = file.sql; + } + } + function togglePinnedTab(id: string) { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; @@ -1084,9 +1103,9 @@ export const useQueryStore = defineStore("query", () => { // Sync connection change back to the saved SQL file if this tab is linked if (tab.savedSqlId) { const savedSqlStore = useSavedSqlStore(); - const existing = savedSqlStore.getFile(tab.savedSqlId); - if (existing) { - void savedSqlStore.saveFile({ + void savedSqlStore.ensureFileContent(tab.savedSqlId).then((existing) => { + if (!existing) return; + return savedSqlStore.saveFile({ id: existing.id, connectionId, name: existing.name, @@ -1094,7 +1113,7 @@ export const useQueryStore = defineStore("query", () => { schema: existing.schema, sql: existing.sql, }); - } + }); } } @@ -2376,6 +2395,7 @@ export const useQueryStore = defineStore("query", () => { linkSavedSql, linkExternalSqlPath, openSavedSql, + hydrateSavedSqlTabs, togglePinnedTab, reorderTab, updateDatabase, diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index 4120a9006..bde4d6978 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -81,6 +81,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const files = ref([]); const isLoaded = ref(false); let pendingSync: Promise | null = null; + let initFromStoragePromise: Promise | null = null; const pendingFolderCreates = new Map>(); const version = ref(0); @@ -90,7 +91,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { function applyLibrary(library: SavedSqlLibrary) { folders.value = library.folders; - files.value = library.files; + files.value = library.files.map((file) => ({ ...file, sqlLoaded: file.sqlLoaded ?? Boolean(file.sql) })); bumpVersion(); } @@ -108,10 +109,17 @@ export const useSavedSqlStore = defineStore("savedSql", () => { } async function initFromStorage() { - await migrateLegacyLocalStorage(); - applyLibrary(await api.loadSavedSqlLibrary()); - isLoaded.value = true; - await syncToLocalDirectory(); + if (isLoaded.value) return; + if (!initFromStoragePromise) { + initFromStoragePromise = (async () => { + await migrateLegacyLocalStorage(); + applyLibrary(await api.loadSavedSqlLibrary()); + isLoaded.value = true; + })().finally(() => { + initFromStoragePromise = null; + }); + } + await initFromStoragePromise; } function listFolders(connectionId: string) { @@ -134,6 +142,19 @@ export const useSavedSqlStore = defineStore("savedSql", () => { return files.value.find((file) => file.id === id); } + async function ensureFileContent(id: string) { + const existing = getFile(id); + if (!existing) return undefined; + if (existing.sqlLoaded !== false) return existing; + + const loaded = await api.loadSavedSqlFile(id); + if (!loaded) return existing; + const hydrated = { ...loaded, sqlLoaded: true }; + files.value = files.value.map((file) => (file.id === id ? hydrated : file)); + bumpVersion(); + return hydrated; + } + async function createFolder(connectionId: string, name: string, parentFolderId?: string) { const key = folderCreateKey(connectionId, name, parentFolderId); const pending = pendingFolderCreates.get(key); @@ -196,6 +217,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { database: input.database, schema: input.schema, sql: input.sql, + sqlLoaded: true, connectionId: input.connectionId, updatedAt: timestamp, } @@ -207,12 +229,13 @@ export const useSavedSqlStore = defineStore("savedSql", () => { database: input.database, schema: input.schema, sql: input.sql, + sqlLoaded: true, orderIndex: maxOrderIndex(files.value.filter((file) => file.connectionId === input.connectionId && (file.folderId || "") === (input.folderId || undefined || ""))) + 1, createdAt: timestamp, updatedAt: timestamp, }; const saved = await api.saveSavedSqlFile(file); - files.value = [...files.value.filter((item) => item.id !== saved.id), saved]; + files.value = [...files.value.filter((item) => item.id !== saved.id), { ...saved, sqlLoaded: true }]; bumpVersion(); await syncToLocalDirectory(); return saved; @@ -222,7 +245,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const existing = getFile(id); if (!existing) return; const saved = await api.saveSavedSqlFile({ ...existing, name, updatedAt: nowIso() }); - files.value = files.value.map((file) => (file.id === id ? saved : file)); + files.value = files.value.map((file) => (file.id === id ? { ...saved, sql: file.sql, sqlLoaded: file.sqlLoaded } : file)); bumpVersion(); await syncToLocalDirectory(); } @@ -236,7 +259,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { openCount: (existing.openCount ?? 0) + 1, openedAt: nowIso(), }); - files.value = files.value.map((file) => (file.id === id ? saved : file)); + files.value = files.value.map((file) => (file.id === id ? { ...saved, sql: file.sql, sqlLoaded: file.sqlLoaded } : file)); bumpVersion(); return saved; } catch (error) { @@ -269,13 +292,16 @@ export const useSavedSqlStore = defineStore("savedSql", () => { } async function persistFiles(nextFiles: SavedSqlFile[]) { - await Promise.all(nextFiles.map((file) => api.saveSavedSqlFile(file))); - files.value = nextFiles; + const savedFiles = await Promise.all(nextFiles.map((file) => api.saveSavedSqlFile(file))); + files.value = savedFiles.map((saved) => { + const existing = files.value.find((file) => file.id === saved.id); + return { ...saved, sql: existing?.sql ?? saved.sql, sqlLoaded: existing?.sqlLoaded ?? saved.sqlLoaded }; + }); bumpVersion(); await syncToLocalDirectory(); } - function syncEntries() { + async function syncEntries() { const folderById = new Map(folders.value.map((folder) => [folder.id, folder])); const folderPath = (folderId?: string): string | undefined => { if (!folderId) return undefined; @@ -289,7 +315,8 @@ export const useSavedSqlStore = defineStore("savedSql", () => { } return parts.join("/"); }; - return sortFilesByOrder(files.value).map((file) => ({ + const loadedFiles = await Promise.all(sortFilesByOrder(files.value).map((file) => ensureFileContent(file.id))); + return loadedFiles.filter((file): file is SavedSqlFile => Boolean(file)).map((file) => ({ folderName: folderPath(file.folderId), fileName: file.name, sql: file.sql, @@ -302,7 +329,8 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const targetDir = settingsStore.desktopSettings.saved_sql_sync_dir?.trim(); if (!targetDir) return; - const syncPromise = pendingSync?.catch(() => {}).then(() => api.syncSavedSqlDirectory({ targetDir, entries: syncEntries() })) ?? api.syncSavedSqlDirectory({ targetDir, entries: syncEntries() }); + const entries = await syncEntries(); + const syncPromise = pendingSync?.catch(() => {}).then(() => api.syncSavedSqlDirectory({ targetDir, entries })) ?? api.syncSavedSqlDirectory({ targetDir, entries }); pendingSync = syncPromise; try { await syncPromise; @@ -476,6 +504,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { listChildFolders, listFiles, getFile, + ensureFileContent, createFolder, renameFolder, deleteFolder, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 3cf5d7609..8364d5dcc 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -673,6 +673,7 @@ export interface SavedSqlFile { database: string; schema?: string; sql: string; + sqlLoaded?: boolean; orderIndex?: number; openCount?: number; openedAt?: string; diff --git a/crates/dbx-core/src/saved_sql.rs b/crates/dbx-core/src/saved_sql.rs index 5fbd63724..55bac4b79 100644 --- a/crates/dbx-core/src/saved_sql.rs +++ b/crates/dbx-core/src/saved_sql.rs @@ -1,5 +1,9 @@ use serde::{Deserialize, Serialize}; +fn default_sql_loaded() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SavedSqlFolder { @@ -23,6 +27,8 @@ pub struct SavedSqlFile { pub database: String, pub schema: Option, pub sql: String, + #[serde(default = "default_sql_loaded")] + pub sql_loaded: bool, #[serde(default)] pub order_index: i64, #[serde(default)] diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 03134ed01..e2b86ec9a 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -1146,6 +1146,7 @@ impl Storage { database: row.get(4)?, schema: row.get(5)?, sql: row.get(6)?, + sql_loaded: true, order_index: row.get(7)?, open_count: row.get(8)?, opened_at: row.get(9)?, @@ -1162,6 +1163,97 @@ impl Storage { .await } + pub async fn load_saved_sql_library_summary(&self) -> Result { + self.with_conn(|conn| { + let mut folder_stmt = conn + .prepare( + "SELECT id, connection_id, parent_folder_id, name, order_index, created_at, updated_at \ + FROM saved_sql_folders ORDER BY COALESCE(parent_folder_id, ''), order_index, connection_id, name COLLATE NOCASE", + ) + .map_err(|e| e.to_string())?; + let folders = folder_stmt + .query_map([], |row| { + Ok(SavedSqlFolder { + id: row.get(0)?, + connection_id: row.get(1)?, + parent_folder_id: row.get(2)?, + name: row.get(3)?, + order_index: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + let mut file_stmt = conn + .prepare( + "SELECT id, connection_id, folder_id, name, database_name, schema_name, order_index, open_count, opened_at, created_at, updated_at \ + FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE", + ) + .map_err(|e| e.to_string())?; + let files = file_stmt + .query_map([], |row| { + Ok(SavedSqlFile { + id: row.get(0)?, + connection_id: row.get(1)?, + folder_id: row.get(2)?, + name: row.get(3)?, + database: row.get(4)?, + schema: row.get(5)?, + sql: String::new(), + sql_loaded: false, + order_index: row.get(6)?, + open_count: row.get(7)?, + opened_at: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + }) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + Ok(SavedSqlLibrary { folders, files }) + }) + .await + } + + pub async fn load_saved_sql_file(&self, id: &str) -> Result, String> { + let id = id.to_string(); + self.with_conn(move |conn| { + let mut stmt = conn + .prepare( + "SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \ + FROM saved_sql_files WHERE id = ?1", + ) + .map_err(|e| e.to_string())?; + match stmt.query_row([id], |row| { + Ok(SavedSqlFile { + id: row.get(0)?, + connection_id: row.get(1)?, + folder_id: row.get(2)?, + name: row.get(3)?, + database: row.get(4)?, + schema: row.get(5)?, + sql: row.get(6)?, + sql_loaded: true, + order_index: row.get(7)?, + open_count: row.get(8)?, + opened_at: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }) + }) { + Ok(file) => Ok(Some(file)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.to_string()), + } + }) + .await + } + pub async fn save_saved_sql_folder(&self, folder: &SavedSqlFolder) -> Result<(), String> { let folder = folder.clone(); self.with_conn(move |conn| { @@ -1233,7 +1325,7 @@ impl Storage { name = excluded.name, \ database_name = excluded.database_name, \ schema_name = excluded.schema_name, \ - sql_text = excluded.sql_text, \ + sql_text = CASE WHEN ?13 THEN excluded.sql_text ELSE saved_sql_files.sql_text END, \ order_index = excluded.order_index, \ open_count = excluded.open_count, \ opened_at = excluded.opened_at, \ @@ -1250,7 +1342,8 @@ impl Storage { file.open_count, file.opened_at, file.created_at, - file.updated_at + file.updated_at, + file.sql_loaded ], ) .map(|_| ()) @@ -1866,6 +1959,7 @@ mod tests { use super::{DesktopIconTheme, DesktopSettings, Storage}; use crate::connection_secrets::{MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY}; use crate::models::connection::{ConnectionConfig, DatabaseType}; + use crate::saved_sql::SavedSqlFile; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_db_path(name: &str) -> std::path::PathBuf { @@ -2241,4 +2335,69 @@ mod tests { storage.delete_tab_runtime_cache("tab:1:result").await.unwrap(); assert_eq!(storage.load_tab_runtime_cache("tab:1:result").await.unwrap(), None); } + + #[tokio::test] + async fn saved_sql_summary_omits_sql_text_and_loads_file_on_demand() { + let path = temp_db_path("saved-sql-summary"); + let storage = Storage::open(&path).await.unwrap(); + let file = SavedSqlFile { + id: "sql-1".to_string(), + connection_id: "conn-1".to_string(), + folder_id: None, + name: "large.sql".to_string(), + database: "main".to_string(), + schema: None, + sql: "SELECT * FROM very_large_table;".repeat(100), + sql_loaded: true, + order_index: 0, + open_count: 0, + opened_at: None, + created_at: "2026-06-27T00:00:00Z".to_string(), + updated_at: "2026-06-27T00:00:00Z".to_string(), + }; + + storage.save_saved_sql_file(&file).await.unwrap(); + + let summary = storage.load_saved_sql_library_summary().await.unwrap(); + assert_eq!(summary.files.len(), 1); + assert_eq!(summary.files[0].sql, ""); + assert!(!summary.files[0].sql_loaded); + + let loaded = storage.load_saved_sql_file("sql-1").await.unwrap().unwrap(); + assert_eq!(loaded.sql, file.sql); + assert!(loaded.sql_loaded); + } + + #[tokio::test] + async fn saved_sql_metadata_update_preserves_unloaded_sql_text() { + let path = temp_db_path("saved-sql-preserve-unloaded-text"); + let storage = Storage::open(&path).await.unwrap(); + let mut file = SavedSqlFile { + id: "sql-1".to_string(), + connection_id: "conn-1".to_string(), + folder_id: None, + name: "query.sql".to_string(), + database: "main".to_string(), + schema: None, + sql: "SELECT 1;".to_string(), + sql_loaded: true, + order_index: 0, + open_count: 0, + opened_at: None, + created_at: "2026-06-27T00:00:00Z".to_string(), + updated_at: "2026-06-27T00:00:00Z".to_string(), + }; + storage.save_saved_sql_file(&file).await.unwrap(); + + file.name = "renamed.sql".to_string(); + file.sql.clear(); + file.sql_loaded = false; + file.open_count = 1; + storage.save_saved_sql_file(&file).await.unwrap(); + + let loaded = storage.load_saved_sql_file("sql-1").await.unwrap().unwrap(); + assert_eq!(loaded.name, "renamed.sql"); + assert_eq!(loaded.open_count, 1); + assert_eq!(loaded.sql, "SELECT 1;"); + } } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 2dc93a4ba..d1a3b8862 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -381,7 +381,10 @@ async fn main() { "/saved-sql", get(routes::saved_sql::load_saved_sql_library).post(routes::saved_sql::save_saved_sql_file), ) - .route("/saved-sql/{id}", delete(routes::saved_sql::delete_saved_sql_file)) + .route( + "/saved-sql/{id}", + get(routes::saved_sql::load_saved_sql_file).delete(routes::saved_sql::delete_saved_sql_file), + ) .route("/saved-sql/folders", post(routes::saved_sql::save_saved_sql_folder)) .route("/saved-sql/folders/{id}", delete(routes::saved_sql::delete_saved_sql_folder)) // AI diff --git a/crates/dbx-web/src/routes/saved_sql.rs b/crates/dbx-web/src/routes/saved_sql.rs index dcc62f0bf..5ebb8a0ce 100644 --- a/crates/dbx-web/src/routes/saved_sql.rs +++ b/crates/dbx-web/src/routes/saved_sql.rs @@ -8,10 +8,18 @@ use crate::error::AppError; use crate::state::WebState; pub async fn load_saved_sql_library(State(state): State>) -> Result, AppError> { - let library = state.app.storage.load_saved_sql_library().await.map_err(AppError)?; + let library = state.app.storage.load_saved_sql_library_summary().await.map_err(AppError)?; Ok(Json(library)) } +pub async fn load_saved_sql_file( + State(state): State>, + Path(id): Path, +) -> Result>, AppError> { + let file = state.app.storage.load_saved_sql_file(&id).await.map_err(AppError)?; + Ok(Json(file)) +} + pub async fn save_saved_sql_folder( State(state): State>, Json(folder): Json, diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 43bb6ace9..fbd733da9 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -137,6 +137,69 @@ test("external SQL file paths persist with open query tabs", async () => { } }); +test("clean saved SQL tabs persist without duplicating SQL text", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + let store = useQueryStore(); + store.openSavedSql({ + id: "saved-1", + connectionId: "conn-1", + name: "large.sql", + database: "db", + sql: "SELECT * FROM large_table;".repeat(100), + sqlLoaded: true, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }); + store.flushPendingPersist(); + + const rawTabs = localStorage.getItem("dbx-open-tabs") ?? ""; + assert.equal(rawTabs.includes("large_table"), false); + + setActivePinia(createPinia()); + store = useQueryStore(); + const tab = store.tabs.find((item) => item.savedSqlId === "saved-1"); + + assert.equal(tab?.sql, ""); + assert.equal(store.isTabDirty(tab!), false); + } finally { + restoreStorage(); + } +}); + +test("dirty saved SQL tabs keep unsaved edits in open tab persistence", async () => { + const restoreStorage = installMemoryStorage(); + try { + setActivePinia(createPinia()); + let store = useQueryStore(); + const tabId = store.openSavedSql({ + id: "saved-1", + connectionId: "conn-1", + name: "draft.sql", + database: "db", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }); + store.updateSql(tabId, "SELECT 2;"); + store.flushPendingPersist(); + + const rawTabs = localStorage.getItem("dbx-open-tabs") ?? ""; + assert.equal(rawTabs.includes("SELECT 2;"), true); + + setActivePinia(createPinia()); + store = useQueryStore(); + const tab = store.tabs.find((item) => item.savedSqlId === "saved-1"); + + assert.equal(tab?.sql, "SELECT 2;"); + assert.equal(store.isTabDirty(tab!), true); + } finally { + restoreStorage(); + } +}); + test("marked-clean object source tabs close without unsaved confirmation", () => { setActivePinia(createPinia()); const store = useQueryStore(); diff --git a/packages/app-tests/savedSqlStore.test.ts b/packages/app-tests/savedSqlStore.test.ts index 338d14cc2..b322e4b49 100644 --- a/packages/app-tests/savedSqlStore.test.ts +++ b/packages/app-tests/savedSqlStore.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; import { createPinia, setActivePinia } from "pinia"; import { beforeEach, test, vi } from "vitest"; -import type { SavedSqlFolder, SavedSqlLibrary } from "../../apps/desktop/src/types/database.ts"; +import type { SavedSqlFile, SavedSqlFolder, SavedSqlLibrary } from "../../apps/desktop/src/types/database.ts"; import { useSavedSqlStore } from "../../apps/desktop/src/stores/savedSqlStore.ts"; const apiMock = vi.hoisted(() => ({ loadSavedSqlLibrary: vi.fn<() => Promise>(), + loadSavedSqlFile: vi.fn<(id: string) => Promise>(), saveSavedSqlFolder: vi.fn<(folder: SavedSqlFolder) => Promise>(), syncSavedSqlDirectory: vi.fn<() => Promise>(), })); @@ -15,6 +16,7 @@ vi.mock("@/lib/api", () => apiMock); beforeEach(() => { setActivePinia(createPinia()); apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [] }); + apiMock.loadSavedSqlFile.mockResolvedValue(null); apiMock.saveSavedSqlFolder.mockImplementation(async (folder) => folder); apiMock.syncSavedSqlDirectory.mockResolvedValue(); vi.clearAllMocks(); @@ -42,3 +44,31 @@ test("concurrent saved SQL folder creates reuse the same pending folder", async assert.equal(store.folders.length, 1); assert.equal(store.folders[0]?.id, firstFolder.id); }); + +test("saved SQL summaries load file content on demand", async () => { + const summaryFile: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "large.sql", + database: "", + sql: "", + sqlLoaded: false, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }; + const loadedFile = { ...summaryFile, sql: "SELECT 1;", sqlLoaded: true }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [summaryFile] }); + apiMock.loadSavedSqlFile.mockResolvedValue(loadedFile); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + assert.equal(store.files[0]?.sql, ""); + assert.equal(store.files[0]?.sqlLoaded, false); + + const hydrated = await store.ensureFileContent("sql-1"); + + assert.equal(hydrated?.sql, "SELECT 1;"); + assert.equal(store.files[0]?.sql, "SELECT 1;"); + assert.equal(apiMock.loadSavedSqlFile.mock.calls.length, 1); +}); diff --git a/src-tauri/src/commands/saved_sql.rs b/src-tauri/src/commands/saved_sql.rs index 2125e2744..707873bf1 100644 --- a/src-tauri/src/commands/saved_sql.rs +++ b/src-tauri/src/commands/saved_sql.rs @@ -35,7 +35,12 @@ struct SavedSqlSyncManifest { #[tauri::command] pub async fn load_saved_sql_library(state: State<'_, Arc>) -> Result { - state.storage.load_saved_sql_library().await + state.storage.load_saved_sql_library_summary().await +} + +#[tauri::command] +pub async fn load_saved_sql_file(state: State<'_, Arc>, id: String) -> Result, String> { + state.storage.load_saved_sql_file(&id).await } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93c38314b..cb6240297 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -766,6 +766,7 @@ pub fn run() { commands::nacos_cmd::nacos_update_instance, commands::nacos_cmd::nacos_raw_request, commands::saved_sql::load_saved_sql_library, + commands::saved_sql::load_saved_sql_file, commands::saved_sql::save_saved_sql_folder, commands::saved_sql::delete_saved_sql_folder, commands::saved_sql::save_saved_sql_file,