Files
dbx/apps/desktop/src/components/layout/AppSidebar.vue
T

328 lines
15 KiB
Vue

<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import { Upload, Download, ArrowDownUp, FolderPlus, FolderOpen, RefreshCw, ChevronsLeft, ChevronsDownUp, 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";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { QueryTab, TreeNode } from "@/types/database";
defineProps<{
sidebarWidth: number;
classicLayout?: boolean;
}>();
const emit = defineEmits<{
import: [source: "dbx" | "navicat" | "dbeaver" | "datagrip"];
export: [];
startResize: [event: PointerEvent];
collapse: [];
"open-settings": [initialTab: string];
"add-to-ai": [nodes: TreeNode | TreeNode[]];
}>();
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 connectionTransferItems = computed(() => [
...importSourceItems.value.map((item) => ({
...item,
value: `import:${item.value}`,
icon: Download,
})),
{
value: "export",
label: t("sidebar.export"),
icon: Upload,
separatorBefore: true,
},
]);
const connectionTransferLabel = computed(() => t("sidebar.importExport"));
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(() => [
...connectionGroupDestinationRows(connectionStore.sidebarLayout).map((group) => ({
value: group.id,
label: group.name,
title: group.path.join(" / "),
icon: FolderOpen,
indentLevel: group.depth,
})),
{
value: UNGROUPED_GROUP_VALUE,
label: t("connectionGroup.ungrouped"),
separatorBefore: connectionStore.sidebarLayout.groups.length > 0,
},
]);
async function refreshTree() {
try {
await connectionStore.refreshAllTree();
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e) }), 5000);
}
}
function createNewGroup() {
void connectionTreeRef.value?.createNewGroup();
}
function selectImportSource(source: string) {
emit("import", source as ImportSource);
}
function selectConnectionTransferAction(action: string) {
if (action === "export") {
emit("export");
return;
}
if (action.startsWith("import:")) selectImportSource(action.slice("import:".length));
}
function collapseAllTreeNodes() {
connectionTreeRef.value?.collapseAllTreeNodes();
}
function focusSearch(): boolean {
return connectionTreeRef.value?.focusSearch() ?? false;
}
function locateTabInSidebar(tab: QueryTab) {
return connectionTreeRef.value?.locateTabInSidebar(tab);
}
function clearConnectionMultiSelection() {
applyConnectionMultiSelection(connectionStore, emptyConnectionMultiSelection());
}
function isEditableSidebarTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target.isContentEditable || !!target.closest("[contenteditable='true'], [role='textbox']");
}
function onSidebarKeydown(event: KeyboardEvent) {
if (event.defaultPrevented || !connectionStore.connectionMultiSelectActive || !isExitConnectionMultiSelectionShortcut(event) || isEditableSidebarTarget(event.target)) return;
event.preventDefault();
event.stopPropagation();
clearConnectionMultiSelection();
}
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;
const ids = selectedConnectionIds.value;
for (const connectionId of ids) {
connectionStore.moveConnectionToGroup(connectionId, groupId);
}
// The moved connections stay selected otherwise, so the next batch would be
// moved together with them (issue #5758).
clearConnectionMultiSelection();
}
function openCreateSelectedGroupDialog() {
selectedGroupName.value = "";
showCreateSelectedGroupDialog.value = true;
}
function confirmCreateSelectedGroup() {
const name = selectedGroupName.value.trim();
const ids = selectedConnectionIds.value;
if (!name || ids.length === 0) return;
const groupId = connectionStore.createConnectionGroup(name);
for (const connectionId of ids) {
connectionStore.moveConnectionToGroup(connectionId, groupId);
}
clearConnectionMultiSelection();
showCreateSelectedGroupDialog.value = false;
}
defineExpose({ focusSearch, locateTabInSidebar });
</script>
<template>
<div class="app-sidebar-panel h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }" @keydown="onSidebarKeydown">
<div class="h-full flex flex-col overflow-hidden">
<div class="app-sidebar-toolbar 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 v-if="showConnectionMultiSelectToolbar" class="flex min-w-0 self-stretch items-center" data-tauri-drag-region>
<span class="truncate" data-tauri-drag-region>{{ t("sidebar.connections") }}</span>
<span class="ml-1.5 shrink-0 text-[11px] font-normal text-muted-foreground/80" data-connection-selection-count>
{{ t("connectionGroup.selectedConnections", { count: selectedConnectionCount }) }}
</span>
</span>
<LightDropdown
v-else
model-value=""
:items="connectionTransferItems"
:aria-label="connectionTransferLabel"
:trigger-title="connectionTransferLabel"
:trigger-icon="ArrowDownUp"
:trigger-label="connectionTransferLabel"
trigger-class="inline-flex h-7 min-w-0 items-center gap-1 rounded-md px-1 outline-none hover:bg-muted hover:text-foreground focus-visible:ring-0"
trigger-icon-class="h-3.5 w-3.5 shrink-0"
item-icon-class="h-3.5 w-3.5"
content-class="w-48"
:show-trigger-label="true"
:show-chevron="true"
:highlight-selected="false"
check-position="none"
align="start"
@update:model-value="selectConnectionTransferAction"
/>
<span class="flex-1 self-stretch" data-tauri-drag-region />
<template v-if="showConnectionMultiSelectToolbar">
<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>
<span data-sidebar-toolbar-actions class="flex shrink-0 items-center gap-0.5">
<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">
<ChevronsDownUp 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>
</span>
</template>
</div>
<div class="flex-1 min-h-0">
<ConnectionTree ref="connectionTreeRef" @open-settings="(initialTab) => emit('open-settings', initialTab)" @add-to-ai="(nodes) => emit('add-to-ai', nodes)" />
</div>
</div>
<div class="panel-resize-handle panel-resize-handle--right" @pointerdown="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>