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"); safeLocalStorageSet("dbx-sidebar-open", open ? "true" : "false");
} }
async function locateTabInSidebar(tab: QueryTab) {
setSidebarOpen(true);
await nextTick();
await appSidebarRef.value?.locateTabInSidebar(tab);
}
function ensureQueryTab(): string { function ensureQueryTab(): string {
const tab = activeTab.value; const tab = activeTab.value;
if (tab && tab.mode === "query") return tab.id; if (tab && tab.mode === "query") return tab.id;
@@ -2847,6 +2853,7 @@ onUnmounted(() => {
:agent-driver-update-count="toolbarAgentDriverUpdateCount" :agent-driver-update-count="toolbarAgentDriverUpdateCount"
@activate-driver-store="openDriverStorePage" @activate-driver-store="openDriverStorePage"
@activate-settings-page="activateSettingsPage" @activate-settings-page="activateSettingsPage"
@locate-tab="locateTabInSidebar"
@activate-tab=" @activate-tab="
driverStoreActive = false; driverStoreActive = false;
settingsStore.settingsPageActive = false; settingsStore.settingsPageActive = false;
@@ -12,7 +12,7 @@ import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut } from "@/lib/sidebar/sidebarConnectionMultiSelect"; import { applyConnectionMultiSelection, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { useConnectionStore } from "@/stores/connectionStore"; import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast"; import { useToast } from "@/composables/useToast";
import type { TreeNode } from "@/types/database"; import type { QueryTab, TreeNode } from "@/types/database";
defineProps<{ defineProps<{
sidebarWidth: number; sidebarWidth: number;
@@ -88,6 +88,10 @@ function focusSearch(): boolean {
return connectionTreeRef.value?.focusSearch() ?? false; return connectionTreeRef.value?.focusSearch() ?? false;
} }
function locateTabInSidebar(tab: QueryTab) {
return connectionTreeRef.value?.locateTabInSidebar(tab);
}
function clearConnectionMultiSelection() { function clearConnectionMultiSelection() {
applyConnectionMultiSelection(connectionStore, emptyConnectionMultiSelection()); applyConnectionMultiSelection(connectionStore, emptyConnectionMultiSelection());
} }
@@ -163,7 +167,7 @@ function confirmCreateSelectedGroup() {
showCreateSelectedGroupDialog.value = false; showCreateSelectedGroupDialog.value = false;
} }
defineExpose({ focusSearch }); defineExpose({ focusSearch, locateTabInSidebar });
</script> </script>
<template> <template>
@@ -2,7 +2,7 @@
import { computed, ref, watch, nextTick, onUnmounted } from "vue"; import { computed, ref, watch, nextTick, onUnmounted } from "vue";
import type { CSSProperties } from "vue"; import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n"; 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 CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -19,6 +19,7 @@ import { connectionColor, isConnectionReadonly, tabDisplayTitle, tabTooltipLines
import { hexToRgba } from "@/lib/common/color"; import { hexToRgba } from "@/lib/common/color";
import { copyToClipboard } from "@/lib/common/clipboard"; import { copyToClipboard } from "@/lib/common/clipboard";
import { useToast } from "@/composables/useToast"; import { useToast } from "@/composables/useToast";
import { activeTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { QueryTab } from "@/types/database"; import type { QueryTab } from "@/types/database";
const props = defineProps<{ const props = defineProps<{
@@ -31,6 +32,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
"activate-tab": []; "activate-tab": [];
"locate-tab": [tab: QueryTab];
"activate-driver-store": []; "activate-driver-store": [];
"close-driver-store": []; "close-driver-store": [];
"activate-settings-page": []; "activate-settings-page": [];
@@ -318,6 +320,12 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
}, },
icon: Copy, icon: Copy,
}, },
{
label: t("sidebar.locateActiveTab"),
action: () => emit("locate-tab", tab),
icon: Crosshair,
visible: !!activeTabSidebarTarget(tab),
},
{ label: "", separator: true }, { label: "", separator: true },
{ {
label: tab.pinned ? t("contextMenu.unfixTab") : t("contextMenu.fixTab"), 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"); 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", () => { describe("AppTabBar close confirmation layout", () => {
it("allows long unbroken tab titles to shrink and wrap inside the dialog", () => { 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/); 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", () => { 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", () => { 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")'); expect(tabBarSource).toContain('label: t("contextMenu.closeRightTabs")');
@@ -7,7 +7,7 @@ import { useQueryStore } from "@/stores/queryStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast"; 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 { import {
createSidebarSearchSubtreePreserver, createSidebarSearchSubtreePreserver,
filterSidebarSearchRootsByConnectionState, filterSidebarSearchRootsByConnectionState,
@@ -1250,6 +1250,7 @@ async function scrollToSidebarNode(nodeId: string, options?: { align?: SidebarNo
index, index,
currentScrollTop: scroller.scrollTop, currentScrollTop: scroller.scrollTop,
viewportHeight: scroller.clientHeight, viewportHeight: scroller.clientHeight,
scrollHeight: scroller.scrollHeight,
topOcclusionHeight: topOcclusionHeightForSidebarNode(nodeId), topOcclusionHeight: topOcclusionHeightForSidebarNode(nodeId),
...(options?.align ? { align: options.align } : {}), ...(options?.align ? { align: options.align } : {}),
}); });
@@ -1304,7 +1305,10 @@ async function startRenamingConnectionNode(connectionId: string) {
} }
async function locateActiveTabInSidebar() { 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; if (!tab) return;
const tabTarget = activeTabSidebarTarget(tab); const tabTarget = activeTabSidebarTarget(tab);
@@ -1397,7 +1401,7 @@ async function locateActiveTabInSidebar() {
store.treeSelectionAnchorId = match.id; store.treeSelectionAnchorId = match.id;
await nextTick(); await nextTick();
await scrollToSidebarNode(match.id, { align: "smart" }); await scrollToSidebarNode(match.id, { align });
await flashSidebarNode(match.id); await flashSidebarNode(match.id);
} }
@@ -1955,6 +1959,7 @@ async function selectActiveTabSidebarNode(options: { scroll: boolean }) {
index, index,
currentScrollTop: scroller.scrollTop, currentScrollTop: scroller.scrollTop,
viewportHeight: scroller.clientHeight, viewportHeight: scroller.clientHeight,
scrollHeight: scroller.scrollHeight,
topOcclusionHeight: topOcclusionHeightForSidebarNode(match.id), topOcclusionHeight: topOcclusionHeightForSidebarNode(match.id),
}); });
if (nextScrollTop !== scroller.scrollTop) { if (nextScrollTop !== scroller.scrollTop) {
@@ -2163,7 +2168,7 @@ onUnmounted(() => {
if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame); if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame);
}); });
defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSidebar });
</script> </script>
<template> <template>
@@ -331,30 +331,37 @@ export function shouldScrollActiveSidebarSelection(options: { activeTabId: strin
return options.activeTabId !== options.previousActiveTabId || (options.autoSelectEnabled && options.previousAutoSelectEnabled === false); return options.activeTabId !== options.previousActiveTabId || (options.autoSelectEnabled && options.previousAutoSelectEnabled === false);
} }
// nearest is used for passive auto-selection; smart keeps context for explicit locate actions. // nearest is used for passive auto-selection; smart keeps context for the existing toolbar action.
export type SidebarNodeScrollAlign = "nearest" | "top" | "smart"; 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; const rowHeight = options.rowHeight ?? SIDEBAR_TREE_ROW_HEIGHT;
if (options.index < 0 || options.viewportHeight <= 0) return options.currentScrollTop; if (options.index < 0 || options.viewportHeight <= 0) return options.currentScrollTop;
const rowTop = options.index * rowHeight; const rowTop = options.index * rowHeight;
const rowBottom = rowTop + rowHeight; const rowBottom = rowTop + rowHeight;
const topOcclusionHeight = options.topOcclusionHeight ?? 0; 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") { if (options.align === "smart") {
// Similar to IDE Locate: place the target around the upper third of the viewport. // Similar to IDE Locate: place the target around the upper third of the viewport.
const availableViewportHeight = Math.max(rowHeight, options.viewportHeight - topOcclusionHeight); const availableViewportHeight = Math.max(rowHeight, options.viewportHeight - topOcclusionHeight);
const smartOffset = Math.max(0, (availableViewportHeight - rowHeight) / 3); 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 viewportTop = options.currentScrollTop + topOcclusionHeight;
const viewportBottom = options.currentScrollTop + options.viewportHeight; const viewportBottom = options.currentScrollTop + options.viewportHeight;
if (rowTop < viewportTop) return Math.max(0, rowTop - topOcclusionHeight); if (rowTop < viewportTop) return clampScrollTop(rowTop - topOcclusionHeight);
if (rowBottom > viewportBottom) return Math.max(0, rowBottom - options.viewportHeight); if (rowBottom > viewportBottom) return clampScrollTop(rowBottom - options.viewportHeight);
return options.currentScrollTop; return clampScrollTop(options.currentScrollTop);
} }
export function findNodePathForActiveTab(tab: QueryTab | undefined | null, treeNodes: readonly TreeNode[]): TreeNode[] | null { export function findNodePathForActiveTab(tab: QueryTab | undefined | null, treeNodes: readonly TreeNode[]): TreeNode[] | null {
+7
View File
@@ -542,6 +542,13 @@ test("sidebar node scrolling supports top and smart locate alignment", () => {
assert.equal(scrollTopForSidebarNode({ index: 0, currentScrollTop: 300, viewportHeight: 140, topOcclusionHeight: 28, align: "smart" }), 0); assert.equal(scrollTopForSidebarNode({ index: 0, currentScrollTop: 300, viewportHeight: 140, topOcclusionHeight: 28, align: "smart" }), 0);
}); });
test("sidebar node scrolling centers explicit tab locate with sticky and boundary clamping", () => {
assert.equal(scrollTopForSidebarNode({ index: 20, currentScrollTop: 0, viewportHeight: 140, align: "center" }), 504);
assert.equal(scrollTopForSidebarNode({ index: 11, currentScrollTop: 0, viewportHeight: 140, topOcclusionHeight: 28, align: "center" }), 238);
assert.equal(scrollTopForSidebarNode({ index: 0, currentScrollTop: 300, viewportHeight: 140, topOcclusionHeight: 28, align: "center" }), 0);
assert.equal(scrollTopForSidebarNode({ index: 20, currentScrollTop: 0, viewportHeight: 140, scrollHeight: 588, align: "center" }), 448);
});
test("active sidebar selection only scrolls on tab or setting changes", () => { test("active sidebar selection only scrolls on tab or setting changes", () => {
assert.equal( assert.equal(
shouldScrollActiveSidebarSelection({ shouldScrollActiveSidebarSelection({
+23 -1
View File
@@ -178,7 +178,7 @@ test("saved SQL tree rows expose copy, paste, export, rename, and confirmed dele
test("explicit locate prioritizes the saved SQL row over SQL cursor table navigation", () => { test("explicit locate prioritizes the saved SQL row over SQL cursor table navigation", () => {
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8"); const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
const locateBody = functionBody(connectionTree, "locateActiveTabInSidebar"); const locateBody = functionBody(connectionTree, "locateTabInSidebar");
assert.match(locateBody, /const locatesSavedSql = tabTarget\?\.type === "saved-sql-file"/); assert.match(locateBody, /const locatesSavedSql = tabTarget\?\.type === "saved-sql-file"/);
assert.match(locateBody, /const cursorCandidate = locatesSavedSql \? null : queryCursorTableCandidate/); assert.match(locateBody, /const cursorCandidate = locatesSavedSql \? null : queryCursorTableCandidate/);
@@ -186,6 +186,28 @@ test("explicit locate prioritizes the saved SQL row over SQL cursor table naviga
assert.match(locateBody, /findNodePathForTarget\(target, store\.treeNodes\)/); assert.match(locateBody, /findNodePathForTarget\(target, store\.treeNodes\)/);
}); });
test("tab context menu forwards the exact tab to centered sidebar locate without activating it", () => {
const app = readFileSync("apps/desktop/src/App.vue", "utf8");
const appSidebar = readFileSync("apps/desktop/src/components/layout/AppSidebar.vue", "utf8");
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
const appLocateBody = functionBody(app, "locateTabInSidebar");
const sidebarLocateBody = functionBody(appSidebar, "locateTabInSidebar");
const activeLocateBody = functionBody(connectionTree, "locateActiveTabInSidebar");
const locateBody = functionBody(connectionTree, "locateTabInSidebar");
assert.match(app, /@locate-tab="locateTabInSidebar"/);
assert.match(appLocateBody, /setSidebarOpen\(true\)/);
assert.match(appLocateBody, /await nextTick\(\)/);
assert.match(appLocateBody, /await appSidebarRef\.value\?\.locateTabInSidebar\(tab\)/);
assert.doesNotMatch(appLocateBody, /activateQueryTab|activeTabId/);
assert.match(sidebarLocateBody, /return connectionTreeRef\.value\?\.locateTabInSidebar\(tab\)/);
assert.match(appSidebar, /defineExpose\(\{ focusSearch, locateTabInSidebar \}\)/);
assert.match(activeLocateBody, /await locateTabInSidebar\(activeTab\.value, "smart"\)/);
assert.match(locateBody, /await scrollToSidebarNode\(match\.id, \{ align \}\)/);
assert.match(connectionTree, /defineExpose\(\{ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSidebar \}\)/);
assert.match(connectionTree, /@request-connection-rename="startRenamingConnectionNode"/);
});
test("batch table paste refreshes each object list after all tables are processed", () => { test("batch table paste refreshes each object list after all tables are processed", () => {
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable"); const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable");