feat(sidebar): add connection multi-select toolbar
This commit is contained in:
parent
30b9460442
commit
42826f8f8b
|
|
@ -2,8 +2,10 @@
|
|||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { Upload, Download, FolderPlus, RefreshCw, ChevronsLeft, ChevronsUp } from "@lucide/vue";
|
||||
import { Upload, Download, FolderPlus, RefreshCw, ChevronsLeft, ChevronsUp, Trash2, FolderInput, Check, Minus, Square, X } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import LightDropdown from "@/components/ui/LightDropdown.vue";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
|
||||
|
|
@ -22,16 +24,41 @@ const emit = defineEmits<{
|
|||
collapse: [];
|
||||
}>();
|
||||
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const connectionTreeRef = ref<InstanceType<typeof ConnectionTree>>();
|
||||
const showDeleteSelectedConfirm = ref(false);
|
||||
const showCreateSelectedGroupDialog = ref(false);
|
||||
const selectedGroupName = ref("");
|
||||
const UNGROUPED_GROUP_VALUE = "__ungrouped";
|
||||
const importSourceItems = computed(() => [
|
||||
{ value: "dbx", label: t("sidebar.importDbx") },
|
||||
{ value: "navicat", label: t("sidebar.importNavicat") },
|
||||
{ value: "dbeaver", label: t("sidebar.importDbeaver") },
|
||||
{ value: "datagrip", label: t("sidebar.importDatagrip") },
|
||||
]);
|
||||
const connectionIdSet = computed(() => new Set(connectionStore.connections.map((connection) => connection.id)));
|
||||
const allConnectionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
|
||||
const selectedConnectionIds = computed(() => (connectionStore.connectionMultiSelectActive ? connectionStore.selectedTreeNodeIds.filter((id) => connectionIdSet.value.has(id)) : []));
|
||||
const selectedConnectionCount = computed(() => selectedConnectionIds.value.length);
|
||||
const showConnectionMultiSelectToolbar = computed(() => connectionStore.connectionMultiSelectActive && selectedConnectionCount.value > 0);
|
||||
const allConnectionsSelected = computed(() => allConnectionIds.value.length > 0 && selectedConnectionCount.value === allConnectionIds.value.length);
|
||||
const selectAllIcon = computed(() => (allConnectionsSelected.value ? Check : selectedConnectionCount.value > 0 ? Minus : Square));
|
||||
const selectAllLabel = computed(() => (allConnectionsSelected.value ? t("connectionGroup.deselectAllConnections") : t("connectionGroup.selectAllConnections")));
|
||||
const moveGroupItems = computed(() => [
|
||||
...connectionStore.sidebarLayout.groups.map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
})),
|
||||
{
|
||||
value: UNGROUPED_GROUP_VALUE,
|
||||
label: t("connectionGroup.ungrouped"),
|
||||
separatorBefore: connectionStore.sidebarLayout.groups.length > 0,
|
||||
},
|
||||
]);
|
||||
|
||||
async function refreshTree() {
|
||||
try {
|
||||
|
|
@ -45,6 +72,10 @@ function createNewGroup() {
|
|||
void connectionTreeRef.value?.createNewGroup();
|
||||
}
|
||||
|
||||
function selectImportSource(source: string) {
|
||||
emit("import", source as ImportSource);
|
||||
}
|
||||
|
||||
function collapseAllTreeNodes() {
|
||||
connectionTreeRef.value?.collapseAllTreeNodes();
|
||||
}
|
||||
|
|
@ -53,6 +84,66 @@ function focusSearch(): boolean {
|
|||
return connectionTreeRef.value?.focusSearch() ?? false;
|
||||
}
|
||||
|
||||
function clearConnectionMultiSelection() {
|
||||
connectionStore.connectionMultiSelectActive = false;
|
||||
connectionStore.selectedTreeNodeIds = [];
|
||||
connectionStore.selectedTreeNodeId = null;
|
||||
connectionStore.treeSelectionAnchorId = null;
|
||||
}
|
||||
|
||||
function toggleAllConnectionsSelected() {
|
||||
if (allConnectionsSelected.value) {
|
||||
clearConnectionMultiSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = allConnectionIds.value;
|
||||
connectionStore.connectionMultiSelectActive = ids.length > 0;
|
||||
connectionStore.selectedTreeNodeIds = ids;
|
||||
connectionStore.selectedTreeNodeId = ids[0] ?? null;
|
||||
connectionStore.treeSelectionAnchorId = ids[0] ?? null;
|
||||
}
|
||||
|
||||
async function confirmDeleteSelectedConnections() {
|
||||
const ids = selectedConnectionIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await connectionStore.removeConnections(ids);
|
||||
for (const connectionId of ids) {
|
||||
connectionStore.disconnect(connectionId).catch((error) => {
|
||||
console.warn("[DBX][connection:delete:disconnect-failed]", { connectionId, error });
|
||||
});
|
||||
}
|
||||
clearConnectionMultiSelection();
|
||||
showDeleteSelectedConfirm.value = false;
|
||||
toast(t("connection.deletedSelected", { count: ids.length }), 2000);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelectedConnectionsToGroup(value: string) {
|
||||
const groupId = value === UNGROUPED_GROUP_VALUE ? null : value;
|
||||
for (const connectionId of selectedConnectionIds.value) {
|
||||
connectionStore.moveConnectionToGroup(connectionId, groupId);
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateSelectedGroupDialog() {
|
||||
selectedGroupName.value = "";
|
||||
showCreateSelectedGroupDialog.value = true;
|
||||
}
|
||||
|
||||
function confirmCreateSelectedGroup() {
|
||||
const name = selectedGroupName.value.trim();
|
||||
if (!name || selectedConnectionIds.value.length === 0) return;
|
||||
const groupId = connectionStore.createConnectionGroup(name);
|
||||
for (const connectionId of selectedConnectionIds.value) {
|
||||
connectionStore.moveConnectionToGroup(connectionId, groupId);
|
||||
}
|
||||
showCreateSelectedGroupDialog.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ focusSearch });
|
||||
</script>
|
||||
|
||||
|
|
@ -62,55 +153,125 @@ defineExpose({ focusSearch });
|
|||
<div class="flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20" :class="classicLayout ? 'h-9' : 'h-10'">
|
||||
<span class="flex self-stretch items-center truncate" data-tauri-drag-region>{{ t("sidebar.connections") }}</span>
|
||||
<span class="flex-1 self-stretch" data-tauri-drag-region />
|
||||
<LightTooltip :text="t('sidebar.import')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<span class="inline-flex">
|
||||
<LightDropdown
|
||||
model-value=""
|
||||
:items="importSourceItems"
|
||||
:aria-label="t('sidebar.import')"
|
||||
:trigger-icon="Download"
|
||||
trigger-class="inline-flex h-6 w-5 items-center justify-center rounded-md outline-none hover:bg-muted hover:text-foreground focus-visible:ring-0"
|
||||
trigger-icon-class="h-4 w-4"
|
||||
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' | 'datagrip')"
|
||||
/>
|
||||
</span>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('sidebar.export')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('export')">
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('sidebar.collapseAll')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="collapseAllTreeNodes">
|
||||
<ChevronsUp class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('connectionGroup.createGroup')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="createNewGroup">
|
||||
<FolderPlus class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('contextMenu.refreshChildren')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="refreshTree">
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('sidebar.collapse')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('collapse')">
|
||||
<ChevronsLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<template v-if="showConnectionMultiSelectToolbar">
|
||||
<span class="mr-1 text-[11px] font-medium text-muted-foreground">{{ selectedConnectionCount }}</span>
|
||||
<LightTooltip :text="t('connectionGroup.createGroup')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="openCreateSelectedGroupDialog">
|
||||
<FolderPlus class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('connectionGroup.moveToGroup')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<span class="inline-flex">
|
||||
<LightDropdown
|
||||
model-value=""
|
||||
:items="moveGroupItems"
|
||||
:aria-label="t('connectionGroup.moveToGroup')"
|
||||
:trigger-icon="FolderInput"
|
||||
trigger-class="inline-flex h-5 w-5 items-center justify-center rounded-md outline-none hover:bg-muted hover:text-foreground focus-visible:ring-0"
|
||||
trigger-icon-class="h-3.5 w-3.5"
|
||||
content-class="w-44"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="moveSelectedConnectionsToGroup"
|
||||
/>
|
||||
</span>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('contextMenu.deleteSelectedConnections', { count: selectedConnectionCount })" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5 text-destructive hover:text-destructive" @click="showDeleteSelectedConfirm = true">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="selectAllLabel" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="toggleAllConnectionsSelected">
|
||||
<component :is="selectAllIcon" class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('connectionGroup.exitMultiSelect')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="clearConnectionMultiSelection">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
</template>
|
||||
<template v-else>
|
||||
<LightTooltip :text="t('sidebar.import')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<span class="inline-flex">
|
||||
<LightDropdown
|
||||
model-value=""
|
||||
:items="importSourceItems"
|
||||
:aria-label="t('sidebar.import')"
|
||||
:trigger-icon="Download"
|
||||
trigger-class="inline-flex h-6 w-5 items-center justify-center rounded-md outline-none hover:bg-muted hover:text-foreground focus-visible:ring-0"
|
||||
trigger-icon-class="h-4 w-4"
|
||||
content-class="w-44"
|
||||
:show-trigger-label="false"
|
||||
:show-chevron="false"
|
||||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="selectImportSource"
|
||||
/>
|
||||
</span>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('sidebar.export')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('export')">
|
||||
<Upload class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('sidebar.collapseAll')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="collapseAllTreeNodes">
|
||||
<ChevronsUp class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('connectionGroup.createGroup')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="createNewGroup">
|
||||
<FolderPlus class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('contextMenu.refreshChildren')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="refreshTree">
|
||||
<RefreshCw class="h-3 w-3" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<LightTooltip :text="t('sidebar.collapse')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6" @click="emit('collapse')">
|
||||
<ChevronsLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<ConnectionTree ref="connectionTreeRef" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-resize-handle panel-resize-handle--right" @mousedown="emit('startResize', $event)" />
|
||||
<Dialog v-model:open="showDeleteSelectedConfirm">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("contextMenu.confirmDeleteTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t("contextMenu.confirmDeleteSelectedMessage", { count: selectedConnectionCount }) }}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDeleteSelectedConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteSelectedConnections">{{ t("contextMenu.deleteSelectedConnections", { count: selectedConnectionCount }) }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog v-model:open="showCreateSelectedGroupDialog">
|
||||
<DialogContent class="sm:max-w-[360px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("connectionGroup.createGroup") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input v-model="selectedGroupName" :placeholder="t('connectionGroup.groupNamePlaceholder')" @keydown.enter.prevent="confirmCreateSelectedGroup" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showCreateSelectedGroupDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="!selectedGroupName.trim()" @click="confirmCreateSelectedGroup">{{ t("connectionGroup.createGroup") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -440,6 +440,7 @@ function clearSidebarSelection() {
|
|||
// Clicking the blank area of the tree clears the current selection. Row
|
||||
// clicks call event.stopPropagation(), so this only fires for blank clicks
|
||||
// (issue #681 — selection wasn't cleared in double-click activation mode).
|
||||
store.connectionMultiSelectActive = false;
|
||||
store.selectedTreeNodeId = null;
|
||||
store.selectedTreeNodeIds = [];
|
||||
store.treeSelectionAnchorId = null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, inject } from "vue";
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, inject, type Component } from "vue";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
|
|
@ -48,6 +48,7 @@ import {
|
|||
ListFilter,
|
||||
Package,
|
||||
Clipboard,
|
||||
Check,
|
||||
UsersRound,
|
||||
Lock,
|
||||
HardDriveDownload,
|
||||
|
|
@ -56,8 +57,9 @@ import {
|
|||
ListX,
|
||||
Info,
|
||||
Archive,
|
||||
Square,
|
||||
} from "@lucide/vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import CustomContextMenu from "@/components/ui/CustomContextMenu.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
|
|
@ -151,6 +153,19 @@ const labelOverflowing = ref(false);
|
|||
let labelResizeObserver: ResizeObserver | null = null;
|
||||
let labelMeasureFrame = 0;
|
||||
|
||||
interface ContextMenuItem {
|
||||
label: string;
|
||||
action?: () => void;
|
||||
disabled?: boolean;
|
||||
separator?: boolean;
|
||||
icon?: Component;
|
||||
iconClass?: string;
|
||||
shortcut?: string;
|
||||
variant?: "default" | "destructive";
|
||||
visible?: boolean;
|
||||
children?: ContextMenuItem[];
|
||||
}
|
||||
|
||||
function cancelLabelOverflowMeasure() {
|
||||
if (!labelMeasureFrame) return;
|
||||
window.cancelAnimationFrame(labelMeasureFrame);
|
||||
|
|
@ -715,12 +730,14 @@ function selectedTreeNodesInVisibleOrder(): TreeNode[] {
|
|||
}
|
||||
|
||||
function selectSingleTreeNode(node: TreeNode) {
|
||||
connectionStore.connectionMultiSelectActive = false;
|
||||
connectionStore.selectedTreeNodeId = node.id;
|
||||
connectionStore.selectedTreeNodeIds = [node.id];
|
||||
connectionStore.treeSelectionAnchorId = node.id;
|
||||
}
|
||||
|
||||
function toggleTreeNodeSelection(node: TreeNode) {
|
||||
connectionStore.connectionMultiSelectActive = false;
|
||||
const ids = new Set(connectionStore.selectedTreeNodeIds);
|
||||
if (ids.has(node.id)) ids.delete(node.id);
|
||||
else ids.add(node.id);
|
||||
|
|
@ -730,6 +747,7 @@ function toggleTreeNodeSelection(node: TreeNode) {
|
|||
}
|
||||
|
||||
function selectTreeNodeRange(node: TreeNode) {
|
||||
connectionStore.connectionMultiSelectActive = false;
|
||||
const visible = visibleTreeNodes();
|
||||
const anchorId = connectionStore.treeSelectionAnchorId || connectionStore.selectedTreeNodeId || node.id;
|
||||
const currentIndex = sidebarTreeContext ? sidebarTreeContext.getVisibleNodeIndex(node.id) : -1;
|
||||
|
|
@ -751,6 +769,30 @@ function selectTreeNodeRange(node: TreeNode) {
|
|||
connectionStore.selectedTreeNodeId = node.id;
|
||||
}
|
||||
|
||||
const selectedConnectionIds = computed(() => {
|
||||
const connectionIds = new Set(connectionStore.connections.map((connection) => connection.id));
|
||||
return connectionStore.selectedTreeNodeIds.filter((id) => connectionIds.has(id));
|
||||
});
|
||||
|
||||
const isConnectionSelectionChecked = computed(() => connectionStore.connectionMultiSelectActive && props.node.type === "connection" && !!props.node.connectionId && selectedConnectionIds.value.includes(props.node.connectionId));
|
||||
|
||||
function toggleConnectionMultiSelection(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return;
|
||||
|
||||
const next = new Set(connectionStore.connectionMultiSelectActive ? selectedConnectionIds.value : []);
|
||||
if (next.has(props.node.connectionId)) next.delete(props.node.connectionId);
|
||||
else next.add(props.node.connectionId);
|
||||
|
||||
const ids = [...next];
|
||||
connectionStore.selectedTreeNodeIds = ids;
|
||||
connectionStore.selectedTreeNodeId = ids.includes(props.node.connectionId) ? props.node.connectionId : (ids[0] ?? null);
|
||||
connectionStore.treeSelectionAnchorId = props.node.connectionId;
|
||||
connectionStore.connectionMultiSelectActive = ids.length > 0;
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (suppressNextTableReferenceClick) {
|
||||
suppressNextTableReferenceClick = false;
|
||||
|
|
@ -4529,19 +4571,22 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<CustomContextMenu :items="treeItemMenuItems()" v-slot="{ onContextMenu }">
|
||||
<div @contextmenu="onTreeItemContextMenu($event, onContextMenu)">
|
||||
<CustomContextMenu :items="treeItemMenuItems()" v-slot="contextMenuSlot">
|
||||
<div @contextmenu="onTreeItemContextMenu($event, contextMenuSlot.onContextMenu)">
|
||||
<LightTooltip :text="displayLabel(node)" :disabled="isTooltipDisabled()" side="right" :side-offset="8" :delay="0" :close-delay="0">
|
||||
<div
|
||||
ref="rowRef"
|
||||
class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent relative outline-none"
|
||||
class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer relative outline-none"
|
||||
style="contain: layout style"
|
||||
:class="[
|
||||
rowWidthClass,
|
||||
{
|
||||
'group/sidebar-row': true,
|
||||
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
|
||||
'opacity-50': isDragging,
|
||||
'tree-item-connection-tint': connectionColor,
|
||||
'hover:bg-accent': node.type !== 'connection',
|
||||
'hover:bg-secondary/60': node.type === 'connection',
|
||||
rounded: !isSelected && !isMultiSelected,
|
||||
'tree-item-active rounded-none': connectionColor && (isSelected || isMultiSelected),
|
||||
'tree-item-active rounded-md': !connectionColor && (isSelected || isMultiSelected),
|
||||
|
|
@ -4567,7 +4612,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
</button>
|
||||
</template>
|
||||
<span v-else class="w-3.5 h-3.5 shrink-0" />
|
||||
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="w-3.5 h-3.5 shrink-0" />
|
||||
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="h-3.5 w-3.5 shrink-0" />
|
||||
<Loader2 v-else-if="node.type === 'load-more' && node.isLoading" class="w-3.5 h-3.5 shrink-0 animate-spin text-primary" />
|
||||
<component v-else :is="getIconInfo(node)?.icon || Database" class="w-3.5 h-3.5 shrink-0" :class="nodeIconClass" />
|
||||
<input
|
||||
|
|
@ -4599,6 +4644,18 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
|||
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"><Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }}</Badge>
|
||||
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
|
||||
<Pin v-if="isPinned" class="w-3 h-3 shrink-0 text-primary fill-current" aria-hidden="true" />
|
||||
<button
|
||||
v-if="node.type === 'connection'"
|
||||
type="button"
|
||||
class="ml-auto flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground/55 opacity-0 transition-colors transition-opacity hover:bg-secondary/45 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/sidebar-row:opacity-100"
|
||||
:class="{ 'opacity-100': isConnectionSelectionChecked || connectionStore.connectionMultiSelectActive }"
|
||||
:aria-label="isConnectionSelectionChecked ? t('connectionGroup.deselectConnection') : t('connectionGroup.selectConnection')"
|
||||
@mousedown.stop
|
||||
@click="toggleConnectionMultiSelection"
|
||||
>
|
||||
<Check v-if="isConnectionSelectionChecked" class="h-3 w-3 text-primary" />
|
||||
<Square v-else class="h-3 w-3 stroke-[1.7]" />
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="detailTooltip" #content>
|
||||
<div class="w-max min-w-40 max-w-[min(28rem,calc(100vw-24px))] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-lg">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ defineEmits<{
|
|||
close: [];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
default(props: { onContextMenu: (event: MouseEvent) => void }): any;
|
||||
}>();
|
||||
|
||||
// ---- module-level singleton state ----
|
||||
const openMenus = new Set<() => void>();
|
||||
let globalSetup = false;
|
||||
|
|
@ -261,7 +265,7 @@ onBeforeUnmount(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<slot :on-context-menu="onContextMenu" />
|
||||
<slot :onContextMenu="onContextMenu" />
|
||||
<!-- Main menu -->
|
||||
<Teleport to="body">
|
||||
<div v-if="show" ref="menuRef" :style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }" class="bg-popover text-popover-foreground min-w-40 rounded-[6px] p-1 overflow-x-hidden overflow-y-auto ring-1 ring-foreground/10 shadow-lg">
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ const emit = defineEmits<{
|
|||
"update:open": [value: boolean];
|
||||
}>();
|
||||
|
||||
defineSlots<{
|
||||
"trigger-label"?(props: { value: string; label: string; loading: boolean }): any;
|
||||
"option-label"?(props: { option: string; label: string }): any;
|
||||
"custom-option-label"?(props: { value: string }): any;
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const searchText = ref("");
|
||||
const searchInput = ref<InstanceType<typeof Input>>();
|
||||
|
|
|
|||
|
|
@ -1079,6 +1079,11 @@ export default {
|
|||
connectionGroup: {
|
||||
createGroup: "New Group",
|
||||
groupNamePlaceholder: "Group name",
|
||||
selectConnection: "Select connection",
|
||||
deselectConnection: "Deselect connection",
|
||||
selectAllConnections: "Select all connections",
|
||||
deselectAllConnections: "Deselect all connections",
|
||||
exitMultiSelect: "Exit selection",
|
||||
renameGroup: "Rename Group",
|
||||
deleteGroup: "Delete Group",
|
||||
moveToGroup: "Move to Group",
|
||||
|
|
|
|||
|
|
@ -1050,6 +1050,11 @@ export default withEnglishFallback({
|
|||
newGroupDefault: "Nuevo grupo",
|
||||
deleteGroupConfirmTitle: "Eliminar grupo",
|
||||
deleteGroupConfirmMessage: '¿Eliminar el grupo "{name}"? Las conexiones dentro serán movidas al nivel superior.',
|
||||
selectConnection: "Seleccionar conexión",
|
||||
deselectConnection: "Deseleccionar conexión",
|
||||
selectAllConnections: "Seleccionar todas las conexiones",
|
||||
deselectAllConnections: "Deseleccionar todas las conexiones",
|
||||
exitMultiSelect: "Salir de selección múltiple",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "Buscar en base de datos",
|
||||
|
|
|
|||
|
|
@ -1048,6 +1048,11 @@ export default withEnglishFallback({
|
|||
newGroupDefault: "Nuovo Gruppo",
|
||||
deleteGroupConfirmTitle: "Elimina Gruppo",
|
||||
deleteGroupConfirmMessage: 'Eliminare il gruppo "{name}"? Le connessioni al suo interno verranno spostate al livello principale.',
|
||||
selectConnection: "Seleziona connessione",
|
||||
deselectConnection: "Deseleziona connessione",
|
||||
selectAllConnections: "Seleziona tutte le connessioni",
|
||||
deselectAllConnections: "Deseleziona tutte le connessioni",
|
||||
exitMultiSelect: "Esci dalla selezione multipla",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "Cerca nel Database",
|
||||
|
|
|
|||
|
|
@ -1048,6 +1048,11 @@ export default withEnglishFallback({
|
|||
newGroupDefault: "新しいグループ",
|
||||
deleteGroupConfirmTitle: "グループを削除",
|
||||
deleteGroupConfirmMessage: "グループ「{name}」を削除しますか?グループ内の接続はトップレベルに移動されます。",
|
||||
selectConnection: "接続を選択",
|
||||
deselectConnection: "接続の選択を解除",
|
||||
selectAllConnections: "すべての接続を選択",
|
||||
deselectAllConnections: "すべての接続の選択を解除",
|
||||
exitMultiSelect: "複数選択を終了",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "データベース検索",
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,11 @@ export default withEnglishFallback({
|
|||
newGroupDefault: "Novo Grupo",
|
||||
deleteGroupConfirmTitle: "Excluir Grupo",
|
||||
deleteGroupConfirmMessage: 'Excluir o grupo "{name}"? As conexões dentro dele serão movidas para o nível superior.',
|
||||
selectConnection: "Selecionar conexão",
|
||||
deselectConnection: "Desmarcar conexão",
|
||||
selectAllConnections: "Selecionar todas as conexões",
|
||||
deselectAllConnections: "Desmarcar todas as conexões",
|
||||
exitMultiSelect: "Sair da seleção múltipla",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "Pesquisar no Banco de Dados",
|
||||
|
|
|
|||
|
|
@ -1081,6 +1081,11 @@ export default withEnglishFallback({
|
|||
connectionGroup: {
|
||||
createGroup: "新建分组",
|
||||
groupNamePlaceholder: "分组名称",
|
||||
selectConnection: "选择连接",
|
||||
deselectConnection: "取消选择连接",
|
||||
selectAllConnections: "全选连接",
|
||||
deselectAllConnections: "取消全选连接",
|
||||
exitMultiSelect: "退出多选",
|
||||
renameGroup: "重命名分组",
|
||||
deleteGroup: "删除分组",
|
||||
moveToGroup: "移至分组",
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,11 @@ export default withEnglishFallback({
|
|||
newGroupDefault: "新群組",
|
||||
deleteGroupConfirmTitle: "刪除群組",
|
||||
deleteGroupConfirmMessage: "刪除群組「{name}」?其中的連線將移到頂層。",
|
||||
selectConnection: "選擇連接",
|
||||
deselectConnection: "取消選擇連接",
|
||||
selectAllConnections: "全選連接",
|
||||
deselectAllConnections: "取消全選連接",
|
||||
exitMultiSelect: "退出多選",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "搜尋資料庫",
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const selectedTreeNodeId = ref<string | null>(null);
|
||||
const selectedTreeNodeIds = ref<string[]>([]);
|
||||
const treeSelectionAnchorId = ref<string | null>(null);
|
||||
const connectionMultiSelectActive = ref(false);
|
||||
const treeClipboard = ref<TreeClipboard | null>(null);
|
||||
|
||||
watch(activeConnectionId, (id) => {
|
||||
|
|
@ -3956,6 +3957,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
selectedTreeNodeId,
|
||||
selectedTreeNodeIds,
|
||||
treeSelectionAnchorId,
|
||||
connectionMultiSelectActive,
|
||||
treeClipboard,
|
||||
treeNodes,
|
||||
removeTreeNode,
|
||||
|
|
|
|||
Loading…
Reference in New Issue