feat(sql-files): create, rename, and delete SQL files in tree

This commit is contained in:
二丫讲梵
2026-08-30 12:26:06 +08:00
committed by GitHub
parent 9491ab08df
commit 7ba30ae761
22 changed files with 590 additions and 8 deletions
+2
View File
@@ -427,6 +427,8 @@ jobs:
env:
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
CARGO_INCREMENTAL: "0"
# Deep async export tests exceed the Rust test harness's default thread stack on Linux.
RUST_MIN_STACK: 8388608
RUSTC_WRAPPER: sccache
# The fast lane skips only system font discovery while retaining the other default capabilities.
RUST_FEATURE_MODE: ${{ github.event_name == 'pull_request' && needs.changes.outputs.rust_full != 'true' && 'fast' || 'full' }}
+1 -1
View File
@@ -113,7 +113,7 @@ cargo-check-fast:
cargo check --no-default-features --features sqlite-bundled
cargo-test-fast:
cargo test --no-default-features --features sqlite-bundled
RUST_MIN_STACK=8388608 cargo test --no-default-features --features sqlite-bundled
db-list:
@$(PNPM) db:env -- list
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import { FolderOpen, FileCode, FolderClosed, ChevronRight, ChevronDown, X, Trash2, RefreshCw, FolderSearch, Copy, Play, ChevronsUpDown, ChevronsDownUp } from "@lucide/vue";
import { FolderOpen, FileCode, FolderClosed, ChevronRight, ChevronDown, X, Trash2, RefreshCw, FolderSearch, Copy, Play, ChevronsUpDown, ChevronsDownUp, FilePlus, Pencil } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useQueryStore } from "@/stores/queryStore";
@@ -11,7 +13,7 @@ import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { translateBackendError } from "@/i18n/backend-errors";
import { copyToClipboard } from "@/lib/common/clipboard";
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { forgetExternalSqlFileTarget, moveExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { externalSqlFileOpenErrorMessage, formatSqlFileSize, isExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api";
@@ -43,6 +45,13 @@ const folders = ref<FolderState[]>([]);
type ContextTarget = { kind: "panel" } | { kind: "folderHeader"; folderPath: string } | { kind: "dir"; folderPath: string; entry: SqlFileEntry } | { kind: "file"; folderPath: string; entry: SqlFileEntry };
const contextTarget = ref<ContextTarget | null>(null);
const createTarget = ref<{ rootPath: string; directoryPath: string } | null>(null);
const renameTarget = ref<Extract<ContextTarget, { kind: "file" }> | null>(null);
const deleteTarget = ref<Extract<ContextTarget, { kind: "file" }> | null>(null);
const fileNameInput = ref("");
const showCreateDialog = ref(false);
const showRenameDialog = ref(false);
const showDeleteDialog = ref(false);
const selectedPaths = ref<Set<string>>(new Set());
const activePath = ref<string | null>(null);
@@ -151,6 +160,11 @@ async function refreshAll() {
toast(t("sqlFileTree.refreshed"), 1500);
}
async function reloadFolderAfterMutation(folderPath: string) {
await loadFolderEntries(folderPath);
notifySqlFileFoldersChanged();
}
async function removeFolder(index: number) {
folders.value.splice(index, 1);
saveFolders();
@@ -335,6 +349,74 @@ function handlePanelClick(event: MouseEvent) {
if (!target?.closest("[data-sql-file-row='true']")) clearSelection();
}
function normalizedSqlFileName(name: string) {
const trimmed = name.trim();
return /\.sql$/i.test(trimmed) ? trimmed : `${trimmed}.sql`;
}
function openCreateDialog(rootPath: string, directoryPath: string) {
createTarget.value = { rootPath, directoryPath };
fileNameInput.value = "";
showCreateDialog.value = true;
}
async function createSqlFile() {
const target = createTarget.value;
if (!target || !fileNameInput.value.trim()) return;
try {
const path = await api.createSqlFileInFolder(target.rootPath, target.directoryPath, normalizedSqlFileName(fileNameInput.value));
showCreateDialog.value = false;
await reloadFolderAfterMutation(target.rootPath);
await openFile(path);
toast(t("sqlFileTree.fileCreated"), 1500);
} catch (e: any) {
toast(t("sqlFileTree.createFailed", { message: translateBackendError(t, e) }), 5000);
}
}
function openRenameDialog(target: Extract<ContextTarget, { kind: "file" }>) {
renameTarget.value = target;
fileNameInput.value = target.entry.name.replace(/\.sql$/i, "");
showRenameDialog.value = true;
}
async function renameSqlFile() {
const target = renameTarget.value;
if (!target || !fileNameInput.value.trim()) return;
try {
const nextPath = await api.renameSqlFileInFolder(target.folderPath, target.entry.path, normalizedSqlFileName(fileNameInput.value));
// Renaming does not change content, and re-reading here would make a
// successful rename appear to fail for files too large for the editor.
queryStore.relocateExternalSqlFilePath(target.entry.path, nextPath);
moveExternalSqlFileTarget(target.entry.path, nextPath);
showRenameDialog.value = false;
await reloadFolderAfterMutation(target.folderPath);
toast(t("sqlFileTree.fileRenamed"), 1500);
} catch (e: any) {
toast(t("sqlFileTree.renameFailed", { message: translateBackendError(t, e) }), 5000);
}
}
function openDeleteDialog(target: Extract<ContextTarget, { kind: "file" }>) {
deleteTarget.value = target;
showDeleteDialog.value = true;
}
async function deleteSqlFile() {
const target = deleteTarget.value;
if (!target) return;
try {
await api.deleteSqlFileInFolder(target.folderPath, target.entry.path);
queryStore.markExternalSqlFileMissingForPath(target.entry.path);
forgetExternalSqlFileTarget(target.entry.path);
showDeleteDialog.value = false;
await reloadFolderAfterMutation(target.folderPath);
toast(t("sqlFileTree.fileDeleted"), 1500);
} catch (e: any) {
toast(t("sqlFileTree.deleteFailed", { message: translateBackendError(t, e) }), 5000);
}
}
// ---- context menu ----
const contextMenuItems = computed<ContextMenuItem[]>(() => {
const target = contextTarget.value;
@@ -353,6 +435,8 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
const folderIdx = folders.value.findIndex((f) => f.path === target.folderPath);
const folder = folderIdx !== -1 ? folders.value[folderIdx] : undefined;
return [
{ label: t("sqlFileTree.newSqlFile"), action: () => openCreateDialog(target.folderPath, target.folderPath), icon: FilePlus },
{ label: "", separator: true },
{ label: t("sqlFileTree.revealInFileManager"), action: () => revealInFileManager(target.folderPath), icon: FolderSearch },
{ label: t("sqlFileTree.copyPath"), action: () => copyPath(target.folderPath), icon: Copy },
{ label: "", separator: true },
@@ -367,6 +451,8 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
if (target.kind === "dir") {
return [
{ label: t("sqlFileTree.newSqlFile"), action: () => openCreateDialog(target.folderPath, target.entry.path), icon: FilePlus },
{ label: "", separator: true },
{ label: t("sqlFileTree.revealInFileManager"), action: () => revealInFileManager(target.entry.path), icon: FolderSearch },
{ label: t("sqlFileTree.copyPath"), action: () => copyPath(target.entry.path), icon: Copy },
{ label: "", separator: true },
@@ -382,6 +468,10 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
{ label: "", separator: true },
{ label: t("sqlFileTree.revealInFileManager"), action: () => revealInFileManager(target.entry.path), icon: FolderSearch },
{ label: t("sqlFileTree.copyPath"), action: () => copyPath(target.entry.path), icon: Copy },
{ label: "", separator: true },
{ label: t("sqlFileTree.renameFile"), action: () => openRenameDialog(target), icon: Pencil },
{ label: "", separator: true },
{ label: t("sqlFileTree.deleteFile"), action: () => openDeleteDialog(target), icon: Trash2, variant: "destructive" },
];
});
@@ -478,6 +568,11 @@ function clearContextTarget() {
<RefreshCw class="h-3 w-3" :class="folder.loading ? 'animate-spin' : ''" />
</Button>
</LightTooltip>
<LightTooltip :text="t('sqlFileTree.newSqlFile')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" @click.stop="openCreateDialog(folder.path, folder.path)">
<FilePlus class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('sqlFileTree.revealInFileManager')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" @click.stop="revealInFileManager(folder.path)">
<FolderSearch class="h-3 w-3" />
@@ -525,7 +620,26 @@ function clearContextTarget() {
<span class="w-3.5 shrink-0" />
<FileCode class="h-4 w-4 shrink-0 text-blue-500" />
</template>
<span class="truncate ml-1">{{ entry.name }}</span>
<span class="truncate ml-1 flex-1">{{ entry.name }}</span>
<template v-if="entry.is_dir">
<LightTooltip :text="t('sqlFileTree.newSqlFile')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" @click.stop="openCreateDialog(folder.path, entry.path)">
<FilePlus class="h-3 w-3" />
</Button>
</LightTooltip>
</template>
<template v-else>
<LightTooltip :text="t('sqlFileTree.renameFile')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" @click.stop="openRenameDialog({ kind: 'file', folderPath: folder.path, entry })">
<Pencil class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip :text="t('sqlFileTree.deleteFile')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-4 w-4 shrink-0 text-muted-foreground hover:text-destructive" @click.stop="openDeleteDialog({ kind: 'file', folderPath: folder.path, entry })">
<Trash2 class="h-3 w-3" />
</Button>
</LightTooltip>
</template>
</div>
</div>
</div>
@@ -534,5 +648,46 @@ function clearContextTarget() {
</div>
</template>
</CustomContextMenu>
<Dialog :open="showCreateDialog" @update:open="(open) => (showCreateDialog = open)">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>{{ t("sqlFileTree.newSqlFile") }}</DialogTitle>
<DialogDescription>{{ t("sqlFileTree.newSqlFileDescription") }}</DialogDescription>
</DialogHeader>
<Input v-model="fileNameInput" autofocus @keydown.enter.prevent="createSqlFile" />
<DialogFooter>
<Button variant="outline" size="sm" @click="showCreateDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button size="sm" :disabled="!fileNameInput.trim()" @click="createSqlFile">{{ t("sqlFileTree.createFile") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :open="showRenameDialog" @update:open="(open) => (showRenameDialog = open)">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>{{ t("sqlFileTree.renameFile") }}</DialogTitle>
<DialogDescription>{{ t("sqlFileTree.renameFileDescription") }}</DialogDescription>
</DialogHeader>
<Input v-model="fileNameInput" autofocus @keydown.enter.prevent="renameSqlFile" />
<DialogFooter>
<Button variant="outline" size="sm" @click="showRenameDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button size="sm" :disabled="!fileNameInput.trim()" @click="renameSqlFile">{{ t("sqlFileTree.renameFile") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :open="showDeleteDialog" @update:open="(open) => (showDeleteDialog = open)">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>{{ t("sqlFileTree.deleteFile") }}</DialogTitle>
<DialogDescription>{{ t("sqlFileTree.deleteFileConfirm", { name: deleteTarget?.entry.name || "" }) }}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" @click="showDeleteDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" size="sm" @click="deleteSqlFile">{{ t("dangerDialog.deleteConfirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
@@ -30,3 +30,19 @@ describe("SqlFilePanel folder headers", () => {
expect(panelSource).not.toContain("bg-muted/10 sticky top-0");
});
});
describe("SqlFilePanel file renaming", () => {
it("does not re-read a successfully renamed file before refreshing the tree", () => {
const renameFunction = panelSource.match(/async function renameSqlFile\(\) \{[\s\S]*?\n\}/)?.[0];
expect(renameFunction).toBeDefined();
expect(renameFunction).toContain("queryStore.relocateExternalSqlFilePath(target.entry.path, nextPath)");
expect(renameFunction).not.toContain("readExternalSqlFileSnapshot(nextPath)");
});
});
describe("SqlFilePanel directory actions", () => {
it("shows a new SQL file icon for nested directories", () => {
expect(panelSource).toContain('@click.stop="openCreateDialog(folder.path, entry.path)"');
});
});
+13
View File
@@ -5755,6 +5755,19 @@ export default {
copyFailed: "Failed to copy path",
openFile: "Open",
executeSqlFile: "Execute SQL file",
newSqlFile: "New SQL file",
newSqlFileDescription: "The .sql extension is added automatically.",
createFile: "Create",
fileCreated: "SQL file created",
createFailed: "Failed to create SQL file: {message}",
renameFile: "Rename",
renameFileDescription: "The .sql extension is added automatically.",
fileRenamed: "SQL file renamed",
renameFailed: "Failed to rename SQL file: {message}",
deleteFile: "Delete file",
deleteFileConfirm: "Delete {name}? This cannot be undone.",
fileDeleted: "SQL file deleted",
deleteFailed: "Failed to delete SQL file: {message}",
expandAll: "Expand all",
collapseAll: "Collapse all",
},
+13
View File
@@ -7358,6 +7358,19 @@ export default withEnglishFallback({
copyFailed: "Error al copiar la ruta",
openFile: "Abrir",
executeSqlFile: "Ejecutar archivo SQL",
newSqlFile: "Nuevo archivo SQL",
newSqlFileDescription: "La extensión .sql se añade automáticamente.",
createFile: "Crear",
fileCreated: "Archivo SQL creado",
createFailed: "No se pudo crear el archivo SQL: {message}",
renameFile: "Cambiar nombre",
renameFileDescription: "La extensión .sql se añade automáticamente.",
fileRenamed: "Archivo SQL renombrado",
renameFailed: "No se pudo cambiar el nombre del archivo SQL: {message}",
deleteFile: "Eliminar archivo",
deleteFileConfirm: "¿Eliminar {name}? Esta acción no se puede deshacer.",
fileDeleted: "Archivo SQL eliminado",
deleteFailed: "No se pudo eliminar el archivo SQL: {message}",
expandAll: "Expandir todo",
collapseAll: "Contraer todo",
},
+13
View File
@@ -7359,6 +7359,19 @@ export default withEnglishFallback({
copyFailed: "Copia del percorso non riuscita",
openFile: "Apri",
executeSqlFile: "Esegui file SQL",
newSqlFile: "Nuovo file SQL",
newSqlFileDescription: "L'estensione .sql viene aggiunta automaticamente.",
createFile: "Crea",
fileCreated: "File SQL creato",
createFailed: "Impossibile creare il file SQL: {message}",
renameFile: "Rinomina",
renameFileDescription: "L'estensione .sql viene aggiunta automaticamente.",
fileRenamed: "File SQL rinominato",
renameFailed: "Impossibile rinominare il file SQL: {message}",
deleteFile: "Elimina file",
deleteFileConfirm: "Eliminare {name}? Questa operazione non può essere annullata.",
fileDeleted: "File SQL eliminato",
deleteFailed: "Impossibile eliminare il file SQL: {message}",
expandAll: "Espandi tutto",
collapseAll: "Comprimi tutto",
},
+13
View File
@@ -7410,6 +7410,19 @@ export default withEnglishFallback({
copyFailed: "パスのコピーに失敗しました",
openFile: "開く",
executeSqlFile: "SQLファイルを実行",
newSqlFile: "新しいSQLファイル",
newSqlFileDescription: ".sql 拡張子は自動的に追加されます。",
createFile: "作成",
fileCreated: "SQLファイルを作成しました",
createFailed: "SQLファイルを作成できませんでした: {message}",
renameFile: "名前を変更",
renameFileDescription: ".sql 拡張子は自動的に追加されます。",
fileRenamed: "SQLファイル名を変更しました",
renameFailed: "SQLファイル名を変更できませんでした: {message}",
deleteFile: "ファイルを削除",
deleteFileConfirm: "{name} を削除しますか?この操作は元に戻せません。",
fileDeleted: "SQLファイルを削除しました",
deleteFailed: "SQLファイルを削除できませんでした: {message}",
expandAll: "すべて展開",
collapseAll: "すべて折りたたむ",
},
+13
View File
@@ -5180,6 +5180,19 @@ export default withEnglishFallback({
copyFailed: "경로 복사에 실패했습니다",
openFile: "열기",
executeSqlFile: "SQL 파일 실행",
newSqlFile: "새 SQL 파일",
newSqlFileDescription: ".sql 확장자가 자동으로 추가됩니다.",
createFile: "생성",
fileCreated: "SQL 파일을 생성했습니다",
createFailed: "SQL 파일을 생성하지 못했습니다: {message}",
renameFile: "이름 바꾸기",
renameFileDescription: ".sql 확장자가 자동으로 추가됩니다.",
fileRenamed: "SQL 파일 이름을 변경했습니다",
renameFailed: "SQL 파일 이름을 변경하지 못했습니다: {message}",
deleteFile: "파일 삭제",
deleteFileConfirm: "{name}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
fileDeleted: "SQL 파일을 삭제했습니다",
deleteFailed: "SQL 파일을 삭제하지 못했습니다: {message}",
expandAll: "모두 펼치기",
collapseAll: "모두 접기",
},
+13
View File
@@ -7360,6 +7360,19 @@ export default withEnglishFallback({
copyFailed: "Falha ao copiar caminho",
openFile: "Abrir",
executeSqlFile: "Executar arquivo SQL",
newSqlFile: "Novo arquivo SQL",
newSqlFileDescription: "A extensão .sql é adicionada automaticamente.",
createFile: "Criar",
fileCreated: "Arquivo SQL criado",
createFailed: "Falha ao criar o arquivo SQL: {message}",
renameFile: "Renomear",
renameFileDescription: "A extensão .sql é adicionada automaticamente.",
fileRenamed: "Arquivo SQL renomeado",
renameFailed: "Falha ao renomear o arquivo SQL: {message}",
deleteFile: "Excluir arquivo",
deleteFileConfirm: "Excluir {name}? Esta ação não pode ser desfeita.",
fileDeleted: "Arquivo SQL excluído",
deleteFailed: "Falha ao excluir o arquivo SQL: {message}",
expandAll: "Expandir todos",
collapseAll: "Recolher todos",
},
+13
View File
@@ -5739,6 +5739,19 @@ export default withEnglishFallback({
copyFailed: "复制路径失败",
openFile: "打开",
executeSqlFile: "执行 SQL 文件",
newSqlFile: "新建 SQL 文件",
newSqlFileDescription: "将自动补充 .sql 后缀。",
createFile: "新建",
fileCreated: "SQL 文件已新建",
createFailed: "新建 SQL 文件失败: {message}",
renameFile: "重命名",
renameFileDescription: "将自动补充 .sql 后缀。",
fileRenamed: "SQL 文件已重命名",
renameFailed: "重命名 SQL 文件失败: {message}",
deleteFile: "删除文件",
deleteFileConfirm: "确定要删除 {name} 吗?此操作无法撤销。",
fileDeleted: "SQL 文件已删除",
deleteFailed: "删除 SQL 文件失败: {message}",
expandAll: "全部展开",
collapseAll: "全部折叠",
},
+13
View File
@@ -7348,6 +7348,19 @@ export default withEnglishFallback({
copyFailed: "複製路徑失敗",
openFile: "開啟",
executeSqlFile: "執行 SQL 檔案",
newSqlFile: "新增 SQL 檔案",
newSqlFileDescription: "將自動加入 .sql 副檔名。",
createFile: "新增",
fileCreated: "SQL 檔案已新增",
createFailed: "新增 SQL 檔案失敗: {message}",
renameFile: "重新命名",
renameFileDescription: "將自動加入 .sql 副檔名。",
fileRenamed: "SQL 檔案已重新命名",
renameFailed: "重新命名 SQL 檔案失敗: {message}",
deleteFile: "刪除檔案",
deleteFileConfirm: "確定要刪除 {name} 嗎?此操作無法復原。",
fileDeleted: "SQL 檔案已刪除",
deleteFailed: "刪除 SQL 檔案失敗: {message}",
expandAll: "全部展開",
collapseAll: "全部摺疊",
},
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, MAX_EXTERNAL_SQL_FILE_TARGETS, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, MAX_EXTERNAL_SQL_FILE_TARGETS, forgetExternalSqlFileTarget, moveExternalSqlFileTarget, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
describe("external SQL file targets", () => {
beforeEach(() => {
@@ -54,6 +54,20 @@ describe("external SQL file targets", () => {
});
});
it("moves and removes saved targets when a managed file is renamed or deleted", () => {
rememberExternalSqlFileTarget("C:\\work\\draft.sql", { connectionId: "saved-connection", database: "sales" });
moveExternalSqlFileTarget("C:/work/draft.sql", "C:/work/report.sql");
expect(resolveExternalSqlFileTarget("C:/work/draft.sql", () => true, unassociatedExternalSqlFileTarget())).toEqual(unassociatedExternalSqlFileTarget());
expect(resolveExternalSqlFileTarget("C:/work/report.sql", () => true, unassociatedExternalSqlFileTarget())).toMatchObject({
connectionId: "saved-connection",
database: "sales",
});
forgetExternalSqlFileTarget("C:/work/report.sql");
expect(resolveExternalSqlFileTarget("C:/work/report.sql", () => true, unassociatedExternalSqlFileTarget())).toEqual(unassociatedExternalSqlFileTarget());
});
it("ignores malformed persisted state", () => {
localStorage.setItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, "not-json");
+3
View File
@@ -380,6 +380,9 @@ export const inspectExternalSqlFile = forward("inspectExternalSqlFile");
export const writeExternalSqlFile = forward("writeExternalSqlFile");
export const saveExternalSqlFile = forward("saveExternalSqlFile");
export const listSqlFilesInFolder = forward("listSqlFilesInFolder");
export const createSqlFileInFolder = forward("createSqlFileInFolder");
export const renameSqlFileInFolder = forward("renameSqlFileInFolder");
export const deleteSqlFileInFolder = forward("deleteSqlFileInFolder");
// Nacos
export const nacosTestConnection = forward("nacosTestConnection");
+12
View File
@@ -2221,6 +2221,18 @@ export async function listSqlFilesInFolder(_folderPath: string): Promise<SqlFile
throw new Error("Listing SQL files in a folder is only available in the desktop app");
}
export async function createSqlFileInFolder(_rootPath: string, _directoryPath: string, _fileName: string): Promise<string> {
throw new Error("Managing SQL files in folders is only available in the desktop app");
}
export async function renameSqlFileInFolder(_rootPath: string, _filePath: string, _fileName: string): Promise<string> {
throw new Error("Managing SQL files in folders is only available in the desktop app");
}
export async function deleteSqlFileInFolder(_rootPath: string, _filePath: string): Promise<void> {
throw new Error("Managing SQL files in folders is only available in the desktop app");
}
// ---------------------------------------------------------------------------
// Data Transfer
// ---------------------------------------------------------------------------
+12
View File
@@ -891,6 +891,18 @@ export async function listSqlFilesInFolder(folderPath: string): Promise<SqlFileE
return invoke("list_sql_files_in_folder", { folderPath });
}
export async function createSqlFileInFolder(rootPath: string, directoryPath: string, fileName: string): Promise<string> {
return invoke("create_sql_file_in_folder", { rootPath, directoryPath, fileName });
}
export async function renameSqlFileInFolder(rootPath: string, filePath: string, fileName: string): Promise<string> {
return invoke("rename_sql_file_in_folder", { rootPath, filePath, fileName });
}
export async function deleteSqlFileInFolder(rootPath: string, filePath: string): Promise<void> {
return invoke("delete_sql_file_in_folder", { rootPath, filePath });
}
// --- AI Conversations ---
export interface AiChatMessage {
@@ -47,6 +47,26 @@ export function rememberExternalSqlFileTarget(path: string, target: ExternalSqlF
saveExternalSqlFileTargets([{ path: normalizedPath, ...target, updatedAt: Date.now() }, ...remaining].slice(0, MAX_EXTERNAL_SQL_FILE_TARGETS));
}
export function moveExternalSqlFileTarget(previousPath: string, nextPath: string) {
const previous = normalizeExternalSqlPath(previousPath);
const next = normalizeExternalSqlPath(nextPath);
if (!previous || !next || previous === next) return;
const targets = loadExternalSqlFileTargets();
const target = targets.find((item) => item.path === previous);
const remaining = targets.filter((item) => item.path !== previous && item.path !== next);
if (!target) {
saveExternalSqlFileTargets(remaining);
return;
}
saveExternalSqlFileTargets([{ ...target, path: next, updatedAt: Date.now() }, ...remaining].slice(0, MAX_EXTERNAL_SQL_FILE_TARGETS));
}
export function forgetExternalSqlFileTarget(path: string) {
const normalizedPath = normalizeExternalSqlPath(path);
if (!normalizedPath) return;
saveExternalSqlFileTargets(loadExternalSqlFileTargets().filter((item) => item.path !== normalizedPath));
}
export function resolveExternalSqlFileTarget(path: string, connectionExists: (connectionId: string) => boolean, fallback: ExternalSqlFileTarget): ExternalSqlFileTarget {
const normalizedPath = normalizeExternalSqlPath(path);
const saved = loadExternalSqlFileTargets().find((item) => item.path === normalizedPath);
+25
View File
@@ -2563,6 +2563,29 @@ export const useQueryStore = defineStore("query", () => {
tab.externalSqlFileMissing = true;
}
function relocateExternalSqlFilePath(previousPath: string, nextPath: string, version?: QueryTab["externalSqlFileVersion"]) {
const previous = normalizeExternalSqlPath(previousPath);
if (!previous) return;
for (const tab of tabs.value) {
if (tab.mode !== "query" || !tab.externalSqlPath || normalizeExternalSqlPath(tab.externalSqlPath) !== previous) continue;
tab.externalSqlPath = nextPath;
if (version) tab.externalSqlFileVersion = version;
tab.externalSqlIgnoredFileVersion = undefined;
tab.externalSqlFileMissing = undefined;
}
refreshExternalSqlFileTitles();
}
function markExternalSqlFileMissingForPath(path: string) {
const normalizedPath = normalizeExternalSqlPath(path);
if (!normalizedPath) return;
for (const tab of tabs.value) {
if (tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath) {
tab.externalSqlFileMissing = true;
}
}
}
function persistSavedSqlEditorPosition(tab: QueryTab | undefined) {
if (!tab?.savedSqlId || tab.mode !== "query") return;
const pending = savedSqlEditorPositionTimers.get(tab.savedSqlId);
@@ -6785,6 +6808,8 @@ export const useQueryStore = defineStore("query", () => {
updateExternalSqlFileVersion,
ignoreExternalSqlFileVersion,
acknowledgeExternalSqlFileMissing,
relocateExternalSqlFilePath,
markExternalSqlFileMissingForPath,
discardTabChanges,
requestAppCloseConfirmation,
closeOtherTabs,
+20 -2
View File
@@ -3005,8 +3005,26 @@ mod tests {
}
#[cfg(unix)]
#[tokio::test]
async fn external_driver_table_export_closes_cursor_after_fetch_error() {
#[test]
fn external_driver_table_export_closes_cursor_after_fetch_error() {
let handle = std::thread::Builder::new()
.name("table-export-fetch-error".to_string())
.stack_size(8 * 1024 * 1024)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build table export fetch error test runtime")
.block_on(run_external_driver_table_export_closes_cursor_after_fetch_error());
})
.expect("spawn table export fetch error test thread");
if let Err(panic) = handle.join() {
std::panic::resume_unwind(panic);
}
}
#[cfg(unix)]
async fn run_external_driver_table_export_closes_cursor_after_fetch_error() {
let fixture = external_driver_export_fixture(
r#" case "$line" in
*'"method":"executeQueryPage"'*)
+5
View File
@@ -165,6 +165,9 @@ async fn initializes_lists_tools_and_calls_a_tool() {
let tools = client.peer().list_tools(None).await.expect("list tools");
let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
#[cfg(feature = "mq-admin")]
assert_eq!(names.len(), 14);
#[cfg(not(feature = "mq-admin"))]
assert_eq!(names.len(), 13);
assert!(names.contains(&"dbx_list_connections"));
assert!(names.contains(&"dbx_duplicate_connection"));
@@ -172,6 +175,8 @@ async fn initializes_lists_tools_and_calls_a_tool() {
assert!(names.contains(&"dbx_execute_and_show"));
assert!(names.contains(&"dbx_open_session"));
assert!(names.contains(&"dbx_close_session"));
#[cfg(feature = "mq-admin")]
assert!(names.contains(&"dbx_send_message"));
let result = client.peer().call_tool(CallToolRequestParams::new("dbx_list_connections")).await.expect("call tool");
let response = result.content[0].as_text().expect("text response");
+194 -1
View File
@@ -1,6 +1,6 @@
use serde::Serialize;
use std::collections::HashSet;
use std::path::Path;
use std::path::{Path, PathBuf};
const MAX_SCAN_DEPTH: usize = 10;
@@ -33,6 +33,60 @@ pub struct SqlFileEntry {
pub children: Vec<SqlFileEntry>,
}
fn validate_sql_file_name(file_name: &str) -> Result<&str, String> {
let file_name = file_name.trim();
if file_name.is_empty() || file_name == "." || file_name == ".." || file_name.contains(['/', '\\']) {
return Err("SQL file name must not contain a path".to_string());
}
if !file_name.to_ascii_lowercase().ends_with(".sql") || file_name.len() <= 4 {
return Err("Only .sql files can be managed here".to_string());
}
Ok(file_name)
}
fn renamed_sql_file_display_path(file_path: &str, file_name: &str) -> Result<String, String> {
#[cfg(windows)]
let parent_end = file_path.rfind(['/', '\\']).map(|index| index + 1);
#[cfg(not(windows))]
let parent_end = file_path.rfind('/').map(|index| index + 1);
Ok(parent_end.map_or_else(|| file_name.to_owned(), |index| format!("{}{}", &file_path[..index], file_name)))
}
fn canonical_directory(path: &Path, label: &str) -> Result<PathBuf, String> {
let path = std::fs::canonicalize(path).map_err(|error| format!("Failed to resolve {label}: {error}"))?;
if !path.is_dir() {
return Err(format!("{label} is not a directory"));
}
Ok(path)
}
fn managed_directory(root_path: &str, directory_path: &str) -> Result<PathBuf, String> {
let root = canonical_directory(Path::new(root_path), "SQL root folder")?;
let directory = canonical_directory(Path::new(directory_path), "SQL folder")?;
if !directory.starts_with(&root) {
return Err("SQL folder is outside the opened root folder".to_string());
}
Ok(directory)
}
fn managed_sql_file(root_path: &str, file_path: &str) -> Result<PathBuf, String> {
let root = canonical_directory(Path::new(root_path), "SQL root folder")?;
let file = std::fs::canonicalize(file_path).map_err(|error| format!("Failed to resolve SQL file: {error}"))?;
if !file.starts_with(&root) {
return Err("SQL file is outside the opened root folder".to_string());
}
if !file.is_file()
|| !file
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("sql"))
{
return Err("Only .sql files can be managed here".to_string());
}
Ok(file)
}
fn is_pruned_dir(name: &str) -> bool {
PRUNED_DIRS.iter().any(|d| name.eq_ignore_ascii_case(d))
}
@@ -117,6 +171,65 @@ pub async fn list_sql_files_in_folder(folder_path: String) -> Result<Vec<SqlFile
.map_err(|e| format!("Failed to scan folder: {e}"))?
}
#[tauri::command]
pub async fn create_sql_file_in_folder(
root_path: String,
directory_path: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let file_name = validate_sql_file_name(&file_name)?.to_owned();
let directory = managed_directory(&root_path, &directory_path)?;
let path = directory.join(&file_name);
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|error| format!("Failed to create SQL file: {error}"))?;
// Keep the path form used by the file tree. `canonicalize` returns a
// Windows extended-length path, which is a different frontend identity.
Ok(Path::new(&directory_path).join(file_name).to_string_lossy().into_owned())
})
.await
.map_err(|error| format!("Failed to create SQL file: {error}"))?
}
#[tauri::command]
pub async fn rename_sql_file_in_folder(
root_path: String,
file_path: String,
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let file_name = validate_sql_file_name(&file_name)?.to_owned();
let display_path = renamed_sql_file_display_path(&file_path, &file_name)?;
let file = managed_sql_file(&root_path, &file_path)?;
let target =
file.parent().ok_or_else(|| "Failed to resolve SQL file parent folder".to_string())?.join(&file_name);
if target.exists() {
let existing = std::fs::canonicalize(&target)
.map_err(|error| format!("Failed to resolve SQL file target: {error}"))?;
if existing != file {
return Err("A SQL file with this name already exists".to_string());
}
}
std::fs::rename(&file, &target).map_err(|error| format!("Failed to rename SQL file: {error}"))?;
Ok(display_path)
})
.await
.map_err(|error| format!("Failed to rename SQL file: {error}"))?
}
#[tauri::command]
pub async fn delete_sql_file_in_folder(root_path: String, file_path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let file = managed_sql_file(&root_path, &file_path)?;
std::fs::remove_file(file).map_err(|error| format!("Failed to delete SQL file: {error}"))
})
.await
.map_err(|error| format!("Failed to delete SQL file: {error}"))?
}
#[cfg(test)]
mod tests {
use super::*;
@@ -152,4 +265,84 @@ mod tests {
assert_eq!(names, vec!["nested.SQL", "root.sql"]);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn validates_single_sql_file_names() {
assert_eq!(validate_sql_file_name("query.sql").unwrap(), "query.sql");
assert!(validate_sql_file_name("query.txt").is_err());
assert!(validate_sql_file_name("nested/query.sql").is_err());
assert!(validate_sql_file_name("nested\\query.sql").is_err());
assert_eq!(renamed_sql_file_display_path("draft.sql", "report.sql").unwrap(), "report.sql");
}
#[tokio::test]
async fn manages_sql_files_within_the_opened_root() {
let root = std::env::temp_dir().join(format!("dbx-sql-file-manage-{}", uuid::Uuid::new_v4()));
let nested = root.join("queries");
std::fs::create_dir_all(&nested).unwrap();
let created = create_sql_file_in_folder(
root.to_string_lossy().into_owned(),
nested.to_string_lossy().into_owned(),
"draft.sql".to_string(),
)
.await
.unwrap();
assert!(Path::new(&created).is_file());
let renamed = rename_sql_file_in_folder(root.to_string_lossy().into_owned(), created, "report.sql".to_string())
.await
.unwrap();
assert!(Path::new(&renamed).is_file());
delete_sql_file_in_folder(root.to_string_lossy().into_owned(), renamed.clone()).await.unwrap();
assert!(!Path::new(&renamed).exists());
std::fs::remove_dir_all(root).unwrap();
}
#[tokio::test]
async fn keeps_the_file_tree_path_representation_for_managed_files() {
let root = std::env::temp_dir().join(format!("dbx-sql-file-path-{}", uuid::Uuid::new_v4()));
let nested = root.join("queries");
let displayed_root = root.join(".");
let displayed_directory = nested.join(".");
std::fs::create_dir_all(&nested).unwrap();
let created = create_sql_file_in_folder(
displayed_root.to_string_lossy().into_owned(),
displayed_directory.to_string_lossy().into_owned(),
"draft.sql".to_string(),
)
.await
.unwrap();
assert_eq!(created, displayed_directory.join("draft.sql").to_string_lossy());
let renamed =
rename_sql_file_in_folder(displayed_root.to_string_lossy().into_owned(), created, "report.sql".to_string())
.await
.unwrap();
assert_eq!(renamed, displayed_directory.join("report.sql").to_string_lossy());
std::fs::remove_dir_all(root).unwrap();
}
#[cfg(windows)]
#[tokio::test]
async fn renames_files_when_only_the_case_changes() {
let root = std::env::temp_dir().join(format!("dbx-sql-file-case-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let source = root.join("draft.sql");
std::fs::write(&source, "SELECT 1;").unwrap();
let renamed = rename_sql_file_in_folder(
root.to_string_lossy().into_owned(),
source.to_string_lossy().into_owned(),
"DRAFT.sql".to_string(),
)
.await
.unwrap();
assert!(Path::new(&renamed).is_file());
assert_eq!(Path::new(&renamed).file_name().unwrap(), "DRAFT.sql");
std::fs::remove_dir_all(root).unwrap();
}
}
+3
View File
@@ -1941,6 +1941,9 @@ pub fn run() {
commands::external_sql::write_external_sql_file,
commands::external_sql::save_external_sql_file,
commands::list_sql_files::list_sql_files_in_folder,
commands::list_sql_files::create_sql_file_in_folder,
commands::list_sql_files::rename_sql_file_in_folder,
commands::list_sql_files::delete_sql_file_in_folder,
commands::external_db::pending_open_db_files,
commands::keychain::read_keychain_password,
commands::keychain::read_keychain_passwords,