feat(sidebar): add local table search
This commit is contained in:
parent
8a1a791927
commit
fdaa949fb1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ export default {
|
|||
searchScopeSchema: "Schema",
|
||||
searchScopeTable: "Table",
|
||||
searchScopeView: "View",
|
||||
searchTablesInCurrentScope: "Search tables in this database...",
|
||||
clearTableSearch: "Clear table search",
|
||||
clearFilter: "Clear filter",
|
||||
locateActiveTab: "Locate in sidebar",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ export default withEnglishFallback({
|
|||
searchScopeSchema: "Schema",
|
||||
searchScopeTable: "Tabla",
|
||||
searchScopeView: "Vista",
|
||||
searchTablesInCurrentScope: "Buscar tablas en esta base de datos...",
|
||||
clearTableSearch: "Limpiar búsqueda de tablas",
|
||||
clearFilter: "Limpiar filtro",
|
||||
locateActiveTab: "Localizar en la barra lateral",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ export default withEnglishFallback({
|
|||
searchScopeSchema: "Schema",
|
||||
searchScopeTable: "Tabella",
|
||||
searchScopeView: "Vista",
|
||||
searchTablesInCurrentScope: "Cerca tabelle in questo database...",
|
||||
clearTableSearch: "Cancella ricerca tabelle",
|
||||
clearFilter: "Cancella filtro",
|
||||
locateActiveTab: "Trova nella barra laterale",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ export default withEnglishFallback({
|
|||
searchScopeSchema: "スキーマ",
|
||||
searchScopeTable: "テーブル",
|
||||
searchScopeView: "ビュー",
|
||||
searchTablesInCurrentScope: "このデータベースのテーブルを検索...",
|
||||
clearTableSearch: "テーブル検索をクリア",
|
||||
clearFilter: "フィルターをクリア",
|
||||
locateActiveTab: "サイドバーで表示",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ export default withEnglishFallback({
|
|||
searchScopeSchema: "Schema",
|
||||
searchScopeTable: "Tabela",
|
||||
searchScopeView: "Visão",
|
||||
searchTablesInCurrentScope: "Pesquisar tabelas neste banco...",
|
||||
clearTableSearch: "Limpar busca de tabelas",
|
||||
clearFilter: "Limpar filtro",
|
||||
locateActiveTab: "Localizar na barra lateral",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ export default withEnglishFallback({
|
|||
searchScopeSchema: "Schema",
|
||||
searchScopeTable: "表",
|
||||
searchScopeView: "视图",
|
||||
searchTablesInCurrentScope: "搜索当前库的表...",
|
||||
clearTableSearch: "清除表搜索",
|
||||
clearFilter: "清除筛选",
|
||||
locateActiveTab: "在侧边栏中定位",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ export default withEnglishFallback({
|
|||
searchScopeSchema: "Schema",
|
||||
searchScopeTable: "資料表",
|
||||
searchScopeView: "檢視",
|
||||
searchTablesInCurrentScope: "搜尋目前資料庫的資料表...",
|
||||
clearTableSearch: "清除資料表搜尋",
|
||||
clearFilter: "清除篩選",
|
||||
locateActiveTab: "在側邊欄中定位",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import type { FlatTreeNode } from "@/composables/useFlatTree";
|
||||
import type { TreeNode, TreeNodeType } from "@/types/database";
|
||||
|
||||
const simpleObjectParentTypes = new Set<TreeNodeType>(["database", "schema", "linked-server-schema"]);
|
||||
const simpleObjectChildTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "procedure", "function", "sequence", "package", "package-body", "load-more"]);
|
||||
|
||||
export function isSidebarTableSearchControlNode(node: TreeNode): boolean {
|
||||
return node.type === "table-search-control";
|
||||
}
|
||||
|
||||
function tableSearchControlId(parentId: string): string {
|
||||
return `${parentId}:__table_search`;
|
||||
}
|
||||
|
||||
function parentHasDirectTableList(node: TreeNode): boolean {
|
||||
return !!node.children?.some((child) => simpleObjectChildTypes.has(child.type));
|
||||
}
|
||||
|
||||
function shouldInsertTableSearchControl(item: FlatTreeNode, sidebarObjectDisplay: "simple" | "grouped", activeQueries: Readonly<Record<string, string | undefined>>): boolean {
|
||||
const node = item.node;
|
||||
if (!node.isExpanded) return false;
|
||||
if (sidebarObjectDisplay === "grouped") {
|
||||
return node.type === "group-tables";
|
||||
}
|
||||
if (!simpleObjectParentTypes.has(node.type)) return false;
|
||||
return parentHasDirectTableList(node) || !!activeQueries[node.id]?.trim();
|
||||
}
|
||||
|
||||
function buildTableSearchControlNode(parent: TreeNode): TreeNode {
|
||||
return {
|
||||
id: tableSearchControlId(parent.id),
|
||||
label: "sidebar.searchTablesInCurrentScope",
|
||||
type: "table-search-control",
|
||||
connectionId: parent.connectionId,
|
||||
database: parent.database,
|
||||
schema: parent.schema,
|
||||
tableSearchParentId: parent.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertSidebarTableSearchControls(
|
||||
flatNodes: readonly FlatTreeNode[],
|
||||
options: {
|
||||
enabled: boolean;
|
||||
sidebarObjectDisplay: "simple" | "grouped";
|
||||
activeQueries: Readonly<Record<string, string | undefined>>;
|
||||
},
|
||||
): FlatTreeNode[] {
|
||||
if (!options.enabled) return [...flatNodes];
|
||||
|
||||
const result: FlatTreeNode[] = [];
|
||||
for (const item of flatNodes) {
|
||||
result.push(item);
|
||||
if (!shouldInsertTableSearchControl(item, options.sidebarObjectDisplay, options.activeQueries)) continue;
|
||||
|
||||
const node = buildTableSearchControlNode(item.node);
|
||||
result.push({
|
||||
node,
|
||||
depth: item.depth + 1,
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
poolType: `${node.type}:${node.id}`,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { TreeNode } from "@/types/database";
|
|||
export interface SidebarTreeContext {
|
||||
getVisibleNodes: () => TreeNode[];
|
||||
getVisibleNodeIndex: (id: string) => number;
|
||||
setTableSearchQuery?: (parentNodeId: string, query: string) => void;
|
||||
}
|
||||
|
||||
export const sidebarTreeContextKey: InjectionKey<SidebarTreeContext> = Symbol("sidebar-tree-context");
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const leafTypes: Set<TreeNodeType> = new Set([
|
|||
"elasticsearch-index",
|
||||
"user-admin",
|
||||
"saved-sql-file",
|
||||
"table-search-control",
|
||||
"load-more",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ export type TreeClipboard =
|
|||
interface LoadTreeOptions {
|
||||
force?: boolean;
|
||||
expectedSidebarSearchQuery?: string;
|
||||
searchFilter?: string;
|
||||
sidebarTableSearchParentId?: string;
|
||||
expectedSidebarTableSearchQuery?: string;
|
||||
}
|
||||
|
||||
interface PersistedTreeChildrenLoadResult {
|
||||
|
|
@ -218,6 +221,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
|
||||
const schemaListCache = ref<Record<string, string[]>>({});
|
||||
const sidebarSearchQuery = ref("");
|
||||
const sidebarTableSearchQueries = ref<Record<string, string>>({});
|
||||
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
|
||||
const completionObjectIndex = new Map<string, { touched: number; objects: SqlCompletionObject[] }>();
|
||||
const completionColumnIndex = new Map<string, { touched: number; columns: SqlCompletionColumn[] }>();
|
||||
|
|
@ -974,7 +978,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
if (!options.node.connectionId || !options.node.database) {
|
||||
return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset };
|
||||
}
|
||||
const searchFilter = options.searchFilter || sidebarSearchQuery.value || undefined;
|
||||
const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined;
|
||||
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
|
||||
const tables = await api.listTables(options.node.connectionId, options.node.database, options.querySchema, searchFilter, fetchLimit, searchFilter ? undefined : options.offset, options.objectTypes);
|
||||
const hasMore = searchFilter ? false : tables.length > options.pageSize;
|
||||
|
|
@ -1007,7 +1011,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
pageSize: number;
|
||||
searchFilter?: string;
|
||||
}): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number }> {
|
||||
const searchFilter = options.searchFilter || sidebarSearchQuery.value || undefined;
|
||||
const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined;
|
||||
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
|
||||
const tables = await api.listTables(options.connectionId, options.database, options.querySchema, searchFilter, fetchLimit, searchFilter ? undefined : options.offset);
|
||||
const hasMore = searchFilter ? false : tables.length > options.pageSize;
|
||||
|
|
@ -1110,6 +1114,20 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return options?.expectedSidebarSearchQuery !== undefined && (sidebarSearchQuery.value || "") !== options.expectedSidebarSearchQuery;
|
||||
}
|
||||
|
||||
function isSidebarTableSearchQueryChanged(options?: LoadTreeOptions) {
|
||||
if (!options?.sidebarTableSearchParentId || options.expectedSidebarTableSearchQuery === undefined) return false;
|
||||
return (sidebarTableSearchQueries.value[options.sidebarTableSearchParentId]?.trim() || "") !== options.expectedSidebarTableSearchQuery;
|
||||
}
|
||||
|
||||
function activeTreeLoadSearchFilter(options?: LoadTreeOptions): string {
|
||||
return (options?.searchFilter ?? sidebarSearchQuery.value) || "";
|
||||
}
|
||||
|
||||
function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean {
|
||||
if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options);
|
||||
return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options);
|
||||
}
|
||||
|
||||
function isTreeNodeChildrenLoaded(nodeId: string): boolean {
|
||||
return loadedTreeNodeChildrenIds.value.has(nodeId);
|
||||
}
|
||||
|
|
@ -2350,7 +2368,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
if (useCachedChildren(node, options)) return;
|
||||
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||||
const cacheKey = schemaCacheKey(connectionId, database, schema || "", simpleObjectDisplay ? "objects-simple-v3" : "objects-grouped-v3");
|
||||
const searchFilter = sidebarSearchQuery.value || "";
|
||||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||||
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
|
||||
if (!options?.force && !searchFilter) {
|
||||
const cached = await loadPersistedTreeChildren(node, cacheKey);
|
||||
if (cached.hit) {
|
||||
|
|
@ -2388,9 +2407,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
objectTypes: supportedSidebarObjectTypes(config),
|
||||
});
|
||||
}
|
||||
if ((sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options)) return;
|
||||
if (isTreeLoadSearchChanged(searchFilter, options)) return;
|
||||
setChildren(node, children);
|
||||
if (!searchFilter) {
|
||||
if (!searchFilter && !isSidebarTableSearch) {
|
||||
await savePersistedTreeChildren(cacheKey, children);
|
||||
}
|
||||
node.isExpanded = true;
|
||||
|
|
@ -2416,7 +2435,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const querySchema = connectionObjectTreeQuerySchema(config, node.database, node.schema);
|
||||
const effectiveSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema);
|
||||
const cacheKey = objectGroupCacheKey(node);
|
||||
const searchFilter = sidebarSearchQuery.value || "";
|
||||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||||
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
|
||||
if (!options?.force && !searchFilter) {
|
||||
const cached = await loadPersistedTreeChildren(node, cacheKey);
|
||||
if (cached.hit) {
|
||||
|
|
@ -2451,9 +2471,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
});
|
||||
node.objectCount = children.length;
|
||||
}
|
||||
if ((sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options)) return;
|
||||
if (isTreeLoadSearchChanged(searchFilter, options)) return;
|
||||
setChildren(node, children);
|
||||
if (!searchFilter) {
|
||||
if (!searchFilter && !isSidebarTableSearch) {
|
||||
await savePersistedTreeChildren(cacheKey, children);
|
||||
}
|
||||
node.isExpanded = true;
|
||||
|
|
@ -2623,6 +2643,39 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
}
|
||||
}
|
||||
|
||||
function setSidebarTableSearchQuery(parentNodeId: string, query: string) {
|
||||
const normalized = query.trim();
|
||||
const next = { ...sidebarTableSearchQueries.value };
|
||||
if (normalized) {
|
||||
next[parentNodeId] = query;
|
||||
} else {
|
||||
delete next[parentNodeId];
|
||||
}
|
||||
sidebarTableSearchQueries.value = next;
|
||||
}
|
||||
|
||||
async function refreshSidebarTableSearch(parentNodeId: string) {
|
||||
const parent = findNode(treeNodes.value, parentNodeId);
|
||||
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
|
||||
|
||||
const searchFilter = sidebarTableSearchQueries.value[parentNodeId]?.trim() || "";
|
||||
const options: LoadTreeOptions = {
|
||||
force: true,
|
||||
searchFilter: searchFilter || undefined,
|
||||
sidebarTableSearchParentId: parentNodeId,
|
||||
expectedSidebarTableSearchQuery: searchFilter,
|
||||
};
|
||||
|
||||
if (parent.type === "group-tables") {
|
||||
await loadObjectGroupChildren(parent, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") {
|
||||
await loadTables(parent.connectionId, parent.database, parent.schema, options);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedObjectTreeKind(type: string): DatabaseObjectTreeKind {
|
||||
return normalizeSidebarObjectKind(type);
|
||||
}
|
||||
|
|
@ -4313,6 +4366,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
databaseSearchSource,
|
||||
databaseExportSource,
|
||||
sidebarSearchQuery,
|
||||
sidebarTableSearchQueries,
|
||||
setSidebarTableSearchQuery,
|
||||
refreshSidebarTableSearch,
|
||||
createConnectionGroup(name: string, parentGroupId?: string | null) {
|
||||
const result = createGroupOp(sidebarLayout.value, name, parentGroupId);
|
||||
updateLayoutAndRebuild(result.layout);
|
||||
|
|
|
|||
|
|
@ -512,6 +512,7 @@ export type TreeNodeType =
|
|||
| "saved-sql-root"
|
||||
| "saved-sql-folder"
|
||||
| "saved-sql-file"
|
||||
| "table-search-control"
|
||||
| "load-more"
|
||||
| "column"
|
||||
| "index"
|
||||
|
|
@ -570,6 +571,7 @@ export interface TreeNode {
|
|||
partitionParentSchema?: string;
|
||||
partitionParentName?: string;
|
||||
hiddenChildren?: TreeNode[];
|
||||
tableSearchParentId?: string;
|
||||
savedSqlId?: string;
|
||||
savedSqlFolderId?: string;
|
||||
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | VectorCollectionMeta;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { insertSidebarTableSearchControls } from "../../apps/desktop/src/lib/sidebar/sidebarTableSearchControl.ts";
|
||||
import type { FlatTreeNode } from "../../apps/desktop/src/composables/useFlatTree.ts";
|
||||
import type { TreeNode } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
function flat(node: TreeNode, depth = 0): FlatTreeNode {
|
||||
return {
|
||||
node,
|
||||
depth,
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
poolType: node.type,
|
||||
};
|
||||
}
|
||||
|
||||
test("inserts a local table search control above simple table children", () => {
|
||||
const database: TreeNode = {
|
||||
id: "conn:app",
|
||||
label: "app",
|
||||
type: "database",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
isExpanded: true,
|
||||
children: [{ id: "conn:app:orders", label: "orders", type: "table", connectionId: "conn", database: "app" }],
|
||||
};
|
||||
const table = database.children![0];
|
||||
|
||||
const nodes = insertSidebarTableSearchControls([flat(database), flat(table, 1)], {
|
||||
enabled: true,
|
||||
sidebarObjectDisplay: "simple",
|
||||
activeQueries: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((item) => item.node.type),
|
||||
["database", "table-search-control", "table"],
|
||||
);
|
||||
assert.equal(nodes[1].node.tableSearchParentId, "conn:app");
|
||||
assert.equal(nodes[1].depth, 1);
|
||||
});
|
||||
|
||||
test("keeps a simple local table search control visible when the current search has no results", () => {
|
||||
const database: TreeNode = {
|
||||
id: "conn:app",
|
||||
label: "app",
|
||||
type: "database",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
|
||||
const nodes = insertSidebarTableSearchControls([flat(database)], {
|
||||
enabled: true,
|
||||
sidebarObjectDisplay: "simple",
|
||||
activeQueries: { "conn:app": "invoice" },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((item) => item.node.type),
|
||||
["database", "table-search-control"],
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts a grouped local table search control only for expanded table groups", () => {
|
||||
const tableGroup: TreeNode = {
|
||||
id: "conn:app:__tables",
|
||||
label: "tree.tables",
|
||||
type: "group-tables",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
const viewGroup: TreeNode = {
|
||||
id: "conn:app:__views",
|
||||
label: "tree.views",
|
||||
type: "group-views",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
|
||||
const nodes = insertSidebarTableSearchControls([flat(tableGroup, 1), flat(viewGroup, 1)], {
|
||||
enabled: true,
|
||||
sidebarObjectDisplay: "grouped",
|
||||
activeQueries: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
nodes.map((item) => item.node.type),
|
||||
["group-tables", "table-search-control", "group-views"],
|
||||
);
|
||||
assert.equal(nodes[1].node.tableSearchParentId, "conn:app:__tables");
|
||||
});
|
||||
|
||||
test("uses isolated virtual scroller pools for each local table search input", () => {
|
||||
const first: TreeNode = {
|
||||
id: "conn:first:__tables",
|
||||
label: "tree.tables",
|
||||
type: "group-tables",
|
||||
connectionId: "conn",
|
||||
database: "first",
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
const second: TreeNode = {
|
||||
id: "conn:second:__tables",
|
||||
label: "tree.tables",
|
||||
type: "group-tables",
|
||||
connectionId: "conn",
|
||||
database: "second",
|
||||
isExpanded: true,
|
||||
children: [],
|
||||
};
|
||||
|
||||
const nodes = insertSidebarTableSearchControls([flat(first, 1), flat(second, 1)], {
|
||||
enabled: true,
|
||||
sidebarObjectDisplay: "grouped",
|
||||
activeQueries: {},
|
||||
});
|
||||
|
||||
const pools = nodes.filter((item) => item.node.type === "table-search-control").map((item) => item.poolType);
|
||||
assert.equal(pools.length, 2);
|
||||
assert.equal(new Set(pools).size, 2);
|
||||
});
|
||||
|
||||
test("hides local table search controls while global sidebar filtering is active", () => {
|
||||
const database: TreeNode = {
|
||||
id: "conn:app",
|
||||
label: "app",
|
||||
type: "database",
|
||||
connectionId: "conn",
|
||||
database: "app",
|
||||
isExpanded: true,
|
||||
children: [{ id: "conn:app:orders", label: "orders", type: "table", connectionId: "conn", database: "app" }],
|
||||
};
|
||||
|
||||
const nodes = insertSidebarTableSearchControls([flat(database)], {
|
||||
enabled: false,
|
||||
sidebarObjectDisplay: "simple",
|
||||
activeQueries: { "conn:app": "orders" },
|
||||
});
|
||||
|
||||
assert.deepEqual(nodes.map((item) => item.node.type), ["database"]);
|
||||
});
|
||||
Loading…
Reference in New Issue