fix(sidebar): separate root connection filtering
This commit is contained in:
parent
ad2d52e3ca
commit
b9c1feab82
|
|
@ -1,13 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, shallowRef, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
|
||||
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import type { ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree } from "@/lib/sidebar/sidebarSearchTree";
|
||||
import { filterSidebarSearchRootsByConnectionState, filterSidebarTree, filterSidebarTreeToConnectedConnections, resolveSidebarFilterGuards } from "@/lib/sidebar/sidebarSearchTree";
|
||||
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { copyNameForTreeNode, objectSourceKindForTreeNode } from "@/lib/sidebar/treeNodeClick";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
|
|
@ -49,6 +49,7 @@ const settingsStore = useSettingsStore();
|
|||
const { toast } = useToast();
|
||||
const searchQuery = ref("");
|
||||
const deferredSearchQuery = ref("");
|
||||
const showConnectedConnectionsOnly = ref(false);
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
const rootRef = ref<HTMLElement>();
|
||||
const pointerInsideTree = ref(false);
|
||||
|
|
@ -118,7 +119,7 @@ watch(
|
|||
);
|
||||
|
||||
function refreshActiveSidebarTableSearches() {
|
||||
if (isFiltering.value) return;
|
||||
if (isTreeSearchFiltering.value) return;
|
||||
for (const parentNodeId of Object.keys(store.sidebarTableSearchQueries)) {
|
||||
scheduleSidebarTableSearchRefresh(parentNodeId);
|
||||
}
|
||||
|
|
@ -200,7 +201,11 @@ function collectExpandedObjectSearchTargets(node: TreeNode, tasks: Promise<void>
|
|||
}
|
||||
|
||||
const isSearching = computed(() => !!deferredSearchQuery.value);
|
||||
const isFiltering = computed(() => !!searchQuery.value.trim() || hasSearchScopeFilter.value);
|
||||
const sidebarFilterGuards = computed(() => resolveSidebarFilterGuards(showConnectedConnectionsOnly.value, searchQuery.value, hasSearchScopeFilter.value));
|
||||
// Connected-only filtering changes only root visibility, so descendant-local
|
||||
// features stay available while operations requiring the full root list pause.
|
||||
const isTreeSearchFiltering = computed(() => sidebarFilterGuards.value.isTreeSearchFiltering);
|
||||
const isRootListPartial = computed(() => sidebarFilterGuards.value.isRootListPartial);
|
||||
|
||||
const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
||||
connection: ["connection"],
|
||||
|
|
@ -300,7 +305,7 @@ function clearSearchScopeFilter() {
|
|||
|
||||
function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { restoreFocus?: boolean }) {
|
||||
window.clearTimeout(tableSearchTimers.get(parentNodeId));
|
||||
if (isFiltering.value) return;
|
||||
if (isTreeSearchFiltering.value) return;
|
||||
const restoreToken = options?.restoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0;
|
||||
if (restoreToken) {
|
||||
tableSearchFocusRestoreTokens.clear();
|
||||
|
|
@ -343,6 +348,9 @@ const displayedTreeNodes = computed(() => sortConnectionListForDisplay(store.tre
|
|||
|
||||
const filteredNodes = computed(() => {
|
||||
let nodes = displayedTreeNodes.value;
|
||||
if (showConnectedConnectionsOnly.value) {
|
||||
nodes = filterSidebarTreeToConnectedConnections(nodes, store.connectedIds);
|
||||
}
|
||||
|
||||
const q = deferredSearchQuery.value;
|
||||
nodes = filterSidebarTree(nodes, q, searchCollapsedIds.value, searchableNodeTypes.value);
|
||||
|
|
@ -355,7 +363,7 @@ const filteredNodes = computed(() => {
|
|||
|
||||
const flatNodes = computed<FlatTreeNode[]>(() =>
|
||||
insertSidebarTableSearchControls(flattenTree(filteredNodes.value), {
|
||||
enabled: settingsStore.editorSettings.sidebarTableSearchEnabled && !isFiltering.value,
|
||||
enabled: settingsStore.editorSettings.sidebarTableSearchEnabled && !isTreeSearchFiltering.value,
|
||||
sidebarObjectDisplay: settingsStore.editorSettings.sidebarObjectDisplay,
|
||||
activeQueries: store.sidebarTableSearchQueries,
|
||||
}),
|
||||
|
|
@ -512,7 +520,7 @@ watch(
|
|||
);
|
||||
|
||||
const stickyNode = computed<FlatTreeNode | null>(() => {
|
||||
if (!useVirtualTree.value || isFiltering.value) return null;
|
||||
if (!useVirtualTree.value || isTreeSearchFiltering.value) return null;
|
||||
const nodes = flatNodes.value;
|
||||
const len = nodes.length;
|
||||
if (len === 0) return null;
|
||||
|
|
@ -717,9 +725,10 @@ async function createNewGroup() {
|
|||
async function startRenamingCreatedGroup(groupId: string) {
|
||||
pendingRenameGroupId.value = groupId;
|
||||
store.selectedTreeNodeId = groupId;
|
||||
if (isFiltering.value) {
|
||||
if (isRootListPartial.value) {
|
||||
searchQuery.value = "";
|
||||
deferredSearchQuery.value = "";
|
||||
showConnectedConnectionsOnly.value = false;
|
||||
clearSearchScopeFilter();
|
||||
}
|
||||
|
||||
|
|
@ -754,9 +763,10 @@ async function locateActiveTabInSidebar() {
|
|||
await ensureTreeLoadedForTarget(initialTarget);
|
||||
|
||||
// Clear any active search filter so the node is visible
|
||||
if (isFiltering.value) {
|
||||
if (isRootListPartial.value) {
|
||||
searchQuery.value = "";
|
||||
deferredSearchQuery.value = "";
|
||||
showConnectedConnectionsOnly.value = false;
|
||||
clearSearchScopeFilter();
|
||||
}
|
||||
|
||||
|
|
@ -1450,6 +1460,17 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
|
|||
align="end"
|
||||
@update:model-value="selectSearchScopeMenuItem"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border hover:bg-accent"
|
||||
:class="showConnectedConnectionsOnly ? 'text-primary bg-primary/10 border-primary/30' : 'border-border text-muted-foreground hover:text-foreground'"
|
||||
:aria-label="t('sidebar.showActiveConnectionsOnly')"
|
||||
:aria-pressed="showConnectedConnectionsOnly"
|
||||
:title="t('sidebar.showActiveConnectionsOnly')"
|
||||
@click="showConnectedConnectionsOnly = !showConnectedConnectionsOnly"
|
||||
>
|
||||
<CircleDot class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CustomContextMenu ref="sidebarContextMenuRef" :items="sidebarContextMenuItems" v-slot="contextMenuSlot">
|
||||
|
|
@ -1472,7 +1493,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
|
|||
<TreeItem
|
||||
:node="item.node"
|
||||
:depth="item.depth"
|
||||
:drag-disabled="isFiltering || isConnectionListAlphabeticallySorted"
|
||||
:drag-disabled="isRootListPartial || isConnectionListAlphabeticallySorted"
|
||||
:pending-rename="pendingRenameGroupId === item.node.id"
|
||||
:highlighted="highlightedNodeId === item.node.id"
|
||||
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
|
||||
|
|
@ -1496,7 +1517,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
|
|||
:key="item.id"
|
||||
:node="item.node"
|
||||
:depth="item.depth"
|
||||
:drag-disabled="isFiltering || isConnectionListAlphabeticallySorted"
|
||||
:drag-disabled="isRootListPartial || isConnectionListAlphabeticallySorted"
|
||||
:pending-rename="pendingRenameGroupId === item.node.id"
|
||||
:highlighted="highlightedNodeId === item.id"
|
||||
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export default {
|
|||
expand: "Expand sidebar",
|
||||
showMore: "Show {count} more...",
|
||||
filterByType: "Filter by type",
|
||||
showActiveConnectionsOnly: "Show active connections only",
|
||||
sortConnections: "Sort connections",
|
||||
sortConnectionsManual: "Manual order",
|
||||
sortConnectionsAscending: "Name: A–Z",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export default withEnglishFallback({
|
|||
expand: "Expandir barra lateral",
|
||||
showMore: "Mostrar {count} más...",
|
||||
filterByType: "Filtrar por tipo",
|
||||
showActiveConnectionsOnly: "Mostrar solo conexiones activas",
|
||||
sortConnections: "Ordenar conexiones",
|
||||
sortConnectionsManual: "Orden manual",
|
||||
sortConnectionsAscending: "Nombre: A–Z",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export default withEnglishFallback({
|
|||
expand: "Espandi barra laterale",
|
||||
showMore: "Mostra altri {count}...",
|
||||
filterByType: "Filtra per tipo",
|
||||
showActiveConnectionsOnly: "Mostra solo connessioni attive",
|
||||
sortConnections: "Ordina connessioni",
|
||||
sortConnectionsManual: "Ordine manuale",
|
||||
sortConnectionsAscending: "Nome: A–Z",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export default withEnglishFallback({
|
|||
expand: "サイドバーを展開",
|
||||
showMore: "さらに{count}件表示...",
|
||||
filterByType: "タイプでフィルター",
|
||||
showActiveConnectionsOnly: "アクティブな接続のみを表示",
|
||||
sortConnections: "接続を並べ替え",
|
||||
sortConnectionsManual: "手動順",
|
||||
sortConnectionsAscending: "名前: A–Z",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export default withEnglishFallback({
|
|||
expand: "Expandir barra lateral",
|
||||
showMore: "Mostrar mais {count}...",
|
||||
filterByType: "Filtrar por tipo",
|
||||
showActiveConnectionsOnly: "Mostrar apenas conexões ativas",
|
||||
sortConnections: "Ordenar conexões",
|
||||
sortConnectionsManual: "Ordem manual",
|
||||
sortConnectionsAscending: "Nome: A–Z",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export default withEnglishFallback({
|
|||
expand: "展开侧边栏",
|
||||
showMore: "加载更多 ({count})...",
|
||||
filterByType: "按类型筛选",
|
||||
showActiveConnectionsOnly: "仅显示活跃连接",
|
||||
sortConnections: "连接排序",
|
||||
sortConnectionsManual: "手动排序",
|
||||
sortConnectionsAscending: "名称:A–Z",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ export default withEnglishFallback({
|
|||
expand: "展開側邊欄",
|
||||
showMore: "再顯示 {count} 個……",
|
||||
filterByType: "依類型篩選",
|
||||
showActiveConnectionsOnly: "僅顯示作用中連線",
|
||||
sortConnections: "連線排序",
|
||||
sortConnectionsManual: "手動排序",
|
||||
sortConnectionsAscending: "名稱:A–Z",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { filterSidebarTreeToConnectedConnections } from "@/lib/sidebar/sidebarSearchTree";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
|
||||
function connection(id: string): TreeNode {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
type: "connection",
|
||||
connectionId: id,
|
||||
isExpanded: true,
|
||||
children: [{ id: `${id}:database`, label: "database", type: "database", connectionId: id }],
|
||||
};
|
||||
}
|
||||
|
||||
function group(id: string, children: TreeNode[]): TreeNode {
|
||||
return { id, label: id, type: "connection-group", isExpanded: true, children };
|
||||
}
|
||||
|
||||
describe("connected sidebar connection filter", () => {
|
||||
it("keeps connected connections and their nested groups while removing disconnected connections and empty groups", () => {
|
||||
const connected = connection("connected");
|
||||
const tree = [connection("disconnected"), group("team", [group("production", [connected]), group("inactive", [connection("other")])])];
|
||||
|
||||
const result = filterSidebarTreeToConnectedConnections(tree, new Set(["connected"]));
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe("team");
|
||||
expect(result[0]?.children?.[0]?.id).toBe("production");
|
||||
expect(result[0]?.children?.[0]?.children?.[0]).toBe(connected);
|
||||
expect(result[0]?.children?.some((node) => node.id === "inactive")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the existing tree when every displayed connection is active", () => {
|
||||
const first = connection("first");
|
||||
const tree = [group("team", [first])];
|
||||
|
||||
expect(filterSidebarTreeToConnectedConnections(tree, new Set(["first"]))).toBe(tree);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSidebarFilterGuards } from "@/lib/sidebar/sidebarSearchTree";
|
||||
|
||||
describe("sidebar filter guards", () => {
|
||||
it.each([
|
||||
{ connectedOnly: false, query: "", scoped: false, treeSearch: false, rootPartial: false },
|
||||
{ connectedOnly: true, query: "", scoped: false, treeSearch: false, rootPartial: true },
|
||||
{ connectedOnly: false, query: "table", scoped: false, treeSearch: true, rootPartial: true },
|
||||
{ connectedOnly: true, query: "table", scoped: false, treeSearch: true, rootPartial: true },
|
||||
{ connectedOnly: false, query: " ", scoped: true, treeSearch: true, rootPartial: true },
|
||||
{ connectedOnly: true, query: " ", scoped: true, treeSearch: true, rootPartial: true },
|
||||
{ connectedOnly: false, query: "table", scoped: true, treeSearch: true, rootPartial: true },
|
||||
{ connectedOnly: true, query: "table", scoped: true, treeSearch: true, rootPartial: true },
|
||||
])("separates connected-only=$connectedOnly query=$query scoped=$scoped", ({ connectedOnly, query, scoped, treeSearch, rootPartial }) => {
|
||||
expect(resolveSidebarFilterGuards(connectedOnly, query, scoped)).toEqual({
|
||||
isTreeSearchFiltering: treeSearch,
|
||||
isRootListPartial: rootPartial,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps descendant-local features separate from partial-root operations", () => {
|
||||
const source = readFileSync(new URL("../../../components/sidebar/ConnectionTree.vue", import.meta.url), "utf8");
|
||||
|
||||
expect(source).toContain("sidebarTableSearchEnabled && !isTreeSearchFiltering.value");
|
||||
expect(source).toContain("!useVirtualTree.value || isTreeSearchFiltering.value");
|
||||
expect(source.match(/if \(isRootListPartial\.value\)/g)).toHaveLength(2);
|
||||
expect(source.match(/:drag-disabled="isRootListPartial \|\| isConnectionListAlphabeticallySorted"/g)).toHaveLength(2);
|
||||
expect(source).not.toContain("isFiltering");
|
||||
});
|
||||
});
|
||||
|
|
@ -79,3 +79,51 @@ export function filterSidebarSearchRootsByConnectionState(nodes: TreeNode[], con
|
|||
return node.connectionId ? connectedIds.has(node.connectionId) : true;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSidebarFilterGuards(showConnectedConnectionsOnly: boolean, searchQuery: string, hasSearchScopeFilter: boolean) {
|
||||
const isTreeSearchFiltering = !!searchQuery.trim() || hasSearchScopeFilter;
|
||||
return {
|
||||
isTreeSearchFiltering,
|
||||
isRootListPartial: showConnectedConnectionsOnly || isTreeSearchFiltering,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a display-only connection tree containing connected connections and
|
||||
* the groups that contain them. Connection descendants stay intact because
|
||||
* this filter controls the connection list, not database-object visibility.
|
||||
*/
|
||||
export function filterSidebarTreeToConnectedConnections(nodes: readonly TreeNode[], connectedIds: ReadonlySet<string>): TreeNode[] {
|
||||
let changed = false;
|
||||
const filtered: TreeNode[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === "connection") {
|
||||
if (node.connectionId && connectedIds.has(node.connectionId)) {
|
||||
filtered.push(node);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type !== "connection-group") {
|
||||
filtered.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = filterSidebarTreeToConnectedConnections(node.children ?? [], connectedIds);
|
||||
if (children.length === 0) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (children !== node.children) {
|
||||
changed = true;
|
||||
filtered.push({ ...node, children });
|
||||
} else {
|
||||
filtered.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? filtered : (nodes as TreeNode[]);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue