mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(export): remember last export directory and support preferred directory
This commit is contained in:
@@ -900,6 +900,7 @@ const editSidebarShowTooltips = ref(settingsStore.editorSettings.sidebarShowTool
|
||||
const editSidebarIndent = ref(settingsStore.editorSettings.sidebarIndent);
|
||||
const editSidebarFontSize = ref(settingsStore.editorSettings.sidebarFontSize);
|
||||
const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize);
|
||||
const editPreferredExportPath = ref(settingsStore.editorSettings.preferredExportPath);
|
||||
const editCsvQuoteMode = ref<CsvQuoteMode>(settingsStore.editorSettings.csvQuoteMode);
|
||||
const editCsvNullMode = ref<CsvNullMode>(settingsStore.editorSettings.csvNullMode);
|
||||
const editGlobalDateTimeDisplayFormat = ref(settingsStore.editorSettings.globalDateTimeDisplayFormat);
|
||||
@@ -1177,6 +1178,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
||||
redisKeyTemplates: normalizeRedisKeyTemplates(editRedisKeyTemplates.value),
|
||||
redisDatabaseDisplayLimit: editRedisDatabaseDisplayLimit.value,
|
||||
exportBatchSize: editExportBatchSize.value,
|
||||
preferredExportPath: editPreferredExportPath.value,
|
||||
csvQuoteMode: editCsvQuoteMode.value,
|
||||
csvNullMode: editCsvNullMode.value,
|
||||
globalDateTimeDisplayFormat: editGlobalDateTimeDisplayFormat.value,
|
||||
@@ -1853,6 +1855,7 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editSidebarIndent.value = settingsStore.editorSettings.sidebarIndent;
|
||||
editSidebarFontSize.value = settingsStore.editorSettings.sidebarFontSize;
|
||||
editExportBatchSize.value = settingsStore.editorSettings.exportBatchSize;
|
||||
editPreferredExportPath.value = settingsStore.editorSettings.preferredExportPath;
|
||||
editCsvQuoteMode.value = settingsStore.editorSettings.csvQuoteMode;
|
||||
editCsvNullMode.value = settingsStore.editorSettings.csvNullMode;
|
||||
editGlobalDateTimeDisplayFormat.value = settingsStore.editorSettings.globalDateTimeDisplayFormat;
|
||||
@@ -2003,6 +2006,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = {
|
||||
redisKeyTemplates: editRedisKeyTemplates,
|
||||
redisDatabaseDisplayLimit: editRedisDatabaseDisplayLimit,
|
||||
exportBatchSize: editExportBatchSize,
|
||||
preferredExportPath: editPreferredExportPath,
|
||||
csvQuoteMode: editCsvQuoteMode,
|
||||
csvNullMode: editCsvNullMode,
|
||||
exportRowLimitEnabled: editExportRowLimitEnabled,
|
||||
@@ -2332,6 +2336,7 @@ async function persistSettings() {
|
||||
// result back into the input so an out-of-range draft doesn't keep
|
||||
// reporting unsaved changes after a successful apply.
|
||||
editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit;
|
||||
editPreferredExportPath.value = settingsStore.editorSettings.preferredExportPath;
|
||||
// 同理:落盘口径可能改写键位(跨平台默认键、保留键回退),草稿要跟着回到
|
||||
// 落盘值,避免面板卡在“未保存”无法应用(#9881)。
|
||||
editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
|
||||
@@ -2554,6 +2559,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
||||
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n");
|
||||
editRedisDatabaseDisplayLimit.value = DEFAULT_EDITOR_SETTINGS.redisDatabaseDisplayLimit;
|
||||
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
|
||||
editPreferredExportPath.value = DEFAULT_EDITOR_SETTINGS.preferredExportPath;
|
||||
editCsvQuoteMode.value = DEFAULT_EDITOR_SETTINGS.csvQuoteMode;
|
||||
editCsvNullMode.value = DEFAULT_EDITOR_SETTINGS.csvNullMode;
|
||||
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
|
||||
@@ -2708,6 +2714,7 @@ function resetAllDefaults() {
|
||||
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(DEFAULT_EDITOR_SETTINGS.redisKeyTemplates).join("\n");
|
||||
editRedisDatabaseDisplayLimit.value = DEFAULT_EDITOR_SETTINGS.redisDatabaseDisplayLimit;
|
||||
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
|
||||
editPreferredExportPath.value = DEFAULT_EDITOR_SETTINGS.preferredExportPath;
|
||||
editCsvQuoteMode.value = DEFAULT_EDITOR_SETTINGS.csvQuoteMode;
|
||||
editCsvNullMode.value = DEFAULT_EDITOR_SETTINGS.csvNullMode;
|
||||
editGlobalDateTimeDisplayFormat.value = DEFAULT_EDITOR_SETTINGS.globalDateTimeDisplayFormat;
|
||||
@@ -3188,6 +3195,18 @@ async function commitCustomSkillRoot() {
|
||||
if ((settingsStore.desktopSettings.custom_ai_skill_root ?? "") === trimmed) return;
|
||||
await settingsStore.updateDesktopSettings({ custom_ai_skill_root: trimmed || null });
|
||||
}
|
||||
async function pickPreferredExportPath() {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t("settings.preferredExportPathChoose"),
|
||||
defaultPath: editPreferredExportPath.value.trim() || undefined,
|
||||
});
|
||||
if (typeof selected !== "string" || !selected.trim()) return;
|
||||
editPreferredExportPath.value = selected.trim();
|
||||
}
|
||||
|
||||
async function pickCustomSkillRoot() {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({ directory: true, multiple: false, title: t("settings.aiSkillRoot") });
|
||||
@@ -9037,6 +9056,19 @@ onUnmounted(() => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div data-settings-search-id="data-export-preferred-path" :class="['space-y-2', settingsSearchTargetClass('data-export-preferred-path')]">
|
||||
<Label for="export-preferred-path">{{ t("settings.preferredExportPath") }}</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input id="export-preferred-path" v-model="editPreferredExportPath" :placeholder="t('settings.preferredExportPathPlaceholder')" class="h-9 flex-1 font-mono text-xs" />
|
||||
<Button v-if="!isWeb" type="button" variant="outline" size="sm" class="h-9 shrink-0 px-2.5" :title="t('settings.preferredExportPathChoose')" :aria-label="t('settings.preferredExportPathChoose')" @click="pickPreferredExportPath">
|
||||
<FolderOpen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button v-if="editPreferredExportPath" type="button" variant="ghost" size="sm" class="h-9 shrink-0 px-2" :title="t('settings.preferredExportPathClear')" :aria-label="t('settings.preferredExportPathClear')" @click="editPreferredExportPath = ''">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.preferredExportPathDescription") }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t("settings.exportBatchSize") }}</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Check, Download, RefreshCw, Undo2 } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { promptExportSavePath } from "@/lib/export/exportPath";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { formatSelectionAggregate } from "@/lib/dataGrid/gridSelection";
|
||||
@@ -230,8 +231,7 @@ async function exportMergedRows(scope: "page" | "all"): Promise<void> {
|
||||
try {
|
||||
let outputPath = exportFileName(scope);
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({ defaultPath: outputPath, filters: [{ name: "Excel", extensions: ["xlsx"] }] });
|
||||
const path = await promptExportSavePath({ defaultFileName: outputPath, filters: [{ name: "Excel", extensions: ["xlsx"] }] });
|
||||
if (!path) return;
|
||||
outputPath = String(path);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection } from
|
||||
import { buildAllDatabaseExportPlan, filterExportableSchemas, generateDatabaseExportId, runDatabaseExportUntilTerminal, runWithDatabaseBackupSnapshot, shouldUseDatabaseBackupSnapshot, type AllDatabaseExportPlanItem } from "@/lib/export/databaseExport";
|
||||
import { buildSelectedTablesPayload, isDatabaseExportTableSelectionValid } from "@/lib/export/databaseExportSelection";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { promptExportSavePath } from "@/lib/export/exportPath";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Download, Square, CheckSquare, Search, X, Loader2, Wrench } from "@lucide/vue";
|
||||
@@ -339,10 +340,9 @@ async function startExport() {
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const safeName = sanitizeFileName(database.value || "database");
|
||||
const path = await save({
|
||||
defaultPath: `${safeName}.${splitSqlOutput.value ? "zip" : "sql"}`,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: `${safeName}.${splitSqlOutput.value ? "zip" : "sql"}`,
|
||||
filters: [{ name: splitSqlOutput.value ? "ZIP" : "SQL", extensions: [splitSqlOutput.value ? "zip" : "sql"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
|
||||
@@ -182,6 +182,7 @@ import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elastics
|
||||
import { elasticsearchProfileBodyForResult, parseElasticsearchProfile } from "@/lib/elasticsearch/elasticsearchProfile";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
import { promptExportSavePath } from "@/lib/export/exportPath";
|
||||
import { queryResultExportBaseName } from "@/lib/export/saveTextFile";
|
||||
import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||||
import { buildMongoQueryResultOperations, formatMongoQueryResultOperationPreview } from "@/lib/mongo/mongoQueryResultEditing";
|
||||
@@ -1671,11 +1672,14 @@ async function handleExportQuery(payload: { sql: string; format: "csv" | "xlsx"
|
||||
if (!tab || tab.mode !== "query") return;
|
||||
let filePath = `query-result.${payload.format}`;
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const filterName = payload.format === "csv" ? "CSV" : payload.format === "xlsx" ? "Excel" : "Text";
|
||||
const picked = await save({ defaultPath: filePath, filters: [{ name: filterName, extensions: [payload.format] }] });
|
||||
const picked = await promptExportSavePath({
|
||||
defaultFileName: filePath,
|
||||
filters: [{ name: filterName, extensions: [payload.format] }],
|
||||
preferredPath: settingsStore.editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!picked) return;
|
||||
filePath = picked as string;
|
||||
filePath = picked;
|
||||
}
|
||||
await queryStore.exportQuerySqlDirect(tab.id, payload.sql, payload.format, filePath, payload.columnComments);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ import { useToast } from "@/composables/useToast";
|
||||
import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, executeObjectSourceSave, formatObjectSourceSaveError, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
|
||||
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { promptExportSavePath } from "@/lib/export/exportPath";
|
||||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||||
import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
|
||||
import { showSqlInsertModeDialog, type SqlInsertDialect, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||||
@@ -1954,10 +1955,9 @@ function closeSource() {
|
||||
|
||||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (path) await writeTextFile(path, content);
|
||||
@@ -2413,13 +2413,12 @@ async function exportDataLegacy(row: ObjectBrowserRow, format: "json") {
|
||||
if (format === "json") {
|
||||
let outputPath = `${row.name}.json`;
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
await api.exportQueryResultJson(outputPath, result.columns, result.rows);
|
||||
toast(t("grid.exported"));
|
||||
@@ -2489,10 +2488,9 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : format === "xlsx" ? { name: "Excel", extensions: ["xlsx"] } : splitSqlOutput ? { name: "ZIP", extensions: ["zip"] } : { name: "SQL", extensions: ["sql"] };
|
||||
const path = await save({
|
||||
defaultPath: defaultName,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: defaultName,
|
||||
filters: [filter],
|
||||
});
|
||||
if (!path) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, type ComputedRef, type Ref, createApp } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useDataGridExtractor } from "@/composables/useDataGridExtractor";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { promptExportSavePath } from "@/lib/export/exportPath";
|
||||
import { saveTextFile, sanitizeExportBaseName, compactLocalTimestamp } from "@/lib/export/saveTextFile";
|
||||
import { dropsSchemaQualifier } from "@/lib/table/tableSelectSql";
|
||||
import * as api from "@/lib/backend/api";
|
||||
@@ -904,16 +905,16 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
}
|
||||
let outputPath = exportFileName("export", "csv");
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) {
|
||||
if (exportProgressDialog) exportProgressDialog.value = false;
|
||||
return;
|
||||
}
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
await api.exportQueryResultCsv(outputPath, result.columns, result.rows, useSettingsStore().editorSettings.csvQuoteMode, csvNullLiteralForMode(useSettingsStore().editorSettings.csvNullMode));
|
||||
if (needsFullExport && exportProgressState) {
|
||||
@@ -945,13 +946,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
try {
|
||||
let outputPath = exportFileName("export-page", "csv", { page: true });
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
const result = await resultToExport(undefined, undefined, false);
|
||||
await api.exportQueryResultCsv(outputPath, result.columns, result.rows, useSettingsStore().editorSettings.csvQuoteMode, csvNullLiteralForMode(useSettingsStore().editorSettings.csvNullMode));
|
||||
@@ -970,13 +971,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
|
||||
let outputPath = exportFileName("export", "json");
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
const result = await resultToExport(rowIds, undefined, true, true, "name", true);
|
||||
await api.exportQueryResultJson(outputPath, result.columns, result.rows);
|
||||
@@ -992,13 +993,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
try {
|
||||
let outputPath = exportFileName("export-page", "json", { page: true });
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
const result = await resultToExport(undefined, undefined, false, true, "name", true);
|
||||
await api.exportQueryResultJson(outputPath, result.columns, result.rows);
|
||||
@@ -1016,13 +1017,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
|
||||
let outputPath = exportFileName("export", "md");
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
const result = await resultToExport(rowIds);
|
||||
await api.exportQueryResultMarkdown(outputPath, result.columns, result.rows);
|
||||
@@ -1038,13 +1039,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
try {
|
||||
let outputPath = exportFileName("export-page", "md", { page: true });
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
const result = await resultToExport(undefined, undefined, false);
|
||||
await api.exportQueryResultMarkdown(outputPath, result.columns, result.rows);
|
||||
@@ -1077,13 +1078,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (isTauriRuntime()) {
|
||||
if (openAfterExport) outputPath = await api.createQueryResultTempFile("html");
|
||||
else {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "HTML", extensions: ["html"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
}
|
||||
const result = await resultToExport(rowIds);
|
||||
@@ -1101,13 +1102,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
try {
|
||||
let outputPath = exportFileName("export-page", "html", { page: true });
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "HTML", extensions: ["html"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
const result = await resultToExport(undefined, undefined, false);
|
||||
await api.exportQueryResultHtml(outputPath, currentExportTitle(), result.columns, result.rows);
|
||||
@@ -1144,10 +1145,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (isTauriRuntime()) {
|
||||
if (openAfterExport) outputPath = await api.createQueryResultTempFile("xlsx");
|
||||
else {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({ defaultPath: outputPath, filters: [{ name: "Excel", extensions: ["xlsx"] }] });
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
}
|
||||
const needsFullExport = rowIds === undefined && !!fullExportResult && !hasCompleteLocalResult?.value;
|
||||
@@ -1241,13 +1245,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
try {
|
||||
let outputPath = exportFileName("export-page", "xlsx", { page: true });
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
const result = await resultToExport(undefined, undefined, false, true, exportOptions.headerMode);
|
||||
await writeXlsxResult(outputPath, result, includeSqlSheet, exportOptions.autoFilter, currentPageExportSql());
|
||||
@@ -1277,13 +1281,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
|
||||
let outputPath = exportFileName("query-results", "xlsx", { allResults: true });
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
|
||||
const exportPattern = useSettingsStore().editorSettings.globalDateTimeExportFormat;
|
||||
@@ -1360,13 +1364,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (openAfterExport && isTauriRuntime()) {
|
||||
outputPath = await api.createQueryResultTempFile(extension);
|
||||
} else if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: filterName, extensions: [extension] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return true;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
|
||||
if (exportProgressState) {
|
||||
@@ -1474,10 +1478,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (isTauriRuntime()) {
|
||||
if (openAfterExport) outputPath = await api.createQueryResultTempFile(extension);
|
||||
else {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({ defaultPath: outputPath, filters: [{ name: filterName, extensions: [extension] }] });
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: outputPath,
|
||||
filters: [{ name: filterName, extensions: [extension] }],
|
||||
preferredPath: useSettingsStore().editorSettings.preferredExportPath,
|
||||
});
|
||||
if (!path) return true;
|
||||
outputPath = path as string;
|
||||
outputPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
|
||||
import { joinExportedDdls } from "@/lib/export/ddlExport";
|
||||
import { promptExportSavePath } from "@/lib/export/exportPath";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { sidebarStructureExportTargets, sidebarTableDataExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
|
||||
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
|
||||
@@ -111,11 +112,11 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
|
||||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
preferredPath: settingsStore.editorSettings.preferredExportPath,
|
||||
});
|
||||
if (path) await writeTextFile(path, content);
|
||||
return;
|
||||
@@ -337,10 +338,10 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
|
||||
return outputDirectory ? joinExportFilePath(outputDirectory, fileName) : fileName;
|
||||
}
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: fileName,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: fileName,
|
||||
filters: [{ name: exportFilterName(format), extensions: [format === "bson.gz" ? "gz" : format] }],
|
||||
preferredPath: settingsStore.editorSettings.preferredExportPath,
|
||||
});
|
||||
return path ? String(path) : null;
|
||||
}
|
||||
|
||||
@@ -7692,6 +7692,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "Məhdudiyyətsiz",
|
||||
queryExportKeysetOptimizationEnabled: "Açar dəsti optimallaşdırmasını sına",
|
||||
queryExportKeysetOptimizationEnabledDescription: "Yalnız etibarlı şəkildə tanınan tək cədvəlli sorğulara tətbiq edilir; mürəkkəb sorğular avtomatik olaraq adi üsula qayıdır.",
|
||||
preferredExportPath: "Üstünlük verilən ixrac yolu",
|
||||
preferredExportPathDescription: "Məlumatların ixracı üçün standart qovluq. Boş olduqda son istifadə edilmiş ixrac yolu istifadə olunur.",
|
||||
preferredExportPathPlaceholder: "Təyin edilməyib (son ixrac yolunu istifadə edin)",
|
||||
preferredExportPathChoose: "Üstünlük verilən ixrac qovluğunu seçin",
|
||||
preferredExportPathClear: "Üstünlük verilən ixrac yolunu təmizləyin",
|
||||
exportSection: "İxrac",
|
||||
csvQuoteMode: "CSV sahələrinin dırnaqları",
|
||||
csvQuoteModeDescription: "CSV sahələrində qoşa dırnaqların necə istifadə olunacağını seçin.",
|
||||
|
||||
@@ -8623,6 +8623,11 @@ export default {
|
||||
exportRowLimitUnlimited: "Unlimited",
|
||||
queryExportKeysetOptimizationEnabled: "Try keyset optimization",
|
||||
queryExportKeysetOptimizationEnabledDescription: "Only applies to safely recognized single-table queries; complex queries automatically fall back.",
|
||||
preferredExportPath: "Preferred export path",
|
||||
preferredExportPathDescription: "Default directory for data exports. When empty, the last used export path is used.",
|
||||
preferredExportPathPlaceholder: "Not set (use last export path)",
|
||||
preferredExportPathChoose: "Choose preferred export directory",
|
||||
preferredExportPathClear: "Clear preferred export path",
|
||||
exportSection: "Export",
|
||||
csvQuoteMode: "CSV field quotes",
|
||||
csvQuoteModeDescription: "Choose how CSV fields use double quotes.",
|
||||
|
||||
@@ -7923,6 +7923,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "Sin límite",
|
||||
queryExportKeysetOptimizationEnabled: "Intentar optimización keyset",
|
||||
queryExportKeysetOptimizationEnabledDescription: "Solo aplica a consultas de una sola tabla reconocidas de forma segura; las consultas complejas usan el método estándar automáticamente.",
|
||||
preferredExportPath: "Ruta de exportación preferida",
|
||||
preferredExportPathDescription: "Directorio predeterminado para las exportaciones de datos. Si está vacío, se utiliza la última ruta usada.",
|
||||
preferredExportPathPlaceholder: "No establecido (usar última ruta)",
|
||||
preferredExportPathChoose: "Elegir directorio de exportación preferido",
|
||||
preferredExportPathClear: "Borrar ruta de exportación preferida",
|
||||
exportSection: "Exportar",
|
||||
csvQuoteMode: "Comillas de campos CSV",
|
||||
csvQuoteModeDescription: "Elige cómo usar comillas dobles en los campos CSV.",
|
||||
|
||||
@@ -8417,6 +8417,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "Tanpa Batas",
|
||||
queryExportKeysetOptimizationEnabled: "Coba optimasi keyset",
|
||||
queryExportKeysetOptimizationEnabledDescription: "Hanya berlaku untuk kueri tabel tunggal yang dapat dikenali dengan aman; kueri yang kompleks otomatis kembali ke cara biasa.",
|
||||
preferredExportPath: "Jalur ekspor pilihan",
|
||||
preferredExportPathDescription: "Direktori default untuk ekspor data. Jika kosong, jalur ekspor terakhir yang digunakan akan dipakai.",
|
||||
preferredExportPathPlaceholder: "Belum diatur (gunakan jalur ekspor terakhir)",
|
||||
preferredExportPathChoose: "Pilih direktori ekspor pilihan",
|
||||
preferredExportPathClear: "Hapus jalur ekspor pilihan",
|
||||
exportSection: "Ekspor",
|
||||
csvQuoteMode: "Tanda kutip bidang CSV",
|
||||
csvQuoteModeDescription: "Pilih bagaimana bidang CSV menggunakan tanda kutip ganda.",
|
||||
|
||||
@@ -7685,6 +7685,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "Illimitato",
|
||||
queryExportKeysetOptimizationEnabled: "Prova ottimizzazione keyset",
|
||||
queryExportKeysetOptimizationEnabledDescription: "Si applica solo a query a tabella singola riconosciute in sicurezza; le query complesse ricadono automaticamente.",
|
||||
preferredExportPath: "Percorso di esportazione preferito",
|
||||
preferredExportPathDescription: "Directory predefinita per l'esportazione dei dati. Se vuoto, viene utilizzato l'ultimo percorso utilizzato.",
|
||||
preferredExportPathPlaceholder: "Non impostato (usa l'ultimo percorso)",
|
||||
preferredExportPathChoose: "Scegli la directory di esportazione preferita",
|
||||
preferredExportPathClear: "Cancella il percorso di esportazione preferito",
|
||||
exportSection: "Esportazione",
|
||||
csvQuoteMode: "Virgolette campi CSV",
|
||||
csvQuoteModeDescription: "Scegli come usare le virgolette doppie nei campi CSV.",
|
||||
|
||||
@@ -7681,6 +7681,11 @@ export default withEnglishFallback({
|
||||
redisDatabaseDisplayLimitOption: "{count}個のデータベース",
|
||||
exportBatchSize: "エクスポートバッチサイズ",
|
||||
exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。",
|
||||
preferredExportPath: "優先エクスポートパス",
|
||||
preferredExportPathDescription: "データエクスポートのデフォルト保存先ディレクトリ。空の場合は前回のエクスポート先が使用されます。",
|
||||
preferredExportPathPlaceholder: "未設定(前回のパスを使用)",
|
||||
preferredExportPathChoose: "優先エクスポートディレクトリを選択",
|
||||
preferredExportPathClear: "優先エクスポートパスをクリア",
|
||||
exportSection: "エクスポート",
|
||||
csvQuoteMode: "CSV フィールドの引用符",
|
||||
csvQuoteModeDescription: "CSV フィールドを二重引用符で囲む方法を選択します。",
|
||||
|
||||
@@ -7506,6 +7506,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "제한 없음",
|
||||
queryExportKeysetOptimizationEnabled: "키셋 최적화 시도",
|
||||
queryExportKeysetOptimizationEnabledDescription: "안전하게 인식된 단일 테이블 쿼리에만 적용되며 복잡한 쿼리는 자동으로 폴백됩니다.",
|
||||
preferredExportPath: "기본 내보내기 경로",
|
||||
preferredExportPathDescription: "데이터 내보내기의 기본 저장 디렉터리입니다. 비어 있으면 마지막으로 사용한 내보내기 경로가 사용됩니다.",
|
||||
preferredExportPathPlaceholder: "설정되지 않음 (마지막 내보내기 경로 사용)",
|
||||
preferredExportPathChoose: "기본 내보내기 디렉터리 선택",
|
||||
preferredExportPathClear: "기본 내보내기 경로 지우기",
|
||||
exportSection: "내보내기",
|
||||
performanceSection: "성능",
|
||||
shortcutExecuteSql: "SQL 실행",
|
||||
|
||||
@@ -7686,6 +7686,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "Ilimitado",
|
||||
queryExportKeysetOptimizationEnabled: "Tentar otimização keyset",
|
||||
queryExportKeysetOptimizationEnabledDescription: "Aplica-se apenas a consultas de tabela única reconhecidas com segurança; consultas complexas recuam automaticamente.",
|
||||
preferredExportPath: "Caminho de exportação preferencial",
|
||||
preferredExportPathDescription: "Diretório padrão para exportações de dados. Quando vazio, o último caminho usado é utilizado.",
|
||||
preferredExportPathPlaceholder: "Não definido (usar último caminho)",
|
||||
preferredExportPathChoose: "Escolher diretório de exportação preferencial",
|
||||
preferredExportPathClear: "Limpar caminho de exportação preferencial",
|
||||
exportSection: "Exportar",
|
||||
csvQuoteMode: "Aspas dos campos CSV",
|
||||
csvQuoteModeDescription: "Escolha como usar aspas duplas nos campos CSV.",
|
||||
|
||||
@@ -8937,6 +8937,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "Без ограничений",
|
||||
queryExportKeysetOptimizationEnabled: "Пробовать оптимизацию по ключу",
|
||||
queryExportKeysetOptimizationEnabledDescription: "Применяется только к надёжно распознанным запросам к одной таблице; сложные запросы автоматически переключаются на обычный режим.",
|
||||
preferredExportPath: "Предпочтительный путь экспорта",
|
||||
preferredExportPathDescription: "Каталог по умолчанию для экспорта данных. Если поле пусто, используется последний путь экспорта.",
|
||||
preferredExportPathPlaceholder: "Не задан (использовать последний путь)",
|
||||
preferredExportPathChoose: "Выбрать предпочтительный каталог экспорта",
|
||||
preferredExportPathClear: "Очистить предпочтительный путь экспорта",
|
||||
exportSection: "Экспорт",
|
||||
csvQuoteMode: "Кавычки в полях CSV",
|
||||
csvQuoteModeDescription: "Выберите, как поля CSV используют двойные кавычки.",
|
||||
|
||||
@@ -7594,6 +7594,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "Sınırsız",
|
||||
queryExportKeysetOptimizationEnabled: "Keyset iyileştirmesini dene",
|
||||
queryExportKeysetOptimizationEnabledDescription: "Yalnızca güvenle tanınan tek tablolu sorgular için geçerlidir; karmaşık sorgular otomatik olarak geri döner.",
|
||||
preferredExportPath: "Tercih edilen dışa aktarma yolu",
|
||||
preferredExportPathDescription: "Veri dışa aktarmaları için varsayılan dizin. Boş bırakıldığında son kullanılan dışa aktarma yolu kullanılır.",
|
||||
preferredExportPathPlaceholder: "Ayarlanmadı (son dışa aktarma yolunu kullan)",
|
||||
preferredExportPathChoose: "Tercih edilen dışa aktarma dizinini seçin",
|
||||
preferredExportPathClear: "Tercih edilen dışa aktarma yolunu temizle",
|
||||
exportSection: "Dışa Aktar",
|
||||
csvQuoteMode: "CSV alan tırnakları",
|
||||
csvQuoteModeDescription: "CSV alanlarının çift tırnağı nasıl kullanacağını seçin.",
|
||||
|
||||
@@ -8606,6 +8606,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "无限制",
|
||||
queryExportKeysetOptimizationEnabled: "尝试 keyset 优化",
|
||||
queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。",
|
||||
preferredExportPath: "首选导出路径",
|
||||
preferredExportPathDescription: "设置数据导出的默认保存目录。留空时将自动使用上次导出的路径。",
|
||||
preferredExportPathPlaceholder: "未设置(自动使用上次导出路径)",
|
||||
preferredExportPathChoose: "选择首选导出目录",
|
||||
preferredExportPathClear: "清除首选导出路径",
|
||||
exportSection: "导出",
|
||||
csvQuoteMode: "CSV 字段引号",
|
||||
csvQuoteModeDescription: "选择 CSV 导出时字段使用双引号的方式。",
|
||||
|
||||
@@ -7593,6 +7593,11 @@ export default withEnglishFallback({
|
||||
exportRowLimitUnlimited: "無限制",
|
||||
queryExportKeysetOptimizationEnabled: "嘗試 keyset 最佳化",
|
||||
queryExportKeysetOptimizationEnabledDescription: "僅適用於可安全識別的單表查詢;複雜查詢會自動回退。",
|
||||
preferredExportPath: "首選匯出路徑",
|
||||
preferredExportPathDescription: "設定資料匯出的預設儲存目錄。留空時將自動使用上次匯出的路徑。",
|
||||
preferredExportPathPlaceholder: "未設定(自動使用上次匯出路徑)",
|
||||
preferredExportPathChoose: "選擇首選匯出目錄",
|
||||
preferredExportPathClear: "清除首選匯出路徑",
|
||||
shortcutUppercaseSelection: "選取內容轉為大寫",
|
||||
shortcutLowercaseSelection: "選取內容轉為小寫",
|
||||
shortcutToggleCaseSelection: "切換選取內容大小寫",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearLastExportDirectory, getLastExportDirectory, getParentDirectory, isAbsolutePath, joinExportPath, LAST_EXPORT_DIRECTORY_STORAGE_KEY, promptExportSavePath, rememberLastExportPath, resolveExportDefaultPath, setLastExportDirectory } from "../exportPath";
|
||||
|
||||
const mockSave = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: (args: unknown) => mockSave(args),
|
||||
}));
|
||||
|
||||
describe("exportPath", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockSave.mockReset();
|
||||
});
|
||||
|
||||
describe("isAbsolutePath", () => {
|
||||
it("recognizes POSIX absolute paths", () => {
|
||||
expect(isAbsolutePath("/home/user/export.csv")).toBe(true);
|
||||
expect(isAbsolutePath("/export.csv")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes Windows absolute paths", () => {
|
||||
expect(isAbsolutePath("C:\\exports\\file.csv")).toBe(true);
|
||||
expect(isAbsolutePath("D:/exports/file.csv")).toBe(true);
|
||||
expect(isAbsolutePath("\\\\server\\share\\file.csv")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes relative paths", () => {
|
||||
expect(isAbsolutePath("export.csv")).toBe(false);
|
||||
expect(isAbsolutePath("exports/file.csv")).toBe(false);
|
||||
expect(isAbsolutePath("./file.csv")).toBe(false);
|
||||
expect(isAbsolutePath("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getParentDirectory", () => {
|
||||
it("extracts parent directory from POSIX paths", () => {
|
||||
expect(getParentDirectory("/home/user/exports/test.csv")).toBe("/home/user/exports");
|
||||
expect(getParentDirectory("/test.csv")).toBe("/");
|
||||
});
|
||||
|
||||
it("extracts parent directory from Windows paths", () => {
|
||||
expect(getParentDirectory("C:\\Users\\John\\test.csv")).toBe("C:\\Users\\John");
|
||||
expect(getParentDirectory("C:\\test.csv")).toBe("C:\\");
|
||||
expect(getParentDirectory("C:/test.csv")).toBe("C:/");
|
||||
expect(getParentDirectory("D:/Users/John/test.csv")).toBe("D:/Users/John");
|
||||
});
|
||||
|
||||
it("returns empty string when there is no directory component", () => {
|
||||
expect(getParentDirectory("test.csv")).toBe("");
|
||||
expect(getParentDirectory("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinExportPath", () => {
|
||||
it("joins directory and filename with appropriate separator", () => {
|
||||
expect(joinExportPath("/home/user", "test.csv")).toBe("/home/user/test.csv");
|
||||
expect(joinExportPath("/home/user/", "test.csv")).toBe("/home/user/test.csv");
|
||||
expect(joinExportPath("C:\\Users\\John", "test.csv")).toBe("C:\\Users\\John\\test.csv");
|
||||
expect(joinExportPath("C:\\Users\\John\\", "test.csv")).toBe("C:\\Users\\John\\test.csv");
|
||||
});
|
||||
|
||||
it("returns filename when directory is empty", () => {
|
||||
expect(joinExportPath("", "test.csv")).toBe("test.csv");
|
||||
expect(joinExportPath(" ", "test.csv")).toBe("test.csv");
|
||||
});
|
||||
|
||||
it("returns absolute filename unchanged", () => {
|
||||
expect(joinExportPath("/home/user", "/opt/test.csv")).toBe("/opt/test.csv");
|
||||
expect(joinExportPath("C:\\Users", "D:\\test.csv")).toBe("D:\\test.csv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage operations", () => {
|
||||
it("reads empty string when no directory is stored", () => {
|
||||
expect(getLastExportDirectory()).toBe("");
|
||||
});
|
||||
|
||||
it("stores and retrieves last export directory", () => {
|
||||
setLastExportDirectory("/custom/export/dir");
|
||||
expect(getLastExportDirectory()).toBe("/custom/export/dir");
|
||||
expect(localStorage.getItem(LAST_EXPORT_DIRECTORY_STORAGE_KEY)).toBe("/custom/export/dir");
|
||||
});
|
||||
|
||||
it("clears last export directory", () => {
|
||||
setLastExportDirectory("/custom/export/dir");
|
||||
clearLastExportDirectory();
|
||||
expect(getLastExportDirectory()).toBe("");
|
||||
});
|
||||
|
||||
it("rememberLastExportPath stores directory of saved file", () => {
|
||||
rememberLastExportPath("/data/reports/monthly.csv");
|
||||
expect(getLastExportDirectory()).toBe("/data/reports");
|
||||
|
||||
rememberLastExportPath("C:\\exports\\annual.xlsx");
|
||||
expect(getLastExportDirectory()).toBe("C:\\exports");
|
||||
});
|
||||
|
||||
it("rememberLastExportPath ignores paths without directory or invalid inputs", () => {
|
||||
setLastExportDirectory("/initial/dir");
|
||||
rememberLastExportPath("plain-file.csv");
|
||||
expect(getLastExportDirectory()).toBe("/initial/dir");
|
||||
|
||||
rememberLastExportPath(null);
|
||||
expect(getLastExportDirectory()).toBe("/initial/dir");
|
||||
|
||||
rememberLastExportPath("");
|
||||
expect(getLastExportDirectory()).toBe("/initial/dir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveExportDefaultPath", () => {
|
||||
it("returns filename unchanged if already absolute", () => {
|
||||
setLastExportDirectory("/last/dir");
|
||||
expect(resolveExportDefaultPath("/already/absolute.csv", { preferredPath: "/preferred/dir" })).toBe("/already/absolute.csv");
|
||||
});
|
||||
|
||||
it("prefers preferredPath when provided", () => {
|
||||
setLastExportDirectory("/last/dir");
|
||||
expect(resolveExportDefaultPath("output.csv", { preferredPath: "/preferred/dir" })).toBe("/preferred/dir/output.csv");
|
||||
});
|
||||
|
||||
it("falls back to last export directory when preferredPath is not set", () => {
|
||||
setLastExportDirectory("/last/dir");
|
||||
expect(resolveExportDefaultPath("output.csv")).toBe("/last/dir/output.csv");
|
||||
expect(resolveExportDefaultPath("output.csv", { preferredPath: "" })).toBe("/last/dir/output.csv");
|
||||
expect(resolveExportDefaultPath("output.csv", { preferredPath: " " })).toBe("/last/dir/output.csv");
|
||||
});
|
||||
|
||||
it("falls back to plain filename when neither preferred nor last directory is set", () => {
|
||||
expect(resolveExportDefaultPath("output.csv")).toBe("output.csv");
|
||||
expect(resolveExportDefaultPath("output.csv", { preferredPath: "" })).toBe("output.csv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("promptExportSavePath", () => {
|
||||
it("resolves defaultPath, opens dialog, and remembers chosen path", async () => {
|
||||
mockSave.mockResolvedValue("/saved/directory/report.csv");
|
||||
const result = await promptExportSavePath({
|
||||
defaultFileName: "report.csv",
|
||||
preferredPath: "/preferred/path",
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
|
||||
expect(mockSave).toHaveBeenCalledWith({
|
||||
defaultPath: "/preferred/path/report.csv",
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
expect(result).toBe("/saved/directory/report.csv");
|
||||
expect(getLastExportDirectory()).toBe("/saved/directory");
|
||||
});
|
||||
|
||||
it("does not update last export directory when user cancels", async () => {
|
||||
setLastExportDirectory("/existing/dir");
|
||||
mockSave.mockResolvedValue(null);
|
||||
|
||||
const result = await promptExportSavePath({
|
||||
defaultFileName: "report.csv",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getLastExportDirectory()).toBe("/existing/dir");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { safeLocalStorageGet, safeLocalStorageRemove, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
|
||||
export const LAST_EXPORT_DIRECTORY_STORAGE_KEY = "dbx-last-export-directory";
|
||||
|
||||
export function isAbsolutePath(filePath: string): boolean {
|
||||
const trimmed = filePath.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("\\\\")) return true;
|
||||
return /^[a-zA-Z]:[/\\]/.test(trimmed);
|
||||
}
|
||||
|
||||
export function getParentDirectory(filePath: string): string {
|
||||
const trimmed = filePath.trim();
|
||||
if (!trimmed) return "";
|
||||
const lastSlash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
||||
if (lastSlash < 0) return "";
|
||||
if (lastSlash === 0) return trimmed.slice(0, 1);
|
||||
if (/^[a-zA-Z]:$/.test(trimmed.slice(0, lastSlash))) {
|
||||
return trimmed.slice(0, lastSlash + 1);
|
||||
}
|
||||
return trimmed.slice(0, lastSlash);
|
||||
}
|
||||
|
||||
export function joinExportPath(directory: string, fileName: string): string {
|
||||
const trimmedDir = directory.trim();
|
||||
const trimmedName = fileName.trim();
|
||||
if (!trimmedDir) return trimmedName;
|
||||
if (!trimmedName) return trimmedDir;
|
||||
if (isAbsolutePath(trimmedName)) return trimmedName;
|
||||
const separator = trimmedDir.includes("\\") && !trimmedDir.includes("/") ? "\\" : "/";
|
||||
return trimmedDir.endsWith("/") || trimmedDir.endsWith("\\") ? `${trimmedDir}${trimmedName}` : `${trimmedDir}${separator}${trimmedName}`;
|
||||
}
|
||||
|
||||
export function getLastExportDirectory(): string {
|
||||
try {
|
||||
return safeLocalStorageGet(LAST_EXPORT_DIRECTORY_STORAGE_KEY)?.trim() || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function setLastExportDirectory(directory: string): void {
|
||||
const trimmed = directory.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
safeLocalStorageSet(LAST_EXPORT_DIRECTORY_STORAGE_KEY, trimmed);
|
||||
} catch {
|
||||
// Ignore storage write errors
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLastExportDirectory(): void {
|
||||
try {
|
||||
safeLocalStorageRemove(LAST_EXPORT_DIRECTORY_STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveExportDefaultPath(fileName: string, options?: { preferredPath?: string }): string {
|
||||
const trimmedName = fileName.trim();
|
||||
if (isAbsolutePath(trimmedName)) {
|
||||
return trimmedName;
|
||||
}
|
||||
const preferred = options?.preferredPath?.trim();
|
||||
if (preferred) {
|
||||
return joinExportPath(preferred, trimmedName);
|
||||
}
|
||||
const lastDir = getLastExportDirectory();
|
||||
if (lastDir) {
|
||||
return joinExportPath(lastDir, trimmedName);
|
||||
}
|
||||
return trimmedName;
|
||||
}
|
||||
|
||||
export function rememberLastExportPath(savedPath: string | null | undefined): void {
|
||||
if (!savedPath || typeof savedPath !== "string") return;
|
||||
const dir = getParentDirectory(savedPath);
|
||||
if (dir) {
|
||||
setLastExportDirectory(dir);
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptExportSavePath(options: { defaultFileName: string; filters?: Array<{ name: string; extensions: string[] }>; preferredPath?: string }): Promise<string | null> {
|
||||
let preferred = options.preferredPath?.trim();
|
||||
if (!preferred) {
|
||||
try {
|
||||
const { getActivePinia } = await import("pinia");
|
||||
if (getActivePinia()) {
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
preferred = useSettingsStore().editorSettings.preferredExportPath?.trim();
|
||||
}
|
||||
} catch {
|
||||
// Pinia not active or store not accessible
|
||||
}
|
||||
}
|
||||
const defaultPath = resolveExportDefaultPath(options.defaultFileName, { preferredPath: preferred });
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath,
|
||||
filters: options.filters,
|
||||
});
|
||||
if (typeof path === "string" && path.trim()) {
|
||||
rememberLastExportPath(path);
|
||||
return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { diagramExportDialogFilter, type DiagramExportFormat } from "./diagramFormats";
|
||||
import { promptExportSavePath } from "./exportPath";
|
||||
|
||||
export async function saveDiagramTextExport(defaultPath: string, content: string, format: DiagramExportFormat): Promise<boolean> {
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, { writeTextFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({
|
||||
defaultPath,
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: defaultPath,
|
||||
filters: [diagramExportDialogFilter(format)],
|
||||
});
|
||||
if (!path) return false;
|
||||
@@ -26,9 +27,9 @@ export async function saveDiagramTextExport(defaultPath: string, content: string
|
||||
|
||||
export async function saveDiagramBinaryExport(defaultPath: string, data: Blob, format: DiagramExportFormat): Promise<boolean> {
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, { writeFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({
|
||||
defaultPath,
|
||||
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName: defaultPath,
|
||||
filters: [diagramExportDialogFilter(format)],
|
||||
});
|
||||
if (!path) return false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { appendDebugLog, getBrowserMemorySnapshot, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
import { promptExportSavePath } from "./exportPath";
|
||||
|
||||
export async function saveTextFile(content: string, defaultFileName: string, filterName: string, filterExt: string, diagnostics: { exportId?: string; operation?: string } = {}): Promise<boolean> {
|
||||
const logSaveStage = (stage: string, details: Record<string, unknown> = {}) => {
|
||||
@@ -16,10 +17,9 @@ export async function saveTextFile(content: string, defaultFileName: string, fil
|
||||
|
||||
logSaveStage("start");
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
const path = await promptExportSavePath({
|
||||
defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
logSaveStage("dialog-result", { selected: !!path });
|
||||
|
||||
@@ -133,6 +133,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
||||
"redisKeyTemplates",
|
||||
"redisDatabaseDisplayLimit",
|
||||
"exportBatchSize",
|
||||
"preferredExportPath",
|
||||
"csvQuoteMode",
|
||||
"csvNullMode",
|
||||
"exportRowLimitEnabled",
|
||||
|
||||
@@ -260,6 +260,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
|
||||
{ id: "data-datetime-export-format", category: "data", titleKey: "settings.globalDateTimeExportFormat", descriptionKey: "settings.globalDateTimeExportFormatDescription", targetId: "data" },
|
||||
{ id: "data-datetime-import-format", category: "data", titleKey: "settings.globalDateTimeImportFormat", descriptionKey: "settings.globalDateTimeImportFormatDescription", targetId: "data" },
|
||||
{ id: "data-export", category: "data", titleKey: "settings.exportSection", targetId: "data" },
|
||||
{ id: "data-export-preferred-path", category: "data", titleKey: "settings.preferredExportPath", descriptionKey: "settings.preferredExportPathDescription", targetId: "data" },
|
||||
{ id: "data-export-batch", category: "data", titleKey: "settings.exportBatchSize", descriptionKey: "settings.exportBatchSizeDescription", targetId: "data" },
|
||||
{ id: "data-export-row-limit-enabled", category: "data", titleKey: "settings.exportRowLimitEnabled", descriptionKey: "settings.exportRowLimitEnabledDescription", targetId: "data" },
|
||||
{ id: "data-export-row-limit", category: "data", titleKey: "settings.exportRowLimit", descriptionKey: "settings.exportRowLimitDescription", targetId: "data" },
|
||||
|
||||
@@ -188,6 +188,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
|
||||
"redisKeyTemplates",
|
||||
"redisDatabaseDisplayLimit",
|
||||
"exportBatchSize",
|
||||
"preferredExportPath",
|
||||
"csvQuoteMode",
|
||||
"csvNullMode",
|
||||
"exportRowLimitEnabled",
|
||||
|
||||
@@ -1050,6 +1050,22 @@ describe("normalizeEditorSettings - sidebarPinDefaultDatabase", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEditorSettings - preferredExportPath", () => {
|
||||
it("defaults preferredExportPath to empty string", () => {
|
||||
expect(normalizeEditorSettings({}).preferredExportPath).toBe("");
|
||||
});
|
||||
|
||||
it("trims and preserves valid string paths", () => {
|
||||
expect(normalizeEditorSettings({ preferredExportPath: " /home/user/exports " }).preferredExportPath).toBe("/home/user/exports");
|
||||
});
|
||||
|
||||
it("falls back to default for non-string values", () => {
|
||||
expect(normalizeEditorSettings({ preferredExportPath: null } as any).preferredExportPath).toBe("");
|
||||
expect(normalizeEditorSettings({ preferredExportPath: undefined } as any).preferredExportPath).toBe("");
|
||||
expect(normalizeEditorSettings({ preferredExportPath: 123 } as any).preferredExportPath).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Helpers for Pinia store tests ---
|
||||
|
||||
function makeTestConfig(overrides: Partial<AiConfigItem> & { id: string }): AiConfigItem {
|
||||
@@ -1707,6 +1723,20 @@ describe("settingsStore persisted settings initialization", () => {
|
||||
expect(saveEditorSettings).toHaveBeenCalledTimes(2);
|
||||
expect(saveEditorSettings).toHaveBeenLastCalledWith(expect.objectContaining({ sidebarShowConnectionNotes: true }));
|
||||
});
|
||||
|
||||
it("persists preferredExportPath update", async () => {
|
||||
const loadEditorSettings = vi.fn().mockResolvedValue({ preferredExportPath: "" });
|
||||
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
|
||||
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const store = useSettingsStore();
|
||||
await store.initEditorSettings();
|
||||
|
||||
await store.updateEditorSettingsAndPersist({ preferredExportPath: " /custom/export/dir " });
|
||||
expect(store.editorSettings.preferredExportPath).toBe("/custom/export/dir");
|
||||
expect(saveEditorSettings).toHaveBeenCalledWith(expect.objectContaining({ preferredExportPath: "/custom/export/dir" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("settingsStore editor settings persistence", () => {
|
||||
|
||||
@@ -1062,6 +1062,7 @@ export interface EditorSettings {
|
||||
sqlShortcuts: SqlShortcutAction[];
|
||||
tableColumnTemplateFields: string[];
|
||||
exportBatchSize: number;
|
||||
preferredExportPath: string;
|
||||
csvQuoteMode: CsvQuoteMode;
|
||||
csvNullMode: CsvNullMode;
|
||||
/** Global Redis key-search templates; overridden by non-empty connection templates. */
|
||||
@@ -1366,6 +1367,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
||||
sqlShortcuts: DEFAULT_SQL_SHORTCUTS,
|
||||
tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS],
|
||||
exportBatchSize: 2000,
|
||||
preferredExportPath: "",
|
||||
csvQuoteMode: DEFAULT_CSV_QUOTE_MODE,
|
||||
csvNullMode: DEFAULT_CSV_NULL_MODE,
|
||||
redisKeyTemplates: [],
|
||||
@@ -2022,6 +2024,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
||||
sqlShortcuts: normalizeSqlShortcuts(settings.sqlShortcuts, existing?.sqlShortcuts),
|
||||
tableColumnTemplateFields: normalizeTableColumnTemplateFields(settings.tableColumnTemplateFields),
|
||||
exportBatchSize: typeof settings.exportBatchSize === "number" && settings.exportBatchSize >= 100 && settings.exportBatchSize <= 100000 ? Math.round(settings.exportBatchSize) : DEFAULT_EDITOR_SETTINGS.exportBatchSize,
|
||||
preferredExportPath: typeof settings.preferredExportPath === "string" ? settings.preferredExportPath.trim() : DEFAULT_EDITOR_SETTINGS.preferredExportPath,
|
||||
csvQuoteMode: normalizeCsvQuoteMode(settings.csvQuoteMode),
|
||||
csvNullMode: normalizeCsvNullMode(settings.csvNullMode),
|
||||
redisKeyTemplates: normalizeRedisKeyTemplates(settings.redisKeyTemplates),
|
||||
@@ -2848,6 +2851,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
||||
if (partial.sqlShortcuts !== undefined) editorSettings.value.sqlShortcuts = normalizeSqlShortcuts(partial.sqlShortcuts);
|
||||
if (partial.tableColumnTemplateFields !== undefined) editorSettings.value.tableColumnTemplateFields = normalizeTableColumnTemplateFields(partial.tableColumnTemplateFields);
|
||||
if (partial.exportBatchSize !== undefined) editorSettings.value.exportBatchSize = Math.min(100000, Math.max(100, Math.round(partial.exportBatchSize)));
|
||||
if (partial.preferredExportPath !== undefined) editorSettings.value.preferredExportPath = typeof partial.preferredExportPath === "string" ? partial.preferredExportPath.trim() : "";
|
||||
if (partial.csvQuoteMode !== undefined) editorSettings.value.csvQuoteMode = normalizeCsvQuoteMode(partial.csvQuoteMode);
|
||||
if (partial.csvNullMode !== undefined) editorSettings.value.csvNullMode = normalizeCsvNullMode(partial.csvNullMode);
|
||||
if (partial.redisKeyTemplates !== undefined) editorSettings.value.redisKeyTemplates = normalizeRedisKeyTemplates(partial.redisKeyTemplates);
|
||||
|
||||
Reference in New Issue
Block a user