feat: add connection group management and sidebar layout persistence
This commit is contained in:
parent
1818cd3b78
commit
bc7f521604
|
|
@ -248,6 +248,33 @@ pub async fn load_connections(app: AppHandle) -> Result<Vec<ConnectionConfig>, S
|
|||
load_connections_from_file(&path, &*store)
|
||||
}
|
||||
|
||||
fn sidebar_layout_file(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir.join("sidebar_layout.json"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_sidebar_layout(
|
||||
app: AppHandle,
|
||||
layout: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let path = sidebar_layout_file(&app)?;
|
||||
let json = serde_json::to_string_pretty(&layout).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_sidebar_layout(app: AppHandle) -> Result<Option<serde_json::Value>, String> {
|
||||
let path = sidebar_layout_file(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||
let value: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn test_connection(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ pub fn run() {
|
|||
commands::connection::disconnect_db,
|
||||
commands::connection::save_connections,
|
||||
commands::connection::load_connections,
|
||||
commands::connection::save_sidebar_layout,
|
||||
commands::connection::load_sidebar_layout,
|
||||
commands::schema::list_databases,
|
||||
commands::schema::list_tables,
|
||||
commands::schema::list_schemas,
|
||||
|
|
|
|||
26
src/App.vue
26
src/App.vue
|
|
@ -135,6 +135,8 @@ const showTableImportDialog = ref(false);
|
|||
const showStructureEditorDialog = ref(false);
|
||||
const showFieldLineageDialog = ref(false);
|
||||
const showDatabaseSearchDialog = ref(false);
|
||||
const showImportLayoutConfirm = ref(false);
|
||||
const pendingImportLayout = ref<import("@/types/database").SidebarLayout | null>(null);
|
||||
const showConfigPassphraseDialog = ref(false);
|
||||
const configPassphraseMode = ref<"export" | "import">("export");
|
||||
const configPassphraseError = ref("");
|
||||
|
|
@ -409,8 +411,12 @@ async function onImportClick() {
|
|||
configPassphraseError.value = "";
|
||||
showConfigPassphraseDialog.value = true;
|
||||
} else {
|
||||
const count = await connectionStore.importConnectionsFromFile(result.content, null);
|
||||
const { count, layout } = await connectionStore.importConnectionsFromFile(result.content, null);
|
||||
toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000);
|
||||
if (layout && count > 0) {
|
||||
pendingImportLayout.value = layout;
|
||||
showImportLayoutConfirm.value = true;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 4000);
|
||||
|
|
@ -419,9 +425,13 @@ async function onImportClick() {
|
|||
|
||||
async function onImportConfirm(passphrase: string) {
|
||||
try {
|
||||
const count = await connectionStore.importConnectionsFromFile(pendingImportContent.value, passphrase);
|
||||
const { count, layout } = await connectionStore.importConnectionsFromFile(pendingImportContent.value, passphrase);
|
||||
showConfigPassphraseDialog.value = false;
|
||||
toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000);
|
||||
if (layout && count > 0) {
|
||||
pendingImportLayout.value = layout;
|
||||
showImportLayoutConfirm.value = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
configPassphraseError.value = e?.message === "wrong_passphrase" ? t("configExport.wrongPassphrase") : (e?.message || String(e));
|
||||
}
|
||||
|
|
@ -1707,6 +1717,18 @@ async function setupFileDrop() {
|
|||
:external-error="configPassphraseError"
|
||||
@confirm="configPassphraseMode === 'export' ? onExportConfirm($event) : onImportConfirm($event)"
|
||||
/>
|
||||
<Dialog v-model:open="showImportLayoutConfirm">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('configExport.importLayoutTitle') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t('configExport.importLayoutConfirm') }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showImportLayoutConfirm = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button @click="showImportLayoutConfirm = false; pendingImportLayout && connectionStore.applySidebarLayout(pendingImportLayout)">{{ t('configExport.importLayoutApply') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog v-model:open="showUpdateDialog">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Search, X, ListFilter, Check } from "lucide-vue-next";
|
||||
import { Search, X, ListFilter, Check, FolderPlus } from "lucide-vue-next";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import TreeItem from "./TreeItem.vue";
|
||||
|
|
@ -20,6 +20,8 @@ const store = useConnectionStore();
|
|||
const searchQuery = ref("");
|
||||
const selectedTypes = ref<string[]>([]);
|
||||
|
||||
const isFiltering = computed(() => !!searchQuery.value.trim() || hasTypeFilter.value);
|
||||
|
||||
const typeStats = computed(() => {
|
||||
const map = new Map<string, { profile: string; label: string; count: number }>();
|
||||
for (const c of store.connections) {
|
||||
|
|
@ -69,6 +71,9 @@ function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
|
|||
}
|
||||
|
||||
function matchesType(node: TreeNode): boolean {
|
||||
if (node.type === "connection-group") {
|
||||
return node.children?.some(matchesType) ?? false;
|
||||
}
|
||||
if (node.type !== "connection" || !node.connectionId) return true;
|
||||
const config = store.getConfig(node.connectionId);
|
||||
if (!config) return true;
|
||||
|
|
@ -80,7 +85,12 @@ const filteredNodes = computed(() => {
|
|||
let nodes = store.treeNodes;
|
||||
|
||||
if (hasTypeFilter.value) {
|
||||
nodes = nodes.filter(matchesType);
|
||||
nodes = nodes.filter(matchesType).map((node) => {
|
||||
if (node.type === "connection-group" && node.children) {
|
||||
return { ...node, children: node.children.filter(matchesType) };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
|
|
@ -90,6 +100,13 @@ const filteredNodes = computed(() => {
|
|||
|
||||
return nodes;
|
||||
});
|
||||
|
||||
const pendingRenameGroupId = ref<string | null>(null);
|
||||
|
||||
function createNewGroup() {
|
||||
const groupId = store.createConnectionGroup(t("connectionGroup.newGroupDefault"));
|
||||
pendingRenameGroupId.value = groupId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -114,6 +131,13 @@ const filteredNodes = computed(() => {
|
|||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 h-6 w-6 flex items-center justify-center rounded border border-border text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('connectionGroup.createGroup')"
|
||||
@click="createNewGroup"
|
||||
>
|
||||
<FolderPlus class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<DropdownMenu v-if="typeStats.length > 1">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button
|
||||
|
|
@ -150,7 +174,15 @@ const filteredNodes = computed(() => {
|
|||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<TreeItem v-for="node in filteredNodes" :key="node.id" :node="node" :depth="0" />
|
||||
<TreeItem
|
||||
v-for="node in filteredNodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:depth="0"
|
||||
:drag-disabled="isFiltering"
|
||||
:pending-rename="pendingRenameGroupId === node.id"
|
||||
@rename-started="pendingRenameGroupId = null"
|
||||
/>
|
||||
<div v-if="store.treeNodes.length === 0" class="px-3 py-8 text-center text-muted-foreground text-xs">
|
||||
{{ t('sidebar.noConnections') }}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
|
||||
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
|
||||
Loader2, FolderOpen, FolderClosed, Trash2, TerminalSquare, RefreshCw,
|
||||
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug,
|
||||
Pin, ArrowRightLeft, Download, FileCode, Network, FileUp, PencilRuler, Search,
|
||||
FolderInput, FolderPlus,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
ContextMenu, ContextMenuContent, ContextMenuItem,
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
|
@ -40,6 +42,12 @@ const { toast } = useToast();
|
|||
const props = defineProps<{
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
dragDisabled?: boolean;
|
||||
pendingRename?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"rename-started": [];
|
||||
}>();
|
||||
|
||||
const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]);
|
||||
|
|
@ -78,6 +86,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
|||
switch (node.type) {
|
||||
case "connection":
|
||||
return null;
|
||||
case "connection-group":
|
||||
return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-amber-500" };
|
||||
case "database":
|
||||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "schema":
|
||||
|
|
@ -116,6 +126,7 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
|||
const leafTypes: Set<TreeNodeType> = new Set(["column", "index", "fkey", "trigger", "redis-db", "mongo-collection"]);
|
||||
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers"]);
|
||||
const pinnableTypes: Set<TreeNodeType> = new Set([
|
||||
"connection-group",
|
||||
"database",
|
||||
"schema",
|
||||
"table",
|
||||
|
|
@ -132,6 +143,13 @@ function isGroupLabel(node: TreeNode): boolean {
|
|||
async function toggle() {
|
||||
const node = props.node;
|
||||
if (node.isLoading) return;
|
||||
|
||||
if (node.type === "connection-group") {
|
||||
node.isExpanded = !node.isExpanded;
|
||||
connectionStore.toggleConnectionGroupCollapsed(node.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.isExpanded) { node.isExpanded = false; return; }
|
||||
|
||||
try {
|
||||
|
|
@ -629,6 +647,109 @@ async function showMore() {
|
|||
displayLimit.value += CHILDREN_PAGE_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Connection Group Management ---
|
||||
const isRenamingGroup = ref(false);
|
||||
const renameInput = ref("");
|
||||
|
||||
function startRenameGroup() {
|
||||
renameInput.value = props.node.label;
|
||||
isRenamingGroup.value = true;
|
||||
emit("rename-started");
|
||||
}
|
||||
|
||||
watch(() => props.pendingRename, (val) => {
|
||||
if (val && props.node.type === "connection-group") {
|
||||
startRenameGroup();
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function finishRenameGroup() {
|
||||
isRenamingGroup.value = false;
|
||||
const trimmed = renameInput.value.trim();
|
||||
if (!trimmed) {
|
||||
connectionStore.deleteConnectionGroup(props.node.id);
|
||||
return;
|
||||
}
|
||||
if (trimmed !== props.node.label) {
|
||||
connectionStore.renameConnectionGroup(props.node.id, trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteConnectionGroup() {
|
||||
showDeleteGroupConfirm.value = true;
|
||||
}
|
||||
|
||||
function confirmDeleteGroup() {
|
||||
connectionStore.deleteConnectionGroup(props.node.id);
|
||||
showDeleteGroupConfirm.value = false;
|
||||
}
|
||||
|
||||
const showDeleteGroupConfirm = ref(false);
|
||||
|
||||
function moveToGroup(groupId: string | null) {
|
||||
if (props.node.connectionId) {
|
||||
connectionStore.moveConnectionToGroup(props.node.connectionId, groupId);
|
||||
}
|
||||
}
|
||||
|
||||
const showMoveToNewGroupDialog = ref(false);
|
||||
const moveToNewGroupName = ref("");
|
||||
|
||||
function moveToNewGroup() {
|
||||
moveToNewGroupName.value = "";
|
||||
showMoveToNewGroupDialog.value = true;
|
||||
}
|
||||
|
||||
function confirmMoveToNewGroup() {
|
||||
const name = moveToNewGroupName.value.trim();
|
||||
if (name && props.node.connectionId) {
|
||||
const groupId = connectionStore.createConnectionGroup(name);
|
||||
connectionStore.moveConnectionToGroup(props.node.connectionId, groupId);
|
||||
}
|
||||
showMoveToNewGroupDialog.value = false;
|
||||
}
|
||||
|
||||
const availableGroups = computed(() => connectionStore.sidebarLayout.groups);
|
||||
|
||||
const currentGroupId = computed(() => {
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return null;
|
||||
for (const entry of connectionStore.sidebarLayout.order) {
|
||||
if (entry.type === "group" && entry.connectionIds.includes(props.node.connectionId)) {
|
||||
return entry.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// --- Drag and Drop ---
|
||||
import { useDragSort } from "@/composables/useDragSort";
|
||||
|
||||
const { state: dragState, startDrag, updateTarget, clearTarget } = useDragSort(
|
||||
(draggedId, targetId, position) => connectionStore.reorderSidebarEntry(draggedId, targetId, position),
|
||||
);
|
||||
|
||||
const isDraggable = computed(() => {
|
||||
if (props.dragDisabled) return false;
|
||||
return props.node.type === "connection" || props.node.type === "connection-group";
|
||||
});
|
||||
|
||||
const isDropTarget = computed(() =>
|
||||
props.node.type === "connection" || props.node.type === "connection-group",
|
||||
);
|
||||
|
||||
const showDropBefore = computed(() =>
|
||||
dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "before",
|
||||
);
|
||||
const showDropAfter = computed(() =>
|
||||
dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "after",
|
||||
);
|
||||
const showDropInside = computed(() =>
|
||||
dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "inside",
|
||||
);
|
||||
const isDragging = computed(() =>
|
||||
dragState.active && dragState.draggedId === props.node.id,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -636,10 +757,19 @@ async function showMore() {
|
|||
<ContextMenuTrigger as-child>
|
||||
<div>
|
||||
<div
|
||||
class="group flex min-w-0 items-center gap-1.5 py-1 px-2 rounded-sm cursor-pointer hover:bg-accent transition-colors"
|
||||
class="group flex min-w-0 items-center gap-1.5 py-1 px-2 rounded-sm cursor-pointer hover:bg-accent transition-colors relative"
|
||||
:class="{
|
||||
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
|
||||
'opacity-50': isDragging,
|
||||
}"
|
||||
:style="{ paddingLeft }"
|
||||
@click="onClick"
|
||||
@mousedown="isDraggable ? startDrag($event, node.id, node.type) : undefined"
|
||||
@mousemove="isDropTarget ? updateTarget($event, node.id, node.type) : undefined"
|
||||
@mouseleave="clearTarget(node.id)"
|
||||
>
|
||||
<div v-if="showDropBefore" class="absolute right-2 top-0 h-0.5 bg-primary rounded-full pointer-events-none" :style="{ left: paddingLeft }" />
|
||||
<div v-if="showDropAfter" class="absolute right-2 bottom-0 h-0.5 bg-primary rounded-full pointer-events-none" :style="{ left: paddingLeft }" />
|
||||
<template v-if="canExpand">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -655,7 +785,17 @@ async function showMore() {
|
|||
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="w-3.5 h-3.5 shrink-0" />
|
||||
<component v-else :is="getIconInfo(node)?.icon || Database" class="w-3.5 h-3.5 shrink-0" :class="getIconInfo(node)?.colorClass" />
|
||||
<span v-if="node.type === 'connection'" class="h-3 w-1.5 rounded-full shrink-0" :style="{ backgroundColor: connectionColor || '#9ca3af' }" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ isGroupLabel(node) ? t(node.label) : node.label }}</span>
|
||||
<input
|
||||
v-if="isRenamingGroup"
|
||||
v-model="renameInput"
|
||||
class="min-w-0 flex-1 truncate bg-transparent border border-primary/50 rounded px-1 text-xs outline-none"
|
||||
@blur="finishRenameGroup"
|
||||
@keydown.enter.prevent="finishRenameGroup"
|
||||
@keydown.escape.prevent="isRenamingGroup = false"
|
||||
@click.stop
|
||||
@vue:mounted="($event: any) => $event.el.focus()"
|
||||
/>
|
||||
<span v-else class="min-w-0 flex-1 truncate">{{ isGroupLabel(node) ? t(node.label) : node.label }}</span>
|
||||
<span v-if="columnComment" class="truncate text-muted-foreground/60 text-[10px] max-w-[40%]">{{ columnComment }}</span>
|
||||
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
<button
|
||||
|
|
@ -669,7 +809,7 @@ async function showMore() {
|
|||
</button>
|
||||
</div>
|
||||
<template v-if="node.isExpanded && node.children">
|
||||
<TreeItem v-for="child in visibleChildren" :key="child.id" :node="child" :depth="depth + 1" />
|
||||
<TreeItem v-for="child in visibleChildren" :key="child.id" :node="child" :depth="depth + 1" :drag-disabled="dragDisabled" />
|
||||
<div
|
||||
v-if="hasMoreChildren"
|
||||
class="flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent text-xs text-muted-foreground"
|
||||
|
|
@ -704,6 +844,32 @@ async function showMore() {
|
|||
<FileCode class="w-4 h-4" /> {{ t('sqlFile.title') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub v-if="availableGroups.length > 0 || currentGroupId">
|
||||
<ContextMenuSubTrigger>
|
||||
<FolderInput class="w-4 h-4" /> {{ t('connectionGroup.moveToGroup') }}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
v-for="group in availableGroups"
|
||||
:key="group.id"
|
||||
:disabled="group.id === currentGroupId"
|
||||
@click="moveToGroup(group.id)"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" /> {{ group.name }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="currentGroupId" />
|
||||
<ContextMenuItem v-if="currentGroupId" @click="moveToGroup(null)">
|
||||
{{ t('connectionGroup.ungrouped') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem @click="moveToNewGroup">
|
||||
<FolderPlus class="w-4 h-4" /> {{ t('connectionGroup.newGroup') }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem v-else @click="moveToNewGroup">
|
||||
<FolderPlus class="w-4 h-4" /> {{ t('connectionGroup.moveToNewGroup') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="refresh">
|
||||
<RefreshCw class="w-4 h-4" /> {{ t('contextMenu.refreshChildren') }}
|
||||
</ContextMenuItem>
|
||||
|
|
@ -716,6 +882,16 @@ async function showMore() {
|
|||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'connection-group'">
|
||||
<ContextMenuItem @click="startRenameGroup">
|
||||
<Pencil class="w-4 h-4" /> {{ t('connectionGroup.renameGroup') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem class="text-destructive" @click="deleteConnectionGroup">
|
||||
<Trash2 class="w-4 h-4" /> {{ t('connectionGroup.deleteGroup') }}
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
|
||||
<template v-if="node.type === 'database' || node.type === 'schema'">
|
||||
<ContextMenuItem @click="newQuery">
|
||||
<TerminalSquare class="w-4 h-4" /> {{ t('contextMenu.newQuery') }}
|
||||
|
|
@ -813,4 +989,34 @@ async function showMore() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showMoveToNewGroupDialog">
|
||||
<DialogContent class="sm:max-w-[360px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('connectionGroup.createGroup') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
v-model="moveToNewGroupName"
|
||||
:placeholder="t('connectionGroup.groupNamePlaceholder')"
|
||||
@keydown.enter.prevent="confirmMoveToNewGroup"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showMoveToNewGroupDialog = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button :disabled="!moveToNewGroupName.trim()" @click="confirmMoveToNewGroup">{{ t('connectionGroup.createGroup') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showDeleteGroupConfirm">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('connectionGroup.deleteGroupConfirmTitle') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t('connectionGroup.deleteGroupConfirmMessage', { name: node.label }) }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showDeleteGroupConfirm = false">{{ t('dangerDialog.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="confirmDeleteGroup">{{ t('connectionGroup.deleteGroup') }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
import { reactive, readonly } from "vue";
|
||||
|
||||
export type DropPosition = "before" | "after" | "inside";
|
||||
|
||||
interface DragState {
|
||||
active: boolean;
|
||||
draggedId: string | null;
|
||||
draggedType: string | null;
|
||||
targetId: string | null;
|
||||
dropPosition: DropPosition | null;
|
||||
startX: number;
|
||||
startY: number;
|
||||
}
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
const state = reactive<DragState>({
|
||||
active: false,
|
||||
draggedId: null,
|
||||
draggedType: null,
|
||||
targetId: null,
|
||||
dropPosition: null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
});
|
||||
|
||||
let pending: { id: string; type: string; x: number; y: number; sourceEl: HTMLElement | null } | null = null;
|
||||
let onDropCallback: ((draggedId: string, targetId: string, position: DropPosition) => void) | null = null;
|
||||
let ghostEl: HTMLElement | null = null;
|
||||
|
||||
function createGhost(sourceEl: HTMLElement, x: number, y: number) {
|
||||
const ghost = document.createElement("div");
|
||||
const textNode = sourceEl.querySelector(".truncate");
|
||||
ghost.textContent = textNode?.textContent || "";
|
||||
ghost.style.cssText = `
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||
border-radius: 4px;
|
||||
background: var(--background, #fff);
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
max-width: 200px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
left: ${x + 12}px;
|
||||
top: ${y - 10}px;
|
||||
`;
|
||||
document.body.appendChild(ghost);
|
||||
return ghost;
|
||||
}
|
||||
|
||||
function moveGhost(x: number, y: number) {
|
||||
if (!ghostEl) return;
|
||||
ghostEl.style.left = `${x + 8}px`;
|
||||
ghostEl.style.top = `${y - 12}px`;
|
||||
}
|
||||
|
||||
function removeGhost() {
|
||||
if (ghostEl) {
|
||||
ghostEl.remove();
|
||||
ghostEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
if (!pending && !state.active) return;
|
||||
|
||||
if (pending && !state.active) {
|
||||
const dx = event.clientX - pending.x;
|
||||
const dy = event.clientY - pending.y;
|
||||
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
|
||||
state.active = true;
|
||||
state.draggedId = pending.id;
|
||||
state.draggedType = pending.type;
|
||||
state.startX = pending.x;
|
||||
state.startY = pending.y;
|
||||
if (pending.sourceEl) {
|
||||
ghostEl = createGhost(pending.sourceEl, event.clientX, event.clientY);
|
||||
}
|
||||
pending = null;
|
||||
document.body.style.cursor = "grabbing";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
if (state.active) {
|
||||
moveGhost(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
|
||||
onDropCallback(state.draggedId, state.targetId, state.dropPosition);
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.active = false;
|
||||
state.draggedId = null;
|
||||
state.draggedType = null;
|
||||
state.targetId = null;
|
||||
state.dropPosition = null;
|
||||
state.startX = 0;
|
||||
state.startY = 0;
|
||||
pending = null;
|
||||
removeGhost();
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
|
||||
let listenersAttached = false;
|
||||
|
||||
function ensureListeners() {
|
||||
if (listenersAttached) return;
|
||||
document.addEventListener("mousemove", onMouseMove, true);
|
||||
document.addEventListener("mouseup", onMouseUp, true);
|
||||
listenersAttached = true;
|
||||
}
|
||||
|
||||
export function useDragSort(onDrop: (draggedId: string, targetId: string, position: DropPosition) => void) {
|
||||
ensureListeners();
|
||||
onDropCallback = onDrop;
|
||||
|
||||
function startDrag(event: MouseEvent, nodeId: string, nodeType: string) {
|
||||
if (event.button !== 0) return;
|
||||
const el = (event.currentTarget as HTMLElement) || null;
|
||||
pending = { id: nodeId, type: nodeType, x: event.clientX, y: event.clientY, sourceEl: el };
|
||||
}
|
||||
|
||||
function updateTarget(event: MouseEvent, nodeId: string, nodeType: string) {
|
||||
if (!state.active || nodeId === state.draggedId) {
|
||||
if (state.targetId === nodeId) {
|
||||
state.targetId = null;
|
||||
state.dropPosition = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state.targetId = nodeId;
|
||||
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const y = event.clientY - rect.top;
|
||||
const third = rect.height / 3;
|
||||
|
||||
if (nodeType === "connection-group" && y > third && y < rect.height - third) {
|
||||
state.dropPosition = "inside";
|
||||
} else if (y < rect.height / 2) {
|
||||
state.dropPosition = "before";
|
||||
} else {
|
||||
state.dropPosition = "after";
|
||||
}
|
||||
}
|
||||
|
||||
function clearTarget(nodeId: string) {
|
||||
if (state.targetId === nodeId) {
|
||||
state.targetId = null;
|
||||
state.dropPosition = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: readonly(state),
|
||||
startDrag,
|
||||
updateTarget,
|
||||
clearTarget,
|
||||
};
|
||||
}
|
||||
|
|
@ -233,6 +233,19 @@ export default {
|
|||
cancel: "Cancel",
|
||||
refresh: "Analyze Again",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "New Group",
|
||||
groupNamePlaceholder: "Group name",
|
||||
renameGroup: "Rename Group",
|
||||
deleteGroup: "Delete Group",
|
||||
moveToGroup: "Move to Group",
|
||||
moveToNewGroup: "Move to New Group",
|
||||
ungrouped: "Ungrouped",
|
||||
newGroup: "New Group...",
|
||||
newGroupDefault: "New Group",
|
||||
deleteGroupConfirmTitle: "Delete Group",
|
||||
deleteGroupConfirmMessage: "Delete group \"{name}\"? Connections inside will be moved to the top level.",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "Search Database",
|
||||
open: "Search Database",
|
||||
|
|
@ -271,6 +284,9 @@ export default {
|
|||
exportSuccess: "Connections exported successfully",
|
||||
importSuccess: "Imported {count} connection(s)",
|
||||
importNone: "No new connections to import",
|
||||
importLayoutConfirm: "The imported file contains connection groups. Apply them?",
|
||||
importLayoutTitle: "Import Groups",
|
||||
importLayoutApply: "Apply",
|
||||
},
|
||||
ai: {
|
||||
placeholder: "Describe your query in natural language...",
|
||||
|
|
|
|||
|
|
@ -233,6 +233,19 @@ export default {
|
|||
cancel: "取消读取",
|
||||
refresh: "重新分析",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "新建分组",
|
||||
groupNamePlaceholder: "分组名称",
|
||||
renameGroup: "重命名分组",
|
||||
deleteGroup: "删除分组",
|
||||
moveToGroup: "移至分组",
|
||||
moveToNewGroup: "移至新分组",
|
||||
ungrouped: "取消分组",
|
||||
newGroup: "新建分组...",
|
||||
newGroupDefault: "新分组",
|
||||
deleteGroupConfirmTitle: "删除分组",
|
||||
deleteGroupConfirmMessage: "删除分组「{name}」?其中的连接将移到顶层。",
|
||||
},
|
||||
databaseSearch: {
|
||||
title: "在数据库中查找",
|
||||
open: "在数据库中查找",
|
||||
|
|
@ -271,6 +284,9 @@ export default {
|
|||
exportSuccess: "连接配置导出成功",
|
||||
importSuccess: "已导入 {count} 个连接",
|
||||
importNone: "没有新的连接需要导入",
|
||||
importLayoutConfirm: "导入文件包含连接分组信息,是否一并应用?",
|
||||
importLayoutTitle: "导入分组",
|
||||
importLayoutApply: "应用",
|
||||
},
|
||||
ai: {
|
||||
placeholder: "描述你想查什么...",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
import type { ConnectionConfig, ConnectionGroup, SidebarLayout, SidebarOrderEntry, TreeNode } from "@/types/database";
|
||||
|
||||
export function emptyLayout(): SidebarLayout {
|
||||
return { groups: [], order: [] };
|
||||
}
|
||||
|
||||
export function reconcileLayout(connectionIds: string[], layout: SidebarLayout | null): SidebarLayout {
|
||||
if (!layout) {
|
||||
return {
|
||||
groups: [],
|
||||
order: connectionIds.map((id) => ({ type: "connection" as const, id })),
|
||||
};
|
||||
}
|
||||
|
||||
const validIds = new Set(connectionIds);
|
||||
const seen = new Set<string>();
|
||||
|
||||
const order: SidebarOrderEntry[] = [];
|
||||
for (const entry of layout.order) {
|
||||
if (entry.type === "group") {
|
||||
const filtered = entry.connectionIds.filter((id) => {
|
||||
if (!validIds.has(id) || seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
order.push({ type: "group", id: entry.id, connectionIds: filtered });
|
||||
} else {
|
||||
if (validIds.has(entry.id) && !seen.has(entry.id)) {
|
||||
seen.add(entry.id);
|
||||
order.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of connectionIds) {
|
||||
if (!seen.has(id)) {
|
||||
order.push({ type: "connection", id });
|
||||
}
|
||||
}
|
||||
|
||||
const usedGroupIds = new Set(order.filter((e) => e.type === "group").map((e) => e.id));
|
||||
const groups = layout.groups.filter((g) => usedGroupIds.has(g.id));
|
||||
|
||||
return { groups, order };
|
||||
}
|
||||
|
||||
function makeConnectionNode(config: ConnectionConfig, pinned: boolean): TreeNode {
|
||||
return {
|
||||
id: config.id,
|
||||
label: config.name,
|
||||
type: "connection",
|
||||
connectionId: config.id,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
pinned,
|
||||
};
|
||||
}
|
||||
|
||||
function orderPinnedFirst(nodes: TreeNode[]): TreeNode[] {
|
||||
const pinned: TreeNode[] = [];
|
||||
const unpinned: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.pinned) pinned.push(node);
|
||||
else unpinned.push(node);
|
||||
}
|
||||
return [...pinned, ...unpinned];
|
||||
}
|
||||
|
||||
export function buildTreeNodesFromLayout(
|
||||
layout: SidebarLayout,
|
||||
connections: ConnectionConfig[],
|
||||
pinnedIds: Set<string>,
|
||||
): TreeNode[] {
|
||||
const configMap = new Map(connections.map((c) => [c.id, c]));
|
||||
const groupMap = new Map(layout.groups.map((g) => [g.id, g]));
|
||||
|
||||
const nodes: TreeNode[] = [];
|
||||
for (const entry of layout.order) {
|
||||
if (entry.type === "group") {
|
||||
const group = groupMap.get(entry.id);
|
||||
if (!group) continue;
|
||||
const children: TreeNode[] = [];
|
||||
for (const connId of entry.connectionIds) {
|
||||
const config = configMap.get(connId);
|
||||
if (config) children.push(makeConnectionNode(config, pinnedIds.has(connId)));
|
||||
}
|
||||
nodes.push({
|
||||
id: group.id,
|
||||
label: group.name,
|
||||
type: "connection-group",
|
||||
isExpanded: !group.collapsed,
|
||||
children: orderPinnedFirst(children),
|
||||
});
|
||||
} else {
|
||||
const config = configMap.get(entry.id);
|
||||
if (config) nodes.push(makeConnectionNode(config, pinnedIds.has(entry.id)));
|
||||
}
|
||||
}
|
||||
|
||||
return orderPinnedFirst(nodes);
|
||||
}
|
||||
|
||||
export function findConnectionLocation(layout: SidebarLayout, connectionId: string): { entryIndex: number; groupId?: string; innerIndex?: number } | null {
|
||||
for (let i = 0; i < layout.order.length; i++) {
|
||||
const entry = layout.order[i];
|
||||
if (entry.type === "connection" && entry.id === connectionId) {
|
||||
return { entryIndex: i };
|
||||
}
|
||||
if (entry.type === "group") {
|
||||
const innerIndex = entry.connectionIds.indexOf(connectionId);
|
||||
if (innerIndex >= 0) {
|
||||
return { entryIndex: i, groupId: entry.id, innerIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeConnectionFromLayout(order: SidebarOrderEntry[], connectionId: string): SidebarOrderEntry[] {
|
||||
return order.map((entry) => {
|
||||
if (entry.type === "connection" && entry.id === connectionId) return null;
|
||||
if (entry.type === "group") {
|
||||
return { ...entry, connectionIds: entry.connectionIds.filter((id) => id !== connectionId) };
|
||||
}
|
||||
return entry;
|
||||
}).filter(Boolean) as SidebarOrderEntry[];
|
||||
}
|
||||
|
||||
export function moveConnectionToGroup(layout: SidebarLayout, connectionId: string, targetGroupId: string | null): SidebarLayout {
|
||||
const order = removeConnectionFromLayout([...layout.order], connectionId);
|
||||
|
||||
if (targetGroupId) {
|
||||
const groupEntry = order.find((e) => e.type === "group" && e.id === targetGroupId);
|
||||
if (groupEntry && groupEntry.type === "group") {
|
||||
groupEntry.connectionIds.push(connectionId);
|
||||
}
|
||||
} else {
|
||||
order.push({ type: "connection", id: connectionId });
|
||||
}
|
||||
|
||||
return { ...layout, order };
|
||||
}
|
||||
|
||||
export type DropPosition = "before" | "after" | "inside";
|
||||
|
||||
export function reorderEntry(
|
||||
layout: SidebarLayout,
|
||||
draggedId: string,
|
||||
targetId: string,
|
||||
position: DropPosition,
|
||||
): SidebarLayout {
|
||||
if (draggedId === targetId) return layout;
|
||||
|
||||
const isDraggedGroup = layout.order.some((e) => e.type === "group" && e.id === draggedId);
|
||||
const isTargetGroup = layout.order.some((e) => e.type === "group" && e.id === targetId);
|
||||
|
||||
if (isDraggedGroup) {
|
||||
return reorderGroup(layout, draggedId, targetId, position);
|
||||
}
|
||||
|
||||
if (position === "inside" && isTargetGroup) {
|
||||
return moveConnectionToGroup(layout, draggedId, targetId);
|
||||
}
|
||||
|
||||
return reorderConnection(layout, draggedId, targetId, position);
|
||||
}
|
||||
|
||||
function reorderGroup(layout: SidebarLayout, draggedId: string, targetId: string, position: DropPosition): SidebarLayout {
|
||||
const order = [...layout.order];
|
||||
const draggedIndex = order.findIndex((e) => e.type === "group" && e.id === draggedId);
|
||||
if (draggedIndex < 0) return layout;
|
||||
|
||||
const [dragged] = order.splice(draggedIndex, 1);
|
||||
|
||||
let targetIndex = order.findIndex((e) => (e.type === "group" && e.id === targetId) || (e.type === "connection" && e.id === targetId));
|
||||
if (targetIndex < 0) {
|
||||
order.push(dragged);
|
||||
} else {
|
||||
if (position === "after") targetIndex++;
|
||||
order.splice(targetIndex, 0, dragged);
|
||||
}
|
||||
|
||||
return { ...layout, order };
|
||||
}
|
||||
|
||||
function reorderConnection(layout: SidebarLayout, draggedId: string, targetId: string, position: DropPosition): SidebarLayout {
|
||||
const order = removeConnectionFromLayout([...layout.order], draggedId);
|
||||
|
||||
const targetLoc = findConnectionLocation({ ...layout, order }, targetId);
|
||||
if (!targetLoc) {
|
||||
order.push({ type: "connection", id: draggedId });
|
||||
return { ...layout, order };
|
||||
}
|
||||
|
||||
if (targetLoc.groupId) {
|
||||
const groupEntry = order[targetLoc.entryIndex];
|
||||
if (groupEntry.type === "group") {
|
||||
const insertAt = position === "after" ? targetLoc.innerIndex! + 1 : targetLoc.innerIndex!;
|
||||
groupEntry.connectionIds.splice(insertAt, 0, draggedId);
|
||||
}
|
||||
} else {
|
||||
const isTargetGroup = order[targetLoc.entryIndex]?.type === "group";
|
||||
if (isTargetGroup && position === "inside") {
|
||||
const entry = order[targetLoc.entryIndex];
|
||||
if (entry.type === "group") entry.connectionIds.push(draggedId);
|
||||
} else {
|
||||
const insertAt = position === "after" ? targetLoc.entryIndex + 1 : targetLoc.entryIndex;
|
||||
order.splice(insertAt, 0, { type: "connection", id: draggedId });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...layout, order };
|
||||
}
|
||||
|
||||
export function createGroup(layout: SidebarLayout, name: string): { layout: SidebarLayout; groupId: string } {
|
||||
const groupId = crypto.randomUUID();
|
||||
const group: ConnectionGroup = { id: groupId, name, collapsed: false };
|
||||
return {
|
||||
groupId,
|
||||
layout: {
|
||||
groups: [...layout.groups, group],
|
||||
order: [...layout.order, { type: "group" as const, id: groupId, connectionIds: [] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renameGroup(layout: SidebarLayout, groupId: string, name: string): SidebarLayout {
|
||||
return {
|
||||
...layout,
|
||||
groups: layout.groups.map((g) => g.id === groupId ? { ...g, name } : g),
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteGroup(layout: SidebarLayout, groupId: string): SidebarLayout {
|
||||
const order: SidebarOrderEntry[] = [];
|
||||
for (const entry of layout.order) {
|
||||
if (entry.type === "group" && entry.id === groupId) {
|
||||
for (const connId of entry.connectionIds) {
|
||||
order.push({ type: "connection", id: connId });
|
||||
}
|
||||
} else {
|
||||
order.push(entry);
|
||||
}
|
||||
}
|
||||
return {
|
||||
groups: layout.groups.filter((g) => g.id !== groupId),
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleGroupCollapsed(layout: SidebarLayout, groupId: string): SidebarLayout {
|
||||
return {
|
||||
...layout,
|
||||
groups: layout.groups.map((g) => g.id === groupId ? { ...g, collapsed: !g.collapsed } : g),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeConnectionFromSidebarLayout(layout: SidebarLayout, connectionId: string): SidebarLayout {
|
||||
return { ...layout, order: removeConnectionFromLayout(layout.order, connectionId) };
|
||||
}
|
||||
|
||||
export function appendConnectionToLayout(layout: SidebarLayout, connectionId: string): SidebarLayout {
|
||||
return { ...layout, order: [...layout.order, { type: "connection" as const, id: connectionId }] };
|
||||
}
|
||||
|
|
@ -178,6 +178,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
|
|||
return invoke("load_connections");
|
||||
}
|
||||
|
||||
export async function saveSidebarLayout(layout: import("@/types/database").SidebarLayout): Promise<void> {
|
||||
return invoke("save_sidebar_layout", { layout });
|
||||
}
|
||||
|
||||
export async function loadSidebarLayout(): Promise<import("@/types/database").SidebarLayout | null> {
|
||||
return invoke("load_sidebar_layout");
|
||||
}
|
||||
|
||||
// --- Updates ---
|
||||
export interface UpdateInfo {
|
||||
current_version: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,21 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import type { ColumnInfo, ConnectionConfig, TreeNode } from "@/types/database";
|
||||
import type { ColumnInfo, ConnectionConfig, SidebarLayout, TreeNode } from "@/types/database";
|
||||
import { orderPinnedFirst } from "@/lib/pinnedItems";
|
||||
import {
|
||||
reconcileLayout,
|
||||
buildTreeNodesFromLayout,
|
||||
emptyLayout,
|
||||
appendConnectionToLayout,
|
||||
removeConnectionFromSidebarLayout,
|
||||
createGroup as createGroupOp,
|
||||
renameGroup as renameGroupOp,
|
||||
deleteGroup as deleteGroupOp,
|
||||
toggleGroupCollapsed as toggleGroupCollapsedOp,
|
||||
moveConnectionToGroup as moveConnectionToGroupOp,
|
||||
reorderEntry as reorderEntryOp,
|
||||
type DropPosition,
|
||||
} from "@/lib/sidebarLayout";
|
||||
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion";
|
||||
import * as api from "@/lib/tauri";
|
||||
|
||||
|
|
@ -24,6 +38,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const structureEditorSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string } | null>(null);
|
||||
const fieldLineageSource = ref<{ connectionId: string; database: string; schema?: string; tableName: string; columnName: string } | null>(null);
|
||||
const databaseSearchSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null);
|
||||
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
|
||||
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function startEditing(id: string) {
|
||||
editingConnectionId.value = id;
|
||||
|
|
@ -61,23 +77,6 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
};
|
||||
}
|
||||
|
||||
function upsertConnectionNode(config: ConnectionConfig) {
|
||||
const node: TreeNode = {
|
||||
id: config.id,
|
||||
label: config.name,
|
||||
type: "connection",
|
||||
connectionId: config.id,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
};
|
||||
const existing = treeNodes.value.findIndex((n) => n.id === config.id);
|
||||
if (existing >= 0) {
|
||||
treeNodes.value[existing] = { ...treeNodes.value[existing], ...node };
|
||||
} else {
|
||||
treeNodes.value.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPinnedTreeNodeIds(): Set<string> {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return new Set();
|
||||
|
|
@ -128,11 +127,15 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const node = findNode(treeNodes.value, id);
|
||||
if (node) node.pinned = next.has(id);
|
||||
|
||||
const parent = findParentNode(treeNodes.value, id);
|
||||
if (parent?.children) {
|
||||
parent.children = orderPinnedFirst(parent.children, (child) => !!child.pinned);
|
||||
const isConnectionOrGroup = treeNodes.value.some((n) => n.id === id) ||
|
||||
treeNodes.value.some((n) => n.type === "connection-group" && n.children?.some((c) => c.id === id));
|
||||
if (isConnectionOrGroup) {
|
||||
rebuildTreeNodes();
|
||||
} else {
|
||||
treeNodes.value = orderPinnedFirst(treeNodes.value, (child) => !!child.pinned);
|
||||
const parent = findParentNode(treeNodes.value, id);
|
||||
if (parent?.children) {
|
||||
parent.children = orderPinnedFirst(parent.children, (child) => !!child.pinned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,10 +147,12 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
nextConnections[existing] = normalized;
|
||||
} else {
|
||||
nextConnections.push(normalized);
|
||||
sidebarLayout.value = appendConnectionToLayout(sidebarLayout.value, normalized.id);
|
||||
}
|
||||
await persistConnections(nextConnections);
|
||||
connections.value = nextConnections;
|
||||
upsertConnectionNode(normalized);
|
||||
rebuildTreeNodes();
|
||||
persistSidebarLayoutDebounced();
|
||||
}
|
||||
|
||||
function invalidateCompletionCache(connectionId: string) {
|
||||
|
|
@ -164,7 +169,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
const nextConnections = connections.value.filter((c) => c.id !== id);
|
||||
await persistConnections(nextConnections);
|
||||
connections.value = nextConnections;
|
||||
treeNodes.value = treeNodes.value.filter((n) => n.id !== id);
|
||||
sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id);
|
||||
rebuildTreeNodes();
|
||||
persistSidebarLayoutDebounced();
|
||||
if (activeConnectionId.value === id) {
|
||||
activeConnectionId.value = null;
|
||||
}
|
||||
|
|
@ -179,12 +186,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
nextConnections[idx] = config;
|
||||
await persistConnections(nextConnections);
|
||||
connections.value = nextConnections;
|
||||
const node = findNode(treeNodes.value, config.id);
|
||||
if (node) {
|
||||
node.label = config.name;
|
||||
node.isExpanded = false;
|
||||
node.children = [];
|
||||
}
|
||||
rebuildTreeNodes();
|
||||
connectedIds.value.delete(config.id);
|
||||
invalidateCompletionCache(config.id);
|
||||
}
|
||||
|
|
@ -578,13 +580,53 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
await api.saveConnections(nextConnections);
|
||||
}
|
||||
|
||||
function persistSidebarLayoutDebounced() {
|
||||
if (layoutPersistTimer) clearTimeout(layoutPersistTimer);
|
||||
layoutPersistTimer = setTimeout(() => {
|
||||
api.saveSidebarLayout(sidebarLayout.value).catch(() => {});
|
||||
layoutPersistTimer = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function rebuildTreeNodes() {
|
||||
const existingNodesMap = new Map<string, TreeNode>();
|
||||
const collectExisting = (nodes: TreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
existingNodesMap.set(node.id, node);
|
||||
if (node.children) collectExisting(node.children);
|
||||
}
|
||||
};
|
||||
collectExisting(treeNodes.value);
|
||||
|
||||
const freshNodes = buildTreeNodesFromLayout(sidebarLayout.value, connections.value, pinnedTreeNodeIds.value);
|
||||
const mergeState = (nodes: TreeNode[]): TreeNode[] =>
|
||||
nodes.map((node) => {
|
||||
const existing = existingNodesMap.get(node.id);
|
||||
if (node.type === "connection-group") {
|
||||
return { ...node, children: mergeState(node.children || []) };
|
||||
}
|
||||
if (existing && node.type === "connection") {
|
||||
return { ...existing, label: node.label, pinned: node.pinned };
|
||||
}
|
||||
return node;
|
||||
});
|
||||
treeNodes.value = mergeState(freshNodes);
|
||||
}
|
||||
|
||||
function updateLayoutAndRebuild(nextLayout: SidebarLayout) {
|
||||
sidebarLayout.value = nextLayout;
|
||||
rebuildTreeNodes();
|
||||
persistSidebarLayoutDebounced();
|
||||
}
|
||||
|
||||
async function exportConnectionsToFile(passphrase: string) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const { encryptConfig } = await import("@/lib/configCrypto");
|
||||
const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" });
|
||||
if (!path) return;
|
||||
const json = JSON.stringify(connections.value);
|
||||
const exportData = { connections: connections.value, layout: sidebarLayout.value };
|
||||
const json = JSON.stringify(exportData);
|
||||
const payload = await encryptConfig(json, passphrase);
|
||||
await writeTextFile(path, JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
|
@ -600,18 +642,34 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return { content, encrypted: isEncryptedConfig(parsed) };
|
||||
}
|
||||
|
||||
async function importConnectionsFromFile(content: string, passphrase: string | null): Promise<number> {
|
||||
async function importConnectionsFromFile(content: string, passphrase: string | null): Promise<{ count: number; layout?: SidebarLayout }> {
|
||||
let imported: ConnectionConfig[];
|
||||
let importedLayout: SidebarLayout | undefined;
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
if (passphrase) {
|
||||
const { decryptConfig } = await import("@/lib/configCrypto");
|
||||
const json = await decryptConfig(parsed, passphrase);
|
||||
imported = JSON.parse(json);
|
||||
const decrypted = JSON.parse(json);
|
||||
if (Array.isArray(decrypted)) {
|
||||
imported = decrypted;
|
||||
} else if (decrypted.connections) {
|
||||
imported = decrypted.connections;
|
||||
if (decrypted.layout?.groups && decrypted.layout?.order) {
|
||||
importedLayout = decrypted.layout;
|
||||
}
|
||||
} else {
|
||||
imported = [];
|
||||
}
|
||||
} else if (Array.isArray(parsed)) {
|
||||
imported = parsed;
|
||||
} else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) {
|
||||
imported = parsed.connections;
|
||||
} else if (parsed.connections && Array.isArray(parsed.connections)) {
|
||||
imported = parsed.connections;
|
||||
if (parsed.layout?.groups && parsed.layout?.order) {
|
||||
importedLayout = parsed.layout;
|
||||
}
|
||||
} else {
|
||||
imported = [];
|
||||
}
|
||||
|
|
@ -626,20 +684,26 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
return { count, layout: importedLayout };
|
||||
}
|
||||
|
||||
function applySidebarLayout(layout: SidebarLayout) {
|
||||
const reconciledLayout = reconcileLayout(
|
||||
connections.value.map((c) => c.id),
|
||||
layout,
|
||||
);
|
||||
updateLayoutAndRebuild(reconciledLayout);
|
||||
}
|
||||
|
||||
async function initFromDisk() {
|
||||
const saved = await api.loadConnections();
|
||||
connections.value = saved.map(normalizeConnection);
|
||||
treeNodes.value = saved.map((config) => ({
|
||||
id: config.id,
|
||||
label: config.name,
|
||||
type: "connection" as const,
|
||||
connectionId: config.id,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
const savedLayout = await api.loadSidebarLayout();
|
||||
sidebarLayout.value = reconcileLayout(
|
||||
connections.value.map((c) => c.id),
|
||||
savedLayout,
|
||||
);
|
||||
rebuildTreeNodes();
|
||||
}
|
||||
|
||||
function addEphemeralConnection(config: ConnectionConfig) {
|
||||
|
|
@ -655,6 +719,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
activeConnectionId,
|
||||
treeNodes,
|
||||
connectedIds,
|
||||
sidebarLayout,
|
||||
getConfig,
|
||||
isTreeNodePinned,
|
||||
toggleTreeNodePin,
|
||||
|
|
@ -685,6 +750,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
exportConnectionsToFile,
|
||||
readImportFile,
|
||||
importConnectionsFromFile,
|
||||
applySidebarLayout,
|
||||
transferSource,
|
||||
schemaDiffSource,
|
||||
sqlFileSource,
|
||||
|
|
@ -693,5 +759,25 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
structureEditorSource,
|
||||
fieldLineageSource,
|
||||
databaseSearchSource,
|
||||
createConnectionGroup(name: string) {
|
||||
const result = createGroupOp(sidebarLayout.value, name);
|
||||
updateLayoutAndRebuild(result.layout);
|
||||
return result.groupId;
|
||||
},
|
||||
renameConnectionGroup(groupId: string, name: string) {
|
||||
updateLayoutAndRebuild(renameGroupOp(sidebarLayout.value, groupId, name));
|
||||
},
|
||||
deleteConnectionGroup(groupId: string) {
|
||||
updateLayoutAndRebuild(deleteGroupOp(sidebarLayout.value, groupId));
|
||||
},
|
||||
toggleConnectionGroupCollapsed(groupId: string) {
|
||||
updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId));
|
||||
},
|
||||
moveConnectionToGroup(connectionId: string, groupId: string | null) {
|
||||
updateLayoutAndRebuild(moveConnectionToGroupOp(sidebarLayout.value, connectionId, groupId));
|
||||
},
|
||||
reorderSidebarEntry(draggedId: string, targetId: string, position: DropPosition) {
|
||||
updateLayoutAndRebuild(reorderEntryOp(sidebarLayout.value, draggedId, targetId, position));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,12 +80,27 @@ export interface QueryResult {
|
|||
}
|
||||
|
||||
export type TreeNodeType =
|
||||
| "connection" | "database" | "schema" | "table" | "view"
|
||||
| "connection" | "connection-group" | "database" | "schema" | "table" | "view"
|
||||
| "group-columns" | "group-indexes" | "group-fkeys" | "group-triggers"
|
||||
| "column" | "index" | "fkey" | "trigger"
|
||||
| "redis-db"
|
||||
| "mongo-db" | "mongo-collection";
|
||||
|
||||
export interface ConnectionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export type SidebarOrderEntry =
|
||||
| { type: "group"; id: string; connectionIds: string[] }
|
||||
| { type: "connection"; id: string };
|
||||
|
||||
export interface SidebarLayout {
|
||||
groups: ConnectionGroup[];
|
||||
order: SidebarOrderEntry[];
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
reconcileLayout,
|
||||
buildTreeNodesFromLayout,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
renameGroup,
|
||||
moveConnectionToGroup,
|
||||
reorderEntry,
|
||||
toggleGroupCollapsed,
|
||||
appendConnectionToLayout,
|
||||
removeConnectionFromSidebarLayout,
|
||||
emptyLayout,
|
||||
} from "../src/lib/sidebarLayout.ts";
|
||||
import type { ConnectionConfig, SidebarLayout } from "../src/types/database.ts";
|
||||
|
||||
function conn(id: string, name?: string): ConnectionConfig {
|
||||
return {
|
||||
id,
|
||||
name: name || id,
|
||||
db_type: "postgres",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "user",
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
// --- reconcileLayout ---
|
||||
|
||||
test("reconcileLayout returns all connections ungrouped when layout is null", () => {
|
||||
const result = reconcileLayout(["a", "b", "c"], null);
|
||||
assert.deepEqual(result.groups, []);
|
||||
assert.deepEqual(result.order, [
|
||||
{ type: "connection", id: "a" },
|
||||
{ type: "connection", id: "b" },
|
||||
{ type: "connection", id: "c" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reconcileLayout appends new connections not in layout", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [],
|
||||
order: [{ type: "connection", id: "a" }],
|
||||
};
|
||||
const result = reconcileLayout(["a", "b"], layout);
|
||||
assert.equal(result.order.length, 2);
|
||||
assert.deepEqual(result.order[1], { type: "connection", id: "b" });
|
||||
});
|
||||
|
||||
test("reconcileLayout removes stale connections from layout", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "Group", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", connectionIds: ["a", "removed"] },
|
||||
{ type: "connection", id: "b" },
|
||||
],
|
||||
};
|
||||
const result = reconcileLayout(["a", "b"], layout);
|
||||
const groupEntry = result.order.find((e) => e.type === "group");
|
||||
assert.ok(groupEntry && groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["a"]);
|
||||
assert.equal(result.order.length, 2);
|
||||
});
|
||||
|
||||
test("reconcileLayout removes groups with no order entry", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [
|
||||
{ id: "g1", name: "Used", collapsed: false },
|
||||
{ id: "g2", name: "Orphan", collapsed: false },
|
||||
],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a"] }],
|
||||
};
|
||||
const result = reconcileLayout(["a"], layout);
|
||||
assert.equal(result.groups.length, 1);
|
||||
assert.equal(result.groups[0].id, "g1");
|
||||
});
|
||||
|
||||
// --- buildTreeNodesFromLayout ---
|
||||
|
||||
test("buildTreeNodesFromLayout creates group nodes with connection children", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "Production", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", connectionIds: ["a", "b"] },
|
||||
{ type: "connection", id: "c" },
|
||||
],
|
||||
};
|
||||
const connections = [conn("a", "Server A"), conn("b", "Server B"), conn("c", "Server C")];
|
||||
const nodes = buildTreeNodesFromLayout(layout, connections, new Set());
|
||||
|
||||
assert.equal(nodes.length, 2);
|
||||
assert.equal(nodes[0].type, "connection-group");
|
||||
assert.equal(nodes[0].label, "Production");
|
||||
assert.equal(nodes[0].children?.length, 2);
|
||||
assert.equal(nodes[0].isExpanded, true);
|
||||
assert.equal(nodes[1].type, "connection");
|
||||
assert.equal(nodes[1].id, "c");
|
||||
});
|
||||
|
||||
test("buildTreeNodesFromLayout respects collapsed groups", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: true }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a"] }],
|
||||
};
|
||||
const nodes = buildTreeNodesFromLayout(layout, [conn("a")], new Set());
|
||||
assert.equal(nodes[0].isExpanded, false);
|
||||
});
|
||||
|
||||
test("buildTreeNodesFromLayout applies pinning within groups", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a", "b"] }],
|
||||
};
|
||||
const nodes = buildTreeNodesFromLayout(layout, [conn("a"), conn("b")], new Set(["b"]));
|
||||
const children = nodes[0].children!;
|
||||
assert.equal(children[0].id, "b");
|
||||
assert.equal(children[1].id, "a");
|
||||
});
|
||||
|
||||
// --- createGroup ---
|
||||
|
||||
test("createGroup adds a new empty group", () => {
|
||||
const layout = emptyLayout();
|
||||
const result = createGroup(layout, "Dev");
|
||||
assert.equal(result.layout.groups.length, 1);
|
||||
assert.equal(result.layout.groups[0].name, "Dev");
|
||||
assert.equal(result.layout.order.length, 1);
|
||||
assert.ok(result.layout.order[0].type === "group");
|
||||
});
|
||||
|
||||
// --- renameGroup ---
|
||||
|
||||
test("renameGroup updates group name", () => {
|
||||
const { layout, groupId } = createGroup(emptyLayout(), "Old");
|
||||
const result = renameGroup(layout, groupId, "New");
|
||||
assert.equal(result.groups[0].name, "New");
|
||||
});
|
||||
|
||||
// --- deleteGroup ---
|
||||
|
||||
test("deleteGroup moves connections to ungrouped", () => {
|
||||
let layout = emptyLayout();
|
||||
layout = appendConnectionToLayout(layout, "a");
|
||||
const { layout: withGroup, groupId } = createGroup(layout, "G");
|
||||
const moved = moveConnectionToGroup(withGroup, "a", groupId);
|
||||
const result = deleteGroup(moved, groupId);
|
||||
|
||||
assert.equal(result.groups.length, 0);
|
||||
assert.deepEqual(result.order, [{ type: "connection", id: "a" }]);
|
||||
});
|
||||
|
||||
// --- toggleGroupCollapsed ---
|
||||
|
||||
test("toggleGroupCollapsed flips collapsed state", () => {
|
||||
const { layout, groupId } = createGroup(emptyLayout(), "G");
|
||||
assert.equal(layout.groups[0].collapsed, false);
|
||||
const toggled = toggleGroupCollapsed(layout, groupId);
|
||||
assert.equal(toggled.groups[0].collapsed, true);
|
||||
});
|
||||
|
||||
// --- moveConnectionToGroup ---
|
||||
|
||||
test("moveConnectionToGroup moves connection into a group", () => {
|
||||
let layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", connectionIds: [] },
|
||||
{ type: "connection", id: "a" },
|
||||
],
|
||||
};
|
||||
const result = moveConnectionToGroup(layout, "a", "g1");
|
||||
const groupEntry = result.order.find((e) => e.type === "group" && e.id === "g1");
|
||||
assert.ok(groupEntry && groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["a"]);
|
||||
assert.equal(result.order.length, 1);
|
||||
});
|
||||
|
||||
test("moveConnectionToGroup moves connection out of a group", () => {
|
||||
let layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a"] }],
|
||||
};
|
||||
const result = moveConnectionToGroup(layout, "a", null);
|
||||
assert.equal(result.order.length, 2);
|
||||
assert.deepEqual(result.order[1], { type: "connection", id: "a" });
|
||||
});
|
||||
|
||||
// --- reorderEntry ---
|
||||
|
||||
test("reorderEntry moves connection before another", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [],
|
||||
order: [
|
||||
{ type: "connection", id: "a" },
|
||||
{ type: "connection", id: "b" },
|
||||
{ type: "connection", id: "c" },
|
||||
],
|
||||
};
|
||||
const result = reorderEntry(layout, "c", "a", "before");
|
||||
assert.deepEqual(result.order.map((e) => e.id), ["c", "a", "b"]);
|
||||
});
|
||||
|
||||
test("reorderEntry moves connection after another", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [],
|
||||
order: [
|
||||
{ type: "connection", id: "a" },
|
||||
{ type: "connection", id: "b" },
|
||||
{ type: "connection", id: "c" },
|
||||
],
|
||||
};
|
||||
const result = reorderEntry(layout, "a", "b", "after");
|
||||
assert.deepEqual(result.order.map((e) => e.id), ["b", "a", "c"]);
|
||||
});
|
||||
|
||||
test("reorderEntry moves connection inside a group", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [
|
||||
{ type: "group", id: "g1", connectionIds: [] },
|
||||
{ type: "connection", id: "a" },
|
||||
],
|
||||
};
|
||||
const result = reorderEntry(layout, "a", "g1", "inside");
|
||||
assert.equal(result.order.length, 1);
|
||||
const groupEntry = result.order[0];
|
||||
assert.ok(groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["a"]);
|
||||
});
|
||||
|
||||
test("reorderEntry is a no-op when dragging to same position", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [],
|
||||
order: [{ type: "connection", id: "a" }],
|
||||
};
|
||||
const result = reorderEntry(layout, "a", "a", "before");
|
||||
assert.deepEqual(result, layout);
|
||||
});
|
||||
|
||||
// --- appendConnectionToLayout / removeConnectionFromSidebarLayout ---
|
||||
|
||||
test("appendConnectionToLayout adds to the end", () => {
|
||||
const layout = appendConnectionToLayout(emptyLayout(), "x");
|
||||
assert.equal(layout.order.length, 1);
|
||||
assert.deepEqual(layout.order[0], { type: "connection", id: "x" });
|
||||
});
|
||||
|
||||
test("removeConnectionFromSidebarLayout removes from ungrouped", () => {
|
||||
let layout = appendConnectionToLayout(emptyLayout(), "x");
|
||||
layout = removeConnectionFromSidebarLayout(layout, "x");
|
||||
assert.equal(layout.order.length, 0);
|
||||
});
|
||||
|
||||
test("removeConnectionFromSidebarLayout removes from inside a group", () => {
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "G", collapsed: false }],
|
||||
order: [{ type: "group", id: "g1", connectionIds: ["a", "b"] }],
|
||||
};
|
||||
const result = removeConnectionFromSidebarLayout(layout, "a");
|
||||
const groupEntry = result.order[0];
|
||||
assert.ok(groupEntry.type === "group");
|
||||
assert.deepEqual(groupEntry.connectionIds, ["b"]);
|
||||
});
|
||||
Loading…
Reference in New Issue