From dde8d4584fe357e78c6a046c64eabb890608e9f3 Mon Sep 17 00:00:00 2001 From: ahua <50774091+ahua8522@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:34:32 +0800 Subject: [PATCH] feat(sqlfile): add refresh, locate, and context menu to SQL Files panel (#2715) * feat(sqlfile): add refresh, locate, and context menu to SQL Files panel - Add per-folder Refresh and Reveal-in-file-manager buttons on each opened folder header, plus a Refresh-all button on the panel toolbar. Re-scan prunes expand state for paths that no longer exist after refresh. - Add right-click context menus (via CustomContextMenu) for files, folders, folder headers, and the panel background: open, execute SQL file, reveal in file manager, copy path, expand/collapse all, refresh, remove folder. - Highlight the selected tree row on click/right-click; clicking empty space clears the selection. - Wire "Execute SQL file" from the panel into the App-level SqlFileExecutionDialog via connectionStore.sqlFileSource.filePath + a new prefillFilePath prop, so the dialog opens with the file preview preloaded. - Add zh-CN/en strings under sqlFileTree. * chore(i18n): autofill new translations * fix(sqlfile): clear prefillFilePath on close, dedupe i18n keys --------- Co-authored-by: wanqinghua Co-authored-by: dbx-i18n-bot Co-authored-by: t8y2 <1156263951@qq.com> --- .../src/components/layout/AppDialogs.vue | 2 +- .../src/components/layout/SqlFilePanel.vue | 347 +++++++++++++++--- .../sql-file/SqlFileExecutionDialog.vue | 6 + .../src/composables/useDialogSources.ts | 12 + apps/desktop/src/i18n/locales/en.ts | 12 + apps/desktop/src/i18n/locales/es.ts | 12 + apps/desktop/src/i18n/locales/it.ts | 12 + apps/desktop/src/i18n/locales/ja.ts | 12 + apps/desktop/src/i18n/locales/pt-BR.ts | 12 + apps/desktop/src/i18n/locales/zh-CN.ts | 12 + apps/desktop/src/i18n/locales/zh-TW.ts | 12 + apps/desktop/src/stores/connectionStore.ts | 2 +- 12 files changed, 395 insertions(+), 58 deletions(-) diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue index 9f61bfdc2d..b0dd294bcd 100644 --- a/apps/desktop/src/components/layout/AppDialogs.vue +++ b/apps/desktop/src/components/layout/AppDialogs.vue @@ -135,7 +135,7 @@ watch( :prefill-schema="dialogs.dataComparePrefillSchema.value" :prefill-table="dialogs.dataComparePrefillTable.value" /> - + -import { ref, onMounted } from "vue"; +import { ref, computed, onMounted } from "vue"; import { useI18n } from "vue-i18n"; -import { FolderOpen, FileCode, FolderClosed, ChevronRight, ChevronDown, X, Trash2 } from "@lucide/vue"; +import { FolderOpen, FileCode, FolderClosed, ChevronRight, ChevronDown, X, Trash2, RefreshCw, FolderSearch, Copy, Play, ChevronsUpDown, ChevronsDownUp } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import LightTooltip from "@/components/ui/LightTooltip.vue"; +import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { useQueryStore } from "@/stores/queryStore"; import { useConnectionStore } from "@/stores/connectionStore"; import { useToast } from "@/composables/useToast"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase"; +import { copyToClipboard } from "@/lib/common/clipboard"; import * as api from "@/lib/backend/api"; import type { SqlFileEntry } from "@/lib/backend/api"; @@ -34,6 +36,21 @@ interface FolderState { const folders = ref([]); +// Right-click target. `kind` discriminates between a folder header, a tree +// directory entry, and a tree file entry. `folderPath` is the owning top-level +// folder (for refresh scoping); `entryPath` is the right-clicked node path. +type ContextTarget = { kind: "panel" } | { kind: "folderHeader"; folderPath: string } | { kind: "dir"; folderPath: string; entry: SqlFileEntry } | { kind: "file"; folderPath: string; entry: SqlFileEntry }; + +const contextTarget = ref(null); + +// The currently highlighted tree row (file or folder path). Set on click or +// right-click so the user sees which item an opened context menu refers to. +const selectedPath = ref(null); + +function selectPath(path: string | null) { + selectedPath.value = path; +} + function loadSavedFolders(): string[] { try { const raw = safeLocalStorageGet(STORAGE_KEY); @@ -80,30 +97,84 @@ async function addFolder(folderPath: string) { }; folders.value.push(folder); saveFolders(); + await loadFolderEntries(folderPath); +} + +// Re-scan a single top-level folder and replace its entries. Mutated via the +// reactive proxy (folders.value[idx]) so Vue tracks the change — see the note +// in addFolder above. +async function loadFolderEntries(folderPath: string) { + const idx = folders.value.findIndex((f) => f.path === folderPath); + if (idx === -1) return; + folders.value[idx].loading = true; try { const entries = await api.listSqlFilesInFolder(folderPath); - // Mutate via the reactive proxy (folders.value[idx]) so Vue tracks the - // change. Mutating the local `folder` object bypasses the proxy and the - // view never updates until some other reactive write forces a re-render. - const idx = folders.value.findIndex((f) => f.path === folderPath); - if (idx !== -1) { - folders.value[idx].entries = entries; + const target = folders.value.findIndex((f) => f.path === folderPath); + if (target !== -1) { + folders.value[target].entries = entries; + // Drop expand state for paths that no longer exist after the refresh so + // stale entries don't keep phantom directories open. + const stillPresent = new Set(); + collectPaths(entries, stillPresent); + const nextExpanded = new Set(); + for (const p of folders.value[target].expanded) { + if (stillPresent.has(p)) nextExpanded.add(p); + } + folders.value[target].expanded = nextExpanded; } } catch (e: any) { toast(t("sqlFileTree.loadFailed", { message: e?.message || String(e) }), 5000); } finally { - const idx = folders.value.findIndex((f) => f.path === folderPath); - if (idx !== -1) { - folders.value[idx].loading = false; + const target = folders.value.findIndex((f) => f.path === folderPath); + if (target !== -1) { + folders.value[target].loading = false; } } } +function collectPaths(entries: SqlFileEntry[], into: Set) { + for (const e of entries) { + into.add(e.path); + if (e.is_dir && e.children.length) collectPaths(e.children, into); + } +} + +async function refreshFolder(folderPath: string) { + await loadFolderEntries(folderPath); + toast(t("sqlFileTree.refreshed"), 1500); +} + +async function refreshAll() { + await Promise.all(folders.value.map((f) => loadFolderEntries(f.path))); + toast(t("sqlFileTree.refreshed"), 1500); +} + async function removeFolder(index: number) { folders.value.splice(index, 1); saveFolders(); } +async function revealInFileManager(path: string) { + if (!isTauriRuntime()) { + toast(t("sqlFileTree.desktopOnly"), 3000); + return; + } + try { + await api.revealPathInFileManager(path); + } catch (e: any) { + toast(t("sqlFileTree.revealFailed", { message: e?.message || String(e) }), 5000); + } +} + +async function copyPath(path: string) { + try { + await copyToClipboard(path); + toast(t("sqlFileTree.pathCopied"), 1500); + } catch { + toast(t("sqlFileTree.copyFailed"), 3000); + } +} + function toggleExpand(folder: FolderState, path: string) { const next = new Set(folder.expanded); if (next.has(path)) { @@ -118,6 +189,26 @@ function toggleFolderCollapse(folder: FolderState) { folder.collapsed = !folder.collapsed; } +// Expand/collapse every directory beneath the given top-level folder. +function setAllExpanded(folder: FolderState, expanded: boolean) { + if (expanded) { + const next = new Set(folder.expanded); + collectDirPaths(folder.entries, next); + folder.expanded = next; + } else { + folder.expanded = new Set(); + } +} + +function collectDirPaths(entries: SqlFileEntry[], into: Set) { + for (const e of entries) { + if (e.is_dir) { + into.add(e.path); + collectDirPaths(e.children, into); + } + } +} + async function openFile(path: string) { if (!isTauriRuntime()) return; try { @@ -134,6 +225,16 @@ async function openFile(path: string) { } } +// Open the App-level SQL file execution dialog with this file pre-selected so +// the user can review its statements and pick a connection/database before run. +function executeFile(path: string) { + connectionStore.sqlFileSource = { + connectionId: connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "", + database: "", + filePath: path, + }; +} + function folderName(path: string): string { const normalized = path.replace(/\\/g, "/"); const parts = normalized.split("/").filter(Boolean); @@ -161,6 +262,82 @@ function flatTree(entries: SqlFileEntry[], expanded: Set): TreeEntry[] { walk(entries, 0); return result; } + +// ---- context menu ---- +const contextMenuItems = computed(() => { + const target = contextTarget.value; + if (!target) return []; + + if (target.kind === "panel") { + const items: ContextMenuItem[] = [{ label: t("sqlFileTree.openFolder"), action: pickFolder, icon: FolderOpen }]; + if (folders.value.length > 0) { + items.push({ label: "", separator: true }); + items.push({ label: t("sqlFileTree.refreshAll"), action: refreshAll, icon: RefreshCw }); + } + return items; + } + + if (target.kind === "folderHeader") { + const folderIdx = folders.value.findIndex((f) => f.path === target.folderPath); + const folder = folderIdx !== -1 ? folders.value[folderIdx] : undefined; + return [ + { label: t("sqlFileTree.revealInFileManager"), action: () => revealInFileManager(target.folderPath), icon: FolderSearch }, + { label: t("sqlFileTree.copyPath"), action: () => copyPath(target.folderPath), icon: Copy }, + { label: "", separator: true }, + { label: t("sqlFileTree.expandAll"), action: () => folder && setAllExpanded(folder, true), icon: ChevronsUpDown, disabled: !folder }, + { label: t("sqlFileTree.collapseAll"), action: () => folder && setAllExpanded(folder, false), icon: ChevronsDownUp, disabled: !folder }, + { label: "", separator: true }, + { label: t("sqlFileTree.refreshFolder"), action: () => refreshFolder(target.folderPath), icon: RefreshCw }, + { label: "", separator: true }, + { label: t("sqlFileTree.removeFolder"), action: () => folderIdx !== -1 && removeFolder(folderIdx), icon: Trash2, variant: "destructive" }, + ]; + } + + if (target.kind === "dir") { + return [ + { 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.expandAll"), action: () => expandSubtree(target), icon: ChevronsUpDown }, + { label: t("sqlFileTree.collapseAll"), action: () => collapseSubtree(target), icon: ChevronsDownUp }, + ]; + } + + // file + return [ + { label: t("sqlFileTree.openFile"), action: () => openFile(target.entry.path), icon: FileCode }, + { label: t("sqlFileTree.executeSqlFile"), action: () => executeFile(target.entry.path), icon: Play }, + { 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 }, + ]; +}); + +function expandSubtree(target: Extract) { + const folder = folders.value.find((f) => f.path === target.folderPath); + if (!folder) return; + const next = new Set(folder.expanded); + next.add(target.entry.path); + collectDirPaths(target.entry.children, next); + folder.expanded = next; +} + +function collapseSubtree(target: Extract) { + const folder = folders.value.find((f) => f.path === target.folderPath); + if (!folder) return; + const subtree = new Set(); + collectDirPaths(target.entry.children, subtree); + subtree.add(target.entry.path); + const next = new Set(); + for (const p of folder.expanded) { + if (!subtree.has(p)) next.add(p); + } + folder.expanded = next; +} + +function clearContextTarget() { + contextTarget.value = null; +} diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue index dac1488c28..33be9eab70 100644 --- a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue +++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue @@ -27,6 +27,7 @@ const open = defineModel("open", { default: false }); const props = defineProps<{ prefillConnectionId?: string; prefillDatabase?: string; + prefillFilePath?: string; }>(); const store = useConnectionStore(); @@ -410,6 +411,11 @@ watch( if (connectionId.value) { loadDatabasesForConnection(connectionId.value); } + // When opened from the SQL Files panel with a pre-selected file, load its + // preview automatically so the user can review statements before running. + if (props.prefillFilePath) { + void loadPreview(props.prefillFilePath); + } }, { immediate: true }, ); diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts index ab2068049f..8d75230790 100644 --- a/apps/desktop/src/composables/useDialogSources.ts +++ b/apps/desktop/src/composables/useDialogSources.ts @@ -32,6 +32,7 @@ const dataComparePrefillSchema = ref(""); const dataComparePrefillTable = ref(""); const sqlFilePrefillConnectionId = ref(""); const sqlFilePrefillDatabase = ref(""); +const sqlFilePrefillFilePath = ref(""); const diagramPrefillConnectionId = ref(""); const diagramPrefillDatabase = ref(""); const diagramPrefillSchema = ref(""); @@ -115,12 +116,22 @@ export function useDialogSources() { if (v) { sqlFilePrefillConnectionId.value = v.connectionId; sqlFilePrefillDatabase.value = v.database; + sqlFilePrefillFilePath.value = v.filePath ?? ""; showSqlFileDialog.value = true; connectionStore.sqlFileSource = null; } }, ); + // Clear the pre-filled file path once the dialog closes so a later open + // via the toolbar (which doesn't go through sqlFileSource) doesn't re-load + // the previously previewed file. prefillConnectionId/database are harmless + // when stale (they only preselect dropdowns), but a stale path triggers an + // async file read + preview render — a visible side effect. + watch(showSqlFileDialog, (open) => { + if (!open) sqlFilePrefillFilePath.value = ""; + }); + watch( () => connectionStore.diagramSource, (v) => { @@ -305,6 +316,7 @@ export function useDialogSources() { dataComparePrefillTable, sqlFilePrefillConnectionId, sqlFilePrefillDatabase, + sqlFilePrefillFilePath, diagramPrefillConnectionId, diagramPrefillDatabase, diagramPrefillSchema, diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 78b8e298fb..17ef13c083 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2462,6 +2462,18 @@ export default { openFailed: "Failed to open folder: {message}", loadFailed: "Failed to load SQL files: {message}", desktopOnly: "This feature is only available on desktop", + refreshFolder: "Refresh folder", + refreshAll: "Refresh all", + refreshed: "Refreshed", + revealInFileManager: "Reveal in file manager", + revealFailed: "Failed to open file manager: {message}", + copyPath: "Copy path", + pathCopied: "Path copied", + copyFailed: "Failed to copy path", + openFile: "Open", + executeSqlFile: "Execute SQL file", + expandAll: "Expand all", + collapseAll: "Collapse all", }, diff: { title: "Compare Schemas", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 27edb527c9..7ae2bf4fcf 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3399,5 +3399,17 @@ export default withEnglishFallback({ openFailed: "Error al abrir la carpeta: {message}", loadFailed: "Error al cargar archivos SQL: {message}", desktopOnly: "Esta función solo está disponible en la versión de escritorio", + refreshFolder: "Actualizar carpeta", + refreshAll: "Actualizar todo", + refreshed: "Actualizado", + revealInFileManager: "Mostrar en el explorador de archivos", + revealFailed: "Error al abrir el explorador de archivos: {message}", + copyPath: "Copiar ruta", + pathCopied: "Ruta copiada", + copyFailed: "Error al copiar la ruta", + openFile: "Abrir", + executeSqlFile: "Ejecutar archivo SQL", + expandAll: "Expandir todo", + collapseAll: "Contraer todo", }, }); diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 70653ec05c..5753e12de8 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3397,5 +3397,17 @@ export default withEnglishFallback({ openFailed: "Apertura cartella fallita: {message}", loadFailed: "Caricamento file SQL fallito: {message}", desktopOnly: "Questa funzionalità è disponibile solo su desktop", + refreshFolder: "Aggiorna cartella", + refreshAll: "Aggiorna tutto", + refreshed: "Aggiornato", + revealInFileManager: "Mostra nel file manager", + revealFailed: "Apertura del file manager non riuscita: {message}", + copyPath: "Copia percorso", + pathCopied: "Percorso copiato", + copyFailed: "Copia del percorso non riuscita", + openFile: "Apri", + executeSqlFile: "Esegui file SQL", + expandAll: "Espandi tutto", + collapseAll: "Comprimi tutto", }, }); diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 7d22d7ce66..b5f795e0aa 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3397,5 +3397,17 @@ export default withEnglishFallback({ openFailed: "フォルダを開くのに失敗しました: {message}", loadFailed: "SQL ファイルの読み込みに失敗しました: {message}", desktopOnly: "この機能はデスクトップ版のみで利用可能です", + refreshFolder: "フォルダーをリフレッシュ", + refreshAll: "すべてをリフレッシュ", + refreshed: "リフレッシュ済み", + revealInFileManager: "ファイルマネージャーで表示", + revealFailed: "ファイルマネージャーを開けませんでした: {message}", + copyPath: "パスをコピー", + pathCopied: "パスをコピーしました", + copyFailed: "パスのコピーに失敗しました", + openFile: "開く", + executeSqlFile: "SQLファイルを実行", + expandAll: "すべて展開", + collapseAll: "すべて折りたたむ", }, }); diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 6267349358..008becade0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3398,5 +3398,17 @@ export default withEnglishFallback({ openFailed: "Falha ao abrir a pasta: {message}", loadFailed: "Falha ao carregar arquivos SQL: {message}", desktopOnly: "Recurso disponível apenas no desktop", + refreshFolder: "Atualizar pasta", + refreshAll: "Atualizar tudo", + refreshed: "Atualizado", + revealInFileManager: "Mostrar no gerenciador de arquivos", + revealFailed: "Falha ao abrir o gerenciador de arquivos: {message}", + copyPath: "Copiar caminho", + pathCopied: "Caminho copiado", + copyFailed: "Falha ao copiar caminho", + openFile: "Abrir", + executeSqlFile: "Executar arquivo SQL", + expandAll: "Expandir todos", + collapseAll: "Recolher todos", }, }); diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b698c80f43..f388c7b9cf 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2462,6 +2462,18 @@ export default withEnglishFallback({ openFailed: "打开文件夹失败: {message}", loadFailed: "加载 SQL 文件失败: {message}", desktopOnly: "此功能仅在桌面端可用", + refreshFolder: "刷新文件夹", + refreshAll: "刷新全部", + refreshed: "已刷新", + revealInFileManager: "在文件管理器中显示", + revealFailed: "打开文件管理器失败: {message}", + copyPath: "复制路径", + pathCopied: "路径已复制", + copyFailed: "复制路径失败", + openFile: "打开", + executeSqlFile: "执行 SQL 文件", + expandAll: "全部展开", + collapseAll: "全部折叠", }, diff: { title: "比较架构", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0c58ce5697..52856ee1bf 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3398,5 +3398,17 @@ export default withEnglishFallback({ openFailed: "開啟資料夾失敗: {message}", loadFailed: "載入 SQL 檔案失敗: {message}", desktopOnly: "此功能僅在桌面端可用", + refreshFolder: "重新整理資料夾", + refreshAll: "重新整理全部", + refreshed: "已重新整理", + revealInFileManager: "在檔案管理員中顯示", + revealFailed: "開啟檔案管理員失敗: {message}", + copyPath: "複製路徑", + pathCopied: "路徑已複製", + copyFailed: "複製路徑失敗", + openFile: "開啟", + executeSqlFile: "執行 SQL 檔案", + expandAll: "全部展開", + collapseAll: "全部摺疊", }, }); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 6e448f834d..83beb1a8e5 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -251,7 +251,7 @@ export const useConnectionStore = defineStore("connection", () => { schema?: string; tableName?: string; } | null>(null); - const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null); + const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string } | null>(null); const diagramSource = ref<{ connectionId: string; database: string;