feat(desktop): support tree multi-select actions
This commit is contained in:
1 parent
d27ad8a14f
commit
6dee9705d9
6 files changed
+363
-32
No files matched your search
@@ -46,6 +46,7 @@ import {
|
||||
Code2,
|
||||
ListFilter,
|
||||
Package,
|
||||
Clipboard,
|
||||
} from "lucide-vue-next";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -131,6 +132,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { flattenTree } from "@/composables/useFlatTree";
|
||||
|
||||
const { t } = useI18n();
|
||||
const labelRef = ref<HTMLElement>();
|
||||
@@ -428,6 +430,46 @@ function runRowClickAction() {
|
||||
}
|
||||
}
|
||||
|
||||
function visibleTreeNodes(): TreeNode[] {
|
||||
return flattenTree(connectionStore.treeNodes).map((item) => item.node);
|
||||
}
|
||||
|
||||
function selectedTreeNodesInVisibleOrder(): TreeNode[] {
|
||||
const selectedIds = new Set(connectionStore.selectedTreeNodeIds);
|
||||
if (!selectedIds.size) return [];
|
||||
return visibleTreeNodes().filter((node) => selectedIds.has(node.id));
|
||||
}
|
||||
|
||||
function selectSingleTreeNode(node: TreeNode) {
|
||||
connectionStore.selectedTreeNodeId = node.id;
|
||||
connectionStore.selectedTreeNodeIds = [node.id];
|
||||
connectionStore.treeSelectionAnchorId = node.id;
|
||||
}
|
||||
|
||||
function toggleTreeNodeSelection(node: TreeNode) {
|
||||
const ids = new Set(connectionStore.selectedTreeNodeIds);
|
||||
if (ids.has(node.id)) ids.delete(node.id);
|
||||
else ids.add(node.id);
|
||||
connectionStore.selectedTreeNodeIds = ids.size ? [...ids] : [node.id];
|
||||
connectionStore.selectedTreeNodeId = node.id;
|
||||
connectionStore.treeSelectionAnchorId = node.id;
|
||||
}
|
||||
|
||||
function selectTreeNodeRange(node: TreeNode) {
|
||||
const visible = visibleTreeNodes();
|
||||
const anchorId = connectionStore.treeSelectionAnchorId || connectionStore.selectedTreeNodeId || node.id;
|
||||
const anchorIndex = visible.findIndex((item) => item.id === anchorId);
|
||||
const currentIndex = visible.findIndex((item) => item.id === node.id);
|
||||
if (anchorIndex < 0 || currentIndex < 0) {
|
||||
selectSingleTreeNode(node);
|
||||
return;
|
||||
}
|
||||
const start = Math.min(anchorIndex, currentIndex);
|
||||
const end = Math.max(anchorIndex, currentIndex);
|
||||
connectionStore.selectedTreeNodeIds = visible.slice(start, end + 1).map((item) => item.id);
|
||||
connectionStore.selectedTreeNodeId = node.id;
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (suppressNextTableReferenceClick) {
|
||||
suppressNextTableReferenceClick = false;
|
||||
@@ -435,13 +477,33 @@ function onClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
connectionStore.selectedTreeNodeId = props.node.id;
|
||||
if (event.shiftKey) {
|
||||
selectTreeNodeRange(props.node);
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
toggleTreeNodeSelection(props.node);
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
selectSingleTreeNode(props.node);
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
if (settingsStore.editorSettings.sidebarActivation === "double") return;
|
||||
if (event.detail > 1) return;
|
||||
runRowClickAction();
|
||||
}
|
||||
|
||||
function onTreeItemContextMenu(event: MouseEvent, openContextMenu: (event: MouseEvent) => void) {
|
||||
if (!connectionStore.selectedTreeNodeIds.includes(props.node.id)) {
|
||||
selectSingleTreeNode(props.node);
|
||||
} else {
|
||||
connectionStore.selectedTreeNodeId = props.node.id;
|
||||
}
|
||||
rowRef.value?.focus({ preventScroll: true });
|
||||
openContextMenu(event);
|
||||
}
|
||||
|
||||
function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return (
|
||||
@@ -453,9 +515,9 @@ function isEditableShortcutTarget(target: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!isSelected.value || isEditableShortcutTarget(event.target)) return;
|
||||
if ((!isSelected.value && !isMultiSelected.value) || isEditableShortcutTarget(event.target)) return;
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && isDeleteTreeNodeShortcut(event)) {
|
||||
if (!requestDropSelectedNode()) return;
|
||||
if (!requestDropSelectedNodes()) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
@@ -464,7 +526,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
if (action !== "copy-name") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copyName();
|
||||
copySelectedNames();
|
||||
}
|
||||
|
||||
function isDeleteTreeNodeShortcut(event: KeyboardEvent): boolean {
|
||||
@@ -706,6 +768,18 @@ async function copyName() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copySelectedNames() {
|
||||
const selectedNodes = selectedTreeNodesInVisibleOrder();
|
||||
const nodes =
|
||||
selectedNodes.length > 1 && selectedNodes.some((node) => node.id === props.node.id) ? selectedNodes : [props.node];
|
||||
try {
|
||||
await copyToClipboard(nodes.map(copyNameForTreeNode).join("\n"));
|
||||
toast(t("connection.copied"), 2000);
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateConnection() {
|
||||
const connId = props.node.connectionId;
|
||||
if (!connId) return;
|
||||
@@ -719,6 +793,12 @@ async function duplicateConnection() {
|
||||
// --- Table Management Operations ---
|
||||
const showDropTableConfirm = ref(false);
|
||||
const showDropTableChildObjectConfirm = ref(false);
|
||||
const showBatchDropConfirm = ref(false);
|
||||
const showStructurePreviewDialog = ref(false);
|
||||
const structurePreviewSql = ref("");
|
||||
const structurePreviewTitle = ref("");
|
||||
const structurePreviewError = ref("");
|
||||
const isLoadingStructurePreview = ref(false);
|
||||
const showEmptyTableConfirm = ref(false);
|
||||
const showTruncateTableConfirm = ref(false);
|
||||
const showRenameObjectDialog = ref(false);
|
||||
@@ -730,6 +810,7 @@ const emptyTablePreviewSql = ref("");
|
||||
const truncateTablePreviewSql = ref("");
|
||||
const dropObjectPreviewSql = ref("");
|
||||
const dropTableChildObjectPreviewSql = ref("");
|
||||
const batchDropPreviewSql = ref("");
|
||||
const dropDatabasePreviewSql = ref("");
|
||||
const dropSchemaPreviewSql = ref("");
|
||||
const showDuplicateDialog = ref(false);
|
||||
@@ -750,10 +831,13 @@ const showDropObjectConfirm = ref(false);
|
||||
const showProcedureExecutionConfirm = ref(false);
|
||||
|
||||
function dropObjectSqlOptions(): DropObjectSqlOptions | null {
|
||||
const node = props.node;
|
||||
return dropObjectSqlOptionsForNode(props.node);
|
||||
}
|
||||
|
||||
function dropObjectSqlOptionsForNode(node: TreeNode): DropObjectSqlOptions | null {
|
||||
if (node.type !== "view" && node.type !== "procedure" && node.type !== "function") return null;
|
||||
return {
|
||||
databaseType: currentDatabaseType(),
|
||||
databaseType: node.connectionId ? connectionStore.getConfig(node.connectionId)?.db_type : undefined,
|
||||
objectType: node.type === "view" ? "VIEW" : node.type === "procedure" ? "PROCEDURE" : "FUNCTION",
|
||||
schema: node.schema,
|
||||
name: node.label,
|
||||
@@ -780,13 +864,16 @@ function tableChildDropObjectName(node: TreeNode): string {
|
||||
}
|
||||
|
||||
function dropTableChildObjectSqlOptions(): DropTableChildObjectSqlOptions | null {
|
||||
const node = props.node;
|
||||
return dropTableChildObjectSqlOptionsForNode(props.node);
|
||||
}
|
||||
|
||||
function dropTableChildObjectSqlOptionsForNode(node: TreeNode): DropTableChildObjectSqlOptions | null {
|
||||
const objectType = tableChildDropObjectType(node.type);
|
||||
if (!objectType || !node.tableName) return null;
|
||||
const name = tableChildDropObjectName(node).trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
databaseType: currentDatabaseType(),
|
||||
databaseType: node.connectionId ? connectionStore.getConfig(node.connectionId)?.db_type : undefined,
|
||||
objectType,
|
||||
schema: node.schema,
|
||||
tableName: node.tableName,
|
||||
@@ -795,13 +882,17 @@ function dropTableChildObjectSqlOptions(): DropTableChildObjectSqlOptions | null
|
||||
}
|
||||
|
||||
const canDropTableChildObject = computed(() => {
|
||||
const options = dropTableChildObjectSqlOptions();
|
||||
return canDropTableChildObjectNode(props.node);
|
||||
});
|
||||
|
||||
function canDropTableChildObjectNode(node: TreeNode): boolean {
|
||||
const options = dropTableChildObjectSqlOptionsForNode(node);
|
||||
if (!options) return false;
|
||||
const capabilities = getTableStructureCapabilities(currentDatabaseType());
|
||||
const capabilities = getTableStructureCapabilities(options.databaseType);
|
||||
if (options.objectType === "COLUMN") return capabilities.dropColumn;
|
||||
if (options.objectType === "INDEX") return capabilities.dropIndex;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function dropObjectMenuLabel(): string {
|
||||
if (props.node.type === "view") return t("contextMenu.dropView");
|
||||
@@ -943,6 +1034,82 @@ function requestDropTableChildObject() {
|
||||
showDropTableChildObjectConfirm.value = true;
|
||||
}
|
||||
|
||||
function canDropTreeNode(node: TreeNode): boolean {
|
||||
if (node.type === "table") return !!node.connectionId && !!node.database;
|
||||
if (node.type === "view" || node.type === "procedure" || node.type === "function") {
|
||||
return !!node.connectionId && !!node.database && !!dropObjectSqlOptionsForNode(node);
|
||||
}
|
||||
return canDropTableChildObjectNode(node);
|
||||
}
|
||||
|
||||
function selectedBatchDropTargets(): TreeNode[] {
|
||||
const selected = selectedTreeNodesInVisibleOrder();
|
||||
if (selected.length <= 1 || !selected.some((node) => node.id === props.node.id)) return [];
|
||||
const first = selected[0];
|
||||
if (!first?.connectionId || !first.database || !selected.every((node) => node.type === first.type)) return [];
|
||||
if (
|
||||
!selected.every(
|
||||
(node) => node.connectionId === first.connectionId && node.database === first.database && canDropTreeNode(node),
|
||||
)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function batchDropMenuLabel(): string {
|
||||
return t("contextMenu.batchDrop", { count: selectedBatchDropTargets().length });
|
||||
}
|
||||
|
||||
function batchDropConfirmTitle(): string {
|
||||
return t("contextMenu.confirmBatchDropTitle", { count: selectedBatchDropTargets().length });
|
||||
}
|
||||
|
||||
function batchDropConfirmMessage(): string {
|
||||
return t("contextMenu.confirmBatchDropMessage", { count: selectedBatchDropTargets().length });
|
||||
}
|
||||
|
||||
async function dropSqlForTreeNode(node: TreeNode): Promise<string | null> {
|
||||
if (node.type === "table" && node.connectionId && node.database) {
|
||||
return buildDropTableSql({
|
||||
databaseType: node.connectionId ? connectionStore.getConfig(node.connectionId)?.db_type : undefined,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
});
|
||||
}
|
||||
const objectOptions = dropObjectSqlOptionsForNode(node);
|
||||
if (objectOptions) return buildDropObjectSql(objectOptions);
|
||||
const childOptions = dropTableChildObjectSqlOptionsForNode(node);
|
||||
if (childOptions && canDropTableChildObjectNode(node)) return buildDropTableChildObjectSql(childOptions);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshBatchDropPreviewSql() {
|
||||
const targets = selectedBatchDropTargets();
|
||||
const statements: string[] = [];
|
||||
for (const target of targets) {
|
||||
const sql = await dropSqlForTreeNode(target);
|
||||
if (sql) statements.push(sql);
|
||||
}
|
||||
batchDropPreviewSql.value = statements.join("\n");
|
||||
}
|
||||
|
||||
function requestBatchDrop() {
|
||||
if (!selectedBatchDropTargets().length) return;
|
||||
void refreshBatchDropPreviewSql();
|
||||
showBatchDropConfirm.value = true;
|
||||
}
|
||||
|
||||
function requestDropSelectedNodes(): boolean {
|
||||
const selected = selectedTreeNodesInVisibleOrder();
|
||||
if (selected.length > 1 && selected.some((node) => node.id === props.node.id)) {
|
||||
if (!selectedBatchDropTargets().length) return false;
|
||||
requestBatchDrop();
|
||||
return true;
|
||||
}
|
||||
return requestDropSelectedNode();
|
||||
}
|
||||
|
||||
function requestDropSelectedNode(): boolean {
|
||||
if (props.node.type === "table") {
|
||||
dropTable();
|
||||
@@ -1108,6 +1275,25 @@ async function confirmDropTableChildObject() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchDrop() {
|
||||
const targets = selectedBatchDropTargets();
|
||||
if (!targets.length) return;
|
||||
try {
|
||||
for (const target of targets) {
|
||||
if (!target.connectionId || !target.database) continue;
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
const sql = await dropSqlForTreeNode(target);
|
||||
if (!sql) continue;
|
||||
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
|
||||
connectionStore.removeTreeNode(target.id);
|
||||
}
|
||||
toast(t("contextMenu.batchDropSuccess", { count: targets.length }), 3000);
|
||||
showBatchDropConfirm.value = false;
|
||||
} catch (e: any) {
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
const isTableNotView = computed(() => props.node.type === "table");
|
||||
|
||||
const supportsTruncate = computed(() => {
|
||||
@@ -1493,14 +1679,73 @@ async function saveFileContent(content: string, defaultFileName: string, filterN
|
||||
}
|
||||
|
||||
async function exportStructure() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
const targets = structureExportTargets();
|
||||
if (!targets.length) return;
|
||||
isLoadingStructurePreview.value = true;
|
||||
structurePreviewError.value = "";
|
||||
structurePreviewSql.value = "";
|
||||
structurePreviewTitle.value =
|
||||
targets.length === 1
|
||||
? t("contextMenu.exportStructurePreviewTitle", { name: targets[0]!.label })
|
||||
: t("contextMenu.exportStructurePreviewTitleMultiple", { count: targets.length });
|
||||
showStructurePreviewDialog.value = true;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const ddl = await api.getTableDdl(node.connectionId, node.database, node.schema || node.database, node.label);
|
||||
await saveFileContent(ddl + "\n", `${node.label}.sql`, "SQL", "sql");
|
||||
const parts: string[] = [];
|
||||
for (const target of targets) {
|
||||
await connectionStore.ensureConnected(target.connectionId);
|
||||
const ddl = await api.getTableDdl(
|
||||
target.connectionId,
|
||||
target.database,
|
||||
target.schema || target.database,
|
||||
target.label,
|
||||
);
|
||||
parts.push(ddl.trim());
|
||||
}
|
||||
structurePreviewSql.value = `${parts.filter(Boolean).join("\n\n")}\n`;
|
||||
} catch (e: any) {
|
||||
structurePreviewError.value = e?.message || String(e);
|
||||
console.error("Export structure failed:", e);
|
||||
} finally {
|
||||
isLoadingStructurePreview.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function canExportStructureNode(node: TreeNode): node is TreeNode & { connectionId: string; database: string } {
|
||||
return (node.type === "table" || node.type === "view") && !!node.connectionId && !!node.database;
|
||||
}
|
||||
|
||||
function selectedStructureNodes(): TreeNode[] {
|
||||
const selectedIds = new Set(connectionStore.selectedTreeNodeIds);
|
||||
if (!selectedIds.size) return [];
|
||||
const nodes: TreeNode[] = [];
|
||||
const visit = (items: TreeNode[]) => {
|
||||
for (const item of items) {
|
||||
if (selectedIds.has(item.id) && canExportStructureNode(item)) nodes.push(item);
|
||||
if (item.children) visit(item.children);
|
||||
}
|
||||
};
|
||||
visit(connectionStore.treeNodes);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function structureExportTargets(): Array<TreeNode & { connectionId: string; database: string }> {
|
||||
if (!canExportStructureNode(props.node)) return [];
|
||||
const selected = selectedStructureNodes().filter(
|
||||
(node): node is TreeNode & { connectionId: string; database: string } =>
|
||||
canExportStructureNode(node) &&
|
||||
node.connectionId === props.node.connectionId &&
|
||||
node.database === props.node.database,
|
||||
);
|
||||
return selected.some((node) => node.id === props.node.id) ? selected : [props.node];
|
||||
}
|
||||
|
||||
async function copyStructurePreview() {
|
||||
if (!structurePreviewSql.value) return;
|
||||
try {
|
||||
await copyToClipboard(structurePreviewSql.value);
|
||||
toast(t("contextMenu.exportStructureCopied"), 2000);
|
||||
} catch (e: any) {
|
||||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1856,6 +2101,7 @@ const isActiveConnectionScope = computed(
|
||||
() => !!props.node.connectionId && connectionStore.activeConnectionId === props.node.connectionId,
|
||||
);
|
||||
const isSelected = computed(() => connectionStore.selectedTreeNodeId === props.node.id);
|
||||
const isMultiSelected = computed(() => connectionStore.selectedTreeNodeIds.includes(props.node.id));
|
||||
const rowStyle = computed(() => {
|
||||
const color = connectionColor.value;
|
||||
const backgroundColor = hexToRgba(color, isActiveConnectionScope.value ? 0.14 : 0.08);
|
||||
@@ -2173,6 +2419,9 @@ function exportDataSubmenu(): ContextMenuItem {
|
||||
function treeItemMenuItems(): ContextMenuItem[] {
|
||||
const node = props.node;
|
||||
const items: ContextMenuItem[] = [];
|
||||
const batchDropCount = selectedBatchDropTargets().length;
|
||||
const deleteMenuLabel = (singleLabel: string) => (batchDropCount > 1 ? batchDropMenuLabel() : singleLabel);
|
||||
const deleteMenuAction = (singleAction: () => void) => (batchDropCount > 1 ? requestBatchDrop : singleAction);
|
||||
|
||||
// 1. Pin toggle
|
||||
if (canPin.value) {
|
||||
@@ -2348,8 +2597,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
}
|
||||
if (node.type === "view") {
|
||||
items.push({
|
||||
label: t("contextMenu.dropView"),
|
||||
action: requestDropObject,
|
||||
label: deleteMenuLabel(t("contextMenu.dropView")),
|
||||
action: deleteMenuAction(requestDropObject),
|
||||
icon: Trash2,
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
@@ -2387,8 +2636,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
items.push({
|
||||
label: t("contextMenu.dropTable"),
|
||||
action: dropTable,
|
||||
label: deleteMenuLabel(t("contextMenu.dropTable")),
|
||||
action: deleteMenuAction(dropTable),
|
||||
icon: Trash2,
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
@@ -2408,8 +2657,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
if (canDropTableChildObject.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: dropTableChildObjectMenuLabel(),
|
||||
action: requestDropTableChildObject,
|
||||
label: deleteMenuLabel(dropTableChildObjectMenuLabel()),
|
||||
action: deleteMenuAction(requestDropTableChildObject),
|
||||
icon: Trash2,
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
@@ -2422,8 +2671,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
if (canDropTableChildObject.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: dropTableChildObjectMenuLabel(),
|
||||
action: requestDropTableChildObject,
|
||||
label: deleteMenuLabel(dropTableChildObjectMenuLabel()),
|
||||
action: deleteMenuAction(requestDropTableChildObject),
|
||||
icon: Trash2,
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
@@ -2442,8 +2691,10 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
}
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({
|
||||
label: node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction"),
|
||||
action: requestDropObject,
|
||||
label: deleteMenuLabel(
|
||||
node.type === "procedure" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction"),
|
||||
),
|
||||
action: deleteMenuAction(requestDropObject),
|
||||
icon: Trash2,
|
||||
variant: "destructive" as const,
|
||||
});
|
||||
@@ -2504,7 +2755,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
|
||||
<template>
|
||||
<CustomContextMenu :items="treeItemMenuItems()" v-slot="{ onContextMenu }">
|
||||
<div @contextmenu="onContextMenu">
|
||||
<div @contextmenu="onTreeItemContextMenu($event, onContextMenu)">
|
||||
<div
|
||||
ref="rowRef"
|
||||
class="group flex items-center gap-1.5 py-1 px-2 cursor-pointer hover:bg-accent transition-colors relative outline-none"
|
||||
@@ -2515,14 +2766,14 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
'ring-1 ring-primary/50 bg-primary/5': showDropInside,
|
||||
'opacity-50': isDragging,
|
||||
'tree-item-connection-tint': connectionColor,
|
||||
'rounded-none': connectionColor && !isSelected,
|
||||
'rounded-sm': !connectionColor && !isSelected,
|
||||
'tree-item-active rounded-none': connectionColor && isSelected,
|
||||
'tree-item-active rounded-md': !connectionColor && isSelected,
|
||||
'rounded-none': connectionColor && !isSelected && !isMultiSelected,
|
||||
'rounded-sm': !connectionColor && !isSelected && !isMultiSelected,
|
||||
'tree-item-active rounded-none': connectionColor && (isSelected || isMultiSelected),
|
||||
'tree-item-active rounded-md': !connectionColor && (isSelected || isMultiSelected),
|
||||
'tree-item-highlight': highlighted,
|
||||
},
|
||||
]"
|
||||
:tabindex="isSelected ? 0 : -1"
|
||||
:tabindex="isSelected || isMultiSelected ? 0 : -1"
|
||||
:style="rowStyle"
|
||||
@click="onClick"
|
||||
@dblclick="onDoubleClick"
|
||||
@@ -2739,6 +2990,33 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showStructurePreviewDialog">
|
||||
<DialogContent class="sm:max-w-[760px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ structurePreviewTitle || t("contextMenu.exportStructure") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="grid gap-3">
|
||||
<div v-if="isLoadingStructurePreview" class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span>{{ t("contextMenu.exportStructureLoading") }}</span>
|
||||
</div>
|
||||
<p v-else-if="structurePreviewError" class="text-sm text-destructive">{{ structurePreviewError }}</p>
|
||||
<pre
|
||||
v-else
|
||||
class="max-h-[56vh] min-h-64 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap"
|
||||
v-html="highlight(structurePreviewSql)"
|
||||
></pre>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="showStructurePreviewDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button :disabled="isLoadingStructurePreview || !structurePreviewSql" @click="copyStructurePreview">
|
||||
<Clipboard class="h-4 w-4" />
|
||||
{{ t("contextMenu.copyStructure") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="showDeleteSavedSqlFileConfirm">
|
||||
<DialogContent class="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
@@ -2816,6 +3094,15 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
@confirm="confirmDropTableChildObject"
|
||||
/>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showBatchDropConfirm"
|
||||
:title="batchDropConfirmTitle()"
|
||||
:message="batchDropConfirmMessage()"
|
||||
:sql="batchDropPreviewSql"
|
||||
:confirm-label="batchDropMenuLabel()"
|
||||
@confirm="confirmBatchDrop"
|
||||
/>
|
||||
|
||||
<ProcedureExecutionDialog
|
||||
v-if="node.type === 'procedure' && node.connectionId && node.database"
|
||||
v-model:open="showProcedureExecutionConfirm"
|
||||
|
||||
@@ -918,6 +918,11 @@ export default {
|
||||
importData: "Import Data",
|
||||
exportData: "Export Data",
|
||||
exportStructure: "Export Structure",
|
||||
exportStructurePreviewTitle: "Structure - {name}",
|
||||
exportStructurePreviewTitleMultiple: "Structure - {count} objects",
|
||||
exportStructureLoading: "Loading structure...",
|
||||
copyStructure: "Copy Structure",
|
||||
exportStructureCopied: "Structure copied",
|
||||
createTable: "Create Table",
|
||||
dropTable: "Drop Table",
|
||||
emptyTable: "Empty Table",
|
||||
@@ -944,6 +949,7 @@ export default {
|
||||
dropIndex: "Drop Index",
|
||||
dropForeignKey: "Drop Foreign Key",
|
||||
dropTrigger: "Drop Trigger",
|
||||
batchDrop: "Drop selected ({count})",
|
||||
executeProcedure: "Execute Procedure",
|
||||
confirmExecuteProcedureTitle: "Execute Procedure",
|
||||
confirmExecuteProcedureMessage: 'Execute procedure "{name}"? You can fill or adjust parameter values first.',
|
||||
@@ -977,6 +983,8 @@ export default {
|
||||
confirmDropForeignKeyTitle: "Drop Foreign Key",
|
||||
confirmDropTriggerTitle: "Drop Trigger",
|
||||
confirmDropTableChildObjectMessage: 'Are you sure you want to drop "{name}" from "{table}"?',
|
||||
confirmBatchDropTitle: "Drop Selected Objects",
|
||||
confirmBatchDropMessage: "Are you sure you want to drop {count} selected objects? This cannot be undone.",
|
||||
confirmDropProcedureTitle: "Drop Procedure",
|
||||
confirmDropProcedureMessage: 'Are you sure you want to drop procedure "{name}"?',
|
||||
confirmDropFunctionTitle: "Drop Function",
|
||||
@@ -985,6 +993,7 @@ export default {
|
||||
dropProcedureSuccess: 'Procedure "{name}" dropped',
|
||||
dropFunctionSuccess: 'Function "{name}" dropped',
|
||||
dropTableChildObjectSuccess: '"{name}" dropped',
|
||||
batchDropSuccess: "Dropped {count} objects",
|
||||
emptyTableSuccess: 'All data deleted from "{name}"',
|
||||
truncateTableSuccess: 'Table "{name}" truncated',
|
||||
duplicateStructureSuccess: 'Table structure duplicated as "{name}"',
|
||||
|
||||
@@ -814,6 +814,11 @@ export default {
|
||||
importData: "Importar datos",
|
||||
exportData: "Exportar datos",
|
||||
exportStructure: "Exportar estructura",
|
||||
exportStructurePreviewTitle: "Estructura - {name}",
|
||||
exportStructurePreviewTitleMultiple: "Estructura - {count} objetos",
|
||||
exportStructureLoading: "Cargando estructura...",
|
||||
copyStructure: "Copiar estructura",
|
||||
exportStructureCopied: "Estructura copiada",
|
||||
createTable: "Crear tabla",
|
||||
dropTable: "Eliminar tabla",
|
||||
emptyTable: "Vaciar tabla",
|
||||
@@ -836,6 +841,7 @@ export default {
|
||||
dropIndex: "Eliminar índice",
|
||||
dropForeignKey: "Eliminar clave foránea",
|
||||
dropTrigger: "Eliminar disparador",
|
||||
batchDrop: "Eliminar seleccionados ({count})",
|
||||
executeProcedure: "Ejecutar procedimiento",
|
||||
confirmExecuteProcedureTitle: "Ejecutar procedimiento",
|
||||
confirmExecuteProcedureMessage:
|
||||
@@ -870,6 +876,9 @@ export default {
|
||||
confirmDropForeignKeyTitle: "Eliminar clave foránea",
|
||||
confirmDropTriggerTitle: "Eliminar disparador",
|
||||
confirmDropTableChildObjectMessage: '¿Seguro que deseas eliminar "{name}" de "{table}"?',
|
||||
confirmBatchDropTitle: "Eliminar objetos seleccionados",
|
||||
confirmBatchDropMessage:
|
||||
"¿Seguro que deseas eliminar {count} objetos seleccionados? Esta acción no se puede deshacer.",
|
||||
confirmDropProcedureTitle: "Eliminar procedimiento",
|
||||
confirmDropProcedureMessage: '¿Seguro que deseas eliminar el procedimiento "{name}"?',
|
||||
confirmDropFunctionTitle: "Eliminar función",
|
||||
@@ -878,6 +887,7 @@ export default {
|
||||
dropProcedureSuccess: 'Procedimiento "{name}" eliminado',
|
||||
dropFunctionSuccess: 'Función "{name}" eliminada',
|
||||
dropTableChildObjectSuccess: '"{name}" eliminado',
|
||||
batchDropSuccess: "{count} objetos eliminados",
|
||||
emptyTableSuccess: 'Todos los datos eliminados de "{name}"',
|
||||
truncateTableSuccess: 'Tabla "{name}" truncada',
|
||||
duplicateStructureSuccess: 'Estructura de tabla duplicada como "{name}"',
|
||||
|
||||
@@ -899,6 +899,11 @@ export default {
|
||||
importData: "导入数据",
|
||||
exportData: "导出数据",
|
||||
exportStructure: "导出表结构",
|
||||
exportStructurePreviewTitle: "表结构 - {name}",
|
||||
exportStructurePreviewTitleMultiple: "表结构 - {count} 个对象",
|
||||
exportStructureLoading: "正在读取表结构...",
|
||||
copyStructure: "复制表结构",
|
||||
exportStructureCopied: "表结构已复制",
|
||||
createTable: "新建表",
|
||||
dropTable: "删除表",
|
||||
emptyTable: "清空数据",
|
||||
@@ -924,6 +929,7 @@ export default {
|
||||
dropIndex: "删除索引",
|
||||
dropForeignKey: "删除外键",
|
||||
dropTrigger: "删除触发器",
|
||||
batchDrop: "删除所选({count})",
|
||||
executeProcedure: "执行过程",
|
||||
confirmExecuteProcedureTitle: "执行存储过程",
|
||||
confirmExecuteProcedureMessage: "确认执行存储过程「{name}」?可先补充或调整参数值。",
|
||||
@@ -955,6 +961,8 @@ export default {
|
||||
confirmDropForeignKeyTitle: "删除外键",
|
||||
confirmDropTriggerTitle: "删除触发器",
|
||||
confirmDropTableChildObjectMessage: "确定要从「{table}」删除「{name}」吗?",
|
||||
confirmBatchDropTitle: "删除所选对象",
|
||||
confirmBatchDropMessage: "确定要删除已选择的 {count} 个对象吗?此操作不可撤销。",
|
||||
confirmDropProcedureTitle: "删除存储过程",
|
||||
confirmDropProcedureMessage: "确定要删除存储过程「{name}」吗?",
|
||||
confirmDropFunctionTitle: "删除函数",
|
||||
@@ -963,6 +971,7 @@ export default {
|
||||
dropProcedureSuccess: "存储过程「{name}」已删除",
|
||||
dropFunctionSuccess: "函数「{name}」已删除",
|
||||
dropTableChildObjectSuccess: "「{name}」已删除",
|
||||
batchDropSuccess: "已删除 {count} 个对象",
|
||||
emptyTableSuccess: "已清空「{name}」的所有数据",
|
||||
truncateTableSuccess: "表「{name}」已截断",
|
||||
duplicateStructureSuccess: "已复制表结构为「{name}」",
|
||||
|
||||
@@ -899,6 +899,11 @@ export default {
|
||||
importData: "匯入資料",
|
||||
exportData: "匯出資料",
|
||||
exportStructure: "匯出資料表結構",
|
||||
exportStructurePreviewTitle: "資料表結構 - {name}",
|
||||
exportStructurePreviewTitleMultiple: "資料表結構 - {count} 個物件",
|
||||
exportStructureLoading: "正在讀取資料表結構...",
|
||||
copyStructure: "複製資料表結構",
|
||||
exportStructureCopied: "資料表結構已複製",
|
||||
createTable: "建立資料表",
|
||||
dropTable: "刪除資料表",
|
||||
emptyTable: "清空資料",
|
||||
@@ -924,6 +929,7 @@ export default {
|
||||
dropIndex: "刪除索引",
|
||||
dropForeignKey: "刪除外鍵",
|
||||
dropTrigger: "刪除觸發器",
|
||||
batchDrop: "刪除所選({count})",
|
||||
executeProcedure: "執行預存程序",
|
||||
confirmExecuteProcedureTitle: "執行預存程序",
|
||||
confirmExecuteProcedureMessage: "確認執行預存程序「{name}」?可先補充或調整參數值。",
|
||||
@@ -955,6 +961,8 @@ export default {
|
||||
confirmDropForeignKeyTitle: "刪除外鍵",
|
||||
confirmDropTriggerTitle: "刪除觸發器",
|
||||
confirmDropTableChildObjectMessage: "確定要從「{table}」刪除「{name}」嗎?",
|
||||
confirmBatchDropTitle: "刪除所選物件",
|
||||
confirmBatchDropMessage: "確定要刪除已選擇的 {count} 個物件嗎?此操作無法復原。",
|
||||
confirmDropProcedureTitle: "刪除預存程序",
|
||||
confirmDropProcedureMessage: "確定要刪除預存程序「{name}」嗎?",
|
||||
confirmDropFunctionTitle: "刪除函式",
|
||||
@@ -963,6 +971,7 @@ export default {
|
||||
dropProcedureSuccess: "預存程序「{name}」已刪除",
|
||||
dropFunctionSuccess: "函式「{name}」已刪除",
|
||||
dropTableChildObjectSuccess: "「{name}」已刪除",
|
||||
batchDropSuccess: "已刪除 {count} 個物件",
|
||||
emptyTableSuccess: "已清空「{name}」的所有資料",
|
||||
truncateTableSuccess: "資料表「{name}」已截斷",
|
||||
duplicateStructureSuccess: "已複製資料表結構為「{name}」",
|
||||
|
||||
@@ -72,6 +72,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const isDesktop = isTauriRuntime();
|
||||
const activeConnectionId = ref<string | null>(localStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY));
|
||||
const selectedTreeNodeId = ref<string | null>(null);
|
||||
const selectedTreeNodeIds = ref<string[]>([]);
|
||||
const treeSelectionAnchorId = ref<string | null>(null);
|
||||
|
||||
watch(activeConnectionId, (id) => {
|
||||
if (id) localStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, id);
|
||||
@@ -315,6 +317,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (parent?.children) {
|
||||
parent.children = parent.children.filter((c) => c.id !== nodeId);
|
||||
}
|
||||
if (selectedTreeNodeId.value === nodeId) selectedTreeNodeId.value = null;
|
||||
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => id !== nodeId);
|
||||
if (treeSelectionAnchorId.value === nodeId) treeSelectionAnchorId.value = null;
|
||||
}
|
||||
|
||||
function buildSavedSqlRootNode(connectionId: string, existingRoot?: TreeNode): TreeNode | undefined {
|
||||
@@ -1888,6 +1893,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connections,
|
||||
activeConnectionId,
|
||||
selectedTreeNodeId,
|
||||
selectedTreeNodeIds,
|
||||
treeSelectionAnchorId,
|
||||
treeNodes,
|
||||
removeTreeNode,
|
||||
refreshAllTree,
|
||||
|
||||
Reference in new issue
Block a user