diff --git a/src/libs/ConnectionSettingTree.tsx b/src/libs/ConnectionSettingTree.tsx new file mode 100644 index 0000000..d3f9c64 --- /dev/null +++ b/src/libs/ConnectionSettingTree.tsx @@ -0,0 +1,144 @@ +import { faFolder } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { ConnectionConfigTree } from 'drivers/SQLLikeConnection'; +import Icon from 'renderer/components/Icon'; +import { TreeViewItemData } from 'renderer/components/TreeView'; + +export default class ConnectionSettingTree { + protected tree: ConnectionConfigTree[]; + protected dict: Record = {}; + + constructor(tree: ConnectionConfigTree[]) { + this.tree = tree; + this.rebuildHashTable(); + } + + protected rebuildHashTable() { + this.dict = {}; + this.buildHashTable(this.tree); + } + + protected buildHashTable(tree?: ConnectionConfigTree[]) { + if (!tree) return; + for (const node of tree) { + this.dict[node.id] = node; + this.buildHashTable(node.children); + } + } + + protected buildTreeViewInternal( + root?: ConnectionConfigTree[] + ): TreeViewItemData[] { + if (!root) return []; + + return root.map((config) => { + return { + id: config.id, + data: config, + icon: + config.nodeType === 'folder' ? ( + + ) : ( + + ), + text: config.name, + children: + config.children && config.children.length > 0 + ? this.buildTreeViewInternal(config.children) + : undefined, + }; + }); + } + + protected sortConnection(tree: ConnectionConfigTree[]) { + const tmp = [...tree]; + tmp.sort((a, b) => { + if (a.nodeType === 'folder' && b.nodeType === 'folder') + return a.name.localeCompare(b.name); + else if (a.nodeType === 'folder') { + return -1; + } else if (b.nodeType === 'folder') { + return 1; + } + return a.name.localeCompare(b.name); + }); + return tmp; + } + + buildTreeView() { + return this.buildTreeViewInternal(this.tree); + } + + getAllNodes() { + return Object.values(this.dict); + } + + getById(id?: string) { + if (!id) return; + return this.dict[id]; + } + + getNewTree() { + return [...this.tree]; + } + + isParentAndChild( + parent: ConnectionConfigTree, + child: ConnectionConfigTree + ): boolean { + let ptr: ConnectionConfigTree | undefined = child; + while (ptr) { + if (ptr.id === parent.id) return true; + ptr = this.getById(ptr.parentId); + } + return false; + } + + detachFromParent(node: ConnectionConfigTree) { + if (node.parentId) { + const parent = this.getById(node.parentId); + if (parent?.children) { + parent.children = parent.children.filter( + (child) => child.id !== node.id + ); + } + } else { + this.tree = this.tree.filter((child) => child.id !== node.id); + } + } + + moveNodeToRoot(from: ConnectionConfigTree) { + this.detachFromParent(from); + this.insertNode(from); + } + + moveNode(from: ConnectionConfigTree, to: ConnectionConfigTree) { + // Stop operation if we are trying to move parent node + // into its child node. It is impossible operation + if (this.isParentAndChild(from, to)) return; + + this.detachFromParent(from); + this.insertNode(from, to.id); + } + + insertNode(node: ConnectionConfigTree, parentId?: string) { + const parent: ConnectionConfigTree | undefined = this.getById(parentId); + + if (parent) { + const folderParent = + parent.nodeType === 'folder' ? parent : this.getById(parent.parentId); + + if (folderParent?.children) { + node.parentId = folderParent.id; + folderParent.children = this.sortConnection([ + ...folderParent.children, + node, + ]); + return; + } + } + + node.parentId = undefined; + this.tree = this.sortConnection([...this.tree, node]); + } +} diff --git a/src/renderer/hooks/useIndexDbConnections.ts b/src/renderer/hooks/useIndexDbConnections.ts index 2c110bc..928057a 100644 --- a/src/renderer/hooks/useIndexDbConnections.ts +++ b/src/renderer/hooks/useIndexDbConnections.ts @@ -1,11 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { db } from 'renderer/db'; import { ConnectionConfigTree } from 'drivers/SQLLikeConnection'; +import ConnectionSettingTree from 'libs/ConnectionSettingTree'; export function useIndexDbConnection() { const [connections, setInternalConnections] = useState(); + const connectionTree = useMemo(() => { + return new ConnectionSettingTree(connections ?? []); + }, [connections]); + + const initialCollapsed = useMemo(() => { try { return JSON.parse(localStorage.getItem('db_collapsed_keys') ?? '[]'); @@ -36,5 +42,11 @@ export function useIndexDbConnection() { [setInternalConnections] ); - return { connections, setConnections, initialCollapsed, saveCollapsed }; + return { + connections, + setConnections, + connectionTree, + initialCollapsed, + saveCollapsed, + }; } diff --git a/src/renderer/screens/HomeScreen/index.tsx b/src/renderer/screens/HomeScreen/index.tsx index 4b4542f..12244f8 100644 --- a/src/renderer/screens/HomeScreen/index.tsx +++ b/src/renderer/screens/HomeScreen/index.tsx @@ -1,6 +1,4 @@ import { useCallback, useEffect, useState, useMemo } from 'react'; -import Icon from 'renderer/components/Icon'; - import { ConnectionConfigTree, ConnectionStoreItem, @@ -19,7 +17,7 @@ import { useIndexDbConnection } from 'renderer/hooks/useIndexDbConnections'; import TreeView, { TreeViewItemData } from 'renderer/components/TreeView'; import useConnectionContextMenu from './useConnectionContextMenu'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCircleDot, faFolder } from '@fortawesome/free-solid-svg-icons'; +import { faCircleDot } from '@fortawesome/free-solid-svg-icons'; import ListViewEmptyState from 'renderer/components/ListView/ListViewEmptyState'; const WELCOME_SCREEN_ID = '00000000000000000000'; @@ -42,8 +40,13 @@ export function sortConnection(tree: ConnectionConfigTree[]) { export default function HomeScreen() { const { connect } = useConnection(); - const { connections, setConnections, initialCollapsed, saveCollapsed } = - useIndexDbConnection(); + const { + connections, + setConnections, + connectionTree, + initialCollapsed, + saveCollapsed, + } = useIndexDbConnection(); const [selectedItem, setSelectedItem] = useState< TreeViewItemData | undefined >({ id: WELCOME_SCREEN_ID }); @@ -68,32 +71,8 @@ export default function HomeScreen() { const [renameSelectedItem, setRenameSelectedItem] = useState(false); - const { treeItems, treeDict } = useMemo(() => { - const treeDict: Record = {}; - - function buildTree( - configs: ConnectionConfigTree[] - ): TreeViewItemData[] { - return configs.map((config) => { - treeDict[config.id] = config; - - return { - id: config.id, - data: config, - icon: - config.nodeType === 'folder' ? ( - - ) : ( - - ), - text: config.name, - children: - config.children && config.children.length > 0 - ? buildTree(config.children) - : undefined, - }; - }); - } + const treeItems = useMemo(() => { + const treeNode = connectionTree.buildTreeView(); const welcomeNode = { id: WELCOME_SCREEN_ID, @@ -103,26 +82,18 @@ export default function HomeScreen() { text: 'Welcome to QueryMaster', } as TreeViewItemData; - if (connections) { - const treeNode = buildTree(connections); - return { - treeItems: treeNode.length > 0 ? [welcomeNode, ...treeNode] : [], - treeDict, - }; - } - - return { treeItems: [], treeDict }; - }, [connections]); + return treeNode.length > 0 ? [welcomeNode, ...treeNode] : []; + }, [connectionTree]); const setSaveCollapsedKeys = useCallback( (keys: string[] | undefined) => { const legitKeys = Array.from( - new Set(keys?.filter((key) => !!treeDict[key])) + new Set(keys?.filter((key) => !!connectionTree.getById(key))) ); setCollapsedKeys(legitKeys); }, - [setCollapsedKeys, saveCollapsed, treeDict] + [setCollapsedKeys, saveCollapsed, connectionTree] ); // ----------------------------------------------- @@ -142,55 +113,15 @@ export default function HomeScreen() { from: TreeViewItemData, to: TreeViewItemData ) => { - if (connections) { - let toData; - - // You cannot drag anything into connection - if (to.data?.nodeType === 'connection') { - if (to.data?.parentId) { - const parentTo = treeDict[to.data.parentId]; - if (parentTo) { - toData = parentTo; - } - } - } else { - toData = to.data; - } - - const fromData = from.data; - if (!fromData) return; - - let newConnection = connections; - - // Remove itself from its parent; - if (fromData.parentId) { - const parent = treeDict[fromData.parentId]; - if (parent?.children) { - parent.children = parent.children.filter( - (child) => child.id !== fromData.id - ); - } - } else { - newConnection = connections.filter( - (child) => child.id !== fromData.id - ); - } - - if (toData) { - fromData.parentId = toData.id; - toData.children = sortConnection([ - ...(toData.children || []), - fromData, - ]); - } else { - fromData.parentId = undefined; - newConnection = [...newConnection, fromData]; - } - - setConnections(sortConnection(newConnection)); + if (from.data && to.data) { + connectionTree.moveNode(from.data, to.data); + setConnections(connectionTree.getNewTree()); + } else if (from.data) { + connectionTree.moveNodeToRoot(from.data); + setConnections(connectionTree.getNewTree()); } }, - [treeDict, connections, setConnections] + [connectionTree, setConnections] ); const handleRenameExit = useCallback( @@ -208,7 +139,7 @@ export default function HomeScreen() { prev ? { ...prev, name: newValue } : prev ); - const parent = treeDict[selectedItem.id]; + const parent = connectionTree.getById(selectedItem.id); if (parent?.children) { parent.children = sortConnection(parent.children); } @@ -218,7 +149,7 @@ export default function HomeScreen() { setRenameSelectedItem(false); }, [ - treeDict, + connectionTree, connections, setConnections, selectedItem, @@ -259,7 +190,7 @@ export default function HomeScreen() { setConnections, setRenameSelectedItem, selectedItem, - treeDict, + connectionTree, }); return ( diff --git a/src/renderer/screens/HomeScreen/useConnectionContextMenu.tsx b/src/renderer/screens/HomeScreen/useConnectionContextMenu.tsx index 930b2be..542cf43 100644 --- a/src/renderer/screens/HomeScreen/useConnectionContextMenu.tsx +++ b/src/renderer/screens/HomeScreen/useConnectionContextMenu.tsx @@ -8,40 +8,10 @@ import { ConnectionStoreConfig, } from 'drivers/SQLLikeConnection'; import { TreeViewItemData } from 'renderer/components/TreeView'; -import { sortConnection } from '.'; - -function insertNodeToConnection( - connections: ConnectionConfigTree[] | undefined, - selectedItem: TreeViewItemData | undefined, - treeDict: Record, - newNode: ConnectionConfigTree -) { - if (connections) { - let insideFolder: ConnectionConfigTree | undefined; - if (selectedItem?.data) { - if (selectedItem.data.nodeType === 'folder') { - insideFolder = selectedItem.data; - } else if (selectedItem.data?.parentId) { - insideFolder = treeDict[selectedItem.data.parentId]; - } - } - - if (insideFolder?.children) { - newNode.parentId = insideFolder.id; - insideFolder.children = sortConnection([ - ...insideFolder.children, - newNode, - ]); - return [...connections]; - } else { - return sortConnection([...connections, newNode]); - } - } - return []; -} +import ConnectionSettingTree from 'libs/ConnectionSettingTree'; export default function useConnectionContextMenu({ - treeDict, + connectionTree, connections, selectedItem, setConnections, @@ -50,7 +20,7 @@ export default function useConnectionContextMenu({ setSaveCollapsedKeys, collapsedKeys, }: { - treeDict: Record; + connectionTree: ConnectionSettingTree; connections: ConnectionConfigTree[] | undefined; selectedItem?: TreeViewItemData; setConnections: (v: ConnectionConfigTree[]) => void; @@ -75,7 +45,7 @@ export default function useConnectionContextMenu({ if (buttonIndex !== 0) return; if (selectedItem.data?.parentId) { - const parent = treeDict[selectedItem.data.parentId]; + const parent = connectionTree.getById(selectedItem.data.parentId); if (parent?.children) { parent.children = parent.children.filter( (node) => node.id !== selectedItem.id @@ -86,7 +56,13 @@ export default function useConnectionContextMenu({ setConnections(connections.filter((db) => db.id !== selectedItem.id)); setSelectedItem(undefined); } - }, [selectedItem, setSelectedItem, connections, setConnections, treeDict]); + }, [ + selectedItem, + setSelectedItem, + connections, + setConnections, + connectionTree, + ]); // ---------------------------------------------- // Handle new connection @@ -96,7 +72,7 @@ export default function useConnectionContextMenu({ const newConfig = { id: newConnectionId, name: generateIncrementalName( - Object.values(treeDict).map((node) => node.name), + connectionTree.getAllNodes().map((node) => node.name), 'Unnamed' ), type: 'mysql', @@ -123,9 +99,8 @@ export default function useConnectionContextMenu({ icon: , }); - setConnections( - insertNodeToConnection(connections, selectedItem, treeDict, newTreeNode) - ); + connectionTree.insertNode(newTreeNode, selectedItem?.id); + setConnections(connectionTree.getNewTree()); if (selectedItem) { setSaveCollapsedKeys([...(collapsedKeys ?? []), selectedItem.id]); @@ -135,7 +110,7 @@ export default function useConnectionContextMenu({ setSelectedItem, selectedItem, connections, - treeDict, + connectionTree, collapsedKeys, setSaveCollapsedKeys, ]); @@ -143,7 +118,7 @@ export default function useConnectionContextMenu({ const newFolderClicked = useCallback(() => { const newFolderId = uuidv1(); const newFolderName = generateIncrementalName( - Object.values(treeDict).map((node) => node.name), + connectionTree.getAllNodes().map((node) => node.name), 'Unnamed Folders' ); @@ -160,9 +135,8 @@ export default function useConnectionContextMenu({ data: newTreeNode, }); - setConnections( - insertNodeToConnection(connections, selectedItem, treeDict, newTreeNode) - ); + connectionTree.insertNode(newTreeNode, selectedItem?.id); + setConnections(connectionTree.getNewTree()); if (selectedItem) { setSaveCollapsedKeys([...(collapsedKeys ?? []), selectedItem.id]); @@ -172,7 +146,7 @@ export default function useConnectionContextMenu({ setSelectedItem, connections, selectedItem, - treeDict, + connectionTree, collapsedKeys, setSaveCollapsedKeys, ]);