feat(editor): support selectable SQL file encodings

Closes #3937
This commit is contained in:
二丫讲梵
2026-10-06 00:53:51 +08:00
committed by GitHub
parent e983f2432c
commit 220dfa2170
25 changed files with 248 additions and 52 deletions
+9 -8
View File
@@ -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<boolean> {
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)),
@@ -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<QueryTab["externalSqlEncoding"]>) {
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()"
@@ -105,6 +105,7 @@ const emit = defineEmits<{
unfoldAll: [];
toggleSqlKeywordCase: [];
saveSql: [tabId: string];
"change-encoding": [value: NonNullable<QueryTab["externalSqlEncoding"]>];
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<QueryTab["externalSqlEncoding"]>;
// 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) {
</SearchableSelect>
</div>
</div>
<select v-if="activeTab.externalSqlPath" class="ml-1 h-6 rounded border border-border bg-background px-1 text-[11px]" :value="activeTab.externalSqlEncoding ?? 'auto'" :aria-label="t('toolbar.fileEncoding')" @change="changeEncoding">
<option value="auto">{{ t("toolbar.encodingAuto") }}</option>
<option value="utf8">UTF-8</option>
<option value="utf8Bom">UTF-8 BOM</option>
<option value="utf16le">UTF-16 LE</option>
<option value="utf16be">UTF-16 BE</option>
<option value="gbk">GBK / GB18030</option>
</select>
<div v-if="activeTab.mode === 'data' && activeTab.tableMeta" class="ml-2 inline-flex shrink-0 items-center gap-1 rounded border border-border bg-muted/30 px-2 py-0.5 font-medium text-muted-foreground tabular-nums">
<Table2 class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}</span>
@@ -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);
@@ -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) {
+3 -3
View File
@@ -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);
+2
View File
@@ -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ə",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -399,6 +399,8 @@ export default withEnglishFallback({
logoutConfirm: "ログアウトしてもよろしいですか?",
},
toolbar: {
fileEncoding: "ファイルの文字コード",
encodingAuto: "自動",
immediateSync: "今すぐ同期",
commitOutcomeUnknown: "コミット結果は不明で、セッションは既に存在しません。SQL を再実行する前にデータを確認してください。",
moreActions: "その他の操作",
+2
View File
@@ -407,6 +407,8 @@ export default withEnglishFallback({
logoutConfirm: "로그아웃하시겠습니까?",
},
toolbar: {
fileEncoding: "파일 인코딩",
encodingAuto: "자동",
immediateSync: "지금 동기화",
commitOutcomeUnknown: "커밋 결과를 알 수 없고 세션이 더 이상 존재하지 않습니다. SQL을 다시 실행하기 전에 데이터를 확인하세요。",
moreActions: "더 많은 작업",
+2
View File
@@ -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",
+2
View File
@@ -381,6 +381,8 @@ export default withEnglishFallback({
logoutConfirm: "Вы действительно хотите выйти?",
},
toolbar: {
fileEncoding: "Кодировка файла",
encodingAuto: "Автоматически",
immediateSync: "Синхронизировать сейчас",
commitOutcomeUnknown: "Результат фиксации неизвестен, а сессия больше не существует. Проверьте данные перед повторным выполнением этого SQL.",
moreActions: "Дополнительные действия",
+2
View File
@@ -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ı",
+2
View File
@@ -348,6 +348,8 @@ export default withEnglishFallback({
logoutConfirm: "确定要退出登录吗?",
},
toolbar: {
fileEncoding: "文件编码",
encodingAuto: "自动",
immediateSync: "立即同步",
commitOutcomeUnknown: "提交结果未知,事务会话已不存在。请核对数据后再决定是否重新执行 SQL。",
moreActions: "更多操作",
+2
View File
@@ -404,6 +404,8 @@ export default withEnglishFallback({
logoutConfirm: "確定要登出嗎?",
},
toolbar: {
fileEncoding: "檔案編碼",
encodingAuto: "自動",
commitOutcomeUnknown: "提交結果未知,交易工作階段已不存在。請核對資料後再決定是否重新執行 SQL。",
moreActions: "更多操作",
newConnection: "建立連線",
@@ -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",
});
});
+3 -3
View File
@@ -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<import("@/lib/backend/tauri").ExternalSqlFileSnapshot> {
export async function readExternalSqlFileSnapshot(_path: string, _maxSizeBytes?: number, _encoding?: string): Promise<import("@/lib/backend/tauri").ExternalSqlFileSnapshot> {
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<import("@/l
throw new Error("Inspecting external SQL file paths is only available in the desktop app");
}
export async function writeExternalSqlFile(_path: string, _content: string, _options: { expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise<import("@/lib/backend/tauri").ExternalSqlFileWriteResult> {
export async function writeExternalSqlFile(_path: string, _content: string, _options: { expectedContentHash?: string; expectedMissing?: boolean; encoding?: string } = {}): Promise<import("@/lib/backend/tauri").ExternalSqlFileWriteResult> {
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");
}
+10 -8
View File
@@ -1246,39 +1246,41 @@ export async function pendingOpenPluginInstallLinks(): Promise<string[]> {
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<ExternalSqlFileSnapshot> {
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<ExternalSqlFileSnapshot> {
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<string> {
return (await readExternalSqlFileSnapshot(path, maxSizeBytes)).content;
export async function readExternalSqlFile(path: string, maxSizeBytes?: number, encoding?: string): Promise<string> {
return (await readExternalSqlFileSnapshot(path, maxSizeBytes, encoding)).content;
}
export async function inspectExternalSqlFile(path: string): Promise<ExternalSqlFileStatus> {
return invoke("inspect_external_sql_file", { path });
}
export async function writeExternalSqlFile(path: string, content: string, options: { expectedContentHash?: string; expectedMissing?: boolean } = {}): Promise<ExternalSqlFileWriteResult> {
export async function writeExternalSqlFile(path: string, content: string, options: { expectedContentHash?: string; expectedMissing?: boolean; encoding?: string } = {}): Promise<ExternalSqlFileWriteResult> {
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 {
+7 -2
View File
@@ -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;
+1
View File
@@ -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;
+64
View File
@@ -422,6 +422,54 @@ pub fn decode_sql_file_bytes(bytes: &[u8]) -> Result<String, String> {
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<String, String> {
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<Vec<u8>, 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<String, String> {
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中😀");
}
}
}
+75 -21
View File
@@ -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<u64>,
encoding: Option<String>,
) -> Result<ExternalSqlFileReadResult, String> {
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<String>,
expected_missing: bool,
encoding: Option<String>,
) -> Result<ExternalSqlFileWriteResult, String> {
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<String>,
encoding: Option<String>,
) -> Result<Option<ExternalSqlFileSaveResult>, 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<ExternalSqlFileReadResult, String> {
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<ExternalSqlFileStatus, String> {
@@ -281,6 +323,7 @@ async fn write_external_sql_file_checked_async(
expected_content_hash: Option<String>,
expected_missing: bool,
force: bool,
encoding: &str,
) -> Result<ExternalSqlFileWriteResult, String> {
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<PathBuf>,
content: String,
encoding: &str,
) -> Result<Option<ExternalSqlFileSaveResult>, 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;");