perf(desktop): use lightweight dropdown menus

This commit is contained in:
t8y2 2026-05-30 11:02:16 +08:00
parent 4d32127522
commit 133f3cfc04
8 changed files with 528 additions and 315 deletions

View File

@ -33,13 +33,8 @@ import {
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
@ -166,6 +161,27 @@ const activePlaceholder = computed(
() => `${t(`ai.placeholders.${activeAction.value}`)} ${t("ai.tableMentionPlaceholderHint")}`,
);
const activeModeHint = computed(() => t(`ai.modeHints.${assistantMode.value}`));
const assistantModeItems = computed(() => [
{
value: "ask",
label: t("ai.modes.ask"),
title: t("ai.modeHints.ask"),
icon: MessageSquarePlus,
},
{
value: "agent",
label: t("ai.modes.agent"),
title: t("ai.modeHints.agent"),
icon: Bot,
},
]);
const actionMenuItems = computed(() =>
actionButtons.map((button) => ({
value: button.action,
label: t(button.key),
icon: button.icon,
})),
);
const aiCodeAppearance = computed(() => (isDark.value ? "dark" : "light"));
const { databaseOptions: allDbOptions, loadDatabaseOptions } = useDatabaseOptions();
@ -956,78 +972,19 @@ const messageRenderer = computed(() => {
@keydown="onPromptKeydown"
/>
<div class="flex items-center gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
:title="activeModeHint"
>
<component :is="assistantMode === 'agent' ? Bot : MessageSquarePlus" class="h-3 w-3" />
<span>{{ t(`ai.modes.${assistantMode}`) }}</span>
<svg
class="h-3 w-3 opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem class="text-xs gap-1.5" :title="t('ai.modeHints.ask')" @click="assistantMode = 'ask'">
<Check class="h-3 w-3 shrink-0" :class="{ 'opacity-0': assistantMode !== 'ask' }" />
<MessageSquarePlus class="h-3 w-3 shrink-0 text-muted-foreground" />
<span>{{ t("ai.modes.ask") }}</span>
</DropdownMenuItem>
<DropdownMenuItem
class="text-xs gap-1.5"
:title="t('ai.modeHints.agent')"
@click="assistantMode = 'agent'"
>
<Check class="h-3 w-3 shrink-0" :class="{ 'opacity-0': assistantMode !== 'agent' }" />
<Bot class="h-3 w-3 shrink-0 text-muted-foreground" />
<span>{{ t("ai.modes.agent") }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground"
>
<component :is="actionButtons.find((b) => b.action === activeAction)?.icon" class="h-3 w-3" />
<span>{{ t(`ai.actions.${activeAction}`) }}</span>
<svg
class="h-3 w-3 opacity-50"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-max min-w-0">
<DropdownMenuItem
v-for="btn in actionButtons"
:key="btn.action"
class="text-xs gap-1.5"
@click="selectAction(btn.action)"
>
<component :is="btn.icon" class="h-3 w-3" />
<span>{{ t(btn.key) }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LightDropdown
v-model="assistantMode"
:items="assistantModeItems"
:aria-label="activeModeHint"
item-class="text-xs px-2"
/>
<LightDropdown
:model-value="activeAction"
:items="actionMenuItems"
content-class="w-max min-w-0"
item-class="text-xs px-2"
@update:model-value="(value) => selectAction(value as AiAction)"
/>
<span class="flex-1" />
<button
v-if="isGenerating"

View File

@ -51,11 +51,10 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -2623,6 +2622,46 @@ const {
hasRowSelection,
});
const pageSizeMenuItems = computed(() =>
pageSizeOptions.value.map((size) => ({
value: String(size),
label: `${size} ${t("grid.rowsPerPageShort")}`,
})),
);
const exportMenuItems = computed(() => [
{ value: "csv", label: t("grid.exportCsv") },
{ value: "xlsx", label: t("grid.exportXlsx") },
{ value: "json", label: t("grid.exportJson") },
{ value: "markdown", label: t("grid.exportMarkdown") },
...(isMultiRow.value
? [
{ value: "selected-csv", label: t("grid.exportSelectedRowsCsv"), separatorBefore: true },
{ value: "selected-xlsx", label: t("grid.exportSelectedRowsXlsx") },
{ value: "selected-json", label: t("grid.exportSelectedRowsJson") },
{ value: "selected-markdown", label: t("grid.exportSelectedRowsMarkdown") },
]
: []),
]);
function selectPageSizeMenuItem(value: string) {
changePageSize(Number(value));
}
function selectExportMenuItem(value: string) {
const actions: Record<string, () => void> = {
csv: exportCsv,
xlsx: exportXlsx,
json: exportJson,
markdown: exportMarkdown,
"selected-csv": exportSelectedRowsCsv,
"selected-xlsx": exportSelectedRowsXlsx,
"selected-json": exportSelectedRowsJson,
"selected-markdown": exportSelectedRowsMarkdown,
};
actions[value]?.();
}
// --- Cell selection and detail ---
function showCellDetails(rowIndex: number, colIndex: number) {
resetDetailEdit();
@ -5728,45 +5767,45 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span class="ml-auto flex items-center gap-1">
<Loader2 v-if="loading" class="w-3 h-3 animate-spin text-muted-foreground" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5">
{{ pageSize }}{{ t("grid.rowsPerPageShort") }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-36">
<DropdownMenuItem v-for="s in pageSizeOptions" :key="s" @click="changePageSize(s)">
{{ s }} {{ t("grid.rowsPerPageShort") }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-xs">{{ t("grid.customRowsPerPage") }}</DropdownMenuLabel>
<div class="flex items-center gap-1 px-2 pb-2" @click.stop @keydown.stop>
<Input
v-model="customPageSizeInput"
type="number"
inputmode="numeric"
:min="MIN_RESULT_PAGE_SIZE"
:max="MAX_RESULT_PAGE_SIZE"
class="h-7 w-24 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@keydown.enter.prevent.stop="applyCustomPageSize"
/>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="h-7 w-7 shrink-0"
:aria-label="t('grid.applyPageSize')"
@click.stop="applyCustomPageSize"
>
<Check class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.applyPageSize") }}</TooltipContent>
</Tooltip>
</div>
</DropdownMenuContent>
</DropdownMenu>
<LightDropdown
:model-value="String(pageSize)"
:items="pageSizeMenuItems"
:trigger-label="`${pageSize}${t('grid.rowsPerPageShort')}`"
trigger-class="inline-flex h-5 items-center justify-center rounded-md px-1.5 text-xs hover:bg-accent hover:text-accent-foreground"
content-class="w-36"
:highlight-selected="false"
check-position="none"
align="end"
@update:model-value="selectPageSizeMenuItem"
>
<div class="bg-border -mx-1 my-1 h-px" />
<div class="text-muted-foreground px-1.5 py-1 text-xs">{{ t("grid.customRowsPerPage") }}</div>
<div class="flex items-center gap-1 px-1.5 pb-1" @click.stop @keydown.stop>
<Input
v-model="customPageSizeInput"
type="number"
inputmode="numeric"
:min="MIN_RESULT_PAGE_SIZE"
:max="MAX_RESULT_PAGE_SIZE"
class="h-6 w-20 px-1.5 text-xs tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@keydown.enter.prevent.stop="applyCustomPageSize"
/>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="outline"
size="icon"
class="h-6 w-6 shrink-0"
:aria-label="t('grid.applyPageSize')"
@click.stop="applyCustomPageSize"
>
<Check class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.applyPageSize") }}</TooltipContent>
</Tooltip>
</div>
</LightDropdown>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="currentPage <= 1" @click="firstPage">
<ChevronsLeft class="h-3 w-3" />
</Button>
@ -5782,28 +5821,20 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</Button>
</span>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5">
<Download class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="exportCsv">{{ t("grid.exportCsv") }}</DropdownMenuItem>
<DropdownMenuItem @click="exportXlsx">{{ t("grid.exportXlsx") }}</DropdownMenuItem>
<DropdownMenuItem @click="exportJson">{{ t("grid.exportJson") }}</DropdownMenuItem>
<DropdownMenuItem @click="exportMarkdown">{{ t("grid.exportMarkdown") }}</DropdownMenuItem>
<template v-if="isMultiRow">
<DropdownMenuSeparator />
<DropdownMenuItem @click="exportSelectedRowsCsv">{{ t("grid.exportSelectedRowsCsv") }}</DropdownMenuItem>
<DropdownMenuItem @click="exportSelectedRowsXlsx">{{ t("grid.exportSelectedRowsXlsx") }}</DropdownMenuItem>
<DropdownMenuItem @click="exportSelectedRowsJson">{{ t("grid.exportSelectedRowsJson") }}</DropdownMenuItem>
<DropdownMenuItem @click="exportSelectedRowsMarkdown">{{
t("grid.exportSelectedRowsMarkdown")
}}</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
<LightDropdown
model-value=""
:items="exportMenuItems"
:aria-label="t('grid.export')"
:trigger-icon="Download"
trigger-class="inline-flex h-5 w-5 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
trigger-icon-class="h-3 w-3"
:show-trigger-label="false"
:show-chevron="false"
:highlight-selected="false"
check-position="none"
align="end"
@update:model-value="selectExportMenuItem"
/>
<Tooltip v-if="sqlOneLiner">
<TooltipTrigger as-child>

View File

@ -1,16 +1,11 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import { Upload, Download, RefreshCw } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@ -30,6 +25,11 @@ const { t } = useI18n();
const connectionStore = useConnectionStore();
const { toast } = useToast();
const connectionTreeRef = ref<InstanceType<typeof ConnectionTree>>();
const importSourceItems = computed(() => [
{ value: "dbx", label: t("sidebar.importDbx") },
{ value: "navicat", label: t("sidebar.importNavicat") },
{ value: "dbeaver", label: t("sidebar.importDbeaver") },
]);
async function refreshTree() {
try {
@ -69,24 +69,22 @@ defineExpose({ focusSearch });
</TooltipTrigger>
<TooltipContent>{{ t("contextMenu.refreshChildren") }}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t('sidebar.import')">
<Upload class="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuItem @select.prevent="emit('import', 'dbx')">
{{ t("sidebar.importDbx") }}
</DropdownMenuItem>
<DropdownMenuItem @select.prevent="emit('import', 'navicat')">
{{ t("sidebar.importNavicat") }}
</DropdownMenuItem>
<DropdownMenuItem @select.prevent="emit('import', 'dbeaver')">
{{ t("sidebar.importDbeaver") }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LightDropdown
model-value=""
:items="importSourceItems"
:aria-label="t('sidebar.import')"
:trigger-title="t('sidebar.import')"
:trigger-icon="Upload"
trigger-class="inline-flex h-5 w-5 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
trigger-icon-class="h-3 w-3"
content-class="w-44"
:show-trigger-label="false"
:show-chevron="false"
:highlight-selected="false"
check-position="none"
align="end"
@update:model-value="(source) => emit('import', source as 'dbx' | 'navicat' | 'dbeaver')"
/>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('export')">

View File

@ -4,12 +4,7 @@ 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 CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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";
@ -155,6 +150,15 @@ const dataTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "da
const showPinnedDataTabsMenu = computed(
() => dataTabs.value.length > 0 && (canScrollLeft.value || canScrollRight.value),
);
const dataTabMenuItems = computed(() =>
dataTabs.value.map((tab) => ({
value: tab.id,
label: tabDisplayTitle(tab),
title: tabDisplayTitle(tab),
icon: Table2,
iconClass: "text-emerald-600 dark:text-emerald-400",
})),
);
function activateDataTab(tabId: string) {
tabScrollBehavior.value = "auto";
@ -166,12 +170,16 @@ const tabsContainerStyle = computed<CSSProperties>(() => ({
msOverflowStyle: "none",
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
paddingRight: showPinnedDataTabsMenu.value ? "36px" : "0px",
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",
);
const dataTabsMenuContainerClass = computed(() =>
settingsStore.editorSettings.appLayout === "classic"
? "absolute inset-y-0 right-0 z-30 flex items-stretch"
? "relative z-30 flex shrink-0 items-stretch"
: "absolute inset-y-0 -right-2 z-30 flex items-stretch",
);
</script>
@ -301,7 +309,7 @@ const dataTabsMenuContainerClass = computed(() =>
<X class="h-3 w-3" />
</button>
</div>
<div class="min-w-8 flex-1 self-stretch" data-tauri-drag-region />
<div :class="tabTailDragRegionClass" data-tauri-drag-region />
</div>
<div
v-if="canScrollRight"
@ -309,27 +317,24 @@ const dataTabsMenuContainerClass = computed(() =>
aria-hidden="true"
/>
<div v-if="showPinnedDataTabsMenu" :class="dataTabsMenuContainerClass">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="h-full w-7 rounded-none text-foreground/70 hover:text-foreground inline-flex items-center justify-center bg-background"
:aria-label="t('tabs.openDataTabs')"
>
<ChevronDown class="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-auto min-w-36 max-w-60">
<DropdownMenuItem
v-for="tab in dataTabs"
:key="tab.id"
class="text-xs max-w-full"
@click="activateDataTab(tab.id)"
>
<Table2 class="w-3.5 h-3.5 mr-2 shrink-0 text-emerald-600 dark:text-emerald-400" />
<span class="truncate flex-1">{{ tabDisplayTitle(tab) }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LightDropdown
:model-value="queryStore.activeTabId || ''"
:items="dataTabMenuItems"
:aria-label="t('tabs.openDataTabs')"
: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-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"
:show-trigger-label="false"
:show-chevron="false"
:highlight-selected="false"
:match-trigger-width="false"
check-position="none"
align="end"
@update:model-value="activateDataTab"
/>
</div>
</div>
</template>

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import {
DatabaseZap,
@ -8,7 +9,6 @@ import {
Moon,
Sun,
SunMoon,
Check,
History,
Bot,
ArrowLeftRight,
@ -20,13 +20,8 @@ import {
Package,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import WindowControls from "@/components/layout/WindowControls.vue";
import { shouldReserveMacTrafficLightInset, useWindowControls } from "@/composables/useWindowControls";
import { currentLocale, setLocale, type Locale } from "@/i18n";
@ -38,7 +33,7 @@ const localeOptions: { value: Locale; flag: string; label: string }[] = [
{ value: "zh-CN", flag: "🇨🇳", label: "简体中文" },
];
defineProps<{
const props = defineProps<{
isDark: boolean;
themeMode: AppThemeMode;
showAiPanel: boolean;
@ -71,6 +66,23 @@ const { t } = useI18n();
const { isMac, isDesktop, showControls, isMaximized, isFullscreen, minimize, toggleMaximize, close } =
useWindowControls();
const themeItems = computed(() => [
{ value: "light", label: t("toolbar.themeLight"), icon: Sun },
{ value: "dark", label: t("toolbar.themeDark"), icon: Moon },
{ value: "system", label: t("toolbar.themeSystem"), icon: SunMoon },
]);
const localeItems = computed(() =>
localeOptions.map((option) => ({
value: option.value,
label: option.label,
leadingText: option.flag,
})),
);
const themeTriggerIcon = computed(() => {
if (props.themeMode === "system") return SunMoon;
return props.isDark ? Moon : Sun;
});
function onToolbarDblClick(e: MouseEvent) {
if (isDesktop) return;
const target = e.target as HTMLElement;
@ -219,44 +231,20 @@ function onToolbarDblClick(e: MouseEvent) {
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('toolbar.theme')">
<SunMoon v-if="themeMode === 'system'" class="h-4 w-4" />
<Moon v-else-if="isDark" class="h-4 w-4" />
<Sun v-else class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
class="gap-2"
:class="{ 'bg-accent': themeMode === 'light' }"
@select="emit('set-theme-mode', 'light')"
>
<Sun class="h-4 w-4" />
{{ t("toolbar.themeLight") }}
<Check v-if="themeMode === 'light'" class="ml-auto h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
class="gap-2"
:class="{ 'bg-accent': themeMode === 'dark' }"
@select="emit('set-theme-mode', 'dark')"
>
<Moon class="h-4 w-4" />
{{ t("toolbar.themeDark") }}
<Check v-if="themeMode === 'dark'" class="ml-auto h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem
class="gap-2"
:class="{ 'bg-accent': themeMode === 'system' }"
@select="emit('set-theme-mode', 'system')"
>
<SunMoon class="h-4 w-4" />
{{ t("toolbar.themeSystem") }}
<Check v-if="themeMode === 'system'" class="ml-auto h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LightDropdown
:model-value="themeMode"
:items="themeItems"
:aria-label="t('toolbar.theme')"
:trigger-icon="themeTriggerIcon"
trigger-class="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
trigger-icon-class="h-4 w-4"
item-icon-class="h-4 w-4"
:show-trigger-label="false"
:show-chevron="false"
check-position="right"
align="end"
@update:model-value="(value) => emit('set-theme-mode', value as AppThemeMode)"
/>
</span>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.theme") }}</TooltipContent>
@ -265,25 +253,19 @@ function onToolbarDblClick(e: MouseEvent) {
<Tooltip>
<TooltipTrigger as-child>
<span class="inline-flex">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.language')">
<Globe class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-for="option in localeOptions"
:key="option.value"
class="gap-2"
:class="{ 'bg-accent': currentLocale() === option.value }"
@click="setLocale(option.value)"
>
<span class="text-base leading-none">{{ option.flag }}</span>
<span>{{ option.label }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LightDropdown
:model-value="currentLocale()"
:items="localeItems"
:aria-label="t('common.language')"
:trigger-icon="Globe"
trigger-class="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent hover:text-accent-foreground"
trigger-icon-class="h-4 w-4"
:show-trigger-label="false"
:show-chevron="false"
check-position="none"
align="end"
@update:model-value="(value) => setLocale(value as Locale)"
/>
</span>
</TooltipTrigger>
<TooltipContent>{{ t("common.language") }}</TooltipContent>

View File

@ -1,7 +1,18 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from "vue";
import { ref, computed, nextTick, watch, type Component } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
import {
Search,
X,
ListFilter,
FolderPlus,
Server,
Database,
FolderTree,
Table2,
Eye,
RotateCcw,
} from "lucide-vue-next";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
@ -26,14 +37,7 @@ import {
import TreeItem from "./TreeItem.vue";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import LightDropdown from "@/components/ui/LightDropdown.vue";
const { t } = useI18n();
const store = useConnectionStore();
@ -80,13 +84,30 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
const searchScopeOptions = computed(() => {
return [
{ scope: "connection", label: t("sidebar.searchScopeConnection") },
{ scope: "database", label: t("sidebar.searchScopeDatabase") },
{ scope: "schema", label: t("sidebar.searchScopeSchema") },
{ scope: "table", label: t("sidebar.searchScopeTable") },
{ scope: "view", label: t("sidebar.searchScopeView") },
] as const satisfies ReadonlyArray<{ scope: SearchScope; label: string }>;
{ scope: "connection", label: t("sidebar.searchScopeConnection"), icon: Server },
{ scope: "database", label: t("sidebar.searchScopeDatabase"), icon: Database },
{ scope: "schema", label: t("sidebar.searchScopeSchema"), icon: FolderTree },
{ scope: "table", label: t("sidebar.searchScopeTable"), icon: Table2 },
{ scope: "view", label: t("sidebar.searchScopeView"), icon: Eye },
] as const satisfies ReadonlyArray<{ scope: SearchScope; label: string; icon: Component }>;
});
const searchScopeMenuItems = computed(() => [
...searchScopeOptions.value.map((item) => ({
value: item.scope,
label: item.label,
icon: item.icon,
})),
...(hasSearchScopeFilter.value
? [
{
value: "__clear",
label: t("sidebar.clearFilter"),
icon: RotateCcw,
separatorBefore: true,
},
]
: []),
]);
const hasSearchScopeFilter = computed(() => selectedSearchScopes.value.length > 0);
const searchableNodeTypes = computed<Set<TreeNodeType> | undefined>(() => {
@ -100,10 +121,6 @@ const searchableNodeTypes = computed<Set<TreeNodeType> | undefined>(() => {
return types;
});
function isSearchScopeSelected(scope: SearchScope) {
return selectedSearchScopes.value.includes(scope);
}
function toggleSearchScope(scope: SearchScope) {
const idx = selectedSearchScopes.value.indexOf(scope);
if (idx >= 0) {
@ -113,6 +130,14 @@ function toggleSearchScope(scope: SearchScope) {
}
}
function selectSearchScopeMenuItem(value: string) {
if (value === "__clear") {
clearSearchScopeFilter();
return;
}
toggleSearchScope(value as SearchScope);
}
function clearSearchScopeFilter() {
selectedSearchScopes.value = [];
}
@ -268,38 +293,32 @@ defineExpose({ focusSearch });
>
<FolderPlus class="h-3.5 w-3.5" />
</button>
<DropdownMenu v-if="searchScopeOptions.length > 0">
<DropdownMenuTrigger as-child>
<button
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent"
:class="hasSearchScopeFilter ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground'"
:title="t('sidebar.filterByType')"
>
<ListFilter class="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<DropdownMenuLabel class="text-xs">{{ t("sidebar.filterByType") }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="item in searchScopeOptions"
:key="item.scope"
class="gap-2"
:class="isSearchScopeSelected(item.scope) ? 'bg-primary/10 text-primary' : ''"
@select.prevent="toggleSearchScope(item.scope)"
>
<Check v-if="isSearchScopeSelected(item.scope)" class="h-3.5 w-3.5 shrink-0 text-primary" />
<span v-else class="h-3.5 w-3.5 shrink-0" />
<span class="flex-1 truncate">{{ item.label }}</span>
</DropdownMenuItem>
<template v-if="hasSearchScopeFilter">
<DropdownMenuSeparator />
<DropdownMenuItem @select.prevent="clearSearchScopeFilter">
<span class="text-xs text-muted-foreground">{{ t("sidebar.clearFilter") }}</span>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
<LightDropdown
v-if="searchScopeOptions.length > 0"
model-value=""
:items="searchScopeMenuItems"
:selected-values="selectedSearchScopes"
:aria-label="t('sidebar.filterByType')"
:label="t('sidebar.filterByType')"
:trigger-title="t('sidebar.filterByType')"
:trigger-icon="ListFilter"
:trigger-class="
[
'shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border hover:bg-accent',
hasSearchScopeFilter ? 'text-primary bg-primary/10 border-primary/30' : 'text-muted-foreground',
].join(' ')
"
trigger-icon-class="h-3.5 w-3.5"
item-icon-class="h-3.5 w-3.5"
content-class="w-max min-w-0"
selected-item-class="bg-primary/10 text-primary"
selected-check-class="text-primary"
:show-trigger-label="false"
:show-chevron="false"
:close-on-select="false"
align="end"
@update:model-value="selectSearchScopeMenuItem"
/>
</div>
</div>
<RecycleScroller

View File

@ -2629,10 +2629,10 @@ function treeItemMenuItems(): ContextMenuItem[] {
}
/* Focused: soft blue */
.sidebar-tree:focus-within .tree-item-active {
.tree-item-active:focus {
background-color: oklch(0.91 0.03 250) !important;
}
:root.dark .sidebar-tree:focus-within .tree-item-active {
:root.dark .tree-item-active:focus {
background-color: oklch(0.35 0.06 250) !important;
}
</style>

View File

@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, type Component } from "vue";
import { Check, ChevronDown } from "lucide-vue-next";
export interface LightDropdownItem {
label: string;
value: string;
title?: string;
icon?: Component;
iconClass?: string;
leadingText?: string;
disabled?: boolean;
separatorBefore?: boolean;
}
const props = withDefaults(
defineProps<{
modelValue: string;
items: LightDropdownItem[];
ariaLabel?: string;
contentClass?: string;
triggerClass?: string;
triggerTitle?: string;
triggerIcon?: Component;
triggerLabel?: string;
showTriggerLabel?: boolean;
showChevron?: boolean;
checkPosition?: "left" | "right" | "none";
align?: "start" | "end";
triggerIconClass?: string;
itemIconClass?: string;
itemClass?: string;
labelClass?: string;
selectedValues?: string[];
highlightSelected?: boolean;
selectedItemClass?: string;
selectedCheckClass?: string;
closeOnSelect?: boolean;
label?: string;
matchTriggerWidth?: boolean;
}>(),
{
ariaLabel: undefined,
contentClass: "",
triggerClass:
"flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground",
triggerTitle: undefined,
triggerIcon: undefined,
triggerLabel: undefined,
showTriggerLabel: true,
showChevron: true,
checkPosition: "left",
align: "start",
triggerIconClass: "h-3 w-3",
itemIconClass: "h-3 w-3",
itemClass: "",
labelClass: "text-muted-foreground px-1.5 py-1 text-xs font-medium",
selectedValues: undefined,
highlightSelected: true,
selectedItemClass: "bg-accent",
selectedCheckClass: "",
closeOnSelect: true,
label: undefined,
matchTriggerWidth: true,
},
);
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const triggerRef = ref<HTMLButtonElement>();
const menuRef = ref<HTMLElement>();
const open = ref(false);
const x = ref(0);
const y = ref(0);
const minWidth = ref(0);
const selectedItem = computed(() => props.items.find((item) => item.value === props.modelValue));
const triggerIcon = computed(() => props.triggerIcon ?? selectedItem.value?.icon);
const menuStyle = computed(() => ({
left: `${x.value}px`,
top: `${y.value}px`,
minWidth: props.matchTriggerWidth ? `${minWidth.value}px` : undefined,
}));
function close() {
open.value = false;
document.removeEventListener("pointerdown", onOutsidePointerDown, true);
document.removeEventListener("keydown", onKeydown);
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
}
function updatePosition() {
const trigger = triggerRef.value;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
x.value = Math.min(Math.max(8, rect.left), window.innerWidth - 8);
y.value = rect.bottom + 4;
minWidth.value = rect.width;
}
function fitPositionToViewport() {
const trigger = triggerRef.value;
const menu = menuRef.value;
if (!trigger || !menu) return;
const triggerRect = trigger.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const gap = 4;
const margin = 8;
const openBelow = triggerRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
const preferredX = props.align === "end" ? triggerRect.right - menuRect.width : triggerRect.left;
x.value = Math.min(Math.max(margin, preferredX), Math.max(margin, window.innerWidth - menuRect.width - margin));
y.value = openBelow ? triggerRect.bottom + gap : Math.max(margin, triggerRect.top - menuRect.height - gap);
}
function openMenu() {
updatePosition();
open.value = true;
nextTick(fitPositionToViewport);
document.addEventListener("pointerdown", onOutsidePointerDown, true);
document.addEventListener("keydown", onKeydown);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
}
function toggle() {
if (open.value) {
close();
} else {
openMenu();
}
}
function onOutsidePointerDown(event: PointerEvent) {
const target = event.target as Node;
if (triggerRef.value?.contains(target) || menuRef.value?.contains(target)) return;
close();
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
close();
}
}
function isItemSelected(item: LightDropdownItem) {
if (props.selectedValues) return props.selectedValues.includes(item.value);
return item.value === props.modelValue;
}
function selectItem(item: LightDropdownItem) {
if (item.disabled) return;
emit("update:modelValue", item.value);
if (props.closeOnSelect) close();
}
onBeforeUnmount(close);
</script>
<template>
<button
ref="triggerRef"
type="button"
:class="triggerClass"
:title="triggerTitle ?? selectedItem?.title"
:aria-label="ariaLabel"
:aria-expanded="open"
@click="toggle"
>
<component :is="triggerIcon" v-if="triggerIcon" :class="triggerIconClass" />
<span v-if="showTriggerLabel">{{ triggerLabel ?? selectedItem?.label }}</span>
<ChevronDown v-if="showChevron" class="h-3 w-3 opacity-50" />
</button>
<Teleport to="body">
<div
v-if="open"
ref="menuRef"
class="fixed z-50 min-w-32 rounded-lg p-1 cn-menu-translucent text-popover-foreground"
:class="contentClass"
:style="menuStyle"
role="menu"
>
<div v-if="label" :class="labelClass">{{ label }}</div>
<div v-if="label" class="bg-border -mx-1 my-1 h-px" />
<template v-for="item in items" :key="item.value">
<div v-if="item.separatorBefore" class="bg-border -mx-1 my-1 h-px" />
<button
type="button"
class="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-sm outline-hidden hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
:class="[itemClass, highlightSelected && isItemSelected(item) ? selectedItemClass : '']"
:disabled="item.disabled"
:title="item.title"
role="menuitem"
@click="selectItem(item)"
>
<Check
v-if="checkPosition === 'left'"
class="h-3 w-3 shrink-0"
:class="[isItemSelected(item) ? selectedCheckClass : 'opacity-0']"
/>
<span v-if="item.leadingText" class="text-base leading-none">{{ item.leadingText }}</span>
<component
:is="item.icon"
v-if="item.icon"
:class="[itemIconClass, 'shrink-0 text-muted-foreground', item.iconClass]"
/>
<span class="truncate">{{ item.label }}</span>
<Check
v-if="checkPosition === 'right' && isItemSelected(item)"
class="ml-auto h-4 w-4 shrink-0"
:class="selectedCheckClass"
/>
</button>
</template>
<slot />
</div>
</Teleport>
</template>