feat(sql-file): support pasting file paths in execute dialog
This commit is contained in:
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);
|
||||
}
|
||||
Reference in new issue
Block a user