diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 80890c700a..5fb7e667b7 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -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(settingsStore.editorSettings.csvQuoteMode); const editCsvNullMode = ref(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(() => { +
+ +
+ + + +
+

{{ t("settings.preferredExportPathDescription") }}

+
diff --git a/apps/desktop/src/components/editor/MultiSourceMergeView.vue b/apps/desktop/src/components/editor/MultiSourceMergeView.vue index b81c95215c..5b3fc3e2a6 100644 --- a/apps/desktop/src/components/editor/MultiSourceMergeView.vue +++ b/apps/desktop/src/components/editor/MultiSourceMergeView.vue @@ -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 { 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); } diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index 830fd33664..1d37caa09a 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -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; diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 46f0ef0975..196a7aa1a4 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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); } diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 13dfae3289..b7c1faa817 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -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; diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 4a75cf7343..0396d7cd33 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -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; } } diff --git a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts index 7f95f2ae25..cb328f21f5 100644 --- a/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts +++ b/apps/desktop/src/composables/useSidebarTreeExportRuntime.ts @@ -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; } diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index a5c37046e2..7aad1ae7aa 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index d7ce527961..da1b982400 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5320953469..d4028caceb 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index fa06a62d1e..712b698c90 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 36b16ca9f3..c854bd741f 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 20f17a436d..62f7b14207 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7681,6 +7681,11 @@ export default withEnglishFallback({ redisDatabaseDisplayLimitOption: "{count}個のデータベース", exportBatchSize: "エクスポートバッチサイズ", exportBatchSizeDescription: "テーブルデータエクスポート時のバッチあたりの取得行数(100〜100000)。", + preferredExportPath: "優先エクスポートパス", + preferredExportPathDescription: "データエクスポートのデフォルト保存先ディレクトリ。空の場合は前回のエクスポート先が使用されます。", + preferredExportPathPlaceholder: "未設定(前回のパスを使用)", + preferredExportPathChoose: "優先エクスポートディレクトリを選択", + preferredExportPathClear: "優先エクスポートパスをクリア", exportSection: "エクスポート", csvQuoteMode: "CSV フィールドの引用符", csvQuoteModeDescription: "CSV フィールドを二重引用符で囲む方法を選択します。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 54b7405bd2..4a833922c4 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -7506,6 +7506,11 @@ export default withEnglishFallback({ exportRowLimitUnlimited: "제한 없음", queryExportKeysetOptimizationEnabled: "키셋 최적화 시도", queryExportKeysetOptimizationEnabledDescription: "안전하게 인식된 단일 테이블 쿼리에만 적용되며 복잡한 쿼리는 자동으로 폴백됩니다.", + preferredExportPath: "기본 내보내기 경로", + preferredExportPathDescription: "데이터 내보내기의 기본 저장 디렉터리입니다. 비어 있으면 마지막으로 사용한 내보내기 경로가 사용됩니다.", + preferredExportPathPlaceholder: "설정되지 않음 (마지막 내보내기 경로 사용)", + preferredExportPathChoose: "기본 내보내기 디렉터리 선택", + preferredExportPathClear: "기본 내보내기 경로 지우기", exportSection: "내보내기", performanceSection: "성능", shortcutExecuteSql: "SQL 실행", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 87de5dbc3f..087bcbdded 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index dc3cdaf7dc..4810ad6338 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -8937,6 +8937,11 @@ export default withEnglishFallback({ exportRowLimitUnlimited: "Без ограничений", queryExportKeysetOptimizationEnabled: "Пробовать оптимизацию по ключу", queryExportKeysetOptimizationEnabledDescription: "Применяется только к надёжно распознанным запросам к одной таблице; сложные запросы автоматически переключаются на обычный режим.", + preferredExportPath: "Предпочтительный путь экспорта", + preferredExportPathDescription: "Каталог по умолчанию для экспорта данных. Если поле пусто, используется последний путь экспорта.", + preferredExportPathPlaceholder: "Не задан (использовать последний путь)", + preferredExportPathChoose: "Выбрать предпочтительный каталог экспорта", + preferredExportPathClear: "Очистить предпочтительный путь экспорта", exportSection: "Экспорт", csvQuoteMode: "Кавычки в полях CSV", csvQuoteModeDescription: "Выберите, как поля CSV используют двойные кавычки.", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index ed499d828e..733c4681b3 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -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.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ea9ee7d1e7..7e2860ede3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -8606,6 +8606,11 @@ export default withEnglishFallback({ exportRowLimitUnlimited: "无限制", queryExportKeysetOptimizationEnabled: "尝试 keyset 优化", queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。", + preferredExportPath: "首选导出路径", + preferredExportPathDescription: "设置数据导出的默认保存目录。留空时将自动使用上次导出的路径。", + preferredExportPathPlaceholder: "未设置(自动使用上次导出路径)", + preferredExportPathChoose: "选择首选导出目录", + preferredExportPathClear: "清除首选导出路径", exportSection: "导出", csvQuoteMode: "CSV 字段引号", csvQuoteModeDescription: "选择 CSV 导出时字段使用双引号的方式。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4a6a563764..fc6dfb25be 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -7593,6 +7593,11 @@ export default withEnglishFallback({ exportRowLimitUnlimited: "無限制", queryExportKeysetOptimizationEnabled: "嘗試 keyset 最佳化", queryExportKeysetOptimizationEnabledDescription: "僅適用於可安全識別的單表查詢;複雜查詢會自動回退。", + preferredExportPath: "首選匯出路徑", + preferredExportPathDescription: "設定資料匯出的預設儲存目錄。留空時將自動使用上次匯出的路徑。", + preferredExportPathPlaceholder: "未設定(自動使用上次匯出路徑)", + preferredExportPathChoose: "選擇首選匯出目錄", + preferredExportPathClear: "清除首選匯出路徑", shortcutUppercaseSelection: "選取內容轉為大寫", shortcutLowercaseSelection: "選取內容轉為小寫", shortcutToggleCaseSelection: "切換選取內容大小寫", diff --git a/apps/desktop/src/lib/export/__tests__/exportPath.spec.ts b/apps/desktop/src/lib/export/__tests__/exportPath.spec.ts new file mode 100644 index 0000000000..744cb58ee0 --- /dev/null +++ b/apps/desktop/src/lib/export/__tests__/exportPath.spec.ts @@ -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"); + }); + }); +}); diff --git a/apps/desktop/src/lib/export/exportPath.ts b/apps/desktop/src/lib/export/exportPath.ts new file mode 100644 index 0000000000..3a3c82da49 --- /dev/null +++ b/apps/desktop/src/lib/export/exportPath.ts @@ -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 { + 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; +} diff --git a/apps/desktop/src/lib/export/saveDiagramExport.ts b/apps/desktop/src/lib/export/saveDiagramExport.ts index beeeb93f16..0726a59116 100644 --- a/apps/desktop/src/lib/export/saveDiagramExport.ts +++ b/apps/desktop/src/lib/export/saveDiagramExport.ts @@ -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 { 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 { 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; diff --git a/apps/desktop/src/lib/export/saveTextFile.ts b/apps/desktop/src/lib/export/saveTextFile.ts index de5dd2d74c..e85da0283f 100644 --- a/apps/desktop/src/lib/export/saveTextFile.ts +++ b/apps/desktop/src/lib/export/saveTextFile.ts @@ -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 { const logSaveStage = (stage: string, details: Record = {}) => { @@ -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 }); diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index e70db4d501..7eea2afada 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -133,6 +133,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "redisKeyTemplates", "redisDatabaseDisplayLimit", "exportBatchSize", + "preferredExportPath", "csvQuoteMode", "csvNullMode", "exportRowLimitEnabled", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index f5bca11c24..8ad947a609 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -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" }, diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index 15840563d8..b8f97ac036 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -188,6 +188,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record { }); }); +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 & { 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", () => { diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index d5e883ff61..9678bc9eb6 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -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, 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);