Merge pull request #562 from serenez/codex/fix-260-482-overflow-navigation

Fix sidebar and tab overflow navigation
This commit is contained in:
skyler authored and GitHub committed 2026-06-01 00:34:30 +08:00
commit 9b55c08bf9
15 files changed
+322 -59

No files matched your search

@@ -106,6 +106,7 @@ const sidebarObjectDisplayHelp = ref<"grouped" | "simple" | null>(null);
const editAutoSelectActiveSidebarNode = ref(settingsStore.editorSettings.autoSelectActiveSidebarNode);
const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"));
const editSidebarHideTableComments = ref(settingsStore.editorSettings.sidebarHideTableComments);
const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll);
const redisScanPageSizeOptions = [200, 1000, 5000, 10000];
const systemFonts = ref<string[]>([]);
const systemFontsLoading = ref(false);
@@ -241,6 +242,7 @@ watch(
editAutoSelectActiveSidebarNode.value = settingsStore.editorSettings.autoSelectActiveSidebarNode;
editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n");
editSidebarHideTableComments.value = settingsStore.editorSettings.sidebarHideTableComments;
editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll;
editSnippets.value = settingsStore.editorSettings.snippets.map((s) => ({ ...s }));
void loadSystemFontOptions();
}
@@ -279,6 +281,7 @@ function hasChanges(): boolean {
editSidebarObjectDisplay.value !== settingsStore.editorSettings.sidebarObjectDisplay ||
editAutoSelectActiveSidebarNode.value !== settingsStore.editorSettings.autoSelectActiveSidebarNode ||
editSidebarHideTableComments.value !== settingsStore.editorSettings.sidebarHideTableComments ||
editSidebarAllowHorizontalScroll.value !== settingsStore.editorSettings.sidebarAllowHorizontalScroll ||
JSON.stringify(normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value)) !==
JSON.stringify(settingsStore.editorSettings.sidebarHiddenTablePrefixes) ||
JSON.stringify(editSnippets.value) !== JSON.stringify(settingsStore.editorSettings.snippets)
@@ -305,6 +308,7 @@ async function persistSettings() {
sidebarObjectDisplay: editSidebarObjectDisplay.value,
autoSelectActiveSidebarNode: editAutoSelectActiveSidebarNode.value,
sidebarHideTableComments: editSidebarHideTableComments.value,
sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
snippets: editSnippets.value,
});
@@ -344,6 +348,7 @@ function resetDefaults() {
editSidebarObjectDisplay.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectDisplay;
editAutoSelectActiveSidebarNode.value = DEFAULT_EDITOR_SETTINGS.autoSelectActiveSidebarNode;
editSidebarHideTableComments.value = DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments;
editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editSnippets.value = DEFAULT_SQL_SNIPPETS.map((s) => ({ ...s }));
}
@@ -1388,6 +1393,22 @@ watch(
</div>
<Switch id="sidebar-hide-table-comments" v-model="editSidebarHideTableComments" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="sidebar-allow-horizontal-scroll">
{{ t("settings.sidebarAllowHorizontalScroll") }}
</Label>
<Tooltip>
<TooltipTrigger as-child>
<CircleHelp class="h-3.5 w-3.5 cursor-help text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent class="max-w-[320px] text-xs leading-relaxed" side="top" align="start">
{{ t("settings.sidebarAllowHorizontalScrollDescription") }}
</TooltipContent>
</Tooltip>
</div>
<Switch id="sidebar-allow-horizontal-scroll" v-model="editSidebarAllowHorizontalScroll" />
</div>
<div class="space-y-2">
<Label for="sidebar-hidden-table-prefixes">{{ t("settings.sidebarHiddenTablePrefixes") }}</Label>
<textarea
@@ -2,14 +2,31 @@
import { computed, ref, watch, nextTick } from "vue";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, Package, Check } from "lucide-vue-next";
import {
X,
Pin,
ChevronDown,
ChevronLeft,
ChevronRight,
Table2,
Code2,
TableProperties,
PencilRuler,
Package,
Check,
} from "lucide-vue-next";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useTabScroll } from "@/composables/useTabScroll";
import { connectionColor, tabDisplayTitle, tabTooltipLines } from "@/lib/tabPresentation";
import {
connectionColor,
shouldShowTabOverflowControls,
tabDisplayTitle,
tabTooltipLines,
} from "@/lib/tabPresentation";
import { hexToRgba } from "@/lib/color";
import type { QueryTab } from "@/types/database";
@@ -70,7 +87,8 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
}
const tabsContainerRef = ref<HTMLElement | null>(null);
const { canScrollLeft, canScrollRight, updateScrollButtons } = useTabScroll(tabsContainerRef);
const { hasTabOverflow, canScrollLeft, canScrollRight, updateScrollButtons, scrollTabs, onTabsWheel } =
useTabScroll(tabsContainerRef);
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
watch(
@@ -146,21 +164,33 @@ function tabIconClass(tab: QueryTab) {
return "text-blue-600 dark:text-blue-400";
}
const dataTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "data"));
const showPinnedDataTabsMenu = computed(
() => dataTabs.value.length > 0 && (canScrollLeft.value || canScrollRight.value),
const showTabOverflowControls = computed(() =>
shouldShowTabOverflowControls(
queryStore.tabs.length,
hasTabOverflow.value,
canScrollLeft.value,
canScrollRight.value,
),
);
const dataTabMenuItems = computed(() =>
dataTabs.value.map((tab) => ({
const openTabMenuItems = computed(() =>
queryStore.tabs.map((tab) => ({
value: tab.id,
label: tabDisplayTitle(tab, t),
title: tabDisplayTitle(tab, t),
icon: Table2,
iconClass: "text-emerald-600 dark:text-emerald-400",
icon: tabMenuIcon(tab),
iconClass: tabIconClass(tab),
})),
);
function activateDataTab(tabId: string) {
function tabMenuIcon(tab: QueryTab) {
if (tab.mode === "data") return Table2;
if (tab.mode === "objects") return TableProperties;
if (tab.mode === "structure") return PencilRuler;
return Code2;
}
function activateTab(tabId: string) {
tabScrollBehavior.value = "auto";
queryStore.activeTabId = tabId;
emit("close-driver-store");
@@ -170,17 +200,16 @@ const tabsContainerStyle = computed<CSSProperties>(() => ({
msOverflowStyle: "none",
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
paddingRight: showPinnedDataTabsMenu.value && settingsStore.editorSettings.appLayout !== "classic" ? "28px" : "0px",
}));
const tabTailDragRegionClass = computed(() =>
showPinnedDataTabsMenu.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch",
showTabOverflowControls.value ? "w-0 flex-none self-stretch" : "min-w-8 flex-1 self-stretch",
);
const dataTabsMenuContainerClass = computed(() =>
const tabOverflowControlClass = computed(() =>
settingsStore.editorSettings.appLayout === "classic"
? "relative z-30 flex shrink-0 items-stretch"
: "absolute inset-y-0 -right-2 z-30 flex items-stretch",
? "h-full w-8 border-r border-border/80 dark:border-border/45 bg-background/80 text-foreground/75 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40"
: "h-7 w-7 rounded-md border border-border/60 bg-background text-foreground/70 hover:border-border hover:text-foreground",
);
</script>
@@ -194,17 +223,25 @@ const dataTabsMenuContainerClass = computed(() =>
: 'h-10 items-center bg-background px-2'
"
>
<div
v-if="canScrollLeft"
class="pointer-events-none absolute left-0 z-10 h-full w-6 bg-linear-to-r from-background from-40% to-transparent"
aria-hidden="true"
/>
<button
v-if="showTabOverflowControls"
type="button"
class="z-30 shrink-0 inline-flex items-center justify-center"
:class="[tabOverflowControlClass, canScrollLeft ? '' : 'opacity-40']"
:aria-label="t('tabs.scrollLeft')"
:title="t('tabs.scrollLeft')"
:disabled="!canScrollLeft"
@click="scrollTabs('left')"
>
<ChevronLeft class="h-4 w-4 stroke-[2.5]" />
</button>
<div
ref="tabsContainerRef"
class="flex-1 flex items-center overflow-x-auto min-w-0"
:class="settingsStore.editorSettings.appLayout === 'classic' ? '' : 'gap-1.5'"
:style="tabsContainerStyle"
@scroll="updateScrollButtons"
@wheel="onTabsWheel"
>
<CustomContextMenu
v-for="tab in queryStore.tabs"
@@ -311,29 +348,37 @@ const dataTabsMenuContainerClass = computed(() =>
</div>
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
</div>
<div
v-if="canScrollRight"
class="pointer-events-none absolute right-0 z-20 h-full w-6 bg-linear-to-l from-background from-40% to-transparent"
aria-hidden="true"
/>
<div v-if="showPinnedDataTabsMenu" :class="dataTabsMenuContainerClass">
<div v-if="showTabOverflowControls" class="relative z-30 flex shrink-0 items-center">
<button
v-if="showTabOverflowControls"
type="button"
class="inline-flex shrink-0 items-center justify-center"
:class="[tabOverflowControlClass, canScrollRight ? '' : 'opacity-40']"
:aria-label="t('tabs.scrollRight')"
:title="t('tabs.scrollRight')"
:disabled="!canScrollRight"
@click="scrollTabs('right')"
>
<ChevronRight class="h-4 w-4 stroke-[2.5]" />
</button>
<LightDropdown
:model-value="queryStore.activeTabId || ''"
:items="dataTabMenuItems"
:aria-label="t('tabs.openDataTabs')"
:items="openTabMenuItems"
:aria-label="t('tabs.openTabs')"
:trigger-title="t('tabs.openTabs')"
:trigger-icon="ChevronDown"
trigger-class="h-full w-7 rounded-none text-foreground/70 hover:text-foreground inline-flex items-center justify-center bg-background"
:trigger-class="['inline-flex shrink-0 items-center justify-center', tabOverflowControlClass].join(' ')"
trigger-icon-class="h-4 w-4"
item-icon-class="w-3.5 h-3.5 mr-2"
item-class="max-w-full"
content-class="w-auto min-w-36 max-w-60"
content-class="w-auto min-w-48 max-w-72"
:show-trigger-label="false"
:show-chevron="false"
:highlight-selected="false"
:match-trigger-width="false"
check-position="none"
align="end"
@update:model-value="activateDataTab"
@update:model-value="activateTab"
/>
</div>
</div>
@@ -163,6 +163,11 @@ const filteredNodes = computed(() => {
const flatNodes = computed<FlatTreeNode[]>(() => flattenTree(filteredNodes.value));
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
const sidebarTreeOverflowClass = computed(() =>
settingsStore.editorSettings.sidebarAllowHorizontalScroll
? "overflow-x-auto sidebar-tree-horizontal-scroll"
: "overflow-x-hidden",
);
const pendingRenameGroupId = ref<string | null>(null);
const highlightedNodeId = ref<string | null>(null);
@@ -492,7 +497,8 @@ defineExpose({ focusSearch });
<RecycleScroller
v-if="flatNodes.length > 0 && useVirtualTree"
ref="treeScrollerRef"
class="sidebar-tree connection-tree-scroller min-h-0 flex-1 overflow-y-auto overflow-x-auto"
class="sidebar-tree connection-tree-scroller min-h-0 flex-1 overflow-y-auto"
:class="sidebarTreeOverflowClass"
:items="flatNodes"
:item-size="SIDEBAR_TREE_ROW_HEIGHT"
:buffer="SIDEBAR_TREE_SCROLL_BUFFER"
@@ -518,7 +524,8 @@ defineExpose({ focusSearch });
<div
v-else-if="flatNodes.length > 0"
ref="plainTreeScrollerRef"
class="sidebar-tree min-h-0 flex-1 overflow-y-auto overflow-x-auto"
class="sidebar-tree min-h-0 flex-1 overflow-y-auto"
:class="sidebarTreeOverflowClass"
>
<TreeItem
v-for="item in flatNodes"
@@ -546,6 +553,11 @@ defineExpose({ focusSearch });
}
.connection-tree-scroller :deep(.vue-recycle-scroller__item-view) {
contain: layout style paint;
min-width: 100%;
contain: style;
}
.connection-tree-scroller.sidebar-tree-horizontal-scroll :deep(.vue-recycle-scroller__item-view) {
width: max-content;
}
</style>
@@ -56,7 +56,7 @@ import type { DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/api";
import { uuid } from "@/lib/utils";
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
import { canTreeNodeShowExpander, treeItemPaddingLeft } from "@/lib/sidebarTreeItemLayout";
import { canTreeNodeShowExpander, treeItemPaddingLeft, usesFullWidthTreeLabel } from "@/lib/sidebarTreeItemLayout";
import { buildTableSelectSql } from "@/lib/tableSelectSql";
import {
clearActiveTableReferencePayload,
@@ -157,6 +157,14 @@ const emit = defineEmits<{
"search-toggle": [node: TreeNode];
}>();
const usesFullWidthLabel = computed(() =>
usesFullWidthTreeLabel(props.node.type, settingsStore.editorSettings.sidebarAllowHorizontalScroll),
);
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
const labelWidthClass = computed(() =>
usesFullWidthLabel.value ? "shrink-0 whitespace-nowrap" : "min-w-0 flex-1 truncate",
);
function currentDatabaseType(): DatabaseType | undefined {
return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined;
}
@@ -1652,9 +1660,13 @@ const isActiveConnectionScope = computed(
const isSelected = computed(() => connectionStore.selectedTreeNodeId === props.node.id);
const rowStyle = computed(() => {
const color = connectionColor.value;
const backgroundColor = hexToRgba(color, isActiveConnectionScope.value ? 0.14 : 0.08);
return {
paddingLeft: paddingLeft.value,
backgroundColor: hexToRgba(color, isActiveConnectionScope.value ? 0.14 : 0.08),
"--tree-connection-row-bg": backgroundColor,
"--tree-connection-row-hover-bg": hexToRgba(color, isActiveConnectionScope.value ? 0.18 : 0.12),
"--tree-connection-active-bg": hexToRgba(color, 0.18),
"--tree-connection-active-focus-bg": hexToRgba(color, 0.22),
};
});
@@ -2253,16 +2265,21 @@ function treeItemMenuItems(): ContextMenuItem[] {
<div @contextmenu="onContextMenu">
<div
ref="rowRef"
class="group flex min-w-0 items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent transition-colors relative outline-none"
style="contain: layout style paint"
:class="{
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
'opacity-50': isDragging,
'rounded-none': connectionColor && !isSelected,
'rounded-sm': !connectionColor && !isSelected,
'tree-item-active rounded-md': isSelected,
'tree-item-highlight': highlighted,
}"
class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent transition-colors relative outline-none"
style="contain: layout style"
:class="[
rowWidthClass,
{
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
'opacity-50': isDragging,
'tree-item-connection-tint': connectionColor,
'rounded-none': connectionColor && !isSelected,
'rounded-sm': !connectionColor && !isSelected,
'tree-item-active rounded-none': connectionColor && isSelected,
'tree-item-active rounded-md': !connectionColor && isSelected,
'tree-item-highlight': highlighted,
},
]"
:tabindex="isSelected ? 0 : -1"
:style="rowStyle"
@click="onClick"
@@ -2316,7 +2333,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
@click.stop
/>
<LightTooltip v-else :text="displayLabel(node)" :disabled="isTooltipDisabled" side="right" :side-offset="8">
<span ref="labelRef" class="min-w-0 flex-1 truncate">{{ visibleLabel(node) }}</span>
<span ref="labelRef" :class="labelWidthClass">{{ visibleLabel(node) }}</span>
</LightTooltip>
<span
v-if="
@@ -2659,20 +2676,58 @@ function treeItemMenuItems(): ContextMenuItem[] {
</template>
<style>
.tree-item-connection-tint {
isolation: isolate;
background-color: transparent !important;
}
.tree-item-connection-tint::before {
content: "";
position: absolute;
inset: 0 -9999px;
z-index: 0;
background-color: var(--tree-connection-row-bg);
border-radius: inherit;
pointer-events: none;
}
.tree-item-connection-tint > * {
position: relative;
z-index: 1;
}
.tree-item-connection-tint:hover,
.tree-item-connection-tint.tree-item-active,
.tree-item-connection-tint.tree-item-active:focus {
background-color: transparent !important;
}
.tree-item-connection-tint:hover::before {
background-color: var(--tree-connection-row-hover-bg, var(--tree-connection-row-bg));
}
.tree-item-connection-tint.tree-item-active::before {
background-color: var(--tree-connection-active-bg, var(--tree-connection-row-bg));
}
.tree-item-connection-tint.tree-item-active:focus::before {
background-color: var(--tree-connection-active-focus-bg, var(--tree-connection-active-bg));
}
/* Unfocused: subtle gray */
.tree-item-active {
background-color: oklch(0.94 0 0) !important;
background-color: var(--tree-connection-active-bg, oklch(0.94 0 0)) !important;
}
:root.dark .tree-item-active {
background-color: oklch(0.26 0 0) !important;
background-color: var(--tree-connection-active-bg, oklch(0.26 0 0)) !important;
}
/* Focused: soft blue */
.tree-item-active:focus {
background-color: oklch(0.91 0.03 250) !important;
background-color: var(--tree-connection-active-focus-bg, oklch(0.91 0.03 250)) !important;
}
:root.dark .tree-item-active:focus {
background-color: oklch(0.35 0.06 250) !important;
background-color: var(--tree-connection-active-focus-bg, oklch(0.35 0.06 250)) !important;
}
/* Locate highlight: instant amber, then fade on removal */