feat(sql-file): support pasting file paths in execute dialog

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-09-20 01:48:10 +08:00
1 parent be36b45510
commit 01f8326575
4 files changed
+102 -1

No files matched your search

@@ -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");
});
});
@@ -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(
<div class="flex items-center gap-2">
<input ref="fileInput" type="file" accept=".sql,.sql.gz,.zip,text/sql,application/gzip,application/zip" multiple class="hidden" @change="handleFileInputChange" />
<Input :model-value="filePathDisplay" readonly class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" />
<Input v-if="isDesktopRuntime" v-model="pathInput" :disabled="running" class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" @keydown.enter="commitPathInputOnEnter" @blur="commitPathInput" />
<Input v-else :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" />
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { parseSqlFilePathInput } from "./sqlFilePathInput";
describe("parseSqlFilePathInput", () => {
it("returns a single trimmed path", () => {
expect(parseSqlFilePathInput(" /tmp/a.sql ")).toEqual(["/tmp/a.sql"]);
});
it("strips quotes added by Windows Copy as path", () => {
expect(parseSqlFilePathInput('"C:\\Users\\me\\my dump.sql"')).toEqual(["C:\\Users\\me\\my dump.sql"]);
});
it("splits on semicolons and newlines and drops blanks", () => {
expect(parseSqlFilePathInput("/a.sql; /b.sql\r\n/c.sql;\n\n")).toEqual(["/a.sql", "/b.sql", "/c.sql"]);
});
it("returns an empty list for blank input", () => {
expect(parseSqlFilePathInput(" ; \n ")).toEqual([]);
});
});
@@ -0,0 +1,17 @@
/**
* Parse text typed or pasted into the desktop file-path input into a list of
* file paths. Entries are separated by newlines or ";" (the same separator the
* input uses to display several selected files). Surrounding whitespace and one
* pair of wrapping quotes are stripped, since Windows "Copy as path" quotes paths.
*/
export function parseSqlFilePathInput(text: string): string[] {
return text
.split(/[\r\n;]+/)
.map((item) =>
item
.trim()
.replace(/^(["'])(.*)\1$/, "$2")
.trim(),
)
.filter(Boolean);
}