diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 7c7e62992f..72823d3d36 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -2246,6 +2246,7 @@ async function writeExternalSqlTab(tab: QueryTab, options: { closeAfterSave?: bo const result = await api.writeExternalSqlFile(tab.externalSqlPath, await formattedSqlForSave(tab), { expectedContentHash: options.expectedContentHash, expectedMissing: options.expectedMissing, + encoding: tab.externalSqlEncoding === "auto" ? "utf8" : tab.externalSqlEncoding, }); if (result.kind !== "written") return "retry"; rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema }); @@ -2662,9 +2663,9 @@ async function saveExternalSqlTabAs(tab: QueryTab): Promise { const isMongo = databaseType === "mongodb"; const filterExtension = currentFileName.includes(".") ? currentFileName.split(".").pop()?.toLowerCase() : isMongo ? "js" : undefined; const defaultName = currentFileName || defaultSavedQueryFileName(tab.title, databaseType); - const saved = await api.saveExternalSqlFile(defaultName, await formattedSqlForSave(tab), filterExtension); + const saved = await api.saveExternalSqlFile(defaultName, await formattedSqlForSave(tab), filterExtension, tab.externalSqlEncoding === "auto" ? "utf8" : tab.externalSqlEncoding); if (!saved) return false; - queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version); + queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version, tab.externalSqlEncoding); rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema }); invalidateSaveSqlFolderSelection(); showSaveSqlDialog.value = false; @@ -2718,9 +2719,9 @@ async function openSqlFile() { if (path) { const sqlPath = path as string; openedSqlPath = sqlPath; - const snapshot = await api.readExternalSqlFileSnapshot(sqlPath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)); + const snapshot = await api.readExternalSqlFileSnapshot(sqlPath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb), "auto"); queryStore.updateSql(tab.id, snapshot.content); - queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version); + queryStore.linkExternalSqlPath(tab.id, sqlPath, sqlFileTitleFromPath(sqlPath), snapshot.version, snapshot.encoding); applyExternalSqlFileTarget(tab, sqlPath); } } else { @@ -2778,11 +2779,11 @@ async function openSqlFilePath(path: string) { if (!isTauriRuntime()) return; try { await desktopOpenTabsRestorationBarrier?.settled; - const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)); + const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb), "auto"); // Startup and OS-open events have no initiating editor tab. Keep new files // unassociated even if persisted tab restoration has since selected one. const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget()); - queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema); + queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema, undefined, snapshot.encoding); } catch (e: any) { toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } @@ -3555,7 +3556,7 @@ async function handleQuickOpenSelect(item: any) { try { const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)); const target = resolveExternalSqlFileTargetForActiveTab(item.filePath, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId)); - queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, { line: item.line ?? 1, column: item.column }); + queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, { line: item.line ?? 1, column: item.column }, snapshot.encoding); } catch (e: any) { toast( externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)), @@ -3569,7 +3570,7 @@ async function handleQuickOpenSelect(item: any) { try { const snapshot = await api.readExternalSqlFileSnapshot(item.filePath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)); const target = resolveExternalSqlFileTargetForActiveTab(item.filePath, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId)); - queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema); + queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog, target.schema, undefined, snapshot.encoding); } catch (e: any) { toast( externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)), diff --git a/apps/desktop/src/components/layout/EditorGroup.vue b/apps/desktop/src/components/layout/EditorGroup.vue index 4d9afb3a61..ff69fc150a 100644 --- a/apps/desktop/src/components/layout/EditorGroup.vue +++ b/apps/desktop/src/components/layout/EditorGroup.vue @@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; +import { useToast } from "@/composables/useToast"; import EditorGroupTabBar from "./EditorGroupTabBar.vue"; import EditorToolbar from "./EditorToolbar.vue"; import QueryEditorSurface from "./QueryEditorSurface.vue"; @@ -14,6 +15,8 @@ import { isPreviewTab } from "@/lib/tabs/tabPresentation"; import { resolveExecutableSql } from "@/lib/sql/sqlExecutionTarget"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport"; +import * as api from "@/lib/backend/api"; +import { externalSqlEditorMaxBytes, externalSqlFileOpenErrorMessage } from "@/lib/sql/sqlFileOpen"; import { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal"; import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces"; import type { QueryTab, TableInfoTab } from "@/types/database"; @@ -99,6 +102,7 @@ const { t } = useI18n(); const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); +const { toast } = useToast(); const toolbar = inject(EDITOR_TOOLBAR_ACTIONS, createNoopEditorToolbarActions()); const tabBarPortal = inject(GROUP_TAB_BAR_PORTAL, null); const tabBarTarget = computed(() => { @@ -113,6 +117,27 @@ const groupTabs = computed(() => { const activeTab = computed(() => groupTabs.value.find((tab) => tab.id === props.activeTabId) ?? groupTabs.value[0] ?? null); const activeConnection = computed(() => (activeTab.value ? connectionStore.getConfig(activeTab.value.connectionId) : undefined)); const showGroupToolbar = computed(() => activeTab.value?.mode === "query" && !activeTab.value.ddlViewer && !isPreviewTab(activeTab.value)); + +let encodingRequest = 0; +async function changeExternalSqlEncoding(encoding: NonNullable) { + const tab = activeTab.value; + if (!tab?.externalSqlPath) return; + const request = ++encodingRequest; + if (queryStore.isTabDirty(tab) && !window.confirm(t("externalSqlFile.unsavedWarning"))) return; + const path = tab.externalSqlPath; + const sql = tab.sql; + const version = tab.externalSqlFileVersion; + const stillCurrent = () => request === encodingRequest && queryStore.tabs.includes(tab) && tab.externalSqlPath === path && tab.sql === sql && tab.externalSqlFileVersion === version; + try { + const snapshot = await api.readExternalSqlFileSnapshot(tab.externalSqlPath, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb), encoding); + if (!stillCurrent()) return; + queryStore.applyExternalSqlFileSnapshot(tab.id, snapshot.content, snapshot.version); + tab.externalSqlEncoding = snapshot.encoding ?? encoding; + } catch (error) { + if (!stillCurrent()) return; + toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(error, (key, params) => t(key, params)) }), 5000); + } +} const isGroupStickyManualTransaction = computed(() => usesProvenReadOnlyStickyTransactionState(effectiveDatabaseTypeForConnection(activeConnection.value)) && (activeTab.value?.autoCommit ?? true) === false); // Each group previews the executable SQL of its own active tab (selection // stored on the tab), not the focused tab's global selection. @@ -220,6 +245,7 @@ const groupExecutableSql = computed(() => { @unfold-all="activeSurfaceRef?.unfoldAll?.()" @toggle-sql-keyword-case="toolbar.toggleSqlKeywordCase()" @save-sql="(tabId: string) => toolbar.saveSql(tabId)" + @change-encoding="changeExternalSqlEncoding" @open-sql="toolbar.openSqlFile()" @import-result-archive="toolbar.importResultArchive()" @paste-sql-in-condition="toolbar.pasteSqlInCondition()" diff --git a/apps/desktop/src/components/layout/EditorToolbar.vue b/apps/desktop/src/components/layout/EditorToolbar.vue index 11adbca588..f7b73cafde 100644 --- a/apps/desktop/src/components/layout/EditorToolbar.vue +++ b/apps/desktop/src/components/layout/EditorToolbar.vue @@ -105,6 +105,7 @@ const emit = defineEmits<{ unfoldAll: []; toggleSqlKeywordCase: []; saveSql: [tabId: string]; + "change-encoding": [value: NonNullable]; openSql: []; importResultArchive: []; pasteSqlInCondition: []; @@ -521,6 +522,14 @@ function onExecuteInNewResultTabClick(event: MouseEvent) { emit("toolbarExecuteInNewResultTab", event.detail > 0 ? "pointer" : "keyboard"); } +function changeEncoding(event: Event) { + const select = event.target as HTMLSelectElement; + const encoding = select.value as NonNullable; + // Keep the visible selection committed until the asynchronous reload succeeds. + select.value = props.activeTab.externalSqlEncoding ?? "auto"; + emit("change-encoding", encoding); +} + async function changeCatalog(selectedCatalog: string) { const connection = props.activeConnection; if (!connection) return; @@ -1048,6 +1057,14 @@ async function changeCatalog(selectedCatalog: string) { +
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }} diff --git a/apps/desktop/src/components/layout/SqlFilePanel.vue b/apps/desktop/src/components/layout/SqlFilePanel.vue index 27e16a628a..21dd138938 100644 --- a/apps/desktop/src/components/layout/SqlFilePanel.vue +++ b/apps/desktop/src/components/layout/SqlFilePanel.vue @@ -258,7 +258,7 @@ async function openFile(path: string) { try { const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)); const target = resolveExternalSqlFileTargetForActiveTab(path, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId)); - queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema); + queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema, undefined, snapshot.encoding); } catch (e: any) { if (isExternalSqlFileTooLargeError(e) && isSqlFilePath(path)) { executeFile(path); diff --git a/apps/desktop/src/composables/useExternalSqlFileChanges.ts b/apps/desktop/src/composables/useExternalSqlFileChanges.ts index 9c7b2d6445..2417062d13 100644 --- a/apps/desktop/src/composables/useExternalSqlFileChanges.ts +++ b/apps/desktop/src/composables/useExternalSqlFileChanges.ts @@ -85,14 +85,14 @@ export function useExternalSqlFileChanges(options: UseExternalSqlFileChangesOpti return status; } - async function readStableSnapshot(path: string) { + async function readStableSnapshot(path: string, encoding?: QueryTab["externalSqlEncoding"]) { const maxSizeBytes = externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb); try { - return await api.readExternalSqlFileSnapshot(path, maxSizeBytes); + return await api.readExternalSqlFileSnapshot(path, maxSizeBytes, encoding); } catch (firstError) { await delay(MISSING_FILE_RECHECK_DELAY_MS); try { - return await api.readExternalSqlFileSnapshot(path, maxSizeBytes); + return await api.readExternalSqlFileSnapshot(path, maxSizeBytes, encoding); } catch { throw firstError; } @@ -116,7 +116,7 @@ export function useExternalSqlFileChanges(options: UseExternalSqlFileChangesOpti if (!forceContentCheck && !tab.externalSqlFileMissing && externalSqlFileMetadataMatches(tab.externalSqlFileVersion, status)) return null; - const snapshot = await readStableSnapshot(tab.externalSqlPath); + const snapshot = await readStableSnapshot(tab.externalSqlPath, tab.externalSqlEncoding); if (externalSqlFileContentMatchesBaseline(tab, snapshot) || (!tab.externalSqlFileVersion && tab.sql === snapshot.content)) { queryStore.updateExternalSqlFileVersion(tab.id, snapshot.version); return null; @@ -153,7 +153,7 @@ export function useExternalSqlFileChanges(options: UseExternalSqlFileChangesOpti async function loadLatest(tab: QueryTab) { if (!tab.externalSqlPath) return false; try { - const snapshot = await readStableSnapshot(tab.externalSqlPath); + const snapshot = await readStableSnapshot(tab.externalSqlPath, tab.externalSqlEncoding); queryStore.applyExternalSqlFileSnapshot(tab.id, snapshot.content, snapshot.version); return true; } catch (error: any) { diff --git a/apps/desktop/src/composables/useFileDrop.ts b/apps/desktop/src/composables/useFileDrop.ts index 0cbcde4bf9..f4e9d3441a 100644 --- a/apps/desktop/src/composables/useFileDrop.ts +++ b/apps/desktop/src/composables/useFileDrop.ts @@ -24,7 +24,7 @@ export function useFileDrop() { const { toast } = useToast(); const { openInStreamingExecutorOnTooLarge } = useLargeSqlFileStreamingFallback(); - async function openDroppedSqlFile(name: string, content: string, path?: string, version?: ExternalSqlFileVersion) { + async function openDroppedSqlFile(name: string, content: string, path?: string, version?: ExternalSqlFileVersion, encoding?: import("@/types/database").QueryTab["externalSqlEncoding"]) { const options = { allowMongoScripts: !isSqlFilePath(name) }; const target = path ? resolveExternalSqlFileTargetForActiveTab(path, queryStore.tabs, queryStore.activeTabId, (connectionId) => connectionStore.getConfig(connectionId), options) @@ -33,7 +33,7 @@ export function useFileDrop() { // never bind to a relational SQL tab. if (!isSqlFilePath(name) && connectionStore.getConfig(target.connectionId)?.db_type !== "mongodb") return; if (path) { - queryStore.openExternalSqlFile(target.connectionId, target.database, path, content, version, target.catalog, target.schema); + queryStore.openExternalSqlFile(target.connectionId, target.database, path, content, version, target.catalog, target.schema, undefined, encoding); } else { const tabId = queryStore.createTab(target.connectionId, target.database, name, "query", target.schema, undefined, target.catalog); queryStore.updateSql(tabId, content); @@ -89,7 +89,7 @@ export function useFileDrop() { if (isScriptFilePath(path)) { try { const snapshot = await api.readExternalSqlFileSnapshot(path, externalSqlEditorMaxBytes(settingsStore.editorSettings.externalSqlEditorMaxMb)); - await openDroppedSqlFile(name, snapshot.content, path, snapshot.version); + await openDroppedSqlFile(name, snapshot.content, path, snapshot.version, snapshot.encoding); } catch (e: any) { if (!openInStreamingExecutorOnTooLarge(path, e)) { toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index caf1c47b11..982a74b0cb 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -213,6 +213,8 @@ export default withEnglishFallback({ logoutConfirm: "Çıxış etmək istədiyinizdən əminsiniz?", }, toolbar: { + fileEncoding: "Fayl kodlaşdırması", + encodingAuto: "Avtomatik", commitOutcomeUnknown: "Təsdiqləmə nəticəsi məlum deyil və sessiya artıq mövcud deyil. SQL-i yenidən icra etməzdən əvvəl məlumatları yoxlayın.", moreActions: "Digər əməliyyatlar", newConnection: "Yeni əlaqə", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5488572b93..7432b4ae75 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -411,6 +411,8 @@ export default { logoutConfirm: "Are you sure you want to log out?", }, toolbar: { + fileEncoding: "File encoding", + encodingAuto: "Auto", immediateSync: "Sync now", commitOutcomeUnknown: "Commit result is unknown and the session no longer exists. Verify the data before running this SQL again.", moreActions: "More actions", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 34dea7a256..92434df0fa 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -404,6 +404,8 @@ export default withEnglishFallback({ logoutConfirm: "¿Seguro que deseas cerrar sesión?", }, toolbar: { + fileEncoding: "Codificación del archivo", + encodingAuto: "Automática", immediateSync: "Sincronizar ahora", commitOutcomeUnknown: "El resultado del commit es desconocido y la sesión ya no existe. Compruebe los datos antes de volver a ejecutar el SQL.", moreActions: "Más acciones", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 42e8b57668..d5f5ed5c61 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -333,6 +333,8 @@ export default withEnglishFallback({ logoutConfirm: "Apakah Anda yakin ingin keluar?", }, toolbar: { + fileEncoding: "Pengodean berkas", + encodingAuto: "Otomatis", commitOutcomeUnknown: "Hasil commit tidak diketahui dan sesi sudah tidak ada lagi. Verifikasi data sebelum menjalankan SQL ini lagi.", moreActions: "Tindakan lainnya", newConnection: "Koneksi Baru", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 97bbdfb4ee..d53cfe8de2 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -398,6 +398,8 @@ export default withEnglishFallback({ logoutConfirm: "Sei sicuro di voler uscire?", }, toolbar: { + fileEncoding: "Codifica del file", + encodingAuto: "Automatica", immediateSync: "Sincronizza ora", commitOutcomeUnknown: "L'esito del commit è sconosciuto e la sessione non esiste più. Verificare i dati prima di eseguire nuovamente SQL.", moreActions: "Altre azioni", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 8f52db3fd6..41c0c4b021 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -399,6 +399,8 @@ export default withEnglishFallback({ logoutConfirm: "ログアウトしてもよろしいですか?", }, toolbar: { + fileEncoding: "ファイルの文字コード", + encodingAuto: "自動", immediateSync: "今すぐ同期", commitOutcomeUnknown: "コミット結果は不明で、セッションは既に存在しません。SQL を再実行する前にデータを確認してください。", moreActions: "その他の操作", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index e99d50d44f..ab80627a49 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -407,6 +407,8 @@ export default withEnglishFallback({ logoutConfirm: "로그아웃하시겠습니까?", }, toolbar: { + fileEncoding: "파일 인코딩", + encodingAuto: "자동", immediateSync: "지금 동기화", commitOutcomeUnknown: "커밋 결과를 알 수 없고 세션이 더 이상 존재하지 않습니다. SQL을 다시 실행하기 전에 데이터를 확인하세요。", moreActions: "더 많은 작업", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9b5cc7dff1..9db3443099 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -399,6 +399,8 @@ export default withEnglishFallback({ logoutConfirm: "Tem certeza de que deseja sair?", }, toolbar: { + fileEncoding: "Codificação do arquivo", + encodingAuto: "Automática", immediateSync: "Sincronizar agora", commitOutcomeUnknown: "O resultado do commit é desconhecido e a sessão não existe mais. Verifique os dados antes de executar o SQL novamente.", moreActions: "Mais ações", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index 11454592e5..7a22269a46 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -381,6 +381,8 @@ export default withEnglishFallback({ logoutConfirm: "Вы действительно хотите выйти?", }, toolbar: { + fileEncoding: "Кодировка файла", + encodingAuto: "Автоматически", immediateSync: "Синхронизировать сейчас", commitOutcomeUnknown: "Результат фиксации неизвестен, а сессия больше не существует. Проверьте данные перед повторным выполнением этого SQL.", moreActions: "Дополнительные действия", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 6d8d267f25..57fe5824f2 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -213,6 +213,8 @@ export default withEnglishFallback({ logoutConfirm: "Çıkış yapmak istediğinizden emin misiniz?", }, toolbar: { + fileEncoding: "Dosya kodlaması", + encodingAuto: "Otomatik", commitOutcomeUnknown: "Onaylama sonucu bilinmiyor ve oturum artık mevcut değil. SQL'i yeniden çalıştırmadan önce verileri doğrulayın.", moreActions: "Diğer İşlemler", newConnection: "Yeni Bağlantı", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 67b82fe3fc..d2daa6d46e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -348,6 +348,8 @@ export default withEnglishFallback({ logoutConfirm: "确定要退出登录吗?", }, toolbar: { + fileEncoding: "文件编码", + encodingAuto: "自动", immediateSync: "立即同步", commitOutcomeUnknown: "提交结果未知,事务会话已不存在。请核对数据后再决定是否重新执行 SQL。", moreActions: "更多操作", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index d7d733b459..c186e9be51 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -404,6 +404,8 @@ export default withEnglishFallback({ logoutConfirm: "確定要登出嗎?", }, toolbar: { + fileEncoding: "檔案編碼", + encodingAuto: "自動", commitOutcomeUnknown: "提交結果未知,交易工作階段已不存在。請核對資料後再決定是否重新執行 SQL。", moreActions: "更多操作", newConnection: "建立連線", diff --git a/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts b/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts index 34521739d0..37ae507409 100644 --- a/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts +++ b/apps/desktop/src/lib/backend/__tests__/externalSqlFileApi.spec.ts @@ -26,7 +26,7 @@ describe("external SQL file API", () => { mocks.invoke.mockResolvedValue({ kind: "content", content: "select 1;", version }); await expect(readExternalSqlFile("/tmp/demo.sql")).resolves.toBe("select 1;"); - expect(mocks.invoke).toHaveBeenCalledWith("read_external_sql_file", { path: "/tmp/demo.sql" }); + expect(mocks.invoke).toHaveBeenCalledWith("read_external_sql_file", { path: "/tmp/demo.sql", maxSizeBytes: undefined, encoding: null }); }); it("returns the disk version with an editor snapshot", async () => { @@ -51,6 +51,7 @@ describe("external SQL file API", () => { content: "select 2;", expectedContentHash: "abc123", expectedMissing: false, + encoding: "utf8", }); }); @@ -63,6 +64,7 @@ describe("external SQL file API", () => { content: "select 2;", expectedContentHash: null, expectedMissing: true, + encoding: "utf8", }); }); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 6e38815c0a..669991ccd9 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2778,7 +2778,7 @@ export async function readExternalSqlFile(_path: string, _maxSizeBytes?: number) throw new Error("Opening external SQL file paths is only available in the desktop app"); } -export async function readExternalSqlFileSnapshot(_path: string, _maxSizeBytes?: number): Promise { +export async function readExternalSqlFileSnapshot(_path: string, _maxSizeBytes?: number, _encoding?: string): Promise { throw new Error("Opening external SQL file paths is only available in the desktop app"); } @@ -2786,11 +2786,11 @@ export async function inspectExternalSqlFile(_path: string): Promise { +export async function writeExternalSqlFile(_path: string, _content: string, _options: { expectedContentHash?: string; expectedMissing?: boolean; encoding?: string } = {}): Promise { throw new Error("Saving external SQL file paths is only available in the desktop app"); } -export async function saveExternalSqlFile(_defaultFileName: string, _content: string, _filterExtension?: string): Promise<{ path: string; version: import("@/types/database").ExternalSqlFileVersion } | null> { +export async function saveExternalSqlFile(_defaultFileName: string, _content: string, _filterExtension?: string, _encoding?: string): Promise<{ path: string; version: import("@/types/database").ExternalSqlFileVersion } | null> { throw new Error("Saving SQL files locally is only available in the desktop app"); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 0cca5b399f..7d01ac6e29 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1246,39 +1246,41 @@ export async function pendingOpenPluginInstallLinks(): Promise { export interface ExternalSqlFileSnapshot { content: string; version: ExternalSqlFileVersion; + encoding?: import("@/types/database").QueryTab["externalSqlEncoding"]; } export type ExternalSqlFileStatus = { kind: "present"; sizeBytes: number; modifiedNs: string } | { kind: "missing" }; export type ExternalSqlFileWriteResult = { kind: "written"; version: ExternalSqlFileVersion } | { kind: "conflict"; currentVersion: ExternalSqlFileVersion } | { kind: "missing" }; -export async function readExternalSqlFileSnapshot(path: string, maxSizeBytes?: number): Promise { - const result = await invoke<{ kind: "content"; content: string; version: ExternalSqlFileVersion } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path, maxSizeBytes }); +export async function readExternalSqlFileSnapshot(path: string, maxSizeBytes?: number, encoding?: string): Promise { + const result = await invoke<{ kind: "content"; content: string; version: ExternalSqlFileVersion; encoding?: ExternalSqlFileSnapshot["encoding"] } | { kind: "tooLarge"; sizeBytes: number; maxSizeBytes: number }>("read_external_sql_file", { path, maxSizeBytes, encoding: encoding ?? null }); if (result.kind === "tooLarge") { throw new ExternalSqlFileTooLargeError(result.sizeBytes, result.maxSizeBytes); } - return { content: result.content, version: result.version }; + return { content: result.content, version: result.version, ...(result.encoding ? { encoding: result.encoding } : {}) }; } -export async function readExternalSqlFile(path: string, maxSizeBytes?: number): Promise { - return (await readExternalSqlFileSnapshot(path, maxSizeBytes)).content; +export async function readExternalSqlFile(path: string, maxSizeBytes?: number, encoding?: string): Promise { + return (await readExternalSqlFileSnapshot(path, maxSizeBytes, encoding)).content; } export async function inspectExternalSqlFile(path: string): Promise { return invoke("inspect_external_sql_file", { path }); } -export async function writeExternalSqlFile(path: string, content: string, options: { expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise { +export async function writeExternalSqlFile(path: string, content: string, options: { expectedContentHash?: string; expectedMissing?: boolean; encoding?: string } = {}): Promise { return invoke("write_external_sql_file", { path, content, expectedContentHash: options.expectedContentHash ?? null, expectedMissing: options.expectedMissing ?? false, + encoding: options.encoding ?? "utf8", }); } -export async function saveExternalSqlFile(defaultFileName: string, content: string, filterExtension?: string): Promise<{ path: string; version: ExternalSqlFileVersion } | null> { - return invoke("save_external_sql_file", { defaultFileName, content, filterExtension }); +export async function saveExternalSqlFile(defaultFileName: string, content: string, filterExtension?: string, encoding?: string): Promise<{ path: string; version: ExternalSqlFileVersion } | null> { + return invoke("save_external_sql_file", { defaultFileName, content, filterExtension, encoding: encoding ?? "utf8" }); } export interface SqlFileEntry { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 4bdab5346d..7227afc258 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2610,6 +2610,7 @@ export const useQueryStore = defineStore("query", () => { editorSelection: t.editorSelection, savedSqlId: t.savedSqlId, externalSqlPath: t.externalSqlPath, + externalSqlEncoding: t.externalSqlEncoding, externalSqlFileVersion: t.externalSqlFileVersion, externalSqlIgnoredFileVersion: t.externalSqlIgnoredFileVersion, externalSqlFileMissing: t.externalSqlFileMissing, @@ -3249,7 +3250,7 @@ export const useQueryStore = defineStore("query", () => { }); } - function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string, schema?: string, reveal?: { line: number; column?: number }) { + function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string, schema?: string, reveal?: { line: number; column?: number }, encoding: QueryTab["externalSqlEncoding"] = "auto") { const normalizedPath = normalizeExternalSqlPath(path); const existing = tabs.value.find((tab) => tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath); if (existing) { @@ -3276,6 +3277,7 @@ export const useQueryStore = defineStore("query", () => { sql, originalSql: sql, externalSqlPath: path, + externalSqlEncoding: encoding, externalSqlFileVersion: version, editorRevealRequest: reveal ? { id: ++contentRevealSeq, line: reveal.line, column: reveal.column } : undefined, isExecuting: false, @@ -4726,6 +4728,7 @@ export const useQueryStore = defineStore("query", () => { originalSql: "", savedSqlId: undefined, externalSqlPath: undefined, + externalSqlEncoding: undefined, lastExecutedSql: undefined, resultBaseSql: original.resultBaseSql, resultSortedSql: undefined, @@ -5444,6 +5447,7 @@ export const useQueryStore = defineStore("query", () => { if (!tab) return; tab.savedSqlId = savedSqlId; tab.externalSqlPath = undefined; + tab.externalSqlEncoding = undefined; tab.externalSqlFileVersion = undefined; tab.externalSqlIgnoredFileVersion = undefined; tab.externalSqlFileMissing = undefined; @@ -5453,10 +5457,11 @@ export const useQueryStore = defineStore("query", () => { } } - function linkExternalSqlPath(id: string, path: string, title?: string, version?: QueryTab["externalSqlFileVersion"]) { + function linkExternalSqlPath(id: string, path: string, title?: string, version?: QueryTab["externalSqlFileVersion"], encoding: QueryTab["externalSqlEncoding"] = "auto") { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; tab.externalSqlPath = path; + tab.externalSqlEncoding = encoding; tab.externalSqlFileVersion = version; tab.externalSqlIgnoredFileVersion = undefined; tab.externalSqlFileMissing = undefined; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index fb6f0549da..e1907564db 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1991,6 +1991,7 @@ export interface QueryTab { sql: string; savedSqlId?: string; externalSqlPath?: string; + externalSqlEncoding?: "auto" | "utf8" | "utf8Bom" | "utf16le" | "utf16be" | "gbk"; externalSqlFileVersion?: ExternalSqlFileVersion; externalSqlIgnoredFileVersion?: ExternalSqlFileVersion; externalSqlFileMissing?: boolean; diff --git a/crates/dbx-sql-core/src/sql.rs b/crates/dbx-sql-core/src/sql.rs index 80c4ee38e0..e777da4bc1 100644 --- a/crates/dbx-sql-core/src/sql.rs +++ b/crates/dbx-sql-core/src/sql.rs @@ -422,6 +422,54 @@ pub fn decode_sql_file_bytes(bytes: &[u8]) -> Result { decode_sql_file_with_encoding(bytes, encoding_rs::GBK) } +/// Decode an SQL/text file using an explicit user-selected encoding. +pub fn decode_sql_file_bytes_with_encoding(bytes: &[u8], encoding: &str) -> Result { + match encoding { + "utf8" => { + decode_sql_file_with_encoding(bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes), encoding_rs::UTF_8) + } + "utf8Bom" => { + let payload = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + decode_sql_file_with_encoding(payload, encoding_rs::UTF_8) + } + "utf16le" => { + decode_sql_file_with_encoding(bytes.strip_prefix(&[0xFF, 0xFE]).unwrap_or(bytes), encoding_rs::UTF_16LE) + } + "utf16be" => { + decode_sql_file_with_encoding(bytes.strip_prefix(&[0xFE, 0xFF]).unwrap_or(bytes), encoding_rs::UTF_16BE) + } + "gbk" => decode_sql_file_with_encoding(bytes, encoding_rs::GBK), + "auto" | "" => decode_sql_file_bytes(bytes), + _ => Err(format!("Unsupported SQL file encoding: {encoding}")), + } +} + +/// Encode editor text for an explicit file encoding. The returned bytes include +/// a BOM for UTF-8 BOM and UTF-16 variants. +pub fn encode_sql_file_text(content: &str, encoding: &str) -> Result, String> { + if encoding == "utf16le" || encoding == "utf16be" { + let mut bytes = if encoding == "utf16le" { vec![0xFF, 0xFE] } else { vec![0xFE, 0xFF] }; + for unit in content.encode_utf16() { + bytes.extend_from_slice(&if encoding == "utf16le" { unit.to_le_bytes() } else { unit.to_be_bytes() }); + } + return Ok(bytes); + } + let (encoded, _, had_errors) = match encoding { + "utf8" | "auto" | "" => encoding_rs::UTF_8.encode(content), + "utf8Bom" => encoding_rs::UTF_8.encode(content), + "gbk" => encoding_rs::GBK.encode(content), + _ => return Err(format!("Unsupported SQL file encoding: {encoding}")), + }; + if had_errors { + return Err(sql_file_encoding_error()); + } + let mut bytes = encoded.into_owned(); + if encoding == "utf8Bom" { + bytes.splice(0..0, [0xEF, 0xBB, 0xBF]); + } + Ok(bytes) +} + fn decode_sql_file_with_encoding(bytes: &[u8], encoding: &'static encoding_rs::Encoding) -> Result { let (text, had_errors) = encoding.decode_without_bom_handling(bytes); if had_errors { @@ -5807,3 +5855,19 @@ delimiter ;"; assert_eq!(find_statement_at_cursor_for_database(sql, cursor, DatabaseType::Mysql), "SELECT 1 # mysql comment"); } } + +#[cfg(test)] +mod editor_encoding_tests { + use super::*; + #[test] + fn utf16_bytes_and_round_trip() { + for (encoding, expected) in [ + ("utf16le", vec![0xff, 0xfe, 0x41, 0, 0x2d, 0x4e, 0x3d, 0xd8, 0, 0xde]), + ("utf16be", vec![0xfe, 0xff, 0, 0x41, 0x4e, 0x2d, 0xd8, 0x3d, 0xde, 0]), + ] { + let bytes = encode_sql_file_text("A中😀", encoding).unwrap(); + assert_eq!(bytes, expected); + assert_eq!(decode_sql_file_bytes_with_encoding(&bytes, encoding).unwrap(), "A中😀"); + } + } +} diff --git a/src-tauri/src/commands/external_sql.rs b/src-tauri/src/commands/external_sql.rs index 02814b069a..ea1f06ee05 100644 --- a/src-tauri/src/commands/external_sql.rs +++ b/src-tauri/src/commands/external_sql.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::UNIX_EPOCH; -use dbx_core::sql::decode_sql_file_bytes; +use dbx_core::sql::{decode_sql_file_bytes, decode_sql_file_bytes_with_encoding, encode_sql_file_text}; use serde::Serialize; use sha2::{Digest, Sha256}; use tokio::io::AsyncReadExt; @@ -25,7 +25,7 @@ fn exceeds_external_sql_editor_limit(size_bytes: u64, max_size_bytes: u64) -> bo #[derive(Debug, Serialize, PartialEq, Eq)] #[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum ExternalSqlFileReadResult { - Content { content: String, version: ExternalSqlFileVersion }, + Content { content: String, version: ExternalSqlFileVersion, encoding: String }, TooLarge { size_bytes: u64, max_size_bytes: u64 }, } @@ -72,8 +72,14 @@ pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) -> pub async fn read_external_sql_file( path: String, max_size_bytes: Option, + encoding: Option, ) -> Result { - read_external_sql_file_content_async(PathBuf::from(path), clamp_external_sql_editor_limit(max_size_bytes)).await + read_external_sql_file_content_async( + PathBuf::from(path), + clamp_external_sql_editor_limit(max_size_bytes), + encoding.as_deref(), + ) + .await } #[tauri::command] @@ -87,9 +93,17 @@ pub async fn write_external_sql_file( content: String, expected_content_hash: Option, expected_missing: bool, + encoding: Option, ) -> Result { - write_external_sql_file_checked_async(PathBuf::from(path), content, expected_content_hash, expected_missing, false) - .await + write_external_sql_file_checked_async( + PathBuf::from(path), + content, + expected_content_hash, + expected_missing, + false, + encoding.as_deref().unwrap_or("utf8"), + ) + .await } #[tauri::command] @@ -98,6 +112,7 @@ pub async fn save_external_sql_file( default_file_name: String, content: String, filter_extension: Option, + encoding: Option, ) -> Result, String> { let (sender, receiver) = tokio::sync::oneshot::channel(); // Non-SQL external tabs (custom-filtered text files) keep their own @@ -120,7 +135,7 @@ pub async fn save_external_sql_file( // Keep the native dialog result as a PathBuf until after the write so // Windows Unicode paths do not cross an extra frontend IPC boundary. - save_external_sql_file_content_async(path, content).await + save_external_sql_file_content_async(path, content, encoding.as_deref().unwrap_or("utf8")).await } #[derive(Default)] @@ -169,6 +184,20 @@ pub fn is_sql_file_path(path: &Path) -> bool { path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false) } +fn detected_encoding(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + "utf8Bom" + } else if bytes.starts_with(&[0xFF, 0xFE]) { + "utf16le" + } else if bytes.starts_with(&[0xFE, 0xFF]) { + "utf16be" + } else if std::str::from_utf8(bytes).is_ok() { + "utf8" + } else { + "gbk" + } +} + fn modified_ns(metadata: &std::fs::Metadata) -> String { metadata .modified() @@ -210,12 +239,17 @@ fn read_external_sql_file_content_with_limit( } let bytes = std::fs::read(path).map_err(|e| format!("Failed to read SQL file: {e}"))?; let version = external_sql_file_version(&metadata, &bytes); - decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content, version }) + decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { + content, + version, + encoding: detected_encoding(&bytes).to_string(), + }) } async fn read_external_sql_file_content_async( path: PathBuf, max_size_bytes: u64, + encoding: Option<&str>, ) -> Result { let file = tokio::fs::File::open(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?; let metadata = file.metadata().await.map_err(|e| format!("Failed to inspect SQL file: {e}"))?; @@ -231,7 +265,15 @@ async fn read_external_sql_file_content_async( return Ok(ExternalSqlFileReadResult::TooLarge { size_bytes: bytes.len() as u64, max_size_bytes }); } let version = external_sql_file_version(&metadata, &bytes); - decode_sql_file_bytes(&bytes).map(|content| ExternalSqlFileReadResult::Content { content, version }) + let content = match encoding.filter(|value| *value != "auto") { + Some(value) => decode_sql_file_bytes_with_encoding(&bytes, value), + None => decode_sql_file_bytes(&bytes), + }?; + Ok(ExternalSqlFileReadResult::Content { + content, + version, + encoding: encoding.filter(|v| *v != "auto").unwrap_or(detected_encoding(&bytes)).to_string(), + }) } async fn inspect_external_sql_file_async(path: PathBuf) -> Result { @@ -281,6 +323,7 @@ async fn write_external_sql_file_checked_async( expected_content_hash: Option, expected_missing: bool, force: bool, + encoding: &str, ) -> Result { if !force { match external_sql_file_version_async(&path).await? { @@ -296,8 +339,9 @@ async fn write_external_sql_file_checked_async( } } - let hash = content_hash(content.as_bytes()); - tokio::fs::write(&path, content).await.map_err(|error| format!("Failed to save SQL file: {error}"))?; + let bytes = encode_sql_file_text(&content, encoding)?; + let hash = content_hash(&bytes); + tokio::fs::write(&path, &bytes).await.map_err(|error| format!("Failed to save SQL file: {error}"))?; let metadata = tokio::fs::metadata(&path).await.map_err(|error| format!("Failed to inspect saved SQL file: {error}"))?; Ok(ExternalSqlFileWriteResult::Written { @@ -312,11 +356,12 @@ async fn write_external_sql_file_checked_async( async fn save_external_sql_file_content_async( path: Option, content: String, + encoding: &str, ) -> Result, String> { let Some(path) = path else { return Ok(None); }; - let result = write_external_sql_file_checked_async(path.clone(), content, None, false, true).await?; + let result = write_external_sql_file_checked_async(path.clone(), content, None, false, true, encoding).await?; let ExternalSqlFileWriteResult::Written { version } = result else { return Err("Failed to save SQL file".to_string()); }; @@ -392,7 +437,7 @@ mod tests { let result = read_external_sql_file_content(&path); let _ = std::fs::remove_file(&path); - let ExternalSqlFileReadResult::Content { content, version } = result.unwrap() else { + let ExternalSqlFileReadResult::Content { content, version, .. } = result.unwrap() else { panic!("expected SQL file content"); }; assert_eq!(content, "select 1;"); @@ -408,7 +453,7 @@ mod tests { let result = read_external_sql_file_content(&path); let _ = std::fs::remove_file(&path); - let ExternalSqlFileReadResult::Content { content, version } = result.unwrap() else { + let ExternalSqlFileReadResult::Content { content, version, .. } = result.unwrap() else { panic!("expected SQL file content"); }; assert_eq!(content, "select '中文';"); @@ -486,7 +531,7 @@ mod tests { let file_size = MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES + 1; file.set_len(file_size).unwrap(); - let result = read_external_sql_file_content_async(path.clone(), MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES).await; + let result = read_external_sql_file_content_async(path.clone(), MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, None).await; let _ = std::fs::remove_file(&path); assert_eq!( @@ -543,6 +588,7 @@ mod tests { Some(content_hash(b"select 1;")), false, false, + "utf8", ) .await .unwrap(); @@ -562,14 +608,16 @@ mod tests { Some(content_hash(b"select 1;")), false, false, + "utf8", ) .await .unwrap(); assert_eq!(missing, ExternalSqlFileWriteResult::Missing); - let recreated = write_external_sql_file_checked_async(path.clone(), "select 2;".to_string(), None, true, false) - .await - .unwrap(); + let recreated = + write_external_sql_file_checked_async(path.clone(), "select 2;".to_string(), None, true, false, "utf8") + .await + .unwrap(); assert!(matches!(recreated, ExternalSqlFileWriteResult::Written { .. })); assert_eq!(std::fs::read_to_string(&path).unwrap(), "select 2;"); let _ = std::fs::remove_file(&path); @@ -580,10 +628,16 @@ mod tests { let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4())); std::fs::write(&path, "select external;").unwrap(); - let result = - write_external_sql_file_checked_async(path.clone(), "select editor;".to_string(), None, true, false) - .await - .unwrap(); + let result = write_external_sql_file_checked_async( + path.clone(), + "select editor;".to_string(), + None, + true, + false, + "utf8", + ) + .await + .unwrap(); assert!(matches!(result, ExternalSqlFileWriteResult::Conflict { .. })); assert_eq!(std::fs::read_to_string(&path).unwrap(), "select external;");