mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(sql-files): create, rename, and delete SQL files in tree
This commit is contained in:
@@ -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' }}
|
||||
|
||||
@@ -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)"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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: "すべて折りたたむ",
|
||||
},
|
||||
|
||||
@@ -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: "모두 접기",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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: "全部折叠",
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"'*)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user