feat(sidebar): add regular expression search

This commit is contained in:
Lan-zk authored and GitHub committed 2026-08-14 02:25:24 +08:00
1 parent 5df8990802
commit 98f5c52f8e
26 files changed
+2203 -54

No files matched your search

@@ -8,8 +8,19 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import type { ObjectSourceKind, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
import { createSidebarSearchSubtreePreserver, filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards, resolveSidebarObjectSearchFilter, reuseLiveSidebarTreeNodes } from "@/lib/sidebar/sidebarSearchTree";
import { matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
import {
createSidebarSearchSubtreePreserver,
filterSidebarSearchRootsByConnectionState,
filterSidebarTree,
filterSidebarTreeToConnectedConnections,
mergeSidebarRegexIndexScopes,
resolveSidebarFilterGuards,
resolveSidebarObjectSearchFilter,
reuseLiveSidebarTreeNodes,
type SidebarRegexIndexScope,
} from "@/lib/sidebar/sidebarSearchTree";
import { createSidebarLabelMatcher, matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
import { collectSidebarRegexIndexScopes, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "@/lib/sidebar/sidebarRegexSearchIndex";
import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
@@ -54,6 +65,7 @@ import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sideba
import { formatSidebarObjectStorage, sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { sidebarScrollbarGeometry as calculateSidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar";
import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect";
import { compileSearchRegex } from "@/lib/common/searchPattern";
const { t } = useI18n();
const store = useConnectionStore();
@@ -63,6 +75,7 @@ const settingsStore = useSettingsStore();
const { toast } = useToast();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const regexMode = ref(false);
const showConnectedConnectionsOnly = ref(false);
const isDisconnectingAllActiveConnections = ref(false);
const searchInputRef = ref<HTMLInputElement>();
@@ -148,7 +161,7 @@ type TableSearchFocusRestore = {
watch(
searchQuery,
(value) => {
const normalized = value.trim().toLowerCase();
const normalized = regexMode.value ? value.trim() : value.trim().toLowerCase();
window.clearTimeout(searchTimer);
if (!normalized) {
@@ -163,6 +176,14 @@ watch(
{ flush: "sync" },
);
watch(regexMode, (enabled) => {
window.clearTimeout(searchTimer);
searchTimer = undefined;
// Re-evaluate immediately so a pending ordinary-search debounce cannot
// overwrite a case-sensitive regular expression after the mode changes.
deferredSearchQuery.value = enabled ? searchQuery.value.trim() : searchQuery.value.trim().toLowerCase();
});
watch(
[showConnectedConnectionsOnly, () => store.connectedIds.size],
([showConnectedOnly, activeConnectionCount]) => {
@@ -222,10 +243,20 @@ watch(
},
);
watch(deferredSearchQuery, (newQuery, oldQuery) => {
store.sidebarSearchQuery = newQuery;
if (settingsStore.editorSettings.sidebarGlobalSearchLocal) {
if (!newQuery && oldQuery) searchRefreshedNodeIds.clear();
watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, wasRegexMode]) => {
// The regex source is a client-side projection; the remote tree-loading
// search state must never carry it, or explicit node expansion would leak
// the expression as a remote searchFilter.
store.sidebarSearchQuery = resolveSidebarRemoteSearchQuery(isRegexMode, newQuery);
const dispatchMode = resolveSidebarSearchDispatchMode({ query: newQuery, regexMode: isRegexMode, wasRegexMode }, { localSearchEnabled: settingsStore.editorSettings.sidebarGlobalSearchLocal });
if (dispatchMode === "regex") {
// Regex search is a read-only projection over live nodes and the local
// table index. It must never trigger ensureConnected/listTables.
void loadRegexTableSearchIndexes();
return;
}
if (dispatchMode === "none") {
if (!wasRegexMode && !newQuery && oldQuery) searchRefreshedNodeIds.clear();
return;
}
const tasks: Promise<void>[] = [];
@@ -475,6 +506,7 @@ function restoreTableSearchInput(focusRestore: TableSearchFocusRestore) {
const displayedTreeNodes = computed(() => sortConnectionListForDisplay(store.treeNodes, settingsStore.editorSettings.sidebarConnectionSortMode));
const localTableSearchResults = ref<Record<string, TableInfo[] | null>>({});
const regexTableSearchScopes = shallowRef<SidebarRegexIndexScope[]>([]);
const localTableSearchParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema", "group-tables"]);
const localTableSearchChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view"]);
@@ -498,6 +530,28 @@ function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] {
});
}
async function loadRegexTableSearchIndexes() {
if (!regexMode.value || !deferredSearchQuery.value) return;
const loadedScopes = await collectSidebarRegexIndexScopes(
{
loadSidebarTableSearchIndexScopes: () => store.loadSidebarTableSearchIndexScopes(),
loadSidebarTableSearchIndex: (parent) => store.loadSidebarTableSearchIndex(parent.parentNodeId, parent),
},
store.treeNodes,
() => !regexMode.value || deferredSearchQuery.value === "",
);
if (regexMode.value) {
regexTableSearchScopes.value = loadedScopes;
}
}
function filterGloballyIndexedRegexTables(nodes: TreeNode[]): TreeNode[] {
if (!regexMode.value || !deferredSearchQuery.value) return nodes;
const matcher = createSidebarLabelMatcher(deferredSearchQuery.value, { regexMode: true });
const matchingScopes = regexTableSearchScopes.value.map((scope) => ({ ...scope, entries: scope.entries.filter((entry) => !!matcher(entry.name) || !!matcher(entry.comment || "")) })).filter((scope) => scope.entries.length > 0);
return mergeSidebarRegexIndexScopes(nodes, matchingScopes);
}
async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) {
try {
const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId);
@@ -514,10 +568,11 @@ const filteredNodes = computed(() => {
}
nodes = filterLocallySearchedTables(nodes);
nodes = filterGloballyIndexedRegexTables(nodes);
const q = deferredSearchQuery.value;
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value);
if (q) {
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value, { regexMode: regexMode.value });
if (q && !regexMode.value) {
nodes = filterSidebarSearchRootsByConnectionState(nodes, store.connectedIds);
}
@@ -952,8 +1007,17 @@ const pasteHandlerRegistry = createSidebarPasteHandlerRegistry();
provide(sidebarTreeContextKey, {
getVisibleNodes: () => selectableVisibleNodes.value,
getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1,
// Cover both sides of the input debounce: the immediate query prevents a
// collapse while a projection is about to start, and the deferred query
// keeps the currently rendered projection alive while clearing settles.
isSearchProjectionActive: () => isTreeSearchFiltering.value || !!deferredSearchQuery.value,
getTreeLoadSearchOptions: (node) => {
const query = deferredSearchQuery.value;
if (regexMode.value) {
// Explicit expansion stays allowed and may connect, but must never send
// the regex expression as a remote search filter.
return { searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: "" };
}
if (!query) return undefined;
const searchFilter = resolveSidebarObjectSearchFilter(store.treeNodes, node.id, query, searchableNodeTypes.value);
return searchFilter ? undefined : { searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: query };
@@ -1910,7 +1974,9 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="w-full h-6 pl-7 pr-6 text-xs rounded border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring"
class="w-full h-6 pl-7 pr-6 text-xs rounded border bg-background focus:outline-none focus:ring-1 focus:ring-ring"
:class="regexMode && compileSearchRegex(searchQuery).invalid ? 'border-destructive focus:ring-destructive' : 'border-border'"
:aria-invalid="regexMode && compileSearchRegex(searchQuery).invalid ? 'true' : 'false'"
:placeholder="t('grid.search')"
@keydown="onSearchKeydown"
/>
@@ -1919,7 +1985,19 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
</button>
</div>
<LightTooltip :text="t('sidebar.globalLocalSearchTooltip')" side="top" :delay="300">
<Switch size="sm" :model-value="settingsStore.editorSettings.sidebarGlobalSearchLocal" :aria-label="t('sidebar.globalLocalSearch')" @update:model-value="settingsStore.updateEditorSettings({ sidebarGlobalSearchLocal: Boolean($event) })" />
<Switch size="sm" :model-value="settingsStore.editorSettings.sidebarGlobalSearchLocal" :disabled="regexMode" :aria-label="t('sidebar.globalLocalSearch')" @update:model-value="settingsStore.updateEditorSettings({ sidebarGlobalSearchLocal: Boolean($event) })" />
</LightTooltip>
<LightTooltip :text="t('sidebar.regexSearchTooltip')" side="top" :delay="300">
<button
type="button"
class="shrink-0 h-6 min-w-6 px-1 flex items-center justify-center rounded border border-border text-[10px] font-mono hover:bg-accent"
:class="{ 'text-primary bg-primary/10 border-primary/30': regexMode, 'text-destructive border-destructive/60': regexMode && compileSearchRegex(searchQuery).invalid }"
:aria-label="t('sidebar.regexSearch')"
:aria-pressed="regexMode"
@click="regexMode = !regexMode"
>
.*
</button>
</LightTooltip>
<LightTooltip :text="t('sidebar.locateActiveTab')" side="top" :delay="300" nowrap>
<button type="button" class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground" :aria-label="t('sidebar.locateActiveTab')" @click="locateActiveTabInSidebar">
@@ -503,6 +503,10 @@ function routeTreeItemDialogController() {
const sidebarTreeContext = inject(sidebarTreeContextKey, null);
function shouldReleaseCollapsedTreeNodeChildren(): boolean {
return !connectionStore.sidebarSearchQuery && !sidebarTreeContext?.isSearchProjectionActive?.();
}
function currentDatabaseType(): DatabaseType | undefined {
return activeNode.value.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(activeNode.value.connectionId)) : undefined;
}
@@ -584,7 +588,7 @@ async function toggle() {
if (node.isLoading) {
if (node.isExpanded) {
node.isExpanded = false;
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
if (shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
connectionStore.cancelTreeNodeLoad(node.id);
emitNodeToggled(node, true, false);
} else {
@@ -621,7 +625,7 @@ async function toggle() {
const databaseObjectGroup = !!objectTypesForGroupNode(node.type);
if (databaseObjectGroup && connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
return;
}
@@ -640,20 +644,20 @@ async function toggle() {
if (node.type === "group-extensions" && connectionStore.canUseLoadedTreeNodeToggle(node)) {
node.isExpanded = !node.isExpanded;
if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
if (wasExpanded && shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded);
return;
}
if (isXuguTypeMemberContainer(node, connectionStore.getConfig(node.connectionId || "")?.db_type)) {
await connectionStore.loadXuguTypeMembers(node);
await connectionStore.loadXuguTypeMembers(node, { preserveCollapsedChildren: !shouldReleaseCollapsedTreeNodeChildren() });
emitNodeToggled(node, wasExpanded);
return;
}
if (node.isExpanded) {
node.isExpanded = false;
if (!connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
if (shouldReleaseCollapsedTreeNodeChildren()) connectionStore.releaseCollapsedTreeNodeChildren(node.id);
emitNodeToggled(node, wasExpanded, false);
return;
}
@@ -122,6 +122,94 @@ describe("SidebarTreeRuntimeHost expansion", () => {
expect(toggled).toHaveBeenCalledWith(packageNode, true);
});
it("expands an unloaded object group without leaking the regex source as a search filter", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "mysql",
database: "basic",
isExpanded: false,
children: [],
};
// Regex mode keeps the store's remote-search state empty (ConnectionTree
// writes resolveSidebarRemoteSearchQuery(...) into sidebarSearchQuery).
connectionStore.sidebarSearchQuery = "";
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(false);
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup() {
provide(sidebarTreeContextKey, {
getVisibleNodes: () => [group],
getVisibleNodeIndex: () => 0,
// ConnectionTree returns this explicit empty filter while regex
// mode is on: expansion may connect, but never with the regex.
getTreeLoadSearchOptions: () => ({ searchFilter: "", allowGlobalSearchMismatch: true, expectedSidebarSearchQuery: "" }),
});
return () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 0 });
},
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
host.value?.toggleNode(group);
await vi.waitFor(() =>
expect(connectionStore.loadObjectGroupChildren).toHaveBeenCalledWith(group, {
searchFilter: "",
allowGlobalSearchMismatch: true,
expectedSidebarSearchQuery: "",
}),
);
// The regex source never reaches the store options or the connection layer.
expect(JSON.stringify(connectionStore.loadObjectGroupChildren.mock.calls)).not.toContain("A|b");
});
it("preserves loaded group children while a local regex projection is active", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "mysql",
database: "basic",
isExpanded: true,
children: [{ id: "mysql:basic:__tables:orders", label: "orders", type: "table", connectionId: "mysql", database: "basic" }],
};
connectionStore.sidebarSearchQuery = "";
connectionStore.canUseLoadedTreeNodeToggle.mockReturnValue(true);
const host = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const app = createApp(
defineComponent({
setup() {
provide(sidebarTreeContextKey, {
getVisibleNodes: () => [group],
getVisibleNodeIndex: () => 0,
isSearchProjectionActive: () => true,
});
return () => h(SidebarTreeRuntimeHost, { ref: host, node: group, depth: 0 });
},
}),
);
mountedApps.push(app);
const container = document.createElement("div");
document.body.append(container);
app.use(i18n);
app.mount(container);
host.value?.toggleNode(group);
await nextTick();
expect(group.isExpanded).toBe(false);
expect(connectionStore.releaseCollapsedTreeNodeChildren).not.toHaveBeenCalled();
});
it("expands an unloaded object group without leaking a matching connection name", async () => {
const group: TreeNode = {
id: "mysql:basic:__tables",