From 01f8326575bae322358bd513217b47e545a5e899 Mon Sep 17 00:00:00 2001 From: Melody Ruch <396921921@qq.com> Date: Sun, 20 Sep 2026 01:48:10 +0800 Subject: [PATCH] feat(sql-file): support pasting file paths in execute dialog --- .../SqlFileExecutionDialog.retry.spec.ts | 36 +++++++++++++++++++ .../sql-file/SqlFileExecutionDialog.vue | 30 +++++++++++++++- .../sql-file/sqlFilePathInput.spec.ts | 20 +++++++++++ .../components/sql-file/sqlFilePathInput.ts | 17 +++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/sql-file/sqlFilePathInput.spec.ts create mode 100644 apps/desktop/src/components/sql-file/sqlFilePathInput.ts diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts index 1863481961..a551dacfc1 100644 --- a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts +++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.retry.spec.ts @@ -415,4 +415,40 @@ describe("SqlFileExecutionDialog selected-table restore", () => { await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalled()); expect(mocks.executeSqlFiles.mock.calls[0]![0]).not.toHaveProperty("selectedTables"); }); + + it("loads previews from a pasted file path on Enter", async () => { + root = document.createElement("div"); + document.body.append(root); + app = createApp(SqlFileExecutionDialog, { open: true }); + app.mount(root); + await vi.waitFor(() => expect(mocks.fetchSqlFileTargetOptions).toHaveBeenCalled()); + mocks.previewSqlFile.mockClear(); + + const input = root.querySelector("input:not([type=file])") as HTMLInputElement; + input.value = ' "/tmp/pasted.sql" '; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + + await vi.waitFor(() => expect(mocks.previewSqlFile).toHaveBeenCalledWith("/tmp/pasted.sql")); + expect(mocks.previewSqlFile).toHaveBeenCalledTimes(1); + }); + + it("keeps the typed path when the pasted file cannot be loaded", async () => { + root = document.createElement("div"); + document.body.append(root); + app = createApp(SqlFileExecutionDialog, { open: true }); + app.mount(root); + await vi.waitFor(() => expect(mocks.fetchSqlFileTargetOptions).toHaveBeenCalled()); + mocks.previewSqlFile.mockReset(); + mocks.previewSqlFile.mockRejectedValueOnce(new Error("no such file")); + + const input = root.querySelector("input:not([type=file])") as HTMLInputElement; + input.value = "/tmp/missing.sql"; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("blur")); + + await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("no such file", 5000)); + await nextTick(); + expect(input.value).toBe("/tmp/missing.sql"); + }); }); diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue index 2da79970df..16e9d5906b 100644 --- a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue +++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue @@ -21,6 +21,7 @@ import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions"; import { requiresSqlFileTargetDatabaseSelection, supportsConnectionLevelDatabaseBootstrap } from "@/lib/connection/connectionLevelDatabaseBootstrap"; import { cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api"; import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel"; +import { parseSqlFilePathInput } from "./sqlFilePathInput"; import SqlFileProgressIndicator from "./SqlFileProgressIndicator.vue"; import { useExportTracker, type ExportTask } from "@/composables/useExportTracker"; import { translateBackendError } from "@/i18n/backend-errors"; @@ -74,6 +75,32 @@ const filePathDisplay = computed(() => { return previews.value.map((item) => displayFileNames.value.get(item.filePath) ?? item.fileName).join("; "); }); +// Desktop only: the path input is editable so a path can be pasted instead of +// browsing. The draft follows the loaded previews and is committed on Enter/blur. +const pathInput = ref(""); +watch(filePathDisplay, (value) => (pathInput.value = value), { immediate: true }); + +async function commitPathInput() { + if (!isDesktopRuntime || running.value || selectingFile.value || loadingPreview.value) return; + const paths = parseSqlFilePathInput(pathInput.value); + if (paths.length === 0 || paths.join("; ") === filePathDisplay.value) { + pathInput.value = filePathDisplay.value; + return; + } + const typed = pathInput.value; + await loadPreviews(paths); + // Failed load: keep what was typed so the path can be corrected. + pathInput.value = previews.value.length > 0 ? filePathDisplay.value : typed; +} + +// Ignore the Enter that confirms an IME composition (e.g. Chinese paths) so it +// does not commit a half-typed path; only a real Enter commits. +function commitPathInputOnEnter(event: KeyboardEvent) { + if (event.isComposing) return; + event.preventDefault(); + void commitPathInput(); +} + // 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 { @@ -673,7 +700,8 @@ watch(