feat(sql-file): preview multiple files with a side list

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-28 20:17:36 +08:00
1 parent 7ae36ee832
commit 88721925f2
3 files changed
+260 -28

No files matched your search

@@ -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<HTMLInputElement | null>(null);
const filePath = ref("");
const previews = ref<SqlFilePreview[]>([]);
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<SqlFilePreview | null>(() => {
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<string | File>) {
loadingPreview.value = true;
previews.value = [];
filePath.value = "";
resetExecution();
try {
const nextPreviews: SqlFilePreview[] = [];
@@ -232,7 +269,6 @@ async function loadPreviews(filesOrPaths: Array<string | File>) {
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(
<div class="flex items-center gap-2">
<input ref="fileInput" type="file" accept=".sql,text/sql" multiple class="hidden" @change="handleFileInputChange" />
<Input :model-value="filePath" readonly class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" />
<Input :model-value="filePathDisplay" readonly class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" />
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
<FolderOpen v-else class="w-3.5 h-3.5 mr-1.5" />
@@ -485,24 +521,45 @@ watch(
</Button>
</div>
<div v-if="preview" class="min-w-0 max-w-full overflow-hidden rounded-md border">
<div class="flex items-center justify-between gap-3 px-3 py-2 text-xs border-b bg-muted/40">
<div class="min-w-0 flex items-center gap-2">
<FileCode class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="font-medium truncate">{{ preview.fileName }}</span>
<span v-if="previews.length > 1" class="shrink-0 text-muted-foreground">(+{{ previews.length - 1 }})</span>
<div v-if="previews.length" class="flex min-w-0 gap-3">
<div v-if="previews.length > 1" class="flex w-48 shrink-0 flex-col rounded-md border bg-muted/20">
<div class="flex shrink-0 items-center gap-2 rounded-t-md border-b bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<FileCode class="w-3.5 h-3.5 shrink-0" />
<span class="font-medium">{{ previews.length }}</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-muted-foreground">
<span>{{ previewLineSummary }}</span>
<span class="h-3 w-px bg-border" />
<span>{{ formatBytes(preview.sizeBytes) }}</span>
<!-- Keep this max-height in sync with the preview viewer so the list
matches the preview pane height and scrolls internally. -->
<div class="max-h-[min(46vh,420px)] min-h-0 overflow-y-auto p-1">
<Tooltip v-for="item in previews" :key="item.filePath">
<TooltipTrigger as-child>
<button type="button" class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs" :class="item.filePath === activePreviewPath ? 'bg-primary/10 text-primary font-medium' : 'text-muted-foreground hover:bg-muted'" @click="activePreviewPath = item.filePath">
<FileCode class="w-3.5 h-3.5 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ displayFileNames.get(item.filePath) ?? item.fileName }}</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" class="max-w-[360px] break-all">{{ tooltipText(item) }}</TooltipContent>
</Tooltip>
</div>
</div>
<div class="sql-file-preview-viewer flex min-h-56 max-h-[min(42vh,360px)] max-w-full overflow-auto bg-muted/15 text-xs">
<div class="sticky left-0 z-10 select-none border-r bg-background/95 px-2 py-3 text-right font-mono leading-5 text-muted-foreground/70">
<div v-for="lineNumber in previewLineNumbers" :key="lineNumber">{{ lineNumber }}</div>
<div v-if="activePreview" class="min-w-0 flex-1 flex flex-col">
<div class="flex items-center justify-between gap-3 rounded-t-md border border-b-0 px-3 py-2 text-xs bg-muted/40">
<div class="min-w-0 flex items-center gap-2">
<FileCode class="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span class="font-medium truncate">{{ displayFileNames.get(activePreview.filePath) ?? activePreview.fileName }}</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-muted-foreground">
<span>{{ previewLineSummary(activePreview) }}</span>
<span class="h-3 w-px bg-border" />
<span>{{ formatBytes(activePreview.sizeBytes) }}</span>
</div>
</div>
<div class="sql-file-preview-viewer flex max-w-full overflow-auto bg-muted/15 text-xs rounded-b-md border border-t-0" :class="previews.length === 1 ? 'min-h-56 max-h-[min(46vh,420px)]' : 'min-h-0 max-h-[min(46vh,420px)]'">
<div class="sticky left-0 z-10 select-none border-r bg-background/95 px-2 py-3 text-right font-mono leading-5 text-muted-foreground/70">
<div v-for="n in previewLineCount(activePreview)" :key="n">{{ n }}</div>
</div>
<pre class="min-w-max flex-1 p-3 font-mono leading-5 whitespace-pre" v-html="highlight(activePreview.preview)"></pre>
</div>
<pre class="min-w-max flex-1 p-3 font-mono leading-5 whitespace-pre" v-html="highlight(preview.preview)"></pre>
</div>
</div>
</div>
@@ -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<string, string>();
expect(tooltipText(item, labels, false)).toBe("create.sql");
});
});
@@ -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 <stem>-<UUID>.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<string, string> {
const byFileName = new Map<string, SqlFilePreview[]>();
for (const item of items) {
const list = byFileName.get(item.fileName) ?? [];
list.push(item);
byFileName.set(item.fileName, list);
}
const result = new Map<string, string>();
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<string, string>, isDesktop: boolean): string {
return isDesktop ? item.filePath : (displayNames.get(item.filePath) ?? item.fileName);
}