From fdaa949fb1779e29844b1f5ad3c1951c754b44e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Sun, 5 Jul 2026 00:09:04 +0800 Subject: [PATCH] feat(sidebar): add local table search --- .../src/components/sidebar/ConnectionTree.vue | 91 ++++++++++- .../src/components/sidebar/TreeItem.vue | 43 ++++- apps/desktop/src/i18n/locales/en.ts | 2 + apps/desktop/src/i18n/locales/es.ts | 2 + apps/desktop/src/i18n/locales/it.ts | 2 + apps/desktop/src/i18n/locales/ja.ts | 2 + apps/desktop/src/i18n/locales/pt-BR.ts | 2 + apps/desktop/src/i18n/locales/zh-CN.ts | 2 + apps/desktop/src/i18n/locales/zh-TW.ts | 2 + .../lib/sidebar/sidebarTableSearchControl.ts | 66 ++++++++ .../src/lib/sidebar/sidebarTreeContext.ts | 1 + .../src/lib/sidebar/sidebarTreeItemLayout.ts | 1 + apps/desktop/src/stores/connectionStore.ts | 72 ++++++++- apps/desktop/src/types/database.ts | 2 + .../sidebarTableSearchControl.test.ts | 148 ++++++++++++++++++ 15 files changed, 421 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/lib/sidebar/sidebarTableSearchControl.ts create mode 100644 packages/app-tests/sidebarTableSearchControl.test.ts diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 8be31fc56..eaef0c8ed 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -19,6 +19,7 @@ import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeFor import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget"; import { SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; +import { insertSidebarTableSearchControls, isSidebarTableSearchControlNode } from "@/lib/sidebar/sidebarTableSearchControl"; import TreeItem from "./TreeItem.vue"; import { RecycleScroller } from "vue-virtual-scroller"; import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; @@ -42,6 +43,10 @@ const selectedSearchScopes = ref([]); const searchCollapsedIds = ref>(new Set()); const searchRefreshedNodeIds = new Set(); let searchTimer: number | undefined; +const tableSearchTimers = new Map(); +const tableSearchFocusRestoreTokens = new Map(); +let tableSearchFocusRestoreTokenSeq = 0; +let latestTableSearchInteractionParentId: string | null = null; watch( searchQuery, @@ -61,6 +66,13 @@ watch( { flush: "sync" }, ); +function refreshActiveSidebarTableSearches() { + if (isFiltering.value) return; + for (const parentNodeId of Object.keys(store.sidebarTableSearchQueries)) { + scheduleSidebarTableSearchRefresh(parentNodeId); + } +} + watch(deferredSearchQuery, (newQuery, oldQuery) => { store.sidebarSearchQuery = newQuery; const tasks: Promise[] = []; @@ -70,7 +82,11 @@ watch(deferredSearchQuery, (newQuery, oldQuery) => { if (!newQuery && oldQuery) { searchRefreshedNodeIds.clear(); } - Promise.all(tasks).catch(() => {}); + Promise.all(tasks) + .then(() => { + if (!newQuery && oldQuery) refreshActiveSidebarTableSearches(); + }) + .catch(() => {}); }); const searchableObjectGroupTypes = new Set(["group-tables", "group-views", "group-materialized-views"]); @@ -78,7 +94,7 @@ const simpleObjectParentTypes = new Set(["database", "schema", "li const simpleObjectChildTypes = new Set(["table", "view", "materialized_view", "procedure", "function", "sequence", "package", "package-body", "load-more"]); function isSimpleObjectSearchParent(node: TreeNode): boolean { - return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.isExpanded === true && !!node.children?.some((child) => simpleObjectChildTypes.has(child.type)); + return settingsStore.editorSettings.sidebarObjectDisplay === "simple" && simpleObjectParentTypes.has(node.type) && node.isExpanded === true && (!!node.children?.some((child) => simpleObjectChildTypes.has(child.type)) || !!store.sidebarTableSearchQueries[node.id]?.trim()); } function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise[], refreshedNodeIds?: Set) { @@ -185,6 +201,47 @@ function clearSearchScopeFilter() { selectedSearchScopes.value = []; } +function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { restoreFocus?: boolean }) { + window.clearTimeout(tableSearchTimers.get(parentNodeId)); + if (isFiltering.value) return; + const restoreToken = options?.restoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0; + if (restoreToken) { + tableSearchFocusRestoreTokens.clear(); + tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken); + } + const timer = window.setTimeout(() => { + tableSearchTimers.delete(parentNodeId); + void store.refreshSidebarTableSearch(parentNodeId).then(() => { + if (!restoreToken) return; + if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return; + tableSearchFocusRestoreTokens.delete(parentNodeId); + if (latestTableSearchInteractionParentId !== parentNodeId) return; + if (document.activeElement === document.body || activeTableSearchParentId() === parentNodeId) { + focusTableSearchInput(parentNodeId); + } + }); + }, 250); + tableSearchTimers.set(parentNodeId, timer); +} + +function activeTableSearchParentId(): string | null { + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return null; + return active.dataset.sidebarTableSearchParentId || null; +} + +function focusTableSearchInput(parentNodeId: string) { + void nextTick(() => { + const root = rootRef.value; + if (!root) return; + const input = Array.from(root.querySelectorAll("[data-sidebar-table-search-parent-id]")).find((item) => item.dataset.sidebarTableSearchParentId === parentNodeId); + if (!input) return; + input.focus({ preventScroll: true }); + const end = input.value.length; + input.setSelectionRange(end, end); + }); +} + const filteredNodes = computed(() => { let nodes = store.treeNodes; @@ -197,11 +254,18 @@ const filteredNodes = computed(() => { return nodes; }); -const flatNodes = computed(() => flattenTree(filteredNodes.value)); +const flatNodes = computed(() => + insertSidebarTableSearchControls(flattenTree(filteredNodes.value), { + enabled: !isFiltering.value, + sidebarObjectDisplay: settingsStore.editorSettings.sidebarObjectDisplay, + activeQueries: store.sidebarTableSearchQueries, + }), +); const visibleNodes = computed(() => flatNodes.value.map((item) => item.node)); -const visibleNodeIndexById = computed(() => { +const selectableVisibleNodes = computed(() => visibleNodes.value.filter((node) => !isSidebarTableSearchControlNode(node))); +const selectableVisibleNodeIndexById = computed(() => { const next = new Map(); - visibleNodes.value.forEach((node, index) => next.set(node.id, index)); + selectableVisibleNodes.value.forEach((node, index) => next.set(node.id, index)); return next; }); const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length)); @@ -405,8 +469,13 @@ function onSidebarScrollbarThumbPointerDown(event: PointerEvent) { } provide(sidebarTreeContextKey, { - getVisibleNodes: () => visibleNodes.value, - getVisibleNodeIndex: (id: string) => visibleNodeIndexById.value.get(id) ?? -1, + getVisibleNodes: () => selectableVisibleNodes.value, + getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1, + setTableSearchQuery: (parentNodeId, query) => { + latestTableSearchInteractionParentId = parentNodeId; + store.setSidebarTableSearchQuery(parentNodeId, query); + scheduleSidebarTableSearchRefresh(parentNodeId, { restoreFocus: true }); + }, }); const pendingRenameGroupId = ref(null); @@ -805,7 +874,7 @@ function onWindowKeydown(event: KeyboardEvent) { } } - if (!pointerInsideTree.value || isEditableSidebarTypeSearchTarget(event.target)) return; + if (!pointerInsideTree.value || isEditableSidebarTypeSearchTarget(event.target) || isEditableSidebarTypeSearchTarget(document.activeElement)) return; if (isCancelSearchShortcut(event)) { if (!searchQuery.value) return; event.preventDefault(); @@ -905,6 +974,12 @@ onMounted(() => { onUnmounted(() => { window.removeEventListener("keydown", onWindowKeydown); + for (const timer of tableSearchTimers.values()) { + window.clearTimeout(timer); + } + tableSearchTimers.clear(); + tableSearchFocusRestoreTokens.clear(); + latestTableSearchInteractionParentId = null; stopSidebarScrollbarDrag(); sidebarScrollbarResizeObserver?.disconnect(); window.cancelAnimationFrame(sidebarScrollbarAnimationFrame); diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index f71e0e7dc..72c658b33 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -3650,6 +3650,11 @@ const tableComment = computed(() => : null, ); const paddingLeft = computed(() => treeItemPaddingLeft(props.depth)); +const tableSearchParentId = computed(() => props.node.tableSearchParentId || ""); +const tableSearchValue = computed(() => { + const parentId = tableSearchParentId.value; + return parentId ? connectionStore.sidebarTableSearchQueries[parentId] || "" : ""; +}); const isConnected = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId)); const isConnecting = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectingIds.has(props.node.connectionId)); const isConnectionReadonly = computed(() => props.node.type === "connection" && !!props.node.connectionId && (connectionStore.getConfig(props.node.connectionId)?.read_only ?? false)); @@ -3713,6 +3718,22 @@ function togglePin() { connectionStore.toggleTreeNodePin(props.node.id); } +function updateTableSearchQuery(value: string | number) { + const parentId = tableSearchParentId.value; + if (!parentId) return; + const query = String(value); + if (sidebarTreeContext?.setTableSearchQuery) { + sidebarTreeContext.setTableSearchQuery(parentId, query); + return; + } + connectionStore.setSidebarTableSearchQuery(parentId, query); + void connectionStore.refreshSidebarTableSearch(parentId); +} + +function clearTableSearchQuery() { + updateTableSearchQuery(""); +} + function openVisibleDatabasesDialog() { showVisibleDatabasesDialog.value = true; } @@ -4653,7 +4674,27 @@ function treeItemMenuItems(): ContextMenuItem[] {