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

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-30 12:26:06 +08:00
1 parent 9491ab08df
commit 7ba30ae761
22 files changed
+590 -8

No files matched your search

@@ -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)"');
});
});