feat: pin tabs and tree nodes (#17)

Co-authored-by: SuLe <sule@SuLedeMacBook-Air.local>
This commit is contained in:
SuLea-ITandSuLe authored and GitHub committed 2026-04-30 14:50:40 +08:00
1 parent e3b8f57326
commit 48f56f15b1
9 files changed
+179 -28

No files matched your search

+16 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles } from "lucide-vue-next";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles, Pin } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@@ -591,14 +591,26 @@ async function setupFileDrop() {
<div
v-for="tab in queryStore.tabs"
:key="tab.id"
class="flex items-center gap-1 px-3 h-full text-xs cursor-pointer border-r hover:bg-accent transition-colors whitespace-nowrap"
class="group flex min-w-0 items-center gap-1 px-3 h-full text-xs cursor-pointer border-r hover:bg-accent transition-colors whitespace-nowrap"
:class="{ 'bg-background font-medium': tab.id === queryStore.activeTabId }"
@click="queryStore.activeTabId = tab.id"
>
<span v-if="connectionColor(tab.connectionId)" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: connectionColor(tab.connectionId) }" />
<span>{{ tabDisplayTitle(tab) }}</span>
<span class="min-w-0 truncate">{{ tabDisplayTitle(tab) }}</span>
<Tooltip>
<TooltipTrigger as-child>
<button
class="ml-1 rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground focus:opacity-100"
:class="tab.pinned ? 'opacity-100 text-primary' : 'opacity-0 group-hover:opacity-100'"
@click.stop="queryStore.togglePinnedTab(tab.id)"
>
<Pin class="h-3 w-3" :class="{ 'fill-current': tab.pinned }" />
</button>
</TooltipTrigger>
<TooltipContent>{{ tab.pinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}</TooltipContent>
</Tooltip>
<button
class="ml-1 rounded hover:bg-muted-foreground/20 p-0.5"
class="rounded hover:bg-muted-foreground/20 p-0.5"
@click.stop="queryStore.closeTab(tab.id)"
>
<X class="h-3 w-3" />
+33 -2
View File
@@ -5,6 +5,7 @@ import {
Database, Table, Columns3, Eye, ChevronRight, ChevronDown,
Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw,
Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug,
Pin,
} from "lucide-vue-next";
import {
ContextMenu, ContextMenuContent, ContextMenuItem,
@@ -77,6 +78,15 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
const leafTypes: Set<TreeNodeType> = new Set(["column", "index", "fkey", "trigger", "redis-db"]);
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers"]);
const pinnableTypes: Set<TreeNodeType> = new Set([
"database",
"schema",
"table",
"view",
"redis-db",
"mongo-db",
"mongo-collection",
]);
function isGroupLabel(node: TreeNode): boolean {
return groupTypes.has(node.type);
@@ -220,6 +230,8 @@ function disconnectConnection() {
}
const canExpand = !leafTypes.has(props.node.type);
const canPin = computed(() => pinnableTypes.has(props.node.type));
const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id));
const paddingLeft = `${props.depth * 16 + 8}px`;
const isConnected = computed(() =>
props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId)
@@ -249,6 +261,10 @@ const remainingCount = computed(() =>
(props.node.children?.length ?? 0) - displayLimit.value
);
function togglePin() {
connectionStore.toggleTreeNodePin(props.node.id);
}
async function showMore() {
if ((props.node.children?.length ?? 0) > displayLimit.value) {
displayLimit.value += CHILDREN_PAGE_SIZE;
@@ -261,7 +277,7 @@ async function showMore() {
<ContextMenuTrigger as-child>
<div>
<div
class="flex 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"
:style="{ paddingLeft }"
@click="onClick"
>
@@ -274,8 +290,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' && connectionColor" class="h-3 w-1.5 rounded-full shrink-0" :style="{ backgroundColor: connectionColor }" />
<span class="truncate">{{ isGroupLabel(node) ? t(node.label) : node.label }}</span>
<span class="min-w-0 flex-1 truncate">{{ isGroupLabel(node) ? t(node.label) : node.label }}</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
v-if="canPin"
class="rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus:opacity-100"
:class="isPinned ? 'opacity-100 text-primary' : 'opacity-0 group-hover:opacity-100'"
:title="isPinned ? t('contextMenu.unpin') : t('contextMenu.pin')"
@click.stop="togglePin"
>
<Pin class="w-3 h-3" :class="{ 'fill-current': isPinned }" />
</button>
</div>
<template v-if="node.isExpanded && node.children">
<TreeItem v-for="child in visibleChildren" :key="child.id" :node="child" :depth="depth + 1" />
@@ -293,6 +318,12 @@ async function showMore() {
</ContextMenuTrigger>
<ContextMenuContent class="w-48">
<ContextMenuItem v-if="canPin" @click="togglePin">
<Pin class="w-3.5 h-3.5 mr-2" :class="{ 'fill-current': isPinned }" />
{{ isPinned ? t('contextMenu.unpin') : t('contextMenu.pin') }}
</ContextMenuItem>
<ContextMenuSeparator v-if="canPin" />
<template v-if="node.type === 'connection'">
<ContextMenuItem v-if="!isConnected" @click="toggle">
<Plug class="w-3.5 h-3.5 mr-2" /> {{ t('contextMenu.openConnection') }}
+2
View File
@@ -153,6 +153,8 @@ export default {
newQuery: "New Query",
viewData: "View Data",
refreshChildren: "Refresh",
pin: "Pin",
unpin: "Unpin",
copyName: "Copy Name",
},
tree: {
+2
View File
@@ -151,6 +151,8 @@ export default {
newQuery: "新建查询",
viewData: "查看数据",
refreshChildren: "刷新",
pin: "置顶",
unpin: "取消置顶",
copyName: "复制名称",
},
tree: {
+11
View File
@@ -0,0 +1,11 @@
export function orderPinnedFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
const pinned: T[] = [];
const unpinned: T[] = [];
for (const item of items) {
if (isPinned(item)) pinned.push(item);
else unpinned.push(item);
}
return [...pinned, ...unpinned];
}
+86 -22
View File
@@ -1,12 +1,16 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { ConnectionConfig, TreeNode } from "@/types/database";
import { orderPinnedFirst } from "@/lib/pinnedItems";
import * as api from "@/lib/tauri";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
export const useConnectionStore = defineStore("connection", () => {
const connections = ref<ConnectionConfig[]>([]);
const activeConnectionId = ref<string | null>(null);
const treeNodes = ref<TreeNode[]>([]);
const pinnedTreeNodeIds = ref<Set<string>>(loadPinnedTreeNodeIds());
const connectedIds = ref<Set<string>>(new Set());
const editingConnectionId = ref<string | null>(null);
@@ -42,6 +46,64 @@ export const useConnectionStore = defineStore("connection", () => {
};
}
function loadPinnedTreeNodeIds(): Set<string> {
try {
if (typeof localStorage === "undefined") return new Set();
const saved = localStorage.getItem(PINNED_TREE_NODES_STORAGE_KEY);
const ids = saved ? JSON.parse(saved) : [];
return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : []);
} catch {
return new Set();
}
}
function persistPinnedTreeNodeIds() {
if (typeof localStorage === "undefined") return;
localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify([...pinnedTreeNodeIds.value]));
}
function isTreeNodePinned(id: string): boolean {
return pinnedTreeNodeIds.value.has(id);
}
function pinTreeNode(node: TreeNode): TreeNode {
node.pinned = isTreeNodePinned(node.id);
return node;
}
function setChildren(parent: TreeNode, children: TreeNode[]) {
parent.children = orderPinnedFirst(children.map(pinTreeNode), (node) => !!node.pinned);
}
function findParentNode(nodes: TreeNode[], id: string, parent: TreeNode | null = null): TreeNode | null {
for (const node of nodes) {
if (node.id === id) return parent;
if (node.children) {
const found = findParentNode(node.children, id, node);
if (found) return found;
}
}
return null;
}
function toggleTreeNodePin(id: string) {
const next = new Set(pinnedTreeNodeIds.value);
if (next.has(id)) next.delete(id);
else next.add(id);
pinnedTreeNodeIds.value = next;
persistPinnedTreeNodeIds();
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);
} else {
treeNodes.value = orderPinnedFirst(treeNodes.value, (child) => !!child.pinned);
}
}
function addConnection(config: ConnectionConfig) {
connections.value.push(normalizeConnection(config));
persistConnections();
@@ -123,7 +185,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
await ensureConnected(connectionId);
const databases = await api.listDatabases(connectionId);
node.children = databases.map((db) => ({
setChildren(node, databases.map((db) => ({
id: `${connectionId}:${db.name}`,
label: db.name,
type: "database" as const,
@@ -131,7 +193,7 @@ export const useConnectionStore = defineStore("connection", () => {
database: db.name,
isExpanded: false,
children: [],
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -146,7 +208,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
await ensureConnected(connectionId);
const dbs = await api.redisListDatabases(connectionId);
node.children = dbs.map((db) => ({
setChildren(node, dbs.map((db) => ({
id: `${connectionId}:db${db}`,
label: `db${db}`,
type: "redis-db" as const,
@@ -154,7 +216,7 @@ export const useConnectionStore = defineStore("connection", () => {
database: String(db),
isExpanded: false,
children: [],
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -169,7 +231,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
await ensureConnected(connectionId);
const dbs = await api.mongoListDatabases(connectionId);
node.children = dbs.map((db) => ({
setChildren(node, dbs.map((db) => ({
id: `${connectionId}:${db}`,
label: db,
type: "mongo-db" as const,
@@ -177,7 +239,7 @@ export const useConnectionStore = defineStore("connection", () => {
database: db,
isExpanded: false,
children: [],
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -192,14 +254,14 @@ export const useConnectionStore = defineStore("connection", () => {
node.isLoading = true;
try {
const collections = await api.mongoListCollections(connectionId, database);
node.children = collections.map((col) => ({
setChildren(node, collections.map((col) => ({
id: `${nodeId}:${col}`,
label: col,
type: "mongo-collection" as const,
connectionId,
database,
isExpanded: false,
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -214,7 +276,7 @@ export const useConnectionStore = defineStore("connection", () => {
node.isLoading = true;
try {
const schemas = await api.listSchemas(connectionId, database);
node.children = schemas.map((s) => ({
setChildren(node, schemas.map((s) => ({
id: `${connectionId}:${database}:${s}`,
label: s,
type: "schema" as const,
@@ -223,7 +285,7 @@ export const useConnectionStore = defineStore("connection", () => {
schema: s,
isExpanded: false,
children: [],
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -241,7 +303,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
const querySchema = schema || database;
const tables = await api.listTables(connectionId, database, querySchema);
node.children = tables.map((t) => ({
setChildren(node, tables.map((t) => ({
id: `${nodeId}:${t.name}`,
label: t.name,
type: (t.table_type === "VIEW" ? "view" : "table") as "view" | "table",
@@ -250,7 +312,7 @@ export const useConnectionStore = defineStore("connection", () => {
schema,
isExpanded: false,
children: [],
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -264,12 +326,12 @@ export const useConnectionStore = defineStore("connection", () => {
const node = findNode(treeNodes.value, parentId);
if (!node) return;
node.children = [
setChildren(node, [
{ id: `${parentId}:__columns`, label: "tree.columns", type: "group-columns", connectionId, database, schema, tableName: table, isExpanded: false, children: [] },
{ id: `${parentId}:__indexes`, label: "tree.indexes", type: "group-indexes", connectionId, database, schema, tableName: table, isExpanded: false, children: [] },
{ id: `${parentId}:__fkeys`, label: "tree.foreignKeys", type: "group-fkeys", connectionId, database, schema, tableName: table, isExpanded: false, children: [] },
{ id: `${parentId}:__triggers`, label: "tree.triggers", type: "group-triggers", connectionId, database, schema, tableName: table, isExpanded: false, children: [] },
];
]);
node.isExpanded = true;
}
@@ -284,7 +346,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
const querySchema = schema || database;
const columns = await api.getColumns(connectionId, database, querySchema, table);
node.children = columns.map((col) => ({
setChildren(node, columns.map((col) => ({
id: `${parentId}:${col.name}`,
label: `${col.name} (${col.data_type})`,
type: "column" as const,
@@ -292,7 +354,7 @@ export const useConnectionStore = defineStore("connection", () => {
database,
schema,
meta: col,
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -310,7 +372,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
const querySchema = schema || database;
const indexes = await api.listIndexes(connectionId, database, querySchema, table);
node.children = indexes.map((idx) => ({
setChildren(node, indexes.map((idx) => ({
id: `${parentId}:${idx.name}`,
label: `${idx.name} (${idx.columns.join(", ")})`,
type: "index" as const,
@@ -318,7 +380,7 @@ export const useConnectionStore = defineStore("connection", () => {
database,
schema,
meta: idx,
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -336,7 +398,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
const querySchema = schema || database;
const fkeys = await api.listForeignKeys(connectionId, database, querySchema, table);
node.children = fkeys.map((fk) => ({
setChildren(node, fkeys.map((fk) => ({
id: `${parentId}:${fk.name}`,
label: `${fk.column} → ${fk.ref_table}.${fk.ref_column}`,
type: "fkey" as const,
@@ -344,7 +406,7 @@ export const useConnectionStore = defineStore("connection", () => {
database,
schema,
meta: fk,
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -362,7 +424,7 @@ export const useConnectionStore = defineStore("connection", () => {
try {
const querySchema = schema || database;
const triggers = await api.listTriggers(connectionId, database, querySchema, table);
node.children = triggers.map((tr) => ({
setChildren(node, triggers.map((tr) => ({
id: `${parentId}:${tr.name}`,
label: `${tr.name} (${tr.timing} ${tr.event})`,
type: "trigger" as const,
@@ -370,7 +432,7 @@ export const useConnectionStore = defineStore("connection", () => {
database,
schema,
meta: tr,
}));
})));
node.isExpanded = true;
} finally {
node.isLoading = false;
@@ -444,6 +506,8 @@ export const useConnectionStore = defineStore("connection", () => {
treeNodes,
connectedIds,
getConfig,
isTreeNodePinned,
toggleTreeNodePin,
addConnection,
updateConnection,
removeConnection,
+9
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { QueryTab } from "@/types/database";
import { orderPinnedFirst } from "@/lib/pinnedItems";
import * as api from "@/lib/tauri";
export const useQueryStore = defineStore("query", () => {
@@ -50,6 +51,13 @@ export const useQueryStore = defineStore("query", () => {
if (tab) tab.sql = sql;
}
function togglePinnedTab(id: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return;
tab.pinned = !tab.pinned;
tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned);
}
function updateDatabase(id: string, database: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.database === database) return;
@@ -116,6 +124,7 @@ export const useQueryStore = defineStore("query", () => {
createTab,
closeTab,
updateSql,
togglePinnedTab,
updateDatabase,
updateConnection,
setTableMeta,
+2
View File
@@ -82,6 +82,7 @@ export interface TreeNode {
children?: TreeNode[];
isLoading?: boolean;
isExpanded?: boolean;
pinned?: boolean;
connectionId?: string;
database?: string;
schema?: string;
@@ -96,6 +97,7 @@ export interface QueryTab {
database: string;
sql: string;
lastExecutedSql?: string;
pinned?: boolean;
result?: QueryResult;
isExecuting: boolean;
mode: "data" | "query" | "redis" | "mongo";