diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue index 9dcbc190c8..458fa45808 100644 --- a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue +++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue @@ -5,6 +5,7 @@ import { useI18n } from "vue-i18n"; import { useSqlHighlighter } from "@/composables/useSqlHighlighter"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -18,6 +19,7 @@ import { productionContextForDatabase } from "@/lib/database/productionSafety"; import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions"; import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap"; import { cancelSqlFileExecution, executeSqlFiles, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api"; +import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel"; import { useExportTracker } from "@/composables/useExportTracker"; import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "@lucide/vue"; @@ -35,10 +37,45 @@ const props = defineProps<{ const store = useConnectionStore(); const productionSafetyStore = useProductionSafetyStore(); +// Tauri = real filesystem paths; Web = browser File.name (no path) + server temp paths. +const isDesktopRuntime = isTauriRuntime(); const fileInput = ref(null); -const filePath = ref(""); const previews = ref([]); +const activePreviewPath = ref(""); +// Only the active file's preview body is mounted (reka-ui unmounts inactive +// TabsContent), so switching files keeps the DOM small even with many files. +watch(previews, (list) => { + if (list.length === 0) { + activePreviewPath.value = ""; + } else if (!list.some((item) => item.filePath === activePreviewPath.value)) { + activePreviewPath.value = list[0]!.filePath; + } +}); + +const activePreview = computed(() => { + return previews.value.find((item) => item.filePath === activePreviewPath.value) ?? previews.value[0] ?? null; +}); + +// Disambiguate files that share the same fileName. +// Desktop: prepend parent directory segments until unique (e.g. migration/create.sql). +// Web: browser File.name has no path, so use a stable 1-based index suffix. +const displayFileNames = computed(() => buildDisplayFileNames(previews.value, isDesktopRuntime)); + +// Display-only text for the file-path input. In Web mode this shows user-facing +// labels instead of server temp paths (which contain meaningless UUIDs). +// Execution always uses previews[].filePath directly. +const filePathDisplay = computed(() => { + if (previews.value.length === 0) return ""; + if (isDesktopRuntime) return previews.value.map((item) => item.filePath).join("; "); + return previews.value.map((item) => displayFileNames.value.get(item.filePath) ?? item.fileName).join("; "); +}); + +// Desktop tooltip shows the real file path; Web tooltip shows the user-facing +// label only — never the server temp path (which contains a meaningless UUID). +function tooltipText(item: SqlFilePreview): string { + return computeTooltipText(item, displayFileNames.value, isDesktopRuntime); +} const selectingFile = ref(false); const loadingPreview = ref(false); const connectionId = ref(""); @@ -97,14 +134,16 @@ const progressPercent = computed(() => { if (current <= 0) return running.value ? 8 : 0; return Math.min(95, Math.max(8, Math.round((attempted / current) * 100))); }); -const preview = computed(() => previews.value[0] ?? null); -const previewLineCount = computed(() => preview.value?.preview.split(/\r\n|\r|\n/).length ?? 0); -const previewLineNumbers = computed(() => Array.from({ length: previewLineCount.value }, (_, index) => index + 1)); -const previewIsTruncated = computed(() => { - if (!preview.value) return false; - return preview.value.sizeBytes > preview.value.preview.length; -}); -const previewLineSummary = computed(() => (previewIsTruncated.value ? t("sqlFile.previewingFirstLines", { count: previewLineCount.value }) : t("sqlFile.previewingLines", { count: previewLineCount.value }))); +function previewLineCount(item: SqlFilePreview) { + return item.preview.split(/\r\n|\r|\n/).length; +} +function previewIsTruncated(item: SqlFilePreview) { + return item.sizeBytes > item.preview.length; +} +function previewLineSummary(item: SqlFilePreview) { + const count = previewLineCount(item); + return previewIsTruncated(item) ? t("sqlFile.previewingFirstLines", { count }) : t("sqlFile.previewingLines", { count }); +} function connectionIconType(id: string) { const config = store.getConfig(id); @@ -165,7 +204,6 @@ function resetExecution() { } function resetState() { - filePath.value = ""; previews.value = []; selectingFile.value = false; loadingPreview.value = false; @@ -224,7 +262,6 @@ async function previewSelectedSqlFile(fileOrPath: string | File) { async function loadPreviews(filesOrPaths: Array) { loadingPreview.value = true; previews.value = []; - filePath.value = ""; resetExecution(); try { const nextPreviews: SqlFilePreview[] = []; @@ -232,7 +269,6 @@ async function loadPreviews(filesOrPaths: Array) { nextPreviews.push(await previewSelectedSqlFile(fileOrPath)); } previews.value = nextPreviews; - filePath.value = nextPreviews.map((item) => item.filePath).join("; "); } catch (e: any) { toast(e?.message || String(e), 5000); } finally { @@ -327,7 +363,7 @@ async function startExecution() { terminalError.value = ""; progress.value = null; const taskLabel = previews.value.length === 1 ? previews.value[0]!.fileName : `${previews.value[0]!.fileName} (+${previews.value.length - 1})`; - addSqlFileTask(batchId, taskLabel, filePath.value); + addSqlFileTask(batchId, taskLabel, filePathDisplay.value); try { await store.ensureConnected(connectionId.value); @@ -477,7 +513,7 @@ watch(
- +
-
-
-
- - {{ preview.fileName }} - (+{{ previews.length - 1 }}) +
+
+
+ + {{ previews.length }}
-
- {{ previewLineSummary }} - - {{ formatBytes(preview.sizeBytes) }} + +
+ + + + + {{ tooltipText(item) }} +
-
-
-
{{ lineNumber }}
+ +
+
+
+ + {{ displayFileNames.get(activePreview.filePath) ?? activePreview.fileName }} +
+
+ {{ previewLineSummary(activePreview) }} + + {{ formatBytes(activePreview.sizeBytes) }} +
+
+
+
+
{{ n }}
+
+

               
-

             
diff --git a/apps/desktop/src/components/sql-file/sqlFilePreviewLabel.spec.ts b/apps/desktop/src/components/sql-file/sqlFilePreviewLabel.spec.ts new file mode 100644 index 0000000000..49c8b25417 --- /dev/null +++ b/apps/desktop/src/components/sql-file/sqlFilePreviewLabel.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import type { SqlFilePreview } from "@/lib/backend/api"; +import { buildDisplayFileNames, tooltipText } from "./sqlFilePreviewLabel"; + +function makePreview(fileName: string, filePath: string): SqlFilePreview { + return { + fileName, + filePath, + sizeBytes: 100, + preview: "SELECT 1;", + canExecuteWithoutSelectedDatabase: false, + }; +} + +describe("buildDisplayFileNames", () => { + describe("desktop (Tauri) mode", () => { + it("returns just fileName when all names are unique", () => { + const items = [makePreview("users.sql", "/projects/db/migration/users.sql"), makePreview("orders.sql", "/projects/db/migration/orders.sql")]; + const result = buildDisplayFileNames(items, true); + expect(result.get("/projects/db/migration/users.sql")).toBe("users.sql"); + expect(result.get("/projects/db/migration/orders.sql")).toBe("orders.sql"); + }); + + it("disambiguates same-name files in different directories via shortest unique parent", () => { + const items = [makePreview("create.sql", "/projects/migration/create.sql"), makePreview("create.sql", "/projects/seed/create.sql")]; + const result = buildDisplayFileNames(items, true); + expect(result.get("/projects/migration/create.sql")).toBe("migration/create.sql"); + expect(result.get("/projects/seed/create.sql")).toBe("seed/create.sql"); + }); + + it("walks up multiple levels until paths diverge", () => { + const items = [makePreview("create.sql", "/projects/a/b/migration/create.sql"), makePreview("create.sql", "/projects/c/d/migration/create.sql")]; + const result = buildDisplayFileNames(items, true); + // "migration/create.sql" is identical for both, so go one level deeper + expect(result.get("/projects/a/b/migration/create.sql")).toBe("b/migration/create.sql"); + expect(result.get("/projects/c/d/migration/create.sql")).toBe("d/migration/create.sql"); + }); + + it("normalizes Windows backslash paths before splitting", () => { + const items = [makePreview("create.sql", "C:\\projects\\migration\\create.sql"), makePreview("create.sql", "C:\\projects\\seed\\create.sql")]; + const result = buildDisplayFileNames(items, true); + expect(result.get("C:\\projects\\migration\\create.sql")).toBe("migration/create.sql"); + expect(result.get("C:\\projects\\seed\\create.sql")).toBe("seed/create.sql"); + }); + + it("falls back to full normalized path when no shorter unique suffix exists", () => { + // Two identical filePaths won't happen in practice (filePath is the key), + // but if paths are structurally identical at every depth the full path is used. + const items = [makePreview("dup.sql", "/x/dup.sql"), makePreview("dup.sql", "/y/dup.sql")]; + const result = buildDisplayFileNames(items, true); + // "x/dup.sql" and "y/dup.sql" are already unique at depth 2 + expect(result.get("/x/dup.sql")).toBe("x/dup.sql"); + expect(result.get("/y/dup.sql")).toBe("y/dup.sql"); + }); + }); + + describe("web mode", () => { + it("returns just fileName when all names are unique", () => { + const items = [makePreview("users.sql", "/tmp/sql_file/users-abc.sql"), makePreview("orders.sql", "/tmp/sql_file/orders-def.sql")]; + const result = buildDisplayFileNames(items, false); + expect(result.get("/tmp/sql_file/users-abc.sql")).toBe("users.sql"); + expect(result.get("/tmp/sql_file/orders-def.sql")).toBe("orders.sql"); + }); + + it("uses stable 1-based index suffix for duplicate fileNames", () => { + const items = [makePreview("create.sql", "/tmp/sql_file/create-uuid1.sql"), makePreview("create.sql", "/tmp/sql_file/create-uuid2.sql"), makePreview("create.sql", "/tmp/sql_file/create-uuid3.sql")]; + const result = buildDisplayFileNames(items, false); + expect(result.get("/tmp/sql_file/create-uuid1.sql")).toBe("create.sql"); + expect(result.get("/tmp/sql_file/create-uuid2.sql")).toBe("create.sql (2)"); + expect(result.get("/tmp/sql_file/create-uuid3.sql")).toBe("create.sql (3)"); + }); + + it("does not expose server temp paths or UUIDs in labels", () => { + const items = [makePreview("init.sql", "/tmp/sql_file/init-a1b2c3d4.sql"), makePreview("init.sql", "/tmp/sql_file/init-e5f6g7h8.sql")]; + const result = buildDisplayFileNames(items, false); + for (const label of result.values()) { + expect(label).not.toContain("/tmp/"); + expect(label).not.toContain("uuid"); + expect(label).not.toMatch(/[0-9a-f]{8}-/); + } + }); + }); + + it("returns empty map for empty input", () => { + expect(buildDisplayFileNames([], true).size).toBe(0); + expect(buildDisplayFileNames([], false).size).toBe(0); + }); + + it("handles a single file in both modes", () => { + const items = [makePreview("solo.sql", "/path/solo.sql")]; + expect(buildDisplayFileNames(items, true).get("/path/solo.sql")).toBe("solo.sql"); + expect(buildDisplayFileNames(items, false).get("/path/solo.sql")).toBe("solo.sql"); + }); +}); + +describe("tooltipText", () => { + it("shows the real filePath in desktop mode", () => { + const item = makePreview("create.sql", "/projects/migration/create.sql"); + const labels = new Map([["/projects/migration/create.sql", "migration/create.sql"]]); + expect(tooltipText(item, labels, true)).toBe("/projects/migration/create.sql"); + }); + + it("shows the user-facing label in web mode, not the server temp path", () => { + const item = makePreview("create.sql", "/tmp/sql_file/create-uuid1.sql"); + const labels = new Map([["/tmp/sql_file/create-uuid1.sql", "create.sql (2)"]]); + const result = tooltipText(item, labels, false); + expect(result).toBe("create.sql (2)"); + expect(result).not.toContain("/tmp/"); + expect(result).not.toContain("uuid"); + }); + + it("falls back to fileName when label is missing in web mode", () => { + const item = makePreview("create.sql", "/tmp/sql_file/create-uuid1.sql"); + const labels = new Map(); + expect(tooltipText(item, labels, false)).toBe("create.sql"); + }); +}); diff --git a/apps/desktop/src/components/sql-file/sqlFilePreviewLabel.ts b/apps/desktop/src/components/sql-file/sqlFilePreviewLabel.ts new file mode 100644 index 0000000000..ae4efc9e5e --- /dev/null +++ b/apps/desktop/src/components/sql-file/sqlFilePreviewLabel.ts @@ -0,0 +1,58 @@ +import type { SqlFilePreview } from "@/lib/backend/api"; + +/** + * Build a map from filePath to a user-facing display label. + * + * Desktop (Tauri): real filesystem paths are available. When multiple files + * share the same fileName, prepend parent directory segments until each gets + * a unique label (e.g. migration/create.sql vs seed/create.sql). + * + * Web: browser File.name has no path (security restriction), and the server + * stores uploads as -.sql temp paths. Path-based disambiguation + * would leak meaningless UUIDs, so use a stable 1-based index suffix instead + * (e.g. create.sql, create.sql (2), create.sql (3)). + */ +export function buildDisplayFileNames(items: SqlFilePreview[], isDesktop: boolean): Map { + const byFileName = new Map(); + for (const item of items) { + const list = byFileName.get(item.fileName) ?? []; + list.push(item); + byFileName.set(item.fileName, list); + } + const result = new Map(); + for (const [, group] of byFileName) { + if (group.length === 1) { + result.set(group[0]!.filePath, group[0]!.fileName); + continue; + } + if (isDesktop) { + for (const item of group) { + const parts = item.filePath.replace(/\\/g, "/").split("/"); + for (let depth = 2; depth <= parts.length; depth++) { + const candidate = parts.slice(parts.length - depth).join("/"); + const isUnique = !group.some((o) => o.filePath !== item.filePath && o.filePath.replace(/\\/g, "/").split("/").slice(-depth).join("/") === candidate); + if (isUnique) { + result.set(item.filePath, candidate); + break; + } + } + if (!result.has(item.filePath)) { + result.set(item.filePath, item.filePath.replace(/\\/g, "/")); + } + } + } else { + group.forEach((item, index) => { + result.set(item.filePath, index === 0 ? item.fileName : `${item.fileName} (${index + 1})`); + }); + } + } + return result; +} + +/** + * Desktop tooltip shows the real file path; Web tooltip shows the user-facing + * label only — never the server temp path (which contains a meaningless UUID). + */ +export function tooltipText(item: SqlFilePreview, displayNames: Map, isDesktop: boolean): string { + return isDesktop ? item.filePath : (displayNames.get(item.filePath) ?? item.fileName); +}