feat(sql-file): add configurable file name filter

This commit is contained in:
andystenhe authored and GitHub committed 2026-08-31 22:55:42 +08:00
1 parent 8f3134dce8
commit f725c50b2c
18 files changed
+243 -23

No files matched your search

@@ -1,10 +1,11 @@
<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, FilePlus, Pencil } from "@lucide/vue";
import { FolderOpen, FileCode, FolderClosed, ChevronRight, ChevronDown, X, Trash2, RefreshCw, FolderSearch, Copy, Play, ChevronsUpDown, ChevronsDownUp, Settings, 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 { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useQueryStore } from "@/stores/queryStore";
@@ -17,7 +18,7 @@ import { forgetExternalSqlFileTarget, moveExternalSqlFileTarget, resolveExternal
import { externalSqlFileOpenErrorMessage, formatSqlFileSize, isExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api";
import { getSqlFileFolderPaths, saveSqlFileFolderPaths, notifySqlFileFoldersChanged } from "@/lib/sqlFile/sqlFileFolders";
import { getSqlFileFilter, getSqlFileFolderPaths, saveSqlFileFilter, saveSqlFileFolderPaths, notifySqlFileFoldersChanged } from "@/lib/sqlFile/sqlFileFolders";
import { orderedListRangeAnchorIndex, orderedListSelectionIntent, type OrderedListSelectionItem } from "@/lib/selection/orderedListSelection";
const emit = defineEmits<{
@@ -38,6 +39,9 @@ interface FolderState {
}
const folders = ref<FolderState[]>([]);
const filterSettingsOpen = ref(false);
const fileFilterDraft = ref(getSqlFileFilter());
const fileFilterRegexExample = ".*[.](sql|sh|py)$";
// Right-click target. `kind` discriminates between a folder header, a tree
// directory entry, and a tree file entry. `folderPath` is the owning top-level
@@ -112,12 +116,12 @@ async function addFolder(folderPath: string) {
// 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) {
async function loadFolderEntries(folderPath: string): Promise<string | null> {
const idx = folders.value.findIndex((f) => f.path === folderPath);
if (idx === -1) return;
if (idx === -1) return null;
folders.value[idx].loading = true;
try {
const entries = await api.listSqlFilesInFolder(folderPath);
const entries = await api.listSqlFilesInFolder(folderPath, getSqlFileFilter());
const target = folders.value.findIndex((f) => f.path === folderPath);
if (target !== -1) {
folders.value[target].entries = entries;
@@ -132,13 +136,16 @@ async function loadFolderEntries(folderPath: string) {
folders.value[target].expanded = nextExpanded;
}
} catch (e: any) {
toast(t("sqlFileTree.loadFailed", { message: e?.message || String(e) }), 5000);
const message = e?.message || String(e);
toast(t("sqlFileTree.loadFailed", { message }), 5000);
return message;
} finally {
const target = folders.value.findIndex((f) => f.path === folderPath);
if (target !== -1) {
folders.value[target].loading = false;
}
}
return null;
}
function collectPaths(entries: SqlFileEntry[], into: Set<string>) {
@@ -160,6 +167,24 @@ async function refreshAll() {
toast(t("sqlFileTree.refreshed"), 1500);
}
async function saveFileFilter() {
const previousFilter = getSqlFileFilter();
saveSqlFileFilter(fileFilterDraft.value);
const errors = await Promise.all(folders.value.map((f) => loadFolderEntries(f.path)));
// An unparsable pattern must not survive in localStorage: it would fail
// every later refresh and silently empty Quick Open until it is fixed.
const invalidFilter = errors.find((message) => message?.startsWith("Invalid file filter"));
if (invalidFilter) {
saveSqlFileFilter(previousFilter);
await Promise.all(folders.value.map((f) => loadFolderEntries(f.path)));
toast(t("sqlFileTree.filterInvalid", { message: invalidFilter }), 5000);
return;
}
filterSettingsOpen.value = false;
notifySqlFileFoldersChanged();
toast(t("sqlFileTree.refreshed"), 1500);
}
async function reloadFolderAfterMutation(folderPath: string) {
await loadFolderEntries(folderPath);
notifySqlFileFoldersChanged();
@@ -507,6 +532,19 @@ function clearContextTarget() {
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0 bg-muted/20">
<span class="text-[13px] font-medium">{{ t("sqlFileTree.title") }}</span>
<span class="flex-1" />
<LightTooltip :text="t('sqlFileTree.filterSettings')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
variant="ghost"
size="icon"
class="h-5 w-5"
@click="
fileFilterDraft = getSqlFileFilter();
filterSettingsOpen = true;
"
>
<Settings class="h-3 w-3" />
</Button>
</LightTooltip>
<LightTooltip v-if="folders.length > 0" :text="t('sqlFileTree.refreshAll')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="refreshAll">
<RefreshCw class="h-3 w-3" />
@@ -649,6 +687,23 @@ function clearContextTarget() {
</template>
</CustomContextMenu>
<Dialog v-model:open="filterSettingsOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ t("sqlFileTree.filterSettings") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-2 py-2">
<Label for="sql-file-filter">{{ t("sqlFileTree.fileFilter") }}</Label>
<Input id="sql-file-filter" v-model="fileFilterDraft" :placeholder="t('sqlFileTree.fileFilterPlaceholder')" @keydown.enter="saveFileFilter" />
<p class="text-xs text-muted-foreground">{{ t("sqlFileTree.fileFilterHint", { regex: fileFilterRegexExample }) }}</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" @click="filterSettingsOpen = false">{{ t("common.cancel") }}</Button>
<Button type="button" @click="saveFileFilter">{{ t("common.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :open="showCreateDialog" @update:open="(open) => (showCreateDialog = open)">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
@@ -46,3 +46,16 @@ describe("SqlFilePanel directory actions", () => {
expect(panelSource).toContain('@click.stop="openCreateDialog(folder.path, entry.path)"');
});
});
describe("SqlFilePanel file filter", () => {
it("restores the previous filter when the backend rejects the saved pattern", () => {
expect(panelSource).toContain('message?.startsWith("Invalid file filter")');
expect(panelSource).toContain("saveSqlFileFilter(previousFilter)");
expect(panelSource).toContain('t("sqlFileTree.filterInvalid"');
});
it("renders the translated filter placeholder", () => {
expect(panelSource).toContain("t('sqlFileTree.fileFilterPlaceholder')");
expect(panelSource).not.toContain("const fileFilterPlaceholder =");
});
});
@@ -2,7 +2,7 @@ import { nextTick } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { matchQuickOpenText, useQuickOpen } from "@/composables/useQuickOpen";
import * as api from "@/lib/backend/api";
import { getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
import { getSqlFileFilter, getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -22,6 +22,7 @@ vi.mock("@/lib/backend/api", () => ({
vi.mock("@/lib/sqlFile/sqlFileFolders", async () => {
const { ref } = await import("vue");
return {
getSqlFileFilter: vi.fn(() => "*.sql"),
getSqlFileFolderPaths: vi.fn(),
sqlFileFoldersVersion: ref(0),
};
@@ -68,7 +69,7 @@ describe("useQuickOpen", () => {
const { filteredItems, loadExternalSqlFiles, setQuery } = useQuickOpen();
const initialLoad = loadExternalSqlFiles();
expect(api.listSqlFilesInFolder).toHaveBeenCalledWith("/old");
expect(api.listSqlFilesInFolder).toHaveBeenCalledWith("/old", getSqlFileFilter());
sqlFileFoldersVersion.value++;
await nextTick();
@@ -76,7 +77,7 @@ describe("useQuickOpen", () => {
await initialLoad;
expect(api.listSqlFilesInFolder).toHaveBeenCalledTimes(2);
expect(api.listSqlFilesInFolder).toHaveBeenLastCalledWith("/new");
expect(api.listSqlFilesInFolder).toHaveBeenLastCalledWith("/new", getSqlFileFilter());
setQuery(".sql");
expect(filteredItems.value.map((item) => item.label)).toContain("new.sql");
expect(filteredItems.value.map((item) => item.label)).not.toContain("old.sql");
+2 -2
View File
@@ -6,7 +6,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api";
import { getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
import { getSqlFileFilter, getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
import i18n from "@/i18n";
const REMOTE_SEARCH_DEBOUNCE_MS = 180;
@@ -258,7 +258,7 @@ export function useQuickOpen() {
const allEntries: Array<{ entry: SqlFileEntry; rootFolder: string }> = [];
for (const folderPath of folderPaths) {
try {
const entries = await api.listSqlFilesInFolder(folderPath);
const entries = await api.listSqlFilesInFolder(folderPath, getSqlFileFilter());
const collected: SqlFileEntry[] = [];
collectSqlFileEntries(entries, collected);
const rootName = folderNameFromPath(folderPath);
+5
View File
@@ -5815,6 +5815,11 @@ export default {
deleteFailed: "Failed to delete SQL file: {message}",
expandAll: "Expand all",
collapseAll: "Collapse all",
filterSettings: "File Filter",
fileFilter: "File name filter",
fileFilterPlaceholder: "File name filter",
filterInvalid: "Invalid file filter, kept the previous one: {message}",
fileFilterHint: "Use a glob such as *.sql or *.sh, or a regular expression such as {regex}.",
},
diff: {
progress: {
+5
View File
@@ -7421,6 +7421,11 @@ export default withEnglishFallback({
deleteFailed: "No se pudo eliminar el archivo SQL: {message}",
expandAll: "Expandir todo",
collapseAll: "Contraer todo",
filterSettings: "Filtro de archivos",
fileFilter: "Filtro de nombre de archivo",
fileFilterPlaceholder: "Filtro de nombre de archivo",
filterInvalid: "Filtro de archivos no válido; se conserva el anterior: {message}",
fileFilterHint: "Use un comodín como *.sql o *.sh, o una expresión regular como {regex}.",
},
mqTopics: {
title: "Administración de temas",
+5
View File
@@ -7422,6 +7422,11 @@ export default withEnglishFallback({
deleteFailed: "Impossibile eliminare il file SQL: {message}",
expandAll: "Espandi tutto",
collapseAll: "Comprimi tutto",
filterSettings: "Filtro file",
fileFilter: "Filtro nome file",
fileFilterPlaceholder: "Filtro nome file",
filterInvalid: "Filtro file non valido; viene mantenuto il precedente: {message}",
fileFilterHint: "Usa un carattere jolly come *.sql o *.sh oppure un'espressione regolare come {regex}.",
},
mqTopics: {
title: "Gestione topic",
+5
View File
@@ -7473,6 +7473,11 @@ export default withEnglishFallback({
deleteFailed: "SQLファイルを削除できませんでした: {message}",
expandAll: "すべて展開",
collapseAll: "すべて折りたたむ",
filterSettings: "ファイルフィルター",
fileFilter: "ファイル名フィルター",
fileFilterPlaceholder: "ファイル名フィルター",
filterInvalid: "ファイルフィルタが無効なため、以前の設定を維持しました:{message}",
fileFilterHint: "*.sql や *.sh のようなワイルドカード、または {regex} のような正規表現を使用できます。",
},
mqTopics: {
title: "トピック管理",
+5
View File
@@ -5240,6 +5240,11 @@ export default withEnglishFallback({
deleteFailed: "SQL 파일을 삭제하지 못했습니다: {message}",
expandAll: "모두 펼치기",
collapseAll: "모두 접기",
filterSettings: "파일 필터",
fileFilter: "파일 이름 필터",
fileFilterPlaceholder: "파일 이름 필터",
filterInvalid: "파일 필터가 잘못되어 이전 설정을 유지합니다: {message}",
fileFilterHint: "*.sql 또는 *.sh 같은 와일드카드나 {regex} 같은 정규식을 사용할 수 있습니다.",
},
diff: {
progress: {
+5
View File
@@ -7423,6 +7423,11 @@ export default withEnglishFallback({
deleteFailed: "Falha ao excluir o arquivo SQL: {message}",
expandAll: "Expandir todos",
collapseAll: "Recolher todos",
filterSettings: "Filtro de arquivos",
fileFilter: "Filtro de nome de arquivo",
fileFilterPlaceholder: "Filtro de nome de arquivo",
filterInvalid: "Filtro de arquivo inválido; o anterior foi mantido: {message}",
fileFilterHint: "Use um curinga como *.sql ou *.sh, ou uma expressão regular como {regex}.",
},
mqTopics: {
title: "Gerenciamento de tópicos",
+5
View File
@@ -5799,6 +5799,11 @@ export default withEnglishFallback({
deleteFailed: "删除 SQL 文件失败: {message}",
expandAll: "全部展开",
collapseAll: "全部折叠",
filterSettings: "文件过滤",
fileFilter: "文件名过滤规则",
fileFilterPlaceholder: "文件名过滤规则",
filterInvalid: "文件过滤规则无效,已保留原规则:{message}",
fileFilterHint: "支持通配符(如 *.sql、*.sh)或正则表达式(如 {regex})。",
},
diff: {
progress: {
+5
View File
@@ -7411,6 +7411,11 @@ export default withEnglishFallback({
deleteFailed: "刪除 SQL 檔案失敗: {message}",
expandAll: "全部展開",
collapseAll: "全部摺疊",
filterSettings: "檔案篩選",
fileFilter: "檔案名稱篩選規則",
fileFilterPlaceholder: "檔案名稱篩選規則",
filterInvalid: "檔案篩選規則無效,已保留原規則:{message}",
fileFilterHint: "支援萬用字元(如 *.sql、*.sh)或正則表示式(如 {regex})。",
},
mqTopics: {
title: "主題管理",
+1 -1
View File
@@ -2223,7 +2223,7 @@ export interface SqlFileEntry {
children: SqlFileEntry[];
}
export async function listSqlFilesInFolder(_folderPath: string): Promise<SqlFileEntry[]> {
export async function listSqlFilesInFolder(_folderPath: string, _fileFilter?: string): Promise<SqlFileEntry[]> {
throw new Error("Listing SQL files in a folder is only available in the desktop app");
}
+2 -2
View File
@@ -888,8 +888,8 @@ export interface SqlFileEntry {
children: SqlFileEntry[];
}
export async function listSqlFilesInFolder(folderPath: string): Promise<SqlFileEntry[]> {
return invoke("list_sql_files_in_folder", { folderPath });
export async function listSqlFilesInFolder(folderPath: string, fileFilter?: string): Promise<SqlFileEntry[]> {
return invoke("list_sql_files_in_folder", { folderPath, fileFilter });
}
export async function createSqlFileInFolder(rootPath: string, directoryPath: string, fileName: string): Promise<string> {
@@ -2,6 +2,8 @@ import { ref } from "vue";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
const STORAGE_KEY = "dbx-sql-file-folders";
const FILTER_STORAGE_KEY = "dbx-sql-file-filter";
export const DEFAULT_SQL_FILE_FILTER = "*.sql";
/**
* Shared reactive version counter — bumped whenever SQL file folder paths change.
@@ -22,6 +24,18 @@ export function getSqlFileFolderPaths(): string[] {
}
}
/** Read the file-name filter used by the external SQL file browser. */
export function getSqlFileFilter(): string {
const filter = safeLocalStorageGet(FILTER_STORAGE_KEY)?.trim();
return filter || DEFAULT_SQL_FILE_FILTER;
}
/** Persist the file-name filter and invalidate cached external file listings. */
export function saveSqlFileFilter(filter: string): void {
safeLocalStorageSet(FILTER_STORAGE_KEY, filter.trim() || DEFAULT_SQL_FILE_FILTER);
sqlFileFoldersVersion.value++;
}
/** Persist folder paths to localStorage and notify subscribers. */
export function saveSqlFileFolderPaths(paths: string[]): void {
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(paths));