feat(tabs): locate tab objects in sidebar

Closes #6681
This commit is contained in:
t8y2 committed 2026-08-22 10:31:17 +00:00
1 parent 53b7be3c6f
commit 906ea8ba26
8 files changed
+104 -16

No files matched your search

+7
View File
@@ -2041,6 +2041,12 @@ function setSidebarOpen(open: boolean) {
safeLocalStorageSet("dbx-sidebar-open", open ? "true" : "false");
}
async function locateTabInSidebar(tab: QueryTab) {
setSidebarOpen(true);
await nextTick();
await appSidebarRef.value?.locateTabInSidebar(tab);
}
function ensureQueryTab(): string {
const tab = activeTab.value;
if (tab && tab.mode === "query") return tab.id;
@@ -2847,6 +2853,7 @@ onUnmounted(() => {
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
@activate-driver-store="openDriverStorePage"
@activate-settings-page="activateSettingsPage"
@locate-tab="locateTabInSidebar"
@activate-tab="
driverStoreActive = false;
settingsStore.settingsPageActive = false;
@@ -12,7 +12,7 @@ import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { TreeNode } from "@/types/database";
import type { QueryTab, TreeNode } from "@/types/database";
defineProps<{
sidebarWidth: number;
@@ -88,6 +88,10 @@ function focusSearch(): boolean {
return connectionTreeRef.value?.focusSearch() ?? false;
}
function locateTabInSidebar(tab: QueryTab) {
return connectionTreeRef.value?.locateTabInSidebar(tab);
}
function clearConnectionMultiSelection() {
applyConnectionMultiSelection(connectionStore, emptyConnectionMultiSelection());
}
@@ -163,7 +167,7 @@ function confirmCreateSelectedGroup() {
showCreateSelectedGroupDialog.value = false;
}
defineExpose({ focusSearch });
defineExpose({ focusSearch, locateTabInSidebar });
</script>
<template>
@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Search, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch } from "@lucide/vue";
import { X, Pin, ChevronDown, Search, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock, Activity, Gauge, ShieldCheck, Database, GitBranch, Crosshair } from "@lucide/vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -19,6 +19,7 @@ import { connectionColor, isConnectionReadonly, tabDisplayTitle, tabTooltipLines
import { hexToRgba } from "@/lib/common/color";
import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast";
import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { QueryTab } from "@/types/database";
const props = defineProps<{
@@ -31,6 +32,7 @@ const props = defineProps<{
const emit = defineEmits<{
"activate-tab": [];
"locate-tab": [tab: QueryTab];
"activate-driver-store": [];
"close-driver-store": [];
"activate-settings-page": [];
@@ -318,6 +320,12 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
},
icon: Copy,
},
{
label: t("sidebar.locateActiveTab"),
action: () => emit("locate-tab", tab),
icon: Crosshair,
visible: !!activeTabSidebarTarget(tab),
},
{ label: "", separator: true },
{
label: tab.pinned ? t("contextMenu.unfixTab") : t("contextMenu.fixTab"),
@@ -3,6 +3,14 @@ import { describe, expect, it } from "vitest";
const tabBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
function sourceBetween(start: string, end: string): string {
const startIndex = tabBarSource.indexOf(start);
const endIndex = tabBarSource.indexOf(end, startIndex + start.length);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);
return tabBarSource.slice(startIndex, endIndex);
}
describe("AppTabBar close confirmation layout", () => {
it("allows long unbroken tab titles to shrink and wrap inside the dialog", () => {
expect(tabBarSource).toMatch(/<DialogContent class="[^"]*\bmin-w-0\b[^"]*\bsm:max-w-md\b/);
@@ -38,6 +46,26 @@ describe("AppTabBar object browser presentation", () => {
});
});
describe("AppTabBar locate-in-sidebar action", () => {
it("emits the exact right-clicked tab only when it has a sidebar target", () => {
const menu = sourceBetween("function getTabMenuItems", "function handleSaveAndClose");
expect(tabBarSource).toContain('"locate-tab": [tab: QueryTab];');
expect(menu).toMatch(/label: t\("sidebar\.locateActiveTab"\),\s*action: \(\) => emit\("locate-tab", tab\),\s*icon: Crosshair,\s*visible: !!activeTabSidebarTarget\(tab\)/);
expect(menu).not.toContain("activateTab(tab.id)");
});
it("leaves settings, driver, and existing regular-tab menu actions unchanged", () => {
const specialMenu = sourceBetween("function getSpecialRegularTabMenuItems", "function getTabMenuItems");
const regularMenu = sourceBetween("function getTabMenuItems", "function handleSaveAndClose");
expect(specialMenu).not.toContain('t("sidebar.locateActiveTab")');
expect(regularMenu).toContain('label: t("contextMenu.copyName")');
expect(regularMenu).toContain("action: () => queryStore.togglePinnedTab(tab.id)");
expect(regularMenu).toContain("action: () => queryStore.closeTab(tab.id)");
});
});
describe("AppTabBar right-side close action", () => {
it("places the action after close-other and disables it when the target has no tabs to its right", () => {
expect(tabBarSource).toContain('label: t("contextMenu.closeRightTabs")');
@@ -7,7 +7,7 @@ import { useQueryStore } from "@/stores/queryStore";
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 type { ObjectSourceKind, QueryTab, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database";
import {
createSidebarSearchSubtreePreserver,
filterSidebarSearchRootsByConnectionState,
@@ -1250,6 +1250,7 @@ async function scrollToSidebarNode(nodeId: string, options?: { align?: SidebarNo
index,
currentScrollTop: scroller.scrollTop,
viewportHeight: scroller.clientHeight,
scrollHeight: scroller.scrollHeight,
topOcclusionHeight: topOcclusionHeightForSidebarNode(nodeId),
...(options?.align ? { align: options.align } : {}),
});
@@ -1304,7 +1305,10 @@ async function startRenamingConnectionNode(connectionId: string) {
}
async function locateActiveTabInSidebar() {
const tab = activeTab.value;
await locateTabInSidebar(activeTab.value, "smart");
}
async function locateTabInSidebar(tab: QueryTab | undefined | null, align: SidebarNodeScrollAlign = "center") {
if (!tab) return;
const tabTarget = activeTabSidebarTarget(tab);
@@ -1397,7 +1401,7 @@ async function locateActiveTabInSidebar() {
store.treeSelectionAnchorId = match.id;
await nextTick();
await scrollToSidebarNode(match.id, { align: "smart" });
await scrollToSidebarNode(match.id, { align });
await flashSidebarNode(match.id);
}
@@ -1955,6 +1959,7 @@ async function selectActiveTabSidebarNode(options: { scroll: boolean }) {
index,
currentScrollTop: scroller.scrollTop,
viewportHeight: scroller.clientHeight,
scrollHeight: scroller.scrollHeight,
topOcclusionHeight: topOcclusionHeightForSidebarNode(match.id),
});
if (nextScrollTop !== scroller.scrollTop) {
@@ -2163,7 +2168,7 @@ onUnmounted(() => {
if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame);
});
defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSidebar });
</script>
<template>
@@ -331,30 +331,37 @@ export function shouldScrollActiveSidebarSelection(options: { activeTabId: strin
return options.activeTabId !== options.previousActiveTabId || (options.autoSelectEnabled && options.previousAutoSelectEnabled === false);
}
// nearest is used for passive auto-selection; smart keeps context for explicit locate actions.
export type SidebarNodeScrollAlign = "nearest" | "top" | "smart";
// nearest is used for passive auto-selection; smart keeps context for the existing toolbar action.
export type SidebarNodeScrollAlign = "nearest" | "top" | "smart" | "center";
export function scrollTopForSidebarNode(options: { index: number; currentScrollTop: number; viewportHeight: number; rowHeight?: number; topOcclusionHeight?: number; align?: SidebarNodeScrollAlign }): number {
export function scrollTopForSidebarNode(options: { index: number; currentScrollTop: number; viewportHeight: number; scrollHeight?: number; rowHeight?: number; topOcclusionHeight?: number; align?: SidebarNodeScrollAlign }): number {
const rowHeight = options.rowHeight ?? SIDEBAR_TREE_ROW_HEIGHT;
if (options.index < 0 || options.viewportHeight <= 0) return options.currentScrollTop;
const rowTop = options.index * rowHeight;
const rowBottom = rowTop + rowHeight;
const topOcclusionHeight = options.topOcclusionHeight ?? 0;
if (options.align === "top") return Math.max(0, rowTop - topOcclusionHeight);
const maxScrollTop = options.scrollHeight == null ? Number.POSITIVE_INFINITY : Math.max(0, options.scrollHeight - options.viewportHeight);
const clampScrollTop = (value: number) => Math.max(0, Math.min(maxScrollTop, value));
if (options.align === "top") return clampScrollTop(rowTop - topOcclusionHeight);
if (options.align === "smart") {
// Similar to IDE Locate: place the target around the upper third of the viewport.
const availableViewportHeight = Math.max(rowHeight, options.viewportHeight - topOcclusionHeight);
const smartOffset = Math.max(0, (availableViewportHeight - rowHeight) / 3);
return Math.max(0, Math.round(rowTop - topOcclusionHeight - smartOffset));
return clampScrollTop(Math.round(rowTop - topOcclusionHeight - smartOffset));
}
if (options.align === "center") {
const availableViewportHeight = Math.max(rowHeight, options.viewportHeight - topOcclusionHeight);
const centerOffset = Math.max(0, (availableViewportHeight - rowHeight) / 2);
return clampScrollTop(Math.round(rowTop - topOcclusionHeight - centerOffset));
}
const viewportTop = options.currentScrollTop + topOcclusionHeight;
const viewportBottom = options.currentScrollTop + options.viewportHeight;
if (rowTop < viewportTop) return Math.max(0, rowTop - topOcclusionHeight);
if (rowBottom > viewportBottom) return Math.max(0, rowBottom - options.viewportHeight);
return options.currentScrollTop;
if (rowTop < viewportTop) return clampScrollTop(rowTop - topOcclusionHeight);
if (rowBottom > viewportBottom) return clampScrollTop(rowBottom - options.viewportHeight);
return clampScrollTop(options.currentScrollTop);
}
export function findNodePathForActiveTab(tab: QueryTab | undefined | null, treeNodes: readonly TreeNode[]): TreeNode[] | null {