feat(sql-file): add configurable file name filter
This commit is contained in:
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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7473,6 +7473,11 @@ export default withEnglishFallback({
|
||||
deleteFailed: "SQLファイルを削除できませんでした: {message}",
|
||||
expandAll: "すべて展開",
|
||||
collapseAll: "すべて折りたたむ",
|
||||
filterSettings: "ファイルフィルター",
|
||||
fileFilter: "ファイル名フィルター",
|
||||
fileFilterPlaceholder: "ファイル名フィルター",
|
||||
filterInvalid: "ファイルフィルタが無効なため、以前の設定を維持しました:{message}",
|
||||
fileFilterHint: "*.sql や *.sh のようなワイルドカード、または {regex} のような正規表現を使用できます。",
|
||||
},
|
||||
mqTopics: {
|
||||
title: "トピック管理",
|
||||
|
||||
@@ -5240,6 +5240,11 @@ export default withEnglishFallback({
|
||||
deleteFailed: "SQL 파일을 삭제하지 못했습니다: {message}",
|
||||
expandAll: "모두 펼치기",
|
||||
collapseAll: "모두 접기",
|
||||
filterSettings: "파일 필터",
|
||||
fileFilter: "파일 이름 필터",
|
||||
fileFilterPlaceholder: "파일 이름 필터",
|
||||
filterInvalid: "파일 필터가 잘못되어 이전 설정을 유지합니다: {message}",
|
||||
fileFilterHint: "*.sql 또는 *.sh 같은 와일드카드나 {regex} 같은 정규식을 사용할 수 있습니다.",
|
||||
},
|
||||
diff: {
|
||||
progress: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5799,6 +5799,11 @@ export default withEnglishFallback({
|
||||
deleteFailed: "删除 SQL 文件失败: {message}",
|
||||
expandAll: "全部展开",
|
||||
collapseAll: "全部折叠",
|
||||
filterSettings: "文件过滤",
|
||||
fileFilter: "文件名过滤规则",
|
||||
fileFilterPlaceholder: "文件名过滤规则",
|
||||
filterInvalid: "文件过滤规则无效,已保留原规则:{message}",
|
||||
fileFilterHint: "支持通配符(如 *.sql、*.sh)或正则表达式(如 {regex})。",
|
||||
},
|
||||
diff: {
|
||||
progress: {
|
||||
|
||||
@@ -7411,6 +7411,11 @@ export default withEnglishFallback({
|
||||
deleteFailed: "刪除 SQL 檔案失敗: {message}",
|
||||
expandAll: "全部展開",
|
||||
collapseAll: "全部摺疊",
|
||||
filterSettings: "檔案篩選",
|
||||
fileFilter: "檔案名稱篩選規則",
|
||||
fileFilterPlaceholder: "檔案名稱篩選規則",
|
||||
filterInvalid: "檔案篩選規則無效,已保留原規則:{message}",
|
||||
fileFilterHint: "支援萬用字元(如 *.sql、*.sh)或正則表示式(如 {regex})。",
|
||||
},
|
||||
mqTopics: {
|
||||
title: "主題管理",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user