feat(sidebar): extend table grouping to all object containers
This commit is contained in:
1 parent
e1a512fd59
commit
591e8f4909
20 files changed
+607
-169
No files matched your search
@@ -23,8 +23,10 @@ function confirmCreate() {
|
||||
if (!scope || !name) return;
|
||||
const groupId = connectionStore.createTableVGroup(scope, name, tableVGroupDialogParentGroupId.value);
|
||||
if (groupId) {
|
||||
// scope 即右键的源行节点,其 type 就是这批待移入行的类别(多选保证同类型)。
|
||||
const rowType = scope.type;
|
||||
for (const tableName of tableVGroupDialogTableNames.value) {
|
||||
connectionStore.moveTableToVGroup(scope, tableName, groupId);
|
||||
connectionStore.moveTableToVGroup(scope, tableName, groupId, rowType);
|
||||
}
|
||||
emit("created", tableVGroupNodeId(groupId));
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ import { canTreeNodePin, canTreeNodeShowExpander } from "@/lib/sidebar/sidebarTr
|
||||
import { sidebarConnectionVisibleFilterMenu } from "@/lib/sidebar/sidebarVisibleFilterMenu";
|
||||
import { supportsSidebarObjectNameFilter } from "@/lib/sidebar/sidebarObjectNameFilter";
|
||||
import { connectionGroupDestinationRows } from "@/lib/sidebar/sidebarLayout";
|
||||
import { hasTableVGroupEntries, selectedTableVGroupMoveTargets, tableVGroupDestinationRows, tableVGroupPathForTable, tableVGroupsEnabled } from "@/lib/table/tableVGroup";
|
||||
import { hasTableVGroupEntries, isTableVGroupContainerNode, isTableVGroupGroupableRowType, resolveTableVGroupScopeFromNode, selectedTableVGroupMoveTargets, tableVGroupDestinationRows, tableVGroupPathForTable, tableVGroupScopeKey, tableVGroupsEnabled } from "@/lib/table/tableVGroup";
|
||||
import { objectTypesForGroupNode } from "@/lib/table/tableTree";
|
||||
import { loadSidebarObjectGroup } from "@/lib/sidebar/sidebarObjectGroupRouting";
|
||||
import { requestObjectBrowserSearchFocus } from "@/lib/tabs/objectBrowserSearchFocus";
|
||||
@@ -5958,6 +5958,11 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
|
||||
if (currentDatabaseType() === "hbase" && node.type === "table") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
// HBase 表行走本特化分支(先于 buildObjectSidebarMenu 的统一注入短路),分组移动入口需在此补齐。
|
||||
if (isTableVGroupGroupableRowType(node.type) && !!tableVGroupScopeKey(resolveTableVGroupScopeFromNode(connectionStore.treeNodes, node))) {
|
||||
const vgroupMoveItems = buildTableVGroupMoveMenuItems(node);
|
||||
if (vgroupMoveItems.length) items.push({ label: t("tableVGroup.moveToGroup"), icon: FolderInput, children: vgroupMoveItems });
|
||||
}
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties });
|
||||
items.push({
|
||||
@@ -6150,6 +6155,15 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
|
||||
function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
const { node, items, deleteMenuLabel, deleteMenuAction, truncateMenuLabel, truncateMenuAction, emptyMenuLabel, emptyMenuAction, batchAutoIncrementCount, autoIncrementMenuLabel, autoIncrementMenuAction } = context;
|
||||
// 虚拟分组移动入口:对所有可分组行类型统一注入(含 procedure/function/trigger/
|
||||
// sequence/event/synonym/job/package/type 等专属分支——它们各自 return true,
|
||||
// 若只在 table/view/mv 分支注入,这些行将没有任何分组入口)。表行保持原行为
|
||||
// (simple 模式也能经「移动到新分组」建组);其余行须已处于同类别分组容器内,
|
||||
// 否则解析不出 scope,菜单只会静默失败。
|
||||
if (isTableVGroupGroupableRowType(node.type) && (node.type === "table" || !!tableVGroupScopeKey(resolveTableVGroupScopeFromNode(connectionStore.treeNodes, node)))) {
|
||||
const vgroupMoveItems = buildTableVGroupMoveMenuItems(node);
|
||||
if (vgroupMoveItems.length) items.push({ label: t("tableVGroup.moveToGroup"), icon: FolderInput, children: vgroupMoveItems });
|
||||
}
|
||||
// 6. Table / View / Materialized View
|
||||
if (node.type === "table" || node.type === "view" || node.type === "materialized_view") {
|
||||
if (currentDatabaseType() === "victoriametrics" && node.type === "table") {
|
||||
@@ -6169,10 +6183,6 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
}
|
||||
const destructiveActions: ContextMenuItem[] = [];
|
||||
items.push(copyNameMenuItem());
|
||||
if (node.type === "table") {
|
||||
const vgroupMoveItems = buildTableVGroupMoveMenuItems(node);
|
||||
if (vgroupMoveItems.length) items.push({ label: t("tableVGroup.moveToGroup"), icon: FolderInput, children: vgroupMoveItems });
|
||||
}
|
||||
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
|
||||
if (node.type === "table" && supportsAiAssistantContext(currentDatabaseType())) {
|
||||
items.push(addToAiMenuItem(node));
|
||||
@@ -6590,20 +6600,21 @@ function buildTableVGroupMoveMenuItems(node: TreeNode): ContextMenuItem[] {
|
||||
const layout = connectionStore.tableVGroupLayoutFor(node);
|
||||
const targets = selectedTableVGroupMoveTargets(node, selectedTreeNodesInVisibleOrder());
|
||||
const targetNames = targets.map((target) => target.label);
|
||||
const targetsInGroup = (groupId: string) => targetNames.every((name) => tableVGroupPathForTable(layout, name).includes(groupId));
|
||||
const targetRowType = targets[0]?.type;
|
||||
const targetsInGroup = (groupId: string) => targetNames.every((name) => tableVGroupPathForTable(layout, name, targetRowType).includes(groupId));
|
||||
const items: ContextMenuItem[] = tableVGroupDestinationRows(layout).map((row) => ({
|
||||
label: row.name,
|
||||
title: row.path.join(" / "),
|
||||
disabled: targetNames.length > 0 && targetsInGroup(row.id),
|
||||
action: () => {
|
||||
for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, row.id);
|
||||
for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, row.id, targetRowType);
|
||||
},
|
||||
}));
|
||||
if (targetNames.some((name) => tableVGroupPathForTable(layout, name).length > 0)) {
|
||||
if (targetNames.some((name) => tableVGroupPathForTable(layout, name, targetRowType).length > 0)) {
|
||||
items.push({
|
||||
label: t("tableVGroup.removeFromGroup"),
|
||||
action: () => {
|
||||
for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, null);
|
||||
for (const name of targetNames) connectionStore.moveTableToVGroup(node, name, null, targetRowType);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6623,7 +6634,7 @@ function buildTableVGroupMoveMenuItems(node: TreeNode): ContextMenuItem[] {
|
||||
}
|
||||
|
||||
function appendTableVGroupContainerItems(node: TreeNode, items: ContextMenuItem[]) {
|
||||
if (node.type !== "database" && node.type !== "schema" && node.type !== "linked-server-schema" && node.type !== "group-tables") return;
|
||||
if (!isTableVGroupContainerNode(node)) return;
|
||||
const layout = connectionStore.tableVGroupLayoutFor(node);
|
||||
if (!hasTableVGroupEntries(layout)) return;
|
||||
const enabled = tableVGroupsEnabled(layout);
|
||||
|
||||
@@ -74,7 +74,7 @@ import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage
|
||||
import { effectiveRedisDatabaseIndex } from "@/lib/redis/redisDatabaseIndex";
|
||||
import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { selectedTableVGroupMoveTargets, tableVGroupIdFromNodeId } from "@/lib/table/tableVGroup";
|
||||
import { isTableVGroupGroupableRowType, selectedTableVGroupMoveTargets, tableVGroupIdFromNodeId } from "@/lib/table/tableVGroup";
|
||||
import { findTreeNodeById } from "@/lib/sql/newQueryContext";
|
||||
import { resolveTableVGroupDropTarget, setTableVGroupDropTargetNodeId, tableVGroupDropTargetNodeId } from "@/lib/sidebar/sidebarTableVGroupDrag";
|
||||
import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentation";
|
||||
@@ -1375,9 +1375,10 @@ function tableReferenceDragPayload(): QueryEditorTableReferencePayload | null {
|
||||
|
||||
function startTableReferenceDrag(payload: QueryEditorTableReferencePayload) {
|
||||
draggingTableReferencePayload = payload;
|
||||
// 分组成员名单只收真实表:视图/物化视图投影不识别,入组会产生隐形脏数据。
|
||||
// 分组成员名单按可分组行类型收集(表/视图/物化视图/过程/函数/触发器/序列等);
|
||||
// 无匹配容器的行由 scope 解析兜底拒绝,不会产生隐形脏数据。
|
||||
vgroupDragTableNames = selectedTableVGroupMoveTargets(activeNode.value, selectedTreeNodesInVisibleOrder())
|
||||
.filter((node) => node.type === "table")
|
||||
.filter((node) => isTableVGroupGroupableRowType(node.type))
|
||||
.map((node) => node.label);
|
||||
setActiveTableReferencePayload(payload);
|
||||
document.getSelection()?.removeAllRanges();
|
||||
@@ -1402,7 +1403,8 @@ let vgroupDragTableNames: string[] = [];
|
||||
|
||||
function tableVGroupDropTargetFor(payload: QueryEditorTableReferencePayload, event: MouseEvent) {
|
||||
if (!vgroupDragTableNames.length) return null;
|
||||
return resolveTableVGroupDropTarget(event.clientX, event.clientY, connectionStore.treeNodes, payload);
|
||||
// 拖拽源是树行(多选同类型),落点解析按行类别过滤跨类别容器。
|
||||
return resolveTableVGroupDropTarget(event.clientX, event.clientY, connectionStore.treeNodes, { ...payload, objectType: activeNode.value.type });
|
||||
}
|
||||
|
||||
function onTableReferenceMouseMove(event: MouseEvent) {
|
||||
@@ -1431,7 +1433,7 @@ function onTableReferenceMouseUp(event: MouseEvent) {
|
||||
suppressNextTableReferenceClick = true;
|
||||
const dropTarget = tableVGroupDropTargetFor(payload, event);
|
||||
if (dropTarget) {
|
||||
for (const tableName of vgroupDragTableNames) connectionStore.moveTableToVGroup(dropTarget.node, tableName, dropTarget.groupId);
|
||||
for (const tableName of vgroupDragTableNames) connectionStore.moveTableToVGroup(dropTarget.node, tableName, dropTarget.groupId, activeNode.value.type);
|
||||
} else {
|
||||
const target = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (target instanceof Element && target.closest(`[data-query-editor-root], ${AI_ASSISTANT_TABLE_DROP_ROOT_SELECTOR}`)) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const deleteConnectionsWithGroup = ref(false);
|
||||
export const showTableVGroupDialog = ref(false);
|
||||
export const tableVGroupName = ref("");
|
||||
/** Scope snapshot + creation payload for the table vgroup naming dialog. */
|
||||
export const tableVGroupDialogScope = shallowRef<TableVGroupScope | null>(null);
|
||||
export const tableVGroupDialogScope = shallowRef<TreeNode | null>(null);
|
||||
export const tableVGroupDialogParentGroupId = ref<string | null>(null);
|
||||
export const tableVGroupDialogTableNames = ref<string[]>([]);
|
||||
export const showTableVGroupDeleteConfirm = ref(false);
|
||||
|
||||
@@ -2674,17 +2674,17 @@ export default withEnglishFallback({
|
||||
refresh: "Yenidən təhlil et",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "Cədvəl qrupuna köçür",
|
||||
moveToGroup: "Qrupa köçür",
|
||||
moveToNewGroup: "Yeni qrup yarat və köçür",
|
||||
removeFromGroup: "Qrupdan çıxar",
|
||||
newGroupDefault: "Yeni qrup",
|
||||
newSubgroup: "Yeni alt qrup",
|
||||
deleteGroup: "Qrupu sil",
|
||||
disableGroups: "Cədvəl qruplarını gizlət",
|
||||
enableGroups: "Cədvəl qruplarını göstər",
|
||||
deleteGroupConfirmTitle: "Cədvəl qrupunu sil",
|
||||
deleteGroupConfirmMessage: '"{name}" qrupu silinsin? Qrupdakı cədvəllər siyahıya qayıdır, verilənlər bazası dəyişmir.',
|
||||
groupDeleted: "Cədvəl qrupu silindi",
|
||||
disableGroups: "Qrupları gizlət",
|
||||
enableGroups: "Qrupları göstər",
|
||||
deleteGroupConfirmTitle: "Qrupu sil",
|
||||
deleteGroupConfirmMessage: '"{name}" qrupu silinsin? Qrupdakı üzvlər siyahıya qayıdır, verilənlər bazası dəyişmir.',
|
||||
groupDeleted: "Qrup silindi",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "Yeni qrup",
|
||||
|
||||
@@ -2870,17 +2870,17 @@ export default {
|
||||
refresh: "Analyze Again",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "Move to Table Group",
|
||||
moveToGroup: "Move to Group",
|
||||
moveToNewGroup: "New Group and Move In",
|
||||
removeFromGroup: "Remove from Group",
|
||||
newGroupDefault: "New Group",
|
||||
newSubgroup: "New Subgroup",
|
||||
deleteGroup: "Delete Group",
|
||||
disableGroups: "Hide Table Groups",
|
||||
enableGroups: "Show Table Groups",
|
||||
deleteGroupConfirmTitle: "Delete Table Group",
|
||||
deleteGroupConfirmMessage: 'Delete group "{name}"? Its tables return to the table list; the database is untouched.',
|
||||
groupDeleted: "Table group deleted",
|
||||
disableGroups: "Hide Groups",
|
||||
enableGroups: "Show Groups",
|
||||
deleteGroupConfirmTitle: "Delete Group",
|
||||
deleteGroupConfirmMessage: 'Delete group "{name}"? Its members return to the object list; the database is untouched.',
|
||||
groupDeleted: "Group deleted",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "New Group",
|
||||
|
||||
@@ -2770,17 +2770,17 @@ export default withEnglishFallback({
|
||||
refresh: "Analizar de nuevo",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "Mover a grupo de tablas",
|
||||
moveToGroup: "Mover a grupo",
|
||||
moveToNewGroup: "Nuevo grupo y mover",
|
||||
removeFromGroup: "Quitar del grupo",
|
||||
newGroupDefault: "Nuevo grupo",
|
||||
newSubgroup: "Nuevo subgrupo",
|
||||
deleteGroup: "Eliminar grupo",
|
||||
disableGroups: "Ocultar grupos de tablas",
|
||||
enableGroups: "Mostrar grupos de tablas",
|
||||
deleteGroupConfirmTitle: "Eliminar grupo de tablas",
|
||||
deleteGroupConfirmMessage: "¿Eliminar el grupo «{name}»? Sus tablas vuelven a la lista y la base de datos no se modifica.",
|
||||
groupDeleted: "Grupo de tablas eliminado",
|
||||
disableGroups: "Ocultar grupos",
|
||||
enableGroups: "Mostrar grupos",
|
||||
deleteGroupConfirmTitle: "Eliminar grupo",
|
||||
deleteGroupConfirmMessage: "¿Eliminar el grupo «{name}»? Sus miembros vuelven a la lista y la base de datos no se modifica.",
|
||||
groupDeleted: "Grupo eliminado",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "Nuevo grupo",
|
||||
|
||||
@@ -2767,17 +2767,17 @@ export default withEnglishFallback({
|
||||
refresh: "Analizza di Nuovo",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "Sposta nel gruppo tabelle",
|
||||
moveToGroup: "Sposta nel gruppo",
|
||||
moveToNewGroup: "Nuovo gruppo e sposta",
|
||||
removeFromGroup: "Rimuovi dal gruppo",
|
||||
newGroupDefault: "Nuovo gruppo",
|
||||
newSubgroup: "Nuovo sottogruppo",
|
||||
deleteGroup: "Elimina gruppo",
|
||||
disableGroups: "Nascondi gruppi tabelle",
|
||||
enableGroups: "Mostra gruppi tabelle",
|
||||
deleteGroupConfirmTitle: "Elimina gruppo tabelle",
|
||||
deleteGroupConfirmMessage: "Eliminare il gruppo «{name}»? Le tabelle tornano nell'elenco e il database non viene modificato.",
|
||||
groupDeleted: "Gruppo tabelle eliminato",
|
||||
disableGroups: "Nascondi gruppi",
|
||||
enableGroups: "Mostra gruppi",
|
||||
deleteGroupConfirmTitle: "Elimina gruppo",
|
||||
deleteGroupConfirmMessage: "Eliminare il gruppo «{name}»? I membri tornano nell'elenco e il database non viene modificato.",
|
||||
groupDeleted: "Gruppo eliminato",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "Nuovo Gruppo",
|
||||
|
||||
@@ -2793,17 +2793,17 @@ export default withEnglishFallback({
|
||||
refresh: "再分析",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "テーブルグループへ移動",
|
||||
moveToGroup: "グループへ移動",
|
||||
moveToNewGroup: "新規グループを作成して移動",
|
||||
removeFromGroup: "グループから外す",
|
||||
newGroupDefault: "新規グループ",
|
||||
newSubgroup: "新規サブグループ",
|
||||
deleteGroup: "グループを削除",
|
||||
disableGroups: "テーブルグループを非表示",
|
||||
enableGroups: "テーブルグループを表示",
|
||||
deleteGroupConfirmTitle: "テーブルグループの削除",
|
||||
deleteGroupConfirmMessage: "グループ「{name}」を削除しますか?グループ内のテーブルは一覧に戻り、データベースは変更されません。",
|
||||
groupDeleted: "テーブルグループを削除しました",
|
||||
disableGroups: "グループを非表示",
|
||||
enableGroups: "グループを表示",
|
||||
deleteGroupConfirmTitle: "グループの削除",
|
||||
deleteGroupConfirmMessage: "グループ「{name}」を削除しますか?グループ内のメンバーは一覧に戻り、データベースは変更されません。",
|
||||
groupDeleted: "グループを削除しました",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "新しいグループ",
|
||||
|
||||
@@ -2676,17 +2676,17 @@ export default withEnglishFallback({
|
||||
refresh: "다시 분석",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "테이블 그룹으로 이동",
|
||||
moveToGroup: "그룹으로 이동",
|
||||
moveToNewGroup: "새 그룹 만들고 이동",
|
||||
removeFromGroup: "그룹에서 제거",
|
||||
newGroupDefault: "새 그룹",
|
||||
newSubgroup: "새 하위 그룹",
|
||||
deleteGroup: "그룹 삭제",
|
||||
disableGroups: "테이블 그룹 숨기기",
|
||||
enableGroups: "테이블 그룹 표시",
|
||||
deleteGroupConfirmTitle: "테이블 그룹 삭제",
|
||||
deleteGroupConfirmMessage: '"{name}" 그룹을 삭제할까요? 그룹의 테이블은 목록으로 돌아가며 데이터베이스는 변경되지 않습니다.',
|
||||
groupDeleted: "테이블 그룹을 삭제했습니다",
|
||||
disableGroups: "그룹 숨기기",
|
||||
enableGroups: "그룹 표시",
|
||||
deleteGroupConfirmTitle: "그룹 삭제",
|
||||
deleteGroupConfirmMessage: '"{name}" 그룹을 삭제할까요? 그룹의 멤버는 목록으로 돌아가며 데이터베이스는 변경되지 않습니다.',
|
||||
groupDeleted: "그룹을 삭제했습니다",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "새 그룹",
|
||||
|
||||
@@ -2769,17 +2769,17 @@ export default withEnglishFallback({
|
||||
refresh: "Analisar Novamente",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "Mover para grupo de tabelas",
|
||||
moveToGroup: "Mover para grupo",
|
||||
moveToNewGroup: "Novo grupo e mover",
|
||||
removeFromGroup: "Remover do grupo",
|
||||
newGroupDefault: "Novo grupo",
|
||||
newSubgroup: "Novo subgrupo",
|
||||
deleteGroup: "Excluir grupo",
|
||||
disableGroups: "Ocultar grupos de tabelas",
|
||||
enableGroups: "Mostrar grupos de tabelas",
|
||||
deleteGroupConfirmTitle: "Excluir grupo de tabelas",
|
||||
deleteGroupConfirmMessage: 'Excluir o grupo "{name}"? As tabelas voltam para a lista e o banco de dados não é alterado.',
|
||||
groupDeleted: "Grupo de tabelas excluído",
|
||||
disableGroups: "Ocultar grupos",
|
||||
enableGroups: "Mostrar grupos",
|
||||
deleteGroupConfirmTitle: "Excluir grupo",
|
||||
deleteGroupConfirmMessage: 'Excluir o grupo "{name}"? Os membros voltam para a lista e o banco de dados não é alterado.',
|
||||
groupDeleted: "Grupo excluído",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "Novo Grupo",
|
||||
|
||||
@@ -2652,17 +2652,17 @@ export default withEnglishFallback({
|
||||
refresh: "Yeniden Analiz Et",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "Tablo grubuna taşı",
|
||||
moveToGroup: "Gruba taşı",
|
||||
moveToNewGroup: "Yeni grup oluştur ve taşı",
|
||||
removeFromGroup: "Gruptan çıkar",
|
||||
newGroupDefault: "Yeni grup",
|
||||
newSubgroup: "Yeni alt grup",
|
||||
deleteGroup: "Grubu sil",
|
||||
disableGroups: "Tablo gruplarını gizle",
|
||||
enableGroups: "Tablo gruplarını göster",
|
||||
deleteGroupConfirmTitle: "Tablo grubunu sil",
|
||||
deleteGroupConfirmMessage: '"{name}" grubu silinsin mi? Gruptaki tablolar listeye döner, veritabanı değişmez.',
|
||||
groupDeleted: "Tablo grubu silindi",
|
||||
disableGroups: "Grupları gizle",
|
||||
enableGroups: "Grupları göster",
|
||||
deleteGroupConfirmTitle: "Grubu sil",
|
||||
deleteGroupConfirmMessage: '"{name}" grubu silinsin mi? Gruptaki üyeler listeye döner, veritabanı değişmez.',
|
||||
groupDeleted: "Grup silindi",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "Yeni Grup",
|
||||
|
||||
@@ -2782,17 +2782,17 @@ export default withEnglishFallback({
|
||||
refresh: "重新分析",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "移动到表分组",
|
||||
moveToGroup: "移动到分组",
|
||||
moveToNewGroup: "新建分组并移入",
|
||||
removeFromGroup: "移出分组",
|
||||
newGroupDefault: "新建分组",
|
||||
newSubgroup: "新建子分组",
|
||||
deleteGroup: "删除分组",
|
||||
disableGroups: "隐藏表分组",
|
||||
enableGroups: "显示表分组",
|
||||
deleteGroupConfirmTitle: "删除表分组",
|
||||
deleteGroupConfirmMessage: "确定删除分组「{name}」?分组内的表会回到表列表,数据库不受影响。",
|
||||
groupDeleted: "表分组已删除",
|
||||
disableGroups: "隐藏分组",
|
||||
enableGroups: "显示分组",
|
||||
deleteGroupConfirmTitle: "删除分组",
|
||||
deleteGroupConfirmMessage: "确定删除分组「{name}」?分组内的成员会回到原列表,数据库不受影响。",
|
||||
groupDeleted: "分组已删除",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "新建分组",
|
||||
|
||||
@@ -2767,17 +2767,17 @@ export default withEnglishFallback({
|
||||
refresh: "重新分析",
|
||||
},
|
||||
tableVGroup: {
|
||||
moveToGroup: "移動到表分組",
|
||||
moveToGroup: "移動到分組",
|
||||
moveToNewGroup: "新建分組並移入",
|
||||
removeFromGroup: "移出分組",
|
||||
newGroupDefault: "新建分組",
|
||||
newSubgroup: "新建子分組",
|
||||
deleteGroup: "刪除分組",
|
||||
disableGroups: "隱藏表分組",
|
||||
enableGroups: "顯示表分組",
|
||||
deleteGroupConfirmTitle: "刪除表分組",
|
||||
deleteGroupConfirmMessage: "確定刪除分組「{name}」?分組內的資料表會回到清單,資料庫不受影響。",
|
||||
groupDeleted: "表分組已刪除",
|
||||
disableGroups: "隱藏分組",
|
||||
enableGroups: "顯示分組",
|
||||
deleteGroupConfirmTitle: "刪除分組",
|
||||
deleteGroupConfirmMessage: "確定刪除分組「{name}」?分組內的成員會回到清單,資料庫不受影響。",
|
||||
groupDeleted: "分組已刪除",
|
||||
},
|
||||
connectionGroup: {
|
||||
createGroup: "建立群組",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { shallowRef } from "vue";
|
||||
import type { TreeNode } from "@/types/database";
|
||||
import { findTreeNodeById } from "@/lib/sql/newQueryContext";
|
||||
import { isTableVGroupContainerNode } from "@/lib/table/tableVGroup";
|
||||
import { isTableVGroupContainerNode, tableVGroupDropAcceptsRow } from "@/lib/table/tableVGroup";
|
||||
|
||||
/**
|
||||
* Shared drop feedback for dragging a table row onto a virtual group row.
|
||||
@@ -26,7 +26,7 @@ export interface TableVGroupDropTarget {
|
||||
* above the row, so a single elementFromPoint hit often misses it. Cross-scope
|
||||
* drops are rejected so a table name never lands in another database's layout.
|
||||
*/
|
||||
export function resolveTableVGroupDropTarget(x: number, y: number, treeNodes: TreeNode[], source: { connectionId?: string; database?: string; schema?: string }): TableVGroupDropTarget | null {
|
||||
export function resolveTableVGroupDropTarget(x: number, y: number, treeNodes: TreeNode[], source: { connectionId?: string; database?: string; schema?: string; objectType?: TreeNode["type"] }): TableVGroupDropTarget | null {
|
||||
// Some environments (and component-test DOMs) do not implement hit testing.
|
||||
const elements = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(x, y) : [];
|
||||
for (const element of elements) {
|
||||
@@ -37,6 +37,9 @@ export function resolveTableVGroupDropTarget(x: number, y: number, treeNodes: Tr
|
||||
if (!node || node.connectionId !== source.connectionId || (node.database ?? "") !== (source.database ?? "")) continue;
|
||||
// 跨 schema:双方都声明且不同 → 拒绝(任一方未知则容忍,交给 database 校验兜底)。
|
||||
if (node.schema && source.schema && node.schema !== source.schema) continue;
|
||||
// 跨类别:视图/过程/触发器行不得落入其他类别的分组或容器,否则会写幻影成员、
|
||||
// 同名时还会误移真实成员。objectType 缺省(表引用拖拽)保持既有行为。
|
||||
if (source.objectType && !tableVGroupDropAcceptsRow(treeNodes, node, source.objectType)) continue;
|
||||
if (node.type === "table-vgroup" && node.vgroupId) return { node, groupId: node.vgroupId };
|
||||
if (isTableVGroupContainerNode(node)) return { node, groupId: null };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
createTableVGroup,
|
||||
deleteTableVGroups,
|
||||
emptyTableVGroupLayout,
|
||||
findTableVGroupContainerNode,
|
||||
hasTableTreeLoadMore,
|
||||
isTableVGroupContainerNode,
|
||||
isTableVGroupGroupableRowType,
|
||||
moveTableToVGroup,
|
||||
normalizeTableVGroupLayout,
|
||||
pruneTableVGroupMembers,
|
||||
@@ -17,6 +20,8 @@ import {
|
||||
selectedTableVGroupMoveTargets,
|
||||
stripTableVGroupsFromChildren,
|
||||
tableVGroupDestinationRows,
|
||||
tableVGroupDropAcceptsRow,
|
||||
tableVGroupKindOfContainerNode,
|
||||
tableVGroupPathForTable,
|
||||
tableVGroupScopeKey,
|
||||
toggleTableVGroupCollapsed,
|
||||
@@ -390,3 +395,144 @@ function findGroupChildren(layout: ReturnType<typeof reorderTableVGroupEntry>, g
|
||||
};
|
||||
return visit(layout.order);
|
||||
}
|
||||
|
||||
describe("container-kind scoped groups (views/procedures/triggers)", () => {
|
||||
const VIEW_SCOPE = { connectionId: "conn-1", database: "db1", objectType: "views" as const };
|
||||
|
||||
function viewNode(name: string): TreeNode {
|
||||
return { id: `view:${name}`, label: name, type: "view", connectionId: "conn-1", database: "db1" };
|
||||
}
|
||||
|
||||
function materializedViewNode(name: string): TreeNode {
|
||||
return { id: `mview:${name}`, label: name, type: "materialized_view", connectionId: "conn-1", database: "db1" };
|
||||
}
|
||||
|
||||
it("keeps the tables scope key byte-identical and suffixes the other kinds", () => {
|
||||
expect(tableVGroupScopeKey({ connectionId: "conn-1", database: "db1" })).toBe("conn-1\u0000\u0000\u0000db1\u0000");
|
||||
expect(tableVGroupScopeKey(VIEW_SCOPE)).toBe("conn-1\u0000\u0000\u0000db1\u0000\u0000views");
|
||||
expect(tableVGroupScopeKey({ connectionId: "conn-1", database: "db1", objectType: "triggers" })).toBe("conn-1\u0000\u0000\u0000db1\u0000\u0000triggers");
|
||||
});
|
||||
|
||||
it("maps containers to kinds and rejects table-child trigger groups", () => {
|
||||
expect(tableVGroupKindOfContainerNode({ type: "database" })).toBe("tables");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-tables" })).toBe("tables");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-views" })).toBe("views");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-materialized-views" })).toBe("materialized_views");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-procedures" })).toBe("procedures");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-functions" })).toBe("functions");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-triggers" })).toBe("triggers");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-triggers", tableName: "t1" })).toBeNull();
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-sequences" })).toBe("sequences");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-events" })).toBe("events");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-synonyms" })).toBe("synonyms");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-jobs" })).toBe("jobs");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-packages" })).toBe("packages");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-types" })).toBe("types");
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-tablespaces" })).toBeNull();
|
||||
expect(tableVGroupKindOfContainerNode({ type: "group-dolt-system-tables" })).toBeNull();
|
||||
});
|
||||
|
||||
it("projects view rows under the views scope and passes non-member rows through", () => {
|
||||
const created = createTableVGroup(emptyTableVGroupLayout(), "支付视图");
|
||||
const withMember = moveTableToVGroup(created.layout, "v_payment", created.groupId);
|
||||
const projected = applyTableVGroupsToChildren([materializedViewNode("mv_a"), viewNode("v_payment"), viewNode("v_other")], withMember, VIEW_SCOPE);
|
||||
expect(groupIds(projected)).toHaveLength(1);
|
||||
expect(projected[0]!.label).toBe("支付视图");
|
||||
expect((projected[0]!.children ?? []).map((node) => node.label)).toEqual(["v_payment"]);
|
||||
expect(projected.map((node) => node.type)).toEqual(["table-vgroup", "materialized_view", "view"]);
|
||||
});
|
||||
|
||||
it("re-projecting view containers stays idempotent", () => {
|
||||
const created = createTableVGroup(emptyTableVGroupLayout(), "分组");
|
||||
const withMember = moveTableToVGroup(created.layout, "v_a", created.groupId);
|
||||
const once = applyTableVGroupsToChildren([viewNode("v_a"), viewNode("v_b")], withMember, VIEW_SCOPE);
|
||||
const twice = applyTableVGroupsToChildren(once, withMember, VIEW_SCOPE);
|
||||
expect(groupIds(twice)).toEqual(groupIds(once));
|
||||
});
|
||||
|
||||
it("resolves view rows and projected group rows to the views scope of the host", () => {
|
||||
const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" };
|
||||
const groupRow: TreeNode = { id: "table-vgroup:g1", label: "分组", type: "table-vgroup", connectionId: "conn-1", database: "db1", vgroupId: "g1" };
|
||||
const groupViews: TreeNode = { id: "gv:conn-1:db1", label: "Views", type: "group-views", connectionId: "conn-1", database: "db1", children: [groupRow, viewNode("v_a")] };
|
||||
const tree: TreeNode[] = [{ ...database, children: [groupViews, viewNode("v_a")] }];
|
||||
expect(resolveTableVGroupScopeFromNode(tree, viewNode("v_a"))).toMatchObject({ connectionId: "conn-1", database: "db1", objectType: "views" });
|
||||
expect(resolveTableVGroupScopeFromNode(tree, groupRow)).toMatchObject({ connectionId: "conn-1", database: "db1", objectType: "views" });
|
||||
});
|
||||
|
||||
it("refuses a scope for view rows without a views container (simple mixed lists)", () => {
|
||||
const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1", children: [tableNode("t_a"), viewNode("v_a")] };
|
||||
expect(tableVGroupScopeKey(resolveTableVGroupScopeFromNode([database], viewNode("v_a")))).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves a member row already inside a projected group to the same views scope", () => {
|
||||
const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" };
|
||||
const member: TreeNode = { id: "view:v_in_group", label: "v_in_group", type: "view", connectionId: "conn-1", database: "db1" };
|
||||
const groupRow: TreeNode = { id: "table-vgroup:g9", label: "组", type: "table-vgroup", connectionId: "conn-1", database: "db1", vgroupId: "g9", children: [member] };
|
||||
const groupViews: TreeNode = { id: "gv:2", label: "Views", type: "group-views", connectionId: "conn-1", database: "db1", children: [groupRow, viewNode("v_top")] };
|
||||
const tree: TreeNode[] = [{ ...database, children: [groupViews] }];
|
||||
expect(resolveTableVGroupScopeFromNode(tree, member)).toMatchObject({ connectionId: "conn-1", database: "db1", objectType: "views" });
|
||||
});
|
||||
|
||||
it("narrows reprojection candidates to the scope kind even without a member label", () => {
|
||||
const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" };
|
||||
const groupTables: TreeNode = { id: "gt:1", label: "Tables", type: "group-tables", connectionId: "conn-1", database: "db1" };
|
||||
const groupFunctions: TreeNode = { id: "gf:1", label: "Functions", type: "group-functions", connectionId: "conn-1", database: "db1", children: [{ id: "fn:1", label: "vg_count", type: "function", connectionId: "conn-1", database: "db1" }] };
|
||||
const tree: TreeNode[] = [{ ...database, children: [groupTables, groupFunctions] }];
|
||||
expect(findTableVGroupContainerNode(tree, { connectionId: "conn-1", database: "db1", objectType: "tables" })).toBe(tree[0]);
|
||||
});
|
||||
|
||||
it("collects member names per container kind", () => {
|
||||
const children = [viewNode("v_a"), tableNode("t_a"), materializedViewNode("mv_a")];
|
||||
expect(collectTableTreeNames(children, "views")).toEqual(new Set(["v_a"]));
|
||||
expect(collectTableTreeNames(children, "materialized_views")).toEqual(new Set(["mv_a"]));
|
||||
expect(collectTableTreeNames(children, "tables")).toEqual(new Set(["t_a"]));
|
||||
});
|
||||
|
||||
it("keeps mixed selections type-pure when moving to groups", () => {
|
||||
const view = viewNode("v_a");
|
||||
const table = tableNode("t_a");
|
||||
expect(isTableVGroupGroupableRowType("materialized_view")).toBe(true);
|
||||
expect(isTableVGroupGroupableRowType("load-more")).toBe(false);
|
||||
expect(isTableVGroupGroupableRowType("index")).toBe(false);
|
||||
expect(selectedTableVGroupMoveTargets(view, [view, table])).toEqual([view]);
|
||||
expect(selectedTableVGroupMoveTargets(view, [view, viewNode("v_b")])).toEqual([view, viewNode("v_b")]);
|
||||
});
|
||||
|
||||
it("finds the holding container of a member row by kind", () => {
|
||||
const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" };
|
||||
const groupViews: TreeNode = { id: "gv:conn-1:db1", label: "Views", type: "group-views", connectionId: "conn-1", database: "db1", children: [viewNode("v_a")] };
|
||||
const tree: TreeNode[] = [{ ...database, children: [groupViews] }];
|
||||
expect(isTableVGroupContainerNode(groupViews)).toBe(true);
|
||||
expect(findTableVGroupContainerNode(tree, { connectionId: "conn-1", database: "db1" }, "v_a", "view")).toBe(groupViews);
|
||||
// kind 隔离:表成员定位永不落入 views 容器;缺失成员回退宿主(分页语义)
|
||||
expect(findTableVGroupContainerNode(tree, { connectionId: "conn-1", database: "db1" }, "v_a", "table")).toBe(tree[0]);
|
||||
expect(findTableVGroupContainerNode(tree, { connectionId: "conn-1", database: "db1" }, "t_missing", "table")).toBe(tree[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("container-kind drop and scope isolation", () => {
|
||||
it("rejects cross-kind drop targets and accepts same-kind ones", () => {
|
||||
const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" };
|
||||
const groupViews: TreeNode = { id: "gv:1", label: "Views", type: "group-views", connectionId: "conn-1", database: "db1", children: [{ id: "vg-row:1", label: "分组", type: "table-vgroup", connectionId: "conn-1", database: "db1", vgroupId: "g1" }] };
|
||||
const tree: TreeNode[] = [{ ...database, children: [groupViews] }];
|
||||
expect(tableVGroupDropAcceptsRow(tree, groupViews, "view")).toBe(true);
|
||||
expect(tableVGroupDropAcceptsRow(tree, groupViews, "table")).toBe(false);
|
||||
expect(tableVGroupDropAcceptsRow(tree, database, "view")).toBe(false);
|
||||
expect(tableVGroupDropAcceptsRow(tree, database, "table")).toBe(true);
|
||||
const groupRow = groupViews.children![0]!;
|
||||
expect(tableVGroupDropAcceptsRow(tree, groupRow, "view")).toBe(true);
|
||||
expect(tableVGroupDropAcceptsRow(tree, groupRow, "table")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a scope for table-child trigger rows sharing names with database triggers", () => {
|
||||
const database: TreeNode = { id: "db:conn-1:db1", label: "db1", type: "database", connectionId: "conn-1", database: "db1" };
|
||||
const dbTrigger: TreeNode = { id: "dbtrig:1", label: "tg_a", type: "trigger", connectionId: "conn-1", database: "db1" };
|
||||
const dbTriggers: TreeNode = { id: "gt:1", label: "Triggers", type: "group-triggers", connectionId: "conn-1", database: "db1", children: [dbTrigger] };
|
||||
const tableTrigger: TreeNode = { id: "tbltrig:1", label: "tg_a", type: "trigger", connectionId: "conn-1", database: "db1", parentName: "t1" };
|
||||
const tableTriggers: TreeNode = { id: "tt:1", label: "Triggers", type: "group-triggers", connectionId: "conn-1", database: "db1", tableName: "t1", children: [tableTrigger] };
|
||||
const table: TreeNode = { id: "table:t1", label: "t1", type: "table", connectionId: "conn-1", database: "db1", children: [tableTriggers] };
|
||||
const tree: TreeNode[] = [{ ...database, children: [dbTriggers, table] }];
|
||||
expect(tableVGroupScopeKey(resolveTableVGroupScopeFromNode(tree, dbTrigger))).toBe("conn-1\u0000\u0000\u0000db1\u0000\u0000triggers");
|
||||
expect(tableVGroupScopeKey(resolveTableVGroupScopeFromNode(tree, tableTrigger))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,12 +13,21 @@ export type { TableVGroupLayout, TableVGroupOrderEntry };
|
||||
|
||||
export type TableVGroupDropPosition = "before" | "after" | "inside";
|
||||
|
||||
/**
|
||||
* 分组作用域覆盖的对象容器类别。tables 是 #3470 起的既有行为;views /
|
||||
* procedures / triggers 对应 #9446 方向一与 #9733;其余容器类别为同一机制的
|
||||
* 全量补齐(与 groupDefs 的库级对象容器一一对应)。
|
||||
*/
|
||||
export type TableVGroupObjectKind = "tables" | "views" | "materialized_views" | "procedures" | "functions" | "triggers" | "sequences" | "events" | "synonyms" | "jobs" | "packages" | "types";
|
||||
|
||||
export interface TableVGroupScope {
|
||||
connectionId?: string;
|
||||
catalog?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
linkedServer?: string;
|
||||
/** 本布局归属的容器类别;缺省视为 tables(历史 scope 均为表分组)。 */
|
||||
objectType?: TableVGroupObjectKind;
|
||||
}
|
||||
|
||||
type TableVGroupEntry = Extract<TableVGroupOrderEntry, { type: "group" }>;
|
||||
@@ -38,7 +47,10 @@ export function tableVGroupsEnabled(layout: TableVGroupLayout | null | undefined
|
||||
|
||||
export function tableVGroupScopeKey(scope: TableVGroupScope): string | null {
|
||||
if (!scope.connectionId || !scope.database) return null;
|
||||
return [scope.connectionId, scope.linkedServer ?? "", scope.catalog ?? "", scope.database, scope.schema ?? ""].join("\u0000");
|
||||
const parts = [scope.connectionId, scope.linkedServer ?? "", scope.catalog ?? "", scope.database, scope.schema ?? ""];
|
||||
// 仅非表容器在尾部追加类别段:表分组的 key 与历史持久化逐字节一致,零迁移。
|
||||
if (scope.objectType && scope.objectType !== "tables") parts.push(scope.objectType);
|
||||
return parts.join("\u0000");
|
||||
}
|
||||
|
||||
const TABLE_VGROUP_NODE_ID_PREFIX = "table-vgroup:";
|
||||
@@ -53,28 +65,106 @@ export function tableVGroupIdFromNodeId(nodeId: string): string | null {
|
||||
return nodeId.startsWith(TABLE_VGROUP_NODE_ID_PREFIX) ? nodeId.slice(TABLE_VGROUP_NODE_ID_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
/** Tree node types whose direct children may contain table nodes to group. */
|
||||
const TABLE_VGROUP_CONTAINER_TYPES: ReadonlySet<TreeNode["type"]> = new Set(["database", "schema", "linked-server-schema", "group-tables"]);
|
||||
/**
|
||||
* 承载可分组行的容器行 → 分组类别。group-triggers 双栖:库级触发器列表与
|
||||
* 表结构子组共用同一 type,表子级带 tableName,不算库级分组容器。
|
||||
*/
|
||||
export function tableVGroupKindOfContainerNode(node: Pick<TreeNode, "type" | "tableName">): TableVGroupObjectKind | null {
|
||||
switch (node.type) {
|
||||
case "database":
|
||||
case "schema":
|
||||
case "linked-server-schema":
|
||||
case "group-tables":
|
||||
return "tables";
|
||||
case "group-views":
|
||||
return "views";
|
||||
case "group-materialized-views":
|
||||
return "materialized_views";
|
||||
case "group-procedures":
|
||||
return "procedures";
|
||||
case "group-functions":
|
||||
return "functions";
|
||||
case "group-triggers":
|
||||
// 双栖 type:库级触发器列表与表结构子组共用;表子级带 tableName。
|
||||
return node.tableName ? null : "triggers";
|
||||
case "group-sequences":
|
||||
return "sequences";
|
||||
case "group-events":
|
||||
return "events";
|
||||
case "group-synonyms":
|
||||
return "synonyms";
|
||||
case "group-jobs":
|
||||
return "jobs";
|
||||
case "group-packages":
|
||||
return "packages";
|
||||
case "group-types":
|
||||
return "types";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 各类别容器里可被分组的行 type;成员身份仍是容器内的行 label。 */
|
||||
const TABLE_VGROUP_KIND_ROW_TYPES: Record<TableVGroupObjectKind, Record<string, true>> = {
|
||||
tables: { table: true },
|
||||
views: { view: true },
|
||||
materialized_views: { materialized_view: true },
|
||||
procedures: { procedure: true },
|
||||
functions: { function: true },
|
||||
triggers: { trigger: true },
|
||||
sequences: { sequence: true },
|
||||
events: { event: true },
|
||||
synonyms: { synonym: true },
|
||||
jobs: { job: true },
|
||||
// packages/types 的 spec 与 body 行同名(label 键控成员身份),入组必然合并为
|
||||
// 单一成员条目——已知限制:同名双行无法分别归属不同分组。
|
||||
packages: { package: true, "package-body": true },
|
||||
types: { type: true, "type-body": true },
|
||||
};
|
||||
|
||||
/** 该行 type 是否可被分入某类容器(拖拽源、移动菜单等入口的快速过滤)。 */
|
||||
export function isTableVGroupGroupableRowType(type: TreeNode["type"]): boolean {
|
||||
switch (type) {
|
||||
case "table":
|
||||
case "view":
|
||||
case "materialized_view":
|
||||
case "procedure":
|
||||
case "function":
|
||||
case "trigger":
|
||||
case "sequence":
|
||||
case "event":
|
||||
case "synonym":
|
||||
case "job":
|
||||
case "package":
|
||||
case "package-body":
|
||||
case "type":
|
||||
case "type-body":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 行的身份字段即 scope 字段;分组节点与各类容器直接自带这五项。 */
|
||||
function tableVGroupScopeFieldsOf(node: TreeNode): TableVGroupScope {
|
||||
return { connectionId: node.connectionId, catalog: node.catalog, database: node.database, schema: node.schema, linkedServer: node.linkedServer };
|
||||
}
|
||||
|
||||
/** 承载表行的容器行:分组投影的挂载点,也是拖拽「移出分组」的落点。 */
|
||||
/** 分组投影的挂载点,也是拖拽「移出分组」的落点。 */
|
||||
export function isTableVGroupContainerNode(node: TreeNode): boolean {
|
||||
return TABLE_VGROUP_CONTAINER_TYPES.has(node.type);
|
||||
return tableVGroupKindOfContainerNode(node) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection semantics for "move to group", mirroring the connection tree: when
|
||||
* several rows are selected and the right-clicked table is part of that
|
||||
* several rows are selected and the right-clicked row is part of that
|
||||
* selection, the whole selection moves; anything outside the right-clicked
|
||||
* table's connection+database scope is ignored. Otherwise only the clicked row.
|
||||
* row's connection+database+type scope is ignored. Otherwise only the clicked
|
||||
* row.
|
||||
*/
|
||||
export function selectedTableVGroupMoveTargets(currentNode: TreeNode, selectedNodes: readonly TreeNode[]): TreeNode[] {
|
||||
if (currentNode.type !== "table" || !currentNode.connectionId) return currentNode.type === "table" ? [currentNode] : [];
|
||||
const inScope = (node: TreeNode) => node.type === "table" && node.connectionId === currentNode.connectionId && (node.database ?? "") === (currentNode.database ?? "") && (node.schema ?? "") === (currentNode.schema ?? "");
|
||||
if (!isTableVGroupGroupableRowType(currentNode.type) || !currentNode.connectionId) return isTableVGroupGroupableRowType(currentNode.type) ? [currentNode] : [];
|
||||
const inScope = (node: TreeNode) => node.type === currentNode.type && node.connectionId === currentNode.connectionId && (node.database ?? "") === (currentNode.database ?? "") && (node.schema ?? "") === (currentNode.schema ?? "");
|
||||
const selectedContainsCurrent = selectedNodes.some((node) => node.id === currentNode.id);
|
||||
if (selectedNodes.length > 1 && selectedContainsCurrent && selectedNodes.every(inScope)) return [...selectedNodes];
|
||||
return [currentNode];
|
||||
@@ -104,8 +194,13 @@ function containsGroup(entry: TableVGroupOrderEntry, groupId: string): boolean {
|
||||
return entryChildren(entry).some((child) => containsGroup(child, groupId));
|
||||
}
|
||||
|
||||
function removeTableFromEntries(entries: TableVGroupOrderEntry[], tableName: string): TableVGroupOrderEntry[] {
|
||||
return entries.filter((entry) => !(entry.type === "table" && entry.name === tableName)).map((entry) => (entry.type === "group" ? { type: "group" as const, id: entry.id, children: removeTableFromEntries(entryChildren(entry), tableName) } : entry));
|
||||
/** 成员条目与 (名字, 行类型) 的匹配:rowType 给定时旧的无类型条目(历史数据)也视为命中。 */
|
||||
function matchesTableEntry(entry: TableVGroupOrderEntry, name: string, rowType?: string): boolean {
|
||||
return entry.type === "table" && entry.name === name && (!rowType || !entry.rowType || entry.rowType === rowType);
|
||||
}
|
||||
|
||||
function removeTableFromEntries(entries: TableVGroupOrderEntry[], tableName: string, rowType?: string): TableVGroupOrderEntry[] {
|
||||
return entries.filter((entry) => !matchesTableEntry(entry, tableName, rowType)).map((entry) => (entry.type === "group" ? { type: "group" as const, id: entry.id, children: removeTableFromEntries(entryChildren(entry), tableName, rowType) } : entry));
|
||||
}
|
||||
|
||||
function expandGroup(layout: TableVGroupLayout, groupId: string): TableVGroupLayout {
|
||||
@@ -115,10 +210,10 @@ function expandGroup(layout: TableVGroupLayout, groupId: string): TableVGroupLay
|
||||
};
|
||||
}
|
||||
|
||||
export function moveTableToVGroup(layout: TableVGroupLayout, tableName: string, targetGroupId: string | null): TableVGroupLayout {
|
||||
export function moveTableToVGroup(layout: TableVGroupLayout, tableName: string, targetGroupId: string | null, rowType?: string): TableVGroupLayout {
|
||||
if (!hasTableVGroupEntries(layout)) return layout;
|
||||
const order = removeTableFromEntries(cloneEntries(layout.order), tableName);
|
||||
const entry: TableVGroupOrderEntry = { type: "table", name: tableName };
|
||||
const order = removeTableFromEntries(cloneEntries(layout.order), tableName, rowType);
|
||||
const entry: TableVGroupOrderEntry = { type: "table", name: tableName, ...(rowType ? { rowType } : {}) };
|
||||
|
||||
if (targetGroupId) {
|
||||
const group = findGroupEntry(order, targetGroupId);
|
||||
@@ -277,11 +372,11 @@ export function tableVGroupDestinationRows(layout: TableVGroupLayout | null | un
|
||||
}
|
||||
|
||||
/** Stable root-to-leaf group-id path containing the table, empty when ungrouped. */
|
||||
export function tableVGroupPathForTable(layout: TableVGroupLayout | null | undefined, tableName: string): string[] {
|
||||
export function tableVGroupPathForTable(layout: TableVGroupLayout | null | undefined, tableName: string, rowType?: string): string[] {
|
||||
if (!hasTableVGroupEntries(layout)) return [];
|
||||
const walk = (entries: TableVGroupOrderEntry[], path: string[]): string[] | null => {
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "table" && entry.name === tableName) return path;
|
||||
if (matchesTableEntry(entry, tableName, rowType)) return path;
|
||||
if (entry.type !== "group") continue;
|
||||
const found = walk(entryChildren(entry), [...path, entry.id]);
|
||||
if (found) return found;
|
||||
@@ -295,17 +390,30 @@ export function tableVGroupPathForTable(layout: TableVGroupLayout | null | undef
|
||||
// Display projection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TableVGroupFlatTable {
|
||||
interface TableVGroupFlatRow {
|
||||
node: TreeNode;
|
||||
flatIndex: number;
|
||||
}
|
||||
|
||||
function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, tables: Map<string, TableVGroupFlatTable>, scope: TableVGroupScope): TreeNode[] {
|
||||
/** 双索引成员行查找:rowType 精确键 O(1);无 rowType(历史条目与表)走同名首行索引 O(1)。 */
|
||||
interface TableVGroupRowLookup {
|
||||
byKey: Map<string, TableVGroupFlatRow>;
|
||||
byLabel: Map<string, TableVGroupFlatRow>;
|
||||
}
|
||||
|
||||
function findRowIn(lookup: TableVGroupRowLookup, name: string, rowType?: string): TableVGroupFlatRow | undefined {
|
||||
if (rowType) return lookup.byKey.get(`${rowType}\u0000${name}`);
|
||||
return lookup.byLabel.get(name);
|
||||
}
|
||||
|
||||
const tableRowKey = (type: string, label: string): string => `${type}\u0000${label}`;
|
||||
|
||||
function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, lookup: TableVGroupRowLookup, scope: TableVGroupScope): TreeNode[] {
|
||||
const nodes: TreeNode[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "table") {
|
||||
const table = tables.get(entry.name);
|
||||
if (table) nodes.push(table.node);
|
||||
const row = findRowIn(lookup, entry.name, entry.rowType);
|
||||
if (row) nodes.push(row.node);
|
||||
continue;
|
||||
}
|
||||
const group = layout.groups.find((candidate) => candidate.id === entry.id);
|
||||
@@ -320,8 +428,9 @@ function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVG
|
||||
schema: scope.schema,
|
||||
linkedServer: scope.linkedServer,
|
||||
vgroupId: entry.id,
|
||||
vgroupKind: scope.objectType ?? "tables",
|
||||
isExpanded: !group.collapsed,
|
||||
children: buildVGroupChildNodes(entryChildren(entry), layout, tables, scope),
|
||||
children: buildVGroupChildNodes(entryChildren(entry), layout, lookup, scope),
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
@@ -330,15 +439,15 @@ function buildVGroupChildNodes(entries: TableVGroupOrderEntry[], layout: TableVG
|
||||
/**
|
||||
* Build the root-level projected nodes. Virtual groups always float to the top
|
||||
* of the container in layout order (matching Navicat's table groups), followed
|
||||
* by ungrouped tables and non-table rows at their original flat positions.
|
||||
* by ungrouped rows and non-member rows at their original flat positions.
|
||||
*/
|
||||
function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, tables: Map<string, TableVGroupFlatTable>, scope: TableVGroupScope): Array<{ node: TreeNode; sortIndex: number }> {
|
||||
function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGroupLayout, lookup: TableVGroupRowLookup, scope: TableVGroupScope): Array<{ node: TreeNode; sortIndex: number }> {
|
||||
const out: Array<{ node: TreeNode; sortIndex: number }> = [];
|
||||
let groupSeq = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "table") {
|
||||
const table = tables.get(entry.name);
|
||||
if (table) out.push({ node: table.node, sortIndex: table.flatIndex });
|
||||
const row = findRowIn(lookup, entry.name, entry.rowType);
|
||||
if (row) out.push({ node: row.node, sortIndex: row.flatIndex });
|
||||
continue;
|
||||
}
|
||||
const group = layout.groups.find((candidate) => candidate.id === entry.id);
|
||||
@@ -354,8 +463,9 @@ function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGr
|
||||
schema: scope.schema,
|
||||
linkedServer: scope.linkedServer,
|
||||
vgroupId: entry.id,
|
||||
vgroupKind: scope.objectType ?? "tables",
|
||||
isExpanded: !group.collapsed,
|
||||
children: buildVGroupChildNodes(entryChildren(entry), layout, tables, scope),
|
||||
children: buildVGroupChildNodes(entryChildren(entry), layout, lookup, scope),
|
||||
},
|
||||
// -1e6 keeps every group above flat rows while preserving layout order among groups.
|
||||
sortIndex: -1_000_000 + groupSeq++,
|
||||
@@ -365,11 +475,11 @@ function buildVGroupRootNodes(entries: TableVGroupOrderEntry[], layout: TableVGr
|
||||
}
|
||||
|
||||
/**
|
||||
* Rearrange loaded table nodes under their virtual groups. Pure display-level
|
||||
* transform over an already-loaded child list: table node objects are
|
||||
* re-parented by reference so per-node state (isExpanded / isLoading / loaded
|
||||
* children) survives projection. Members missing from the loaded page simply
|
||||
* stay absent (sidebar pagination); stale names are skipped.
|
||||
* Rearrange loaded container rows under their virtual groups. Pure
|
||||
* display-level transform over an already-loaded child list: row node objects
|
||||
* are re-parented by reference so per-node state (isExpanded / isLoading /
|
||||
* loaded children) survives projection. Members missing from the loaded page
|
||||
* simply stay absent (sidebar pagination); stale names are skipped.
|
||||
*/
|
||||
export function applyTableVGroupsToChildren(children: TreeNode[], layout: TableVGroupLayout | null | undefined, scope: TableVGroupScope): TreeNode[] {
|
||||
if (!children.length || !layout || !tableVGroupsEnabled(layout)) return children;
|
||||
@@ -379,27 +489,32 @@ export function applyTableVGroupsToChildren(children: TreeNode[], layout: TableV
|
||||
const activeLayout = layout;
|
||||
const flatChildren = stripTableVGroupsFromChildren(children);
|
||||
|
||||
const tables = new Map<string, TableVGroupFlatTable>();
|
||||
// scope.objectType 决定哪些行参与分组;缺省 tables 维持既有表分组行为。
|
||||
const rowTypes = TABLE_VGROUP_KIND_ROW_TYPES[scope.objectType ?? "tables"];
|
||||
const rows: TableVGroupRowLookup = { byKey: new Map(), byLabel: new Map() };
|
||||
const passthrough: Array<{ node: TreeNode; sortIndex: number }> = [];
|
||||
for (let i = 0; i < flatChildren.length; i++) {
|
||||
const node = flatChildren[i]!;
|
||||
if (node.type === "table" && !tables.has(node.label)) tables.set(node.label, { node, flatIndex: i });
|
||||
else passthrough.push({ node, sortIndex: i });
|
||||
// 键为 (行类型, 名字):包 spec/body 同名双行可各自独立成组员;同名首行别名供历史条目按名匹配。
|
||||
if (rowTypes[node.type] && !rows.byKey.has(tableRowKey(node.type, node.label))) {
|
||||
rows.byKey.set(tableRowKey(node.type, node.label), { node, flatIndex: i });
|
||||
if (!rows.byLabel.has(node.label)) rows.byLabel.set(node.label, { node, flatIndex: i });
|
||||
} else passthrough.push({ node, sortIndex: i });
|
||||
}
|
||||
if (!tables.size) return flatChildren;
|
||||
if (!rows.byKey.size) return flatChildren;
|
||||
|
||||
const rootBuilt = buildVGroupRootNodes(activeLayout.order, activeLayout, tables, scope);
|
||||
// Tables the layout does not reference stay ungrouped at their original slot.
|
||||
const rootBuilt = buildVGroupRootNodes(activeLayout.order, activeLayout, rows, scope);
|
||||
// Rows the layout does not reference stay ungrouped at their original slot.
|
||||
const consumed = new Set<TreeNode>();
|
||||
const consume = (nodes: TreeNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "table") consumed.add(node);
|
||||
if (rowTypes[node.type]) consumed.add(node);
|
||||
else consume(node.children ?? []);
|
||||
}
|
||||
};
|
||||
consume(rootBuilt.map((item) => item.node));
|
||||
for (const table of tables.values()) {
|
||||
if (!consumed.has(table.node)) passthrough.push({ node: table.node, sortIndex: table.flatIndex });
|
||||
for (const row of rows.byKey.values()) {
|
||||
if (!consumed.has(row.node)) passthrough.push({ node: row.node, sortIndex: row.flatIndex });
|
||||
}
|
||||
|
||||
const merged = [...rootBuilt, ...passthrough];
|
||||
@@ -429,18 +544,38 @@ export function stripTableVGroupsFromChildren(children: TreeNode[]): TreeNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the live tree node whose children this scope's layout projects. Table
|
||||
* rows and their container do not always carry identical optional identity
|
||||
* fields (a simple-display database node has no schema while its table rows
|
||||
* do), so optional parts tolerate a missing value on either side; when several
|
||||
* containers match, the one that actually holds table rows wins.
|
||||
* Find the live tree node whose children this scope's layout projects. Rows
|
||||
* and their container do not always carry identical optional identity fields
|
||||
* (a simple-display database node has no schema while its rows do), so
|
||||
* optional parts tolerate a missing value on either side; when several
|
||||
* containers match, the one that actually holds member rows wins. A member
|
||||
* type narrows candidates to containers of a matching kind, and so does a
|
||||
* non-tables scope.objectType — without that, reprojection triggered by, say,
|
||||
* a functions-layout change could fall back onto the database container and
|
||||
* strip the tables grouping out of it.
|
||||
*/
|
||||
export function findTableVGroupContainerNode(nodes: TreeNode[], scope: TableVGroupScope, tableName?: string): TreeNode | null {
|
||||
export function findTableVGroupContainerNode(nodes: TreeNode[], scope: TableVGroupScope, memberLabel?: string, memberType?: string): TreeNode | null {
|
||||
const matchesOptional = (nodeValue: string | undefined, scopeValue: string | undefined) => nodeValue === scopeValue || !nodeValue || !scopeValue;
|
||||
// 目标类别:memberType(行解析)按成员类型收敛;scope.objectType(重投影)按
|
||||
// 类别收敛;tables 作用域(缺省)也必须收敛到 tables——否则其它类别的容器会混入
|
||||
// 候选池,tables 的无成员写入(建组/改名/折叠等)可能误路由过去并抹掉其分组投影。
|
||||
const scopeKind = scope.objectType && scope.objectType !== "tables" ? scope.objectType : null;
|
||||
const expectedKind = scopeKind ?? (memberType ? null : "tables");
|
||||
const memberRowTypes = memberType ? { [memberType]: true as const } : TABLE_VGROUP_KIND_ROW_TYPES[scope.objectType ?? "tables"];
|
||||
const candidates: TreeNode[] = [];
|
||||
const walk = (list: TreeNode[]) => {
|
||||
for (const node of list) {
|
||||
if (isTableVGroupContainerNode(node) && node.connectionId === scope.connectionId && (node.database ?? "") === (scope.database ?? "") && matchesOptional(node.catalog, scope.catalog) && matchesOptional(node.schema, scope.schema) && matchesOptional(node.linkedServer, scope.linkedServer)) {
|
||||
const kind = tableVGroupKindOfContainerNode(node);
|
||||
const kindMatches = expectedKind ? kind === expectedKind : kind !== null && TABLE_VGROUP_KIND_ROW_TYPES[kind][memberType!];
|
||||
if (
|
||||
kindMatches &&
|
||||
isTableVGroupContainerNode(node) &&
|
||||
node.connectionId === scope.connectionId &&
|
||||
(node.database ?? "") === (scope.database ?? "") &&
|
||||
matchesOptional(node.catalog, scope.catalog) &&
|
||||
matchesOptional(node.schema, scope.schema) &&
|
||||
matchesOptional(node.linkedServer, scope.linkedServer)
|
||||
) {
|
||||
candidates.push(node);
|
||||
}
|
||||
if (node.children) walk(node.children);
|
||||
@@ -449,11 +584,11 @@ export function findTableVGroupContainerNode(nodes: TreeNode[], scope: TableVGro
|
||||
walk(nodes);
|
||||
if (!candidates.length) return null;
|
||||
|
||||
if (tableName) {
|
||||
const holder = candidates.find((node) => (node.children ?? []).some((child) => child.type === "table" && child.label === tableName));
|
||||
if (memberLabel) {
|
||||
const holder = candidates.find((node) => (node.children ?? []).some((child) => memberRowTypes[child.type] && child.label === memberLabel));
|
||||
if (holder) return holder;
|
||||
}
|
||||
return candidates.find((node) => (node.children ?? []).some((child) => child.type === "table" || child.type === "table-vgroup")) ?? candidates[0]!;
|
||||
return candidates.find((node) => (node.children ?? []).some((child) => memberRowTypes[child.type] || child.type === "table-vgroup")) ?? candidates[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -482,17 +617,20 @@ export function normalizeTableVGroupLayout(value: unknown): TableVGroupLayout {
|
||||
const normalizeEntries = (entries: unknown): TableVGroupOrderEntry[] => {
|
||||
const out: TableVGroupOrderEntry[] = [];
|
||||
if (!Array.isArray(entries)) return out;
|
||||
// 同一层里重复的表名或分组引用会投影出重复节点(渲染以 id 作 key),
|
||||
// 损坏的持久化数据必须在归一化这一步收敛。
|
||||
// 同一层里重复的成员条目或分组引用会投影出重复节点(渲染以 id 作 key),
|
||||
// 损坏的持久化数据必须在归一化这一步收敛。去重键含行类型:包 spec/body
|
||||
// 同名双行是合法共存,不算重复。
|
||||
const seenTableNames = new Set<string>();
|
||||
const seenGroupEntries = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const candidate = entry as Partial<TableVGroupOrderEntry>;
|
||||
if (candidate.type === "table") {
|
||||
if (typeof candidate.name !== "string" || !candidate.name || seenTableNames.has(candidate.name)) continue;
|
||||
seenTableNames.add(candidate.name);
|
||||
out.push({ type: "table", name: candidate.name });
|
||||
const rowType = typeof candidate.rowType === "string" ? candidate.rowType : undefined;
|
||||
const dedupeKey = `${rowType ?? ""}\u0000${candidate.name}`;
|
||||
if (typeof candidate.name !== "string" || !candidate.name || seenTableNames.has(dedupeKey)) continue;
|
||||
seenTableNames.add(dedupeKey);
|
||||
out.push({ type: "table", name: candidate.name, ...(rowType ? { rowType } : {}) });
|
||||
} else if (candidate.type === "group" && typeof candidate.id === "string" && candidate.id && validGroupIds.has(candidate.id) && !seenGroupEntries.has(candidate.id)) {
|
||||
seenGroupEntries.add(candidate.id);
|
||||
out.push({ type: "group", id: candidate.id, children: normalizeEntries(entryChildren(candidate as TableVGroupEntry)) });
|
||||
@@ -540,24 +678,76 @@ function findTableVGroupHostContainer(nodes: TreeNode[], node: TreeNode): TreeNo
|
||||
return walk(nodes, []);
|
||||
}
|
||||
|
||||
/** 节点到根路径上最近的「可分组容器」;投影分组行的类别由它决定。 */
|
||||
function findTableVGroupHolderContainer(nodes: TreeNode[], node: TreeNode): TreeNode | null {
|
||||
const walk = (list: TreeNode[], ancestors: TreeNode[]): TreeNode | null => {
|
||||
for (const item of list) {
|
||||
if (item === node || item.id === node.id) {
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const ancestor = ancestors[i]!;
|
||||
if (tableVGroupKindOfContainerNode(ancestor)) return ancestor;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (item.children) {
|
||||
const found = walk(item.children, [...ancestors, item]);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(nodes, []);
|
||||
}
|
||||
|
||||
function tableVGroupScopeHostOf(nodes: TreeNode[], node: TreeNode): TreeNode {
|
||||
return isTableVGroupDisplayNode(node.type) ? (findTableVGroupHostContainer(nodes, node) ?? node) : node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the layout scope for any sidebar row: containers and group nodes own
|
||||
* their fields directly, while table rows resolve to their holding container —
|
||||
* in simple display mode a table row carries a schema its container lacks, so
|
||||
* using the row's own fields would split reads and writes across two keys.
|
||||
* Derive the layout scope for any sidebar row. Containers and projected group
|
||||
* rows resolve identity fields up to their host container (simple display
|
||||
* modes fill row-side schema fields the container lacks; using them would
|
||||
* split reads and writes across two keys) while the container kind comes from
|
||||
* the nearest groupable holder. Groupable rows resolve to the container of
|
||||
* their own type; when none exists (a view row in a mixed simple list, say)
|
||||
* the empty scope fails scope-key derivation so the row can never join a
|
||||
* tables-kind layout.
|
||||
*/
|
||||
export function resolveTableVGroupScopeFromNode(nodes: TreeNode[], node: TreeNode | TableVGroupScope): TableVGroupScope {
|
||||
const row = node as Partial<TreeNode> & TableVGroupScope;
|
||||
if (row.type === "table-vgroup" || row.type?.startsWith("group-")) return tableVGroupScopeFieldsOf(tableVGroupScopeHostOf(nodes, row as TreeNode));
|
||||
if (isTableVGroupContainerNode(row as TreeNode)) return tableVGroupScopeFieldsOf(row as TreeNode);
|
||||
const container = findTableVGroupContainerNode(nodes, row as TreeNode, row.type === "table" ? row.label : undefined);
|
||||
const containerKind = isTableVGroupContainerNode(row as TreeNode) ? tableVGroupKindOfContainerNode(row as TreeNode) : null;
|
||||
if (containerKind) return { ...tableVGroupScopeFieldsOf(tableVGroupScopeHostOf(nodes, row as TreeNode)), objectType: containerKind };
|
||||
if (row.type === "table-vgroup" || row.type?.startsWith("group-")) {
|
||||
const host = tableVGroupScopeHostOf(nodes, row as TreeNode);
|
||||
const holder = findTableVGroupHolderContainer(nodes, row as TreeNode);
|
||||
return { ...tableVGroupScopeFieldsOf(host), objectType: (holder && tableVGroupKindOfContainerNode(holder)) || "tables" };
|
||||
}
|
||||
if (row.type && isTableVGroupGroupableRowType(row.type)) {
|
||||
const holder = findTableVGroupContainerNode(nodes, tableVGroupScopeFieldsOf(row as TreeNode), row.label, row.type);
|
||||
// holder 子树(含投影出的分组层级)必须直接持有该行(同 id):label 在跨容器
|
||||
// 同名时会误配——表结构子组里的触发器行与库级触发器列表的行同名不同节点;
|
||||
// 而「子树」而非「顶层」是因为已入组的成员行挂在 table-vgroup 行下,仍属
|
||||
// 同一容器的管辖范围(移出/换组动作就是从这里发起的)。
|
||||
const holdsRow = (nodes?: TreeNode[]): boolean => (nodes ?? []).some((n) => n.id === row.id || (n.type === "table-vgroup" && holdsRow(n.children)));
|
||||
if (!holder || !holdsRow(holder.children)) return {};
|
||||
const host = tableVGroupScopeHostOf(nodes, holder);
|
||||
return { ...tableVGroupScopeFieldsOf(host), objectType: tableVGroupKindOfContainerNode(holder) ?? "tables" };
|
||||
}
|
||||
const container = findTableVGroupContainerNode(nodes, row as TreeNode);
|
||||
return tableVGroupScopeFieldsOf(container ? tableVGroupScopeHostOf(nodes, container) : (row as TreeNode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a drag with the given source row type may land on this container or
|
||||
* projected group row. Cross-kind drops would write phantom members into the
|
||||
* other kind's layout (and same-label members could evict real ones), so the
|
||||
* drop target resolution has to reject them up front.
|
||||
*/
|
||||
export function tableVGroupDropAcceptsRow(nodes: TreeNode[], target: TreeNode, rowType: TreeNode["type"]): boolean {
|
||||
const kind = target.type === "table-vgroup" ? tableVGroupKindOfContainerNode(findTableVGroupHolderContainer(nodes, target) ?? target) : tableVGroupKindOfContainerNode(target);
|
||||
return !!kind && !!TABLE_VGROUP_KIND_ROW_TYPES[kind][rowType];
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意深度出现分页游标都说明列表不完整:TDengine 等把末页游标嵌在 STABLE 的子表
|
||||
* 分区节点下,只看顶层会把部分页当成全量,进而误删分组成员。
|
||||
@@ -566,12 +756,13 @@ export function hasTableTreeLoadMore(nodes: readonly TreeNode[]): boolean {
|
||||
return nodes.some((node) => node.type === "load-more" || (node.children ? hasTableTreeLoadMore(node.children) : false));
|
||||
}
|
||||
|
||||
/** 收集子树里的表行名;多收只会让回收更保守,不会误删成员。 */
|
||||
export function collectTableTreeNames(nodes: readonly TreeNode[]): Set<string> {
|
||||
/** 收集子树里可分组行名;多收只会让回收更保守,不会误删成员。 */
|
||||
export function collectTableTreeNames(nodes: readonly TreeNode[], kind: TableVGroupObjectKind = "tables"): Set<string> {
|
||||
const rowTypes = TABLE_VGROUP_KIND_ROW_TYPES[kind];
|
||||
const names = new Set<string>();
|
||||
const walk = (list: readonly TreeNode[]) => {
|
||||
for (const node of list) {
|
||||
if (node.type === "table" && node.label) names.add(node.label);
|
||||
if (rowTypes[node.type] && node.label) names.add(node.label);
|
||||
if (node.children) walk(node.children);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,10 +15,17 @@ describe("connectionStore table virtual groups during disk reload", () => {
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
loadConnections: vi.fn().mockResolvedValue([]),
|
||||
loadEditorSettings: vi.fn().mockResolvedValue(null),
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
listInstalledAgents: vi.fn().mockResolvedValue([]),
|
||||
listTables: vi.fn().mockResolvedValue([]),
|
||||
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
|
||||
loadSidebarLayout: vi.fn().mockResolvedValue(null),
|
||||
loadTableVGroups: vi.fn().mockResolvedValue({}),
|
||||
loadTunnelProfiles: vi.fn().mockResolvedValue([]),
|
||||
listObjects: vi.fn().mockResolvedValue([]),
|
||||
loadSchemaCache: vi.fn().mockResolvedValue(null),
|
||||
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
|
||||
saveConnections: vi.fn().mockResolvedValue(undefined),
|
||||
saveEditorSettings: vi.fn().mockResolvedValue(undefined),
|
||||
savePinnedTreeNodeIds: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -80,4 +87,55 @@ describe("connectionStore table virtual groups during disk reload", () => {
|
||||
expect(api.loadConnections).toHaveBeenCalledTimes(4);
|
||||
expect(store.tableVGroupLayoutFor(container)).toEqual(layout);
|
||||
});
|
||||
|
||||
it("keeps views layouts separate from tables layouts for the same database", async () => {
|
||||
const api = await import("@/lib/backend/api");
|
||||
const { tableVGroupScopeKey } = await import("@/lib/table/tableVGroup");
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const config: ConnectionConfig = { id: "pg-9733", name: "PG", db_type: "postgresql", host: "127.0.0.1", port: 5432, username: "u", password: "", database: "app" };
|
||||
const viewsScope = { connectionId: config.id, database: "app", objectType: "views" as const };
|
||||
const viewsLayout = { version: 1, enabled: true, groups: [{ id: "vg", name: "支付视图", collapsed: false }], order: [{ type: "group" as const, id: "vg", children: [{ type: "table" as const, name: "v_pay" }] }] };
|
||||
vi.mocked(api.loadConnections).mockResolvedValue([config]);
|
||||
vi.mocked(api.loadTableVGroups).mockResolvedValue({ [tableVGroupScopeKey(viewsScope)!]: viewsLayout });
|
||||
const store = useConnectionStore();
|
||||
await store.initFromDisk();
|
||||
|
||||
const groupViews: TreeNode = { id: `${config.id}:app:__views`, label: "Views", type: "group-views", connectionId: config.id, database: "app" };
|
||||
const database: TreeNode = { id: `${config.id}:app`, label: "app", type: "database", connectionId: config.id, database: "app", children: [groupViews] };
|
||||
store.treeNodes[0]!.isExpanded = true;
|
||||
store.treeNodes[0]!.children!.push(database);
|
||||
expect(store.tableVGroupLayoutFor(groupViews)).toEqual(viewsLayout);
|
||||
expect(store.tableVGroupLayoutFor(database)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps projected view groups through the setChildren reload path", async () => {
|
||||
const api = await import("@/lib/backend/api");
|
||||
const { tableVGroupScopeKey } = await import("@/lib/table/tableVGroup");
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const config: ConnectionConfig = { id: "pg-view-1", name: "PG", db_type: "postgresql", host: "127.0.0.1", port: 5432, username: "u", password: "", database: "app" };
|
||||
const viewsScope = { connectionId: config.id, database: "app", objectType: "views" as const };
|
||||
const viewsLayout = { version: 1, enabled: true, groups: [{ id: "vg", name: "支付视图", collapsed: false }], order: [{ type: "group" as const, id: "vg", children: [{ type: "table" as const, name: "v_pay" }] }] };
|
||||
vi.mocked(api.loadConnections).mockResolvedValue([config]);
|
||||
vi.mocked(api.loadTableVGroups).mockResolvedValue({ [tableVGroupScopeKey(viewsScope)!]: viewsLayout });
|
||||
const store = useConnectionStore();
|
||||
await store.initFromDisk();
|
||||
const groupViews: TreeNode = { id: `${config.id}:app:__views`, label: "Views", type: "group-views", connectionId: config.id, database: "app", children: [], isExpanded: true };
|
||||
const database: TreeNode = { id: `${config.id}:app`, label: "app", type: "database", connectionId: config.id, database: "app", children: [groupViews], isExpanded: true };
|
||||
vi.mocked(api.listTables).mockResolvedValue([
|
||||
{ name: "v_pay", table_type: "VIEW", schema: "public", comment: null },
|
||||
{ name: "v_other", table_type: "VIEW", schema: "public", comment: null },
|
||||
]);
|
||||
store.connectedIds = new Set([config.id]);
|
||||
store.treeNodes[0]!.isExpanded = true;
|
||||
store.treeNodes[0]!.children!.push(database);
|
||||
|
||||
await store.refreshTreeNode(groupViews);
|
||||
|
||||
// setChildren 必须用解析后的 views scope 投影:分组顶置、成员重挂、非成员原位
|
||||
const labels = groupViews.children?.map((node) => node.label) ?? [];
|
||||
expect(labels[0]).toBe("支付视图");
|
||||
expect(groupViews.children![0]!.type).toBe("table-vgroup");
|
||||
expect(groupViews.children![0]!.children?.map((node) => node.label)).toEqual(["v_pay"]);
|
||||
expect(labels).toContain("v_other");
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
resolveTableVGroupScopeFromNode,
|
||||
setTableVGroupsEnabled as setTableVGroupsEnabledOp,
|
||||
stripTableVGroupsFromChildren,
|
||||
tableVGroupKindOfContainerNode,
|
||||
tableVGroupPathForTable as tableVGroupPathForTableOp,
|
||||
tableVGroupScopeKey,
|
||||
toggleTableVGroupCollapsed as toggleTableVGroupCollapsedOp,
|
||||
@@ -1949,7 +1950,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
persistPinnedTreeNodeIds();
|
||||
}
|
||||
syncPinnedTreeState(children);
|
||||
children = applyTableVGroupsToChildren(children, tableVGroupLayoutForNode(parent), parent);
|
||||
const vgroupResolved = resolveTableVGroupScope(parent);
|
||||
children = applyTableVGroupsToChildren(children, vgroupResolved ? tableVGroupLayouts.value[vgroupResolved.scopeKey] : undefined, vgroupResolved?.scope ?? parent);
|
||||
parent.children = markRawLeafTreeNodes(children);
|
||||
loadedTreeNodeChildrenIds.value.add(parent.id);
|
||||
syncConfirmedEmptyTreeNodeId(parent);
|
||||
@@ -6393,7 +6395,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
options?.onChildrenApplied?.(targetNode);
|
||||
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
|
||||
await savePersistedTreeChildren(cacheKey, children);
|
||||
pruneTableVGroupStaleMembers(targetNode, children, targetNode.type === "group-tables");
|
||||
pruneTableVGroupStaleMembers(targetNode, children, tableVGroupKindOfContainerNode(targetNode) !== null);
|
||||
}
|
||||
const currentTargetNode = treeNodeLoadTarget(load);
|
||||
if (currentTargetNode) currentTargetNode.isExpanded = true;
|
||||
@@ -6560,6 +6562,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
setChildren(targetParent, nextChildren);
|
||||
if (!options?.searchFilter) {
|
||||
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
|
||||
// procedures/triggers 等对象组在此分支提前 return,回收必须在 return 前;
|
||||
// 非分组容器由 prune 内部的类别防护挡下。
|
||||
pruneTableVGroupStaleMembers(targetParent, nextChildren, tableVGroupKindOfContainerNode(parent) !== null);
|
||||
}
|
||||
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
|
||||
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
|
||||
@@ -6571,9 +6576,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
setChildren(targetParent, nextChildren);
|
||||
if (!options?.searchFilter) {
|
||||
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
|
||||
// 只认 group-tables:group-views 等分组与表共用 scope_key,其列表不是全量表;
|
||||
// 分页中间态(含 load-more)由 prune 内部再挡一次。
|
||||
pruneTableVGroupStaleMembers(targetParent, nextChildren, parent.type === "group-tables");
|
||||
// 各分组容器按自身类别回收完整列表的失效成员;分页中间态(含
|
||||
// load-more)由 prune 内部再挡一次。
|
||||
pruneTableVGroupStaleMembers(targetParent, nextChildren, tableVGroupKindOfContainerNode(parent) !== null);
|
||||
}
|
||||
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
|
||||
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
|
||||
@@ -9045,9 +9050,22 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return scopeKey ? { scope, scopeKey } : null;
|
||||
}
|
||||
|
||||
function tableVGroupLayoutForNode(node: TreeNode): TableVGroupLayout | undefined {
|
||||
const resolved = resolveTableVGroupScope(node);
|
||||
return resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined;
|
||||
/** 立即落盘全部待写布局(清防抖计时器与脏集合):initFromDisk 等整体覆盖内存布局的
|
||||
* 路径必须先调用,否则窗口内 ≤300ms 的分组编辑会被旧快照静默回滚。 */
|
||||
async function flushTableVGroupPersist(): Promise<void> {
|
||||
if (tableVGroupPersistTimer) {
|
||||
clearTimeout(tableVGroupPersistTimer);
|
||||
tableVGroupPersistTimer = null;
|
||||
}
|
||||
if (!dirtyTableVGroupScopeKeys.size) return;
|
||||
const pending = [...dirtyTableVGroupScopeKeys];
|
||||
dirtyTableVGroupScopeKeys.clear();
|
||||
await Promise.all(
|
||||
pending.map((key) => {
|
||||
const layout = tableVGroupLayouts.value[key];
|
||||
return layout ? api.saveTableVGroups(key, layout).catch(() => {}) : Promise.resolve();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleTableVGroupPersistFlush() {
|
||||
@@ -9063,43 +9081,45 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
|
||||
/** 分组节点是显示层投影:按已解析的作用域重建容器子节点,容器里不留投影副本。 */
|
||||
function reprojectTableVGroupScope(scope: TableVGroupScope, scopeKey: string, tableName?: string) {
|
||||
const container = findTableVGroupContainerNode(treeNodes.value, scope, tableName);
|
||||
function reprojectTableVGroupScope(scope: TableVGroupScope, scopeKey: string, tableName?: string, rowType?: string) {
|
||||
const container = findTableVGroupContainerNode(treeNodes.value, scope, tableName, rowType);
|
||||
if (!container?.children) return;
|
||||
container.children = applyTableVGroupsToChildren(stripTableVGroupsFromChildren(container.children), tableVGroupLayouts.value[scopeKey], scope);
|
||||
}
|
||||
|
||||
/** 表分组成员回收:只有「本 scope 的完整表列表」才能判定成员存亡。
|
||||
* 同一 scope_key 下并存多种列表:grouped 的各对象分组(视图/函数…只含非表行)、
|
||||
* simple 的库/模式表列表、以及分页中间态。非表列表或分页结果作为依据会把有效
|
||||
* 成员误删并持久化,故列表语义由调用点声明(`completeTableList`),本函数只复核分页态。 */
|
||||
/** 分组成员回收:只有「本 scope 的完整列表」才能判定成员存亡。
|
||||
* 同一连接下并存多种列表:各分组容器(tables/views/procedures/triggers 各自的
|
||||
* 完整列表)、非分组容器列表、以及分页中间态。非本容器类别的列表或分页结果作为
|
||||
* 依据会把有效成员误删并持久化,故列表语义由调用点声明(`completeTableList`),
|
||||
* 本函数复核容器类别与分页态,回收范围限定容器自身类别(objectType)。 */
|
||||
function pruneTableVGroupStaleMembers(parent: TreeNode, children: TreeNode[], completeTableList: boolean) {
|
||||
if (!completeTableList) return;
|
||||
const kind = tableVGroupKindOfContainerNode(parent);
|
||||
if (!kind || !completeTableList) return;
|
||||
const resolved = resolveTableVGroupScope(parent);
|
||||
const layout = resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined;
|
||||
if (!resolved || !hasTableVGroupEntries(layout)) return;
|
||||
if (hasTableTreeLoadMore(children)) return;
|
||||
const keepNames = collectTableTreeNames(stripTableVGroupsFromChildren(children));
|
||||
const keepNames = collectTableTreeNames(stripTableVGroupsFromChildren(children), kind);
|
||||
const next = pruneTableVGroupMembersOp(layout, keepNames);
|
||||
if (next === layout) return;
|
||||
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, next);
|
||||
}
|
||||
|
||||
/** 写入某作用域的布局:即时重投影 + 合并 300ms 后落盘。作用域须已解析出 scopeKey。 */
|
||||
function updateTableVGroupLayout(scope: TableVGroupScope, scopeKey: string, nextLayout: TableVGroupLayout, tableName?: string) {
|
||||
function updateTableVGroupLayout(scope: TableVGroupScope, scopeKey: string, nextLayout: TableVGroupLayout, tableName?: string, rowType?: string) {
|
||||
tableVGroupLayouts.value = { ...tableVGroupLayouts.value, [scopeKey]: nextLayout };
|
||||
dirtyTableVGroupScopeKeys.add(scopeKey);
|
||||
scheduleTableVGroupPersistFlush();
|
||||
reprojectTableVGroupScope(scope, scopeKey, tableName);
|
||||
reprojectTableVGroupScope(scope, scopeKey, tableName, rowType);
|
||||
}
|
||||
|
||||
/** 分组变更的统一入口:读当前布局 → 变换 → 写回。作用域不可解析或尚无布局时跳过。 */
|
||||
function updateTableVGroupLayoutFor(scope: TableVGroupScope, transform: (layout: TableVGroupLayout) => TableVGroupLayout, tableName?: string) {
|
||||
function updateTableVGroupLayoutFor(scope: TableVGroupScope, transform: (layout: TableVGroupLayout) => TableVGroupLayout, tableName?: string, rowType?: string) {
|
||||
const resolved = resolveTableVGroupScope(scope);
|
||||
if (!resolved) return;
|
||||
const current = tableVGroupLayouts.value[resolved.scopeKey];
|
||||
if (!current) return;
|
||||
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, transform(current), tableName);
|
||||
updateTableVGroupLayout(resolved.scope, resolved.scopeKey, transform(current), tableName, rowType);
|
||||
}
|
||||
|
||||
function rebuildTreeNodes() {
|
||||
@@ -9635,6 +9655,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
await settingsStore.initEditorSettings();
|
||||
if (!initFromDiskPromise) {
|
||||
initFromDiskPromise = (async () => {
|
||||
// 整体覆盖内存布局前,先落盘窗口内未写盘的分组编辑(防止 300ms 防抖窗口
|
||||
// 内的编辑被备份轮询/重载带回的旧快照静默回滚)。
|
||||
await flushTableVGroupPersist();
|
||||
const [pinnedOrder, saved, , loadedTableVGroups] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init(), api.loadTableVGroups()]);
|
||||
setPinnedTreeNodeOrder(pinnedOrder);
|
||||
await migrateTimeoutInheritance(saved);
|
||||
@@ -9953,8 +9976,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
deleteTableVGroups(scope: TableVGroupScope, groupIds: Iterable<string>) {
|
||||
updateTableVGroupLayoutFor(scope, (layout) => deleteTableVGroupsOp(layout, groupIds));
|
||||
},
|
||||
moveTableToVGroup(scope: TableVGroupScope, tableName: string, groupId: string | null) {
|
||||
updateTableVGroupLayoutFor(scope, (layout) => moveTableToVGroupOp(layout, tableName, groupId), tableName);
|
||||
moveTableToVGroup(scope: TableVGroupScope, tableName: string, groupId: string | null, rowType?: string) {
|
||||
updateTableVGroupLayoutFor(scope, (layout) => moveTableToVGroupOp(layout, tableName, groupId, rowType), tableName, rowType);
|
||||
},
|
||||
reorderTableVGroupEntry(scope: TableVGroupScope, draggedEntryId: string, targetEntryId: string, position: TableVGroupDropPosition) {
|
||||
updateTableVGroupLayoutFor(scope, (layout) => reorderTableVGroupEntryOp(layout, draggedEntryId, targetEntryId, position));
|
||||
@@ -9965,9 +9988,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
setTableVGroupsEnabled(scope: TableVGroupScope, enabled: boolean) {
|
||||
updateTableVGroupLayoutFor(scope, (layout) => setTableVGroupsEnabledOp(layout, enabled));
|
||||
},
|
||||
tableVGroupPathForTable(scope: TableVGroupScope, tableName: string) {
|
||||
tableVGroupPathForTable(scope: TableVGroupScope, tableName: string, rowType?: string) {
|
||||
const resolved = resolveTableVGroupScope(scope);
|
||||
return tableVGroupPathForTableOp(resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined, tableName);
|
||||
return tableVGroupPathForTableOp(resolved ? tableVGroupLayouts.value[resolved.scopeKey] : undefined, tableName, rowType);
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1467,7 +1467,7 @@ export interface SidebarLayout {
|
||||
order: SidebarOrderEntry[];
|
||||
}
|
||||
|
||||
export type TableVGroupOrderEntry = { type: "group"; id: string; children?: TableVGroupOrderEntry[] } | { type: "table"; name: string };
|
||||
export type TableVGroupOrderEntry = { type: "group"; id: string; children?: TableVGroupOrderEntry[] } | { type: "table"; name: string; /** 行类型(view/procedure/…)。同名双行容器(包 spec/body、type/type-body)靠它区分成员;缺省 = 按名字匹配(历史数据与表)。 */ rowType?: string };
|
||||
|
||||
export interface TableVGroupLayout {
|
||||
version?: number;
|
||||
@@ -1534,6 +1534,8 @@ export interface TreeNode {
|
||||
savedSqlFolderId?: string;
|
||||
/** Set on synthetic table virtual-group container nodes. */
|
||||
vgroupId?: string;
|
||||
/** 投影时盖章的分组类别(tables/views/…),供拖拽落点 O(1) 类别判定。 */
|
||||
vgroupKind?: string;
|
||||
meta?: ColumnInfo | IndexInfo | ForeignKeyInfo | TriggerInfo | ConstraintInfo | PartitionInfo | SubpartitionInfo | ExtensionInfo | VectorCollectionMeta | MongoCollectionMeta | CustomTypeTreeMemberMeta;
|
||||
loadMore?: {
|
||||
parentId: string;
|
||||
|
||||
Reference in new issue
Block a user