feat(sidebar): add local table search

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-07-05 00:09:04 +08:00
1 parent 8a1a791927
commit fdaa949fb1
15 files changed
+421 -17

No files matched your search

@@ -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<SearchScope[]>([]);
const searchCollapsedIds = ref<Set<string>>(new Set());
const searchRefreshedNodeIds = new Set<string>();
let searchTimer: number | undefined;
const tableSearchTimers = new Map<string, number>();
const tableSearchFocusRestoreTokens = new Map<string, number>();
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<void>[] = [];
@@ -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<TreeNodeType>(["group-tables", "group-views", "group-materialized-views"]);
@@ -78,7 +94,7 @@ const simpleObjectParentTypes = new Set<TreeNodeType>(["database", "schema", "li
const simpleObjectChildTypes = new Set<TreeNodeType>(["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<void>[], refreshedNodeIds?: Set<string>) {
@@ -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<HTMLInputElement>("[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<FlatTreeNode[]>(() => flattenTree(filteredNodes.value));
const flatNodes = computed<FlatTreeNode[]>(() =>
insertSidebarTableSearchControls(flattenTree(filteredNodes.value), {
enabled: !isFiltering.value,
sidebarObjectDisplay: settingsStore.editorSettings.sidebarObjectDisplay,
activeQueries: store.sidebarTableSearchQueries,
}),
);
const visibleNodes = computed<TreeNode[]>(() => flatNodes.value.map((item) => item.node));
const visibleNodeIndexById = computed(() => {
const selectableVisibleNodes = computed<TreeNode[]>(() => visibleNodes.value.filter((node) => !isSidebarTableSearchControlNode(node)));
const selectableVisibleNodeIndexById = computed(() => {
const next = new Map<string, number>();
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<string | null>(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);
@@ -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[] {
</script>
<template>
<CustomContextMenu :items="treeItemMenuItems()" v-slot="contextMenuSlot">
<div v-if="node.type === 'table-search-control'" class="flex h-7 items-center py-0.5 pr-2" :style="{ paddingLeft }" @click.stop @dblclick.stop @mousedown.stop @keydown.stop>
<div class="relative w-full min-w-0">
<Search class="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
:model-value="tableSearchValue"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-6 w-full rounded border-border/70 bg-background pl-7 pr-6 text-xs shadow-none focus-visible:ring-1"
:placeholder="t(node.label)"
:aria-label="t(node.label)"
:data-sidebar-table-search-parent-id="tableSearchParentId"
@update:model-value="updateTableSearchQuery"
/>
<button v-if="tableSearchValue" type="button" class="absolute right-1.5 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('sidebar.clearTableSearch')" @click.stop="clearTableSearchQuery">
<X class="h-3 w-3" />
</button>
</div>
</div>
<CustomContextMenu v-else :items="treeItemMenuItems()" v-slot="contextMenuSlot">
<div @contextmenu="onTreeItemContextMenu($event, contextMenuSlot.onContextMenu)">
<LightTooltip :text="displayLabel(node)" :disabled="isTooltipDisabled()" side="right" :side-offset="8" :delay="0" :close-delay="0">
<div