diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 0d27e45005..188917f181 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -25,6 +25,7 @@ import { useAppUpdater } from "@/composables/useAppUpdater"; import { useMcpUpdateBadge } from "@/composables/useMcpUpdateBadge"; import { useExportTracker } from "@/composables/useExportTracker"; import { useFileDrop } from "@/composables/useFileDrop"; +import { useLargeSqlFileStreamingFallback } from "@/composables/useLargeSqlFileFallback"; import { usePanelResize } from "@/composables/usePanelResize"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; import { useSqlExecution } from "@/composables/useSqlExecution"; @@ -220,6 +221,7 @@ const { getActiveTaskCount: () => trackedUpdateTaskCount.value, }); const { setupFileDrop } = useFileDrop(); +const { openInStreamingExecutorOnTooLarge } = useLargeSqlFileStreamingFallback(); const isDesktop = isTauriRuntime(); const windowContext = resolveWindowContext(); @@ -1970,6 +1972,7 @@ function applyExternalSqlFileTarget(tab: QueryTab, path: string) { async function openSqlFile() { const tab = activeTab.value; if (!tab) return; + let openedSqlPath: string | undefined; try { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -1979,6 +1982,7 @@ async function openSqlFile() { }); if (path) { const sqlPath = path as string; + openedSqlPath = sqlPath; const snapshot = await api.readExternalSqlFileSnapshot(sqlPath); queryStore.updateSql(tab.id, snapshot.content); queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version); @@ -2000,7 +2004,9 @@ async function openSqlFile() { input.click(); } } catch (e: any) { - toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); + if (!openInStreamingExecutorOnTooLarge(openedSqlPath, e)) { + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); + } } } diff --git a/apps/desktop/src/composables/__tests__/useLargeSqlFileFallback.spec.ts b/apps/desktop/src/composables/__tests__/useLargeSqlFileFallback.spec.ts new file mode 100644 index 0000000000..2e4d12fed9 --- /dev/null +++ b/apps/desktop/src/composables/__tests__/useLargeSqlFileFallback.spec.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen"; + +const mocks = vi.hoisted(() => ({ + toast: vi.fn(), + store: { + connections: [] as { id: string }[], + sqlFileSource: null as { connectionId: string; database: string; filePath?: string } | null, + getConfig: vi.fn(), + }, +})); + +vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => mocks.store })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); + +import { useLargeSqlFileStreamingFallback } from "@/composables/useLargeSqlFileFallback"; + +const mountedApps: App[] = []; + +beforeEach(() => { + mocks.store.connections = []; + mocks.store.sqlFileSource = null; + mocks.store.getConfig.mockReset().mockReturnValue(undefined); + mocks.toast.mockReset(); +}); + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); +}); + +function withComposable(run: (fallback: ReturnType) => T): T { + let result!: T; + const component = defineComponent({ + setup() { + const fallback = useLargeSqlFileStreamingFallback(); + result = run(fallback); + return () => h("div"); + }, + }); + // The composable reads i18n/store in setup, so mount a host component that + // invokes it and hands the result back to the test. + const app = createApp(component); + app.use(i18n); + mountedApps.push(app); + app.mount(document.createElement("div")); + return result; +} + +describe("useLargeSqlFileStreamingFallback", () => { + it("routes oversized sql files to the streaming executor dialog", () => { + const outcome = withComposable((fallback) => { + const handled = fallback.openInStreamingExecutorOnTooLarge("/tmp/dbx_export.sql", new ExternalSqlFileTooLargeError(100 * 1024 * 1024, 64 * 1024 * 1024)); + return { handled, error: null as unknown }; + }); + + expect(outcome.handled).toBe(true); + expect(mocks.store.sqlFileSource).toEqual({ + connectionId: "", + database: "", + filePath: "/tmp/dbx_export.sql", + }); + expect(mocks.toast).toHaveBeenCalledTimes(1); + expect(String(mocks.toast.mock.calls[0][0])).toContain("100.0 MB"); + }); + + it("keeps unrelated open failures as errors", () => { + const outcome = withComposable((fallback) => fallback.openInStreamingExecutorOnTooLarge("/tmp/dbx_export.sql", new Error("disk failure"))); + + expect(outcome).toBe(false); + expect(mocks.store.sqlFileSource).toBeNull(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it("ignores oversized non-sql paths", () => { + const outcome = withComposable((fallback) => fallback.openInStreamingExecutorOnTooLarge("/tmp/notes.txt", new ExternalSqlFileTooLargeError(100 * 1024 * 1024, 64 * 1024 * 1024))); + + expect(outcome).toBe(false); + expect(mocks.store.sqlFileSource).toBeNull(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it("ignores missing paths", () => { + const outcome = withComposable((fallback) => fallback.openInStreamingExecutorOnTooLarge(undefined, new ExternalSqlFileTooLargeError(100 * 1024 * 1024, 64 * 1024 * 1024))); + + expect(outcome).toBe(false); + expect(mocks.store.sqlFileSource).toBeNull(); + expect(mocks.toast).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index 4f27924f73..5201d31d6a 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -4,6 +4,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useToast } from "@/composables/useToast"; +import { useLargeSqlFileStreamingFallback } from "@/composables/useLargeSqlFileFallback"; import * as api from "@/lib/backend/api"; import type { ConnectionConfig, ExternalSqlFileVersion } from "@/types/database"; import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection"; @@ -23,6 +24,7 @@ export function useFileDrop() { const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const { toast } = useToast(); + const { openInStreamingExecutorOnTooLarge } = useLargeSqlFileStreamingFallback(); async function openDroppedSqlFile(name: string, content: string, path?: string, version?: ExternalSqlFileVersion) { if (path) { @@ -88,7 +90,9 @@ export function useFileDrop() { const snapshot = await api.readExternalSqlFileSnapshot(path); await openDroppedSqlFile(name, snapshot.content, path, snapshot.version); } catch (e: any) { - toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); + if (!openInStreamingExecutorOnTooLarge(path, e)) { + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); + } } continue; } diff --git a/apps/desktop/src/composables/useLargeSqlFileFallback.ts b/apps/desktop/src/composables/useLargeSqlFileFallback.ts new file mode 100644 index 0000000000..a6e1e20629 --- /dev/null +++ b/apps/desktop/src/composables/useLargeSqlFileFallback.ts @@ -0,0 +1,34 @@ +import { useI18n } from "vue-i18n"; +import { useConnectionStore } from "@/stores/connectionStore"; +import { useToast } from "@/composables/useToast"; +import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; +import { formatSqlFileSize, isExternalSqlFileTooLargeError, isSqlFilePath } from "@/lib/sql/sqlFileOpen"; + +/** + * Editor-oversized external SQL files are not a dead end: route them to the + * streaming Execute SQL File dialog, mirroring SqlFilePanel's fallback for + * files the editor cannot load. + */ +export function useLargeSqlFileStreamingFallback() { + const { t } = useI18n(); + const connectionStore = useConnectionStore(); + const { toast } = useToast(); + + function openInStreamingExecutor(path: string, sizeBytes: number) { + const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget()); + connectionStore.sqlFileSource = { + connectionId: target.connectionId, + database: target.database, + filePath: path, + }; + toast(t("sqlFile.largeFileExecutionOpened", { size: formatSqlFileSize(sizeBytes) }), 6000); + } + + function openInStreamingExecutorOnTooLarge(path: string | undefined, error: unknown): boolean { + if (!path || !isExternalSqlFileTooLargeError(error) || !isSqlFilePath(path)) return false; + openInStreamingExecutor(path, error.sizeBytes); + return true; + } + + return { openInStreamingExecutor, openInStreamingExecutorOnTooLarge }; +}