1 parent
53b7be3c6f
commit
906ea8ba26
8 files changed
+104
-16
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user