feat(export): remember last export directory and support preferred directory

This commit is contained in:
lxk955
2026-10-04 17:40:59 +08:00
committed by GitHub
parent 2e98a5bb9f
commit 02bc0cf47e
28 changed files with 497 additions and 83 deletions
@@ -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;
}
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -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.",
+5
View File
@@ -7681,6 +7681,11 @@ export default withEnglishFallback({
redisDatabaseDisplayLimitOption: "{count}個のデータベース",
exportBatchSize: "エクスポートバッチサイズ",
exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。",
preferredExportPath: "優先エクスポートパス",
preferredExportPathDescription: "データエクスポートのデフォルト保存先ディレクトリ。空の場合は前回のエクスポート先が使用されます。",
preferredExportPathPlaceholder: "未設定(前回のパスを使用)",
preferredExportPathChoose: "優先エクスポートディレクトリを選択",
preferredExportPathClear: "優先エクスポートパスをクリア",
exportSection: "エクスポート",
csvQuoteMode: "CSV フィールドの引用符",
csvQuoteModeDescription: "CSV フィールドを二重引用符で囲む方法を選択します。",
+5
View File
@@ -7506,6 +7506,11 @@ export default withEnglishFallback({
exportRowLimitUnlimited: "제한 없음",
queryExportKeysetOptimizationEnabled: "키셋 최적화 시도",
queryExportKeysetOptimizationEnabledDescription: "안전하게 인식된 단일 테이블 쿼리에만 적용되며 복잡한 쿼리는 자동으로 폴백됩니다.",
preferredExportPath: "기본 내보내기 경로",
preferredExportPathDescription: "데이터 내보내기의 기본 저장 디렉터리입니다. 비어 있으면 마지막으로 사용한 내보내기 경로가 사용됩니다.",
preferredExportPathPlaceholder: "설정되지 않음 (마지막 내보내기 경로 사용)",
preferredExportPathChoose: "기본 내보내기 디렉터리 선택",
preferredExportPathClear: "기본 내보내기 경로 지우기",
exportSection: "내보내기",
performanceSection: "성능",
shortcutExecuteSql: "SQL 실행",
+5
View File
@@ -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.",
+5
View File
@@ -8937,6 +8937,11 @@ export default withEnglishFallback({
exportRowLimitUnlimited: "Без ограничений",
queryExportKeysetOptimizationEnabled: "Пробовать оптимизацию по ключу",
queryExportKeysetOptimizationEnabledDescription: "Применяется только к надёжно распознанным запросам к одной таблице; сложные запросы автоматически переключаются на обычный режим.",
preferredExportPath: "Предпочтительный путь экспорта",
preferredExportPathDescription: "Каталог по умолчанию для экспорта данных. Если поле пусто, используется последний путь экспорта.",
preferredExportPathPlaceholder: "Не задан (использовать последний путь)",
preferredExportPathChoose: "Выбрать предпочтительный каталог экспорта",
preferredExportPathClear: "Очистить предпочтительный путь экспорта",
exportSection: "Экспорт",
csvQuoteMode: "Кавычки в полях CSV",
csvQuoteModeDescription: "Выберите, как поля CSV используют двойные кавычки.",
+5
View File
@@ -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.",
+5
View File
@@ -8606,6 +8606,11 @@ export default withEnglishFallback({
exportRowLimitUnlimited: "无限制",
queryExportKeysetOptimizationEnabled: "尝试 keyset 优化",
queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。",
preferredExportPath: "首选导出路径",
preferredExportPathDescription: "设置数据导出的默认保存目录。留空时将自动使用上次导出的路径。",
preferredExportPathPlaceholder: "未设置(自动使用上次导出路径)",
preferredExportPathChoose: "选择首选导出目录",
preferredExportPathClear: "清除首选导出路径",
exportSection: "导出",
csvQuoteMode: "CSV 字段引号",
csvQuoteModeDescription: "选择 CSV 导出时字段使用双引号的方式。",
+5
View File
@@ -7593,6 +7593,11 @@ export default withEnglishFallback({
exportRowLimitUnlimited: "無限制",
queryExportKeysetOptimizationEnabled: "嘗試 keyset 最佳化",
queryExportKeysetOptimizationEnabledDescription: "僅適用於可安全識別的單表查詢;複雜查詢會自動回退。",
preferredExportPath: "首選匯出路徑",
preferredExportPathDescription: "設定資料匯出的預設儲存目錄。留空時將自動使用上次匯出的路徑。",
preferredExportPathPlaceholder: "未設定(自動使用上次匯出路徑)",
preferredExportPathChoose: "選擇首選匯出目錄",
preferredExportPathClear: "清除首選匯出路徑",
shortcutUppercaseSelection: "選取內容轉為大寫",
shortcutLowercaseSelection: "選取內容轉為小寫",
shortcutToggleCaseSelection: "切換選取內容大小寫",
+166
View File
@@ -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");
});
});
});
+108
View File
@@ -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;
+3 -3
View File
@@ -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",
+30
View File
@@ -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", () => {
+4
View File
@@ -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);