From 7df317a1b90f071e55b3ab6dc78bdbb975c1e3ab Mon Sep 17 00:00:00 2001 From: hstyi Date: Fri, 21 Feb 2025 16:24:45 +0800 Subject: [PATCH] feat: refactoring HostTree & support sorting (#285) --- .../app/termora/FilterableHostTreeModel.kt | 156 +++++ src/main/kotlin/app/termora/Host.kt | 6 +- src/main/kotlin/app/termora/HostManager.kt | 50 +- .../kotlin/app/termora/HostOptionsPane.kt | 8 +- src/main/kotlin/app/termora/HostTree.kt | 660 ------------------ src/main/kotlin/app/termora/HostTreeDialog.kt | 122 ---- src/main/kotlin/app/termora/HostTreeModel.kt | 159 ----- src/main/kotlin/app/termora/HostTreeNode.kt | 48 ++ src/main/kotlin/app/termora/NewHostTree.kt | 612 ++++++++++++++++ .../kotlin/app/termora/NewHostTreeDialog.kt | 87 +++ .../kotlin/app/termora/NewHostTreeModel.kt | 83 +++ .../app/termora/SearchableHostTreeModel.kt | 70 -- .../kotlin/app/termora/SettingsOptionsPane.kt | 2 +- src/main/kotlin/app/termora/WelcomePanel.kt | 90 +-- .../app/termora/actions/DataProviders.kt | 2 +- .../app/termora/actions/NewHostAction.kt | 27 +- .../app/termora/keymgr/KeyManagerPanel.kt | 6 +- .../app/termora/keymgr/SSHCopyIdDialog.kt | 3 +- .../termora/terminal/panel/TerminalPanel.kt | 13 +- .../app/termora/transport/FileSystemTabbed.kt | 4 +- .../termora/transport/SftpFileSystemPanel.kt | 10 +- src/main/resources/i18n/messages.properties | 3 +- 22 files changed, 1102 insertions(+), 1119 deletions(-) create mode 100644 src/main/kotlin/app/termora/FilterableHostTreeModel.kt delete mode 100644 src/main/kotlin/app/termora/HostTree.kt delete mode 100644 src/main/kotlin/app/termora/HostTreeDialog.kt delete mode 100644 src/main/kotlin/app/termora/HostTreeModel.kt create mode 100644 src/main/kotlin/app/termora/HostTreeNode.kt create mode 100644 src/main/kotlin/app/termora/NewHostTree.kt create mode 100644 src/main/kotlin/app/termora/NewHostTreeDialog.kt create mode 100644 src/main/kotlin/app/termora/NewHostTreeModel.kt delete mode 100644 src/main/kotlin/app/termora/SearchableHostTreeModel.kt diff --git a/src/main/kotlin/app/termora/FilterableHostTreeModel.kt b/src/main/kotlin/app/termora/FilterableHostTreeModel.kt new file mode 100644 index 0000000..8146e93 --- /dev/null +++ b/src/main/kotlin/app/termora/FilterableHostTreeModel.kt @@ -0,0 +1,156 @@ +package app.termora + +import org.apache.commons.lang3.ArrayUtils +import java.util.function.Function +import javax.swing.JTree +import javax.swing.SwingUtilities +import javax.swing.event.TreeModelEvent +import javax.swing.event.TreeModelListener +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.TreeModel +import javax.swing.tree.TreeNode +import javax.swing.tree.TreePath + +class FilterableHostTreeModel( + private val tree: JTree, + /** + * 如果返回 true 则空文件夹也展示 + */ + private val showEmptyFolder: () -> Boolean = { true } +) : TreeModel { + private val model = tree.model + private val root = ReferenceTreeNode(model.root) + private var listeners = emptyArray() + private var filters = emptyArray>() + private val mapping = mutableMapOf() + + init { + refresh() + initEvents() + } + + + /** + * @param a 旧的 + * @param b 新的 + */ + private fun cloneTree(a: HostTreeNode, b: DefaultMutableTreeNode) { + b.removeAllChildren() + for (c in a.children()) { + if (c !is HostTreeNode) { + continue + } + + if (c.host.protocol != Protocol.Folder) { + if (filters.isNotEmpty() && filters.none { it.apply(c) }) { + continue + } + } + + val n = ReferenceTreeNode(c).apply { mapping[c] = this }.apply { b.add(this) } + + // 文件夹递归复制 + if (c.host.protocol == Protocol.Folder) { + cloneTree(c, n) + } + + // 如果是文件夹 + if (c.host.protocol == Protocol.Folder) { + if (n.childCount == 0) { + if (showEmptyFolder.invoke()) { + continue + } + n.removeFromParent() + } + } + } + } + + private fun initEvents() { + model.addTreeModelListener(object : TreeModelListener { + override fun treeNodesChanged(e: TreeModelEvent) { + refresh() + } + + override fun treeNodesInserted(e: TreeModelEvent) { + refresh() + } + + override fun treeNodesRemoved(e: TreeModelEvent) { + refresh() + } + + override fun treeStructureChanged(e: TreeModelEvent) { + refresh() + } + }) + } + + override fun getRoot(): Any { + return root.userObject + } + + override fun getChild(parent: Any, index: Int): Any { + val c = map(parent)?.getChildAt(index) + if (c is ReferenceTreeNode) { + return c.userObject + } + throw IndexOutOfBoundsException("Index out of bounds") + } + + override fun getChildCount(parent: Any): Int { + return map(parent)?.childCount ?: 0 + } + + private fun map(parent: Any): ReferenceTreeNode? { + if (parent is TreeNode) { + return mapping[parent] + } + return null + } + + override fun isLeaf(node: Any?): Boolean { + return (node as TreeNode).isLeaf + } + + override fun valueForPathChanged(path: TreePath, newValue: Any) { + + } + + override fun getIndexOfChild(parent: Any, child: Any): Int { + val c = map(parent) ?: return -1 + for (i in 0 until c.childCount) { + val e = c.getChildAt(i) + if (e is ReferenceTreeNode && e.userObject == child) { + return i + } + } + return -1 + } + + override fun addTreeModelListener(l: TreeModelListener) { + listeners = ArrayUtils.addAll(listeners, l) + } + + override fun removeTreeModelListener(l: TreeModelListener) { + listeners = ArrayUtils.removeElement(listeners, l) + } + + fun addFilter(f: Function) { + filters = ArrayUtils.add(filters, f) + } + + fun refresh() { + mapping.clear() + mapping[model.root as TreeNode] = root + cloneTree(model.root as HostTreeNode, root) + SwingUtilities.updateComponentTreeUI(tree) + } + + fun getModel(): TreeModel { + return model + } + + private class ReferenceTreeNode(any: Any) : DefaultMutableTreeNode(any) + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt index f215fd3..94add47 100644 --- a/src/main/kotlin/app/termora/Host.kt +++ b/src/main/kotlin/app/termora/Host.kt @@ -260,7 +260,7 @@ data class Host( val tunnelings: List = emptyList(), /** - * 排序 + * 排序,越小越靠前 */ val sort: Long = 0, /** @@ -307,4 +307,8 @@ data class Host( result = 31 * result + ownerId.hashCode() return result } + + override fun toString(): String { + return name + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostManager.kt b/src/main/kotlin/app/termora/HostManager.kt index 0863abc..87fc9fb 100644 --- a/src/main/kotlin/app/termora/HostManager.kt +++ b/src/main/kotlin/app/termora/HostManager.kt @@ -1,12 +1,7 @@ package app.termora -import java.util.* - -interface HostListener : EventListener { - fun hostAdded(host: Host) {} - fun hostRemoved(id: String) {} - fun hostsChanged() {} -} +import org.slf4j.LoggerFactory +import kotlin.system.measureTimeMillis class HostManager private constructor() { @@ -14,42 +9,29 @@ class HostManager private constructor() { fun getInstance(): HostManager { return ApplicationScope.forApplicationScope().getOrCreate(HostManager::class) { HostManager() } } + + private val log = LoggerFactory.getLogger(HostManager::class.java) } private val database get() = Database.getDatabase() - private val listeners = mutableListOf() - fun addHost(host: Host, notify: Boolean = true) { + fun addHost(host: Host) { assertEventDispatchThread() database.addHost(host) - if (notify) listeners.forEach { it.hostAdded(host) } - } - - fun removeHost(id: String) { - assertEventDispatchThread() - database.removeHost(id) - listeners.forEach { it.hostRemoved(id) } - } fun hosts(): List { - return database.getHosts() - .sortedWith(compareBy { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort }) + val hosts: List + measureTimeMillis { + hosts = database.getHosts() + .filter { !it.deleted } + .sortedWith(compareBy { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort }) + }.let { + if (log.isDebugEnabled) { + log.debug("hosts: $it ms") + } + } + return hosts } - fun removeAll() { - assertEventDispatchThread() - database.removeAllHost() - listeners.forEach { it.hostsChanged() } - } - - fun addHostListener(listener: HostListener) { - listeners.add(listener) - } - - fun removeHostListener(listener: HostListener) { - listeners.remove(listener) - } - - } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostOptionsPane.kt b/src/main/kotlin/app/termora/HostOptionsPane.kt index 42b06c3..ef16e66 100644 --- a/src/main/kotlin/app/termora/HostOptionsPane.kt +++ b/src/main/kotlin/app/termora/HostOptionsPane.kt @@ -1134,16 +1134,16 @@ open class HostOptionsPane : OptionsPane() { private fun initEvents() { addBtn.addActionListener(object : AbstractAction() { override fun actionPerformed(e: ActionEvent?) { - val dialog = HostTreeDialog(owner) { host -> - jumpHosts.none { it.id == host.id } && filter.invoke(host) - } - + val dialog = NewHostTreeDialog(owner) + dialog.setFilter { node -> jumpHosts.none { it.id == node.host.id } && filter.invoke(node.host) } + dialog.setTreeName("HostOptionsPane.JumpHostsOption.Tree") dialog.setLocationRelativeTo(owner) dialog.isVisible = true val hosts = dialog.hosts if (hosts.isEmpty()) { return } + hosts.forEach { val rowCount = model.rowCount jumpHosts.add(it) diff --git a/src/main/kotlin/app/termora/HostTree.kt b/src/main/kotlin/app/termora/HostTree.kt deleted file mode 100644 index cf75dfe..0000000 --- a/src/main/kotlin/app/termora/HostTree.kt +++ /dev/null @@ -1,660 +0,0 @@ -package app.termora - - -import app.termora.actions.AnActionEvent -import app.termora.actions.NewHostAction -import app.termora.actions.OpenHostAction -import app.termora.transport.SFTPAction -import com.formdev.flatlaf.extras.components.FlatPopupMenu -import com.formdev.flatlaf.icons.FlatTreeClosedIcon -import com.formdev.flatlaf.icons.FlatTreeOpenIcon -import org.apache.commons.lang3.StringUtils -import org.jdesktop.swingx.action.ActionManager -import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer -import java.awt.Component -import java.awt.Dimension -import java.awt.datatransfer.DataFlavor -import java.awt.datatransfer.Transferable -import java.awt.event.ActionEvent -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.util.* -import javax.swing.* -import javax.swing.event.CellEditorListener -import javax.swing.event.ChangeEvent -import javax.swing.event.PopupMenuEvent -import javax.swing.event.PopupMenuListener -import javax.swing.tree.TreePath -import javax.swing.tree.TreeSelectionModel - - -class HostTree : JTree(), Disposable { - private val hostManager get() = HostManager.getInstance() - private val editor = OutlineTextField(64) - - var contextmenu = true - - /** - * 双击是否打开连接 - */ - var doubleClickConnection = true - - val model = HostTreeModel() - val searchableModel = SearchableHostTreeModel(model) - - init { - initView() - initEvents() - } - - - private fun initView() { - setModel(model) - isEditable = true - dropMode = DropMode.ON_OR_INSERT - dragEnabled = true - selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION - editor.preferredSize = Dimension(220, 0) - - setCellRenderer(object : DefaultXTreeCellRenderer() { - private val properties get() = Database.getDatabase().properties - override fun getTreeCellRendererComponent( - tree: JTree, - value: Any, - sel: Boolean, - expanded: Boolean, - leaf: Boolean, - row: Int, - hasFocus: Boolean - ): Component { - val host = value as Host - var text = host.name - - // 是否显示更多信息 - if (properties.getString("HostTree.showMoreInfo", "false").toBoolean()) { - val color = if (sel) { - if (this@HostTree.hasFocus()) { - UIManager.getColor("textHighlightText") - } else { - this.foreground - } - } else { - UIManager.getColor("textInactiveText") - } - - if (host.protocol == Protocol.SSH) { - text = """ - ${host.name} -    - ${host.username}@${host.host} - """.trimIndent() - } else if (host.protocol == Protocol.Serial) { - text = """ - ${host.name} -    - ${host.options.serialComm.port} - """.trimIndent() - } - } - - val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus) - - icon = when (host.protocol) { - Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() - Protocol.Serial -> if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin - else -> if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal - } - return c - } - }) - - setCellEditor(object : DefaultCellEditor(editor) { - override fun isCellEditable(e: EventObject?): Boolean { - if (e is MouseEvent) { - return false - } - return super.isCellEditable(e) - } - - }) - - - val state = Database.getDatabase().properties.getString("HostTreeExpansionState") - if (state != null) { - TreeUtils.loadExpansionState(this@HostTree, state) - } - } - - override fun convertValueToText( - value: Any?, - selected: Boolean, - expanded: Boolean, - leaf: Boolean, - row: Int, - hasFocus: Boolean - ): String { - if (value is Host) { - return value.name - } - return super.convertValueToText(value, selected, expanded, leaf, row, hasFocus) - } - - private fun initEvents() { - // 右键选中 - addMouseListener(object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (!SwingUtilities.isRightMouseButton(e)) { - return - } - - requestFocusInWindow() - - val selectionRows = selectionModel.selectionRows - - val selRow = getClosestRowForLocation(e.x, e.y) - if (selRow < 0) { - selectionModel.clearSelection() - return - } else if (selectionRows != null && selectionRows.contains(selRow)) { - return - } - - selectionPath = getPathForLocation(e.x, e.y) - - setSelectionRow(selRow) - } - - override fun mouseClicked(e: MouseEvent) { - if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { - val host = lastSelectedPathComponent - if (host is Host && host.protocol != Protocol.Folder) { - ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) - ?.actionPerformed(OpenHostActionEvent(e.source, host, e)) - } - } - } - }) - - - // contextmenu - addMouseListener(object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - if (!(SwingUtilities.isRightMouseButton(e))) { - return - } - - if (Objects.isNull(lastSelectedPathComponent)) { - return - } - - SwingUtilities.invokeLater { showContextMenu(e) } - } - }) - - - // rename - getCellEditor().addCellEditorListener(object : CellEditorListener { - override fun editingStopped(e: ChangeEvent) { - val lastHost = lastSelectedPathComponent - if (lastHost !is Host || editor.text.isBlank() || editor.text == lastHost.name) { - return - } - runCatchingHost(lastHost.copy(name = editor.text)) - } - - override fun editingCanceled(e: ChangeEvent) { - } - - }) - - // drag - transferHandler = object : TransferHandler() { - - override fun createTransferable(c: JComponent): Transferable { - val nodes = selectionModel.selectionPaths - .map { it.lastPathComponent } - .filterIsInstance() - .toMutableList() - - val iterator = nodes.iterator() - while (iterator.hasNext()) { - val node = iterator.next() - val parents = model.getPathToRoot(node).filter { it != node } - if (parents.any { nodes.contains(it) }) { - iterator.remove() - } - } - - return MoveHostTransferable(nodes) - } - - override fun getSourceActions(c: JComponent?): Int { - return MOVE - } - - override fun canImport(support: TransferSupport): Boolean { - if (!support.isDrop) { - return false - } - val dropLocation = support.dropLocation - if (dropLocation !is JTree.DropLocation || support.component != this@HostTree - || dropLocation.childIndex != -1 - ) { - return false - } - - val lastNode = dropLocation.path.lastPathComponent - if (lastNode !is Host || lastNode.protocol != Protocol.Folder) { - return false - } - - if (support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) { - val nodes = support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*> - if (nodes.any { it == lastNode }) { - return false - } - for (parent in model.getPathToRoot(lastNode).filter { it != lastNode }) { - if (nodes.any { it == parent }) { - return false - } - } - } - support.setShowDropLocation(true) - return support.isDataFlavorSupported(MoveHostTransferable.dataFlavor) - } - - override fun importData(support: TransferSupport): Boolean { - if (!support.isDrop) { - return false - } - - val dropLocation = support.dropLocation - if (dropLocation !is JTree.DropLocation) { - return false - } - - val lastNode = dropLocation.path.lastPathComponent - if (lastNode !is Host || lastNode.protocol != Protocol.Folder) { - return false - } - - if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) { - return false - } - - val hosts = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as List<*>) - .filterIsInstance().toMutableList() - if (hosts.isEmpty()) { - return false - } - - // 记录展开的节点 - val expandedHosts = mutableListOf() - for (host in hosts) { - model.visit(host) { - if (it.protocol == Protocol.Folder) { - if (isExpanded(TreePath(model.getPathToRoot(it)))) { - expandedHosts.addFirst(it.id) - } - } - } - } - - var now = System.currentTimeMillis() - for (host in hosts) { - model.removeNodeFromParent(host) - val newHost = host.copy( - parentId = lastNode.id, - sort = ++now, - updateDate = now - ) - runCatchingHost(newHost) - } - - expandNode(lastNode) - - // 展开 - for (id in expandedHosts) { - model.getHost(id)?.let { expandNode(it) } - } - - return true - } - } - - } - - override fun isPathEditable(path: TreePath?): Boolean { - if (path == null) return false - if (path.lastPathComponent == model.root) return false - return super.isPathEditable(path) - } - - override fun getLastSelectedPathComponent(): Any? { - val last = super.getLastSelectedPathComponent() ?: return null - if (last is Host) { - return model.getHost(last.id) ?: last - } - return last - } - - private fun showContextMenu(event: MouseEvent) { - if (!contextmenu) return - - val lastHost = lastSelectedPathComponent - if (lastHost !is Host) { - return - } - - val properties = Database.getDatabase().properties - val popupMenu = FlatPopupMenu() - val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new")) - val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder")) - val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host")) - - val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect")) - val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu - val openWithSFTP = openWith.add("SFTP") - val openWithSFTPCommand = openWith.add(I18n.getString("termora.tabbed.contextmenu.sftp-command")) - val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window")) - popupMenu.addSeparator() - val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy")) - val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove")) - val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename")) - popupMenu.addSeparator() - val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all")) - val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all")) - popupMenu.addSeparator() - popupMenu.add(newMenu) - popupMenu.addSeparator() - - val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info")) - showMoreInfo.isSelected = properties.getString("HostTree.showMoreInfo", "false").toBoolean() - showMoreInfo.addActionListener { - properties.putString( - "HostTree.showMoreInfo", - showMoreInfo.isSelected.toString() - ) - SwingUtilities.updateComponentTreeUI(this) - } - popupMenu.add(showMoreInfo) - val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property")) - - open.addActionListener { openHosts(it, false) } - openWithSFTP.addActionListener { openWithSFTP(it) } - openWithSFTPCommand.addActionListener { openWithSFTPCommand(it) } - openInNewWindow.addActionListener { openHosts(it, true) } - - // 如果选中了 SSH 服务器,那么才启用 - openWithSFTP.isEnabled = getSelectionNodes().any { it.protocol == Protocol.SSH } - openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled - openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled } - - rename.addActionListener { - startEditingAtPath(TreePath(model.getPathToRoot(lastHost))) - } - - expandAll.addActionListener { - getSelectionNodes().forEach { expandNode(it, true) } - } - - - colspanAll.addActionListener { - selectionModel.selectionPaths.map { it.lastPathComponent } - .filterIsInstance() - .filter { it.protocol == Protocol.Folder } - .forEach { collapseNode(it) } - } - - copy.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - val parent = model.getParent(lastHost) ?: return - val node = copyNode(parent, lastHost) - selectionPath = TreePath(model.getPathToRoot(node)) - } - }) - - remove.addActionListener { - if (OptionPane.showConfirmDialog( - SwingUtilities.getWindowAncestor(this), - I18n.getString("termora.keymgr.delete-warning"), - I18n.getString("termora.remove"), - JOptionPane.YES_NO_OPTION, - JOptionPane.QUESTION_MESSAGE - ) == JOptionPane.YES_OPTION - ) { - var lastParent: Host? = null - while (!selectionModel.isSelectionEmpty) { - val host = lastSelectedPathComponent ?: break - if (host !is Host) { - break - } else { - lastParent = model.getParent(host) - } - model.visit(host) { hostManager.removeHost(it.id) } - } - if (lastParent != null) { - selectionPath = TreePath(model.getPathToRoot(lastParent)) - } - } - } - - newFolder.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - if (lastHost.protocol != Protocol.Folder) { - return - } - - val host = Host( - id = UUID.randomUUID().toSimpleString(), - protocol = Protocol.Folder, - name = I18n.getString("termora.welcome.contextmenu.new.folder.name"), - sort = System.currentTimeMillis(), - parentId = lastHost.id - ) - - runCatchingHost(host) - - expandNode(lastHost) - selectionPath = TreePath(model.getPathToRoot(host)) - startEditingAtPath(selectionPath) - - } - }) - - newHost.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - ActionManager.getInstance().getAction(NewHostAction.NEW_HOST) - ?.actionPerformed(e) - } - }) - - property.addActionListener(object : AbstractAction() { - override fun actionPerformed(e: ActionEvent) { - val dialog = HostDialog(SwingUtilities.getWindowAncestor(this@HostTree), lastHost) - dialog.title = lastHost.name - dialog.isVisible = true - val host = dialog.host ?: return - runCatchingHost(host) - } - }) - - // 初始化状态 - newFolder.isEnabled = lastHost.protocol == Protocol.Folder - newHost.isEnabled = newFolder.isEnabled - remove.isEnabled = !getSelectionNodes().any { it == model.root } - copy.isEnabled = remove.isEnabled - rename.isEnabled = remove.isEnabled - property.isEnabled = lastHost.protocol != Protocol.Folder - - popupMenu.addPopupMenuListener(object : PopupMenuListener { - override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { - this@HostTree.grabFocus() - } - - override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) { - this@HostTree.requestFocusInWindow() - } - - override fun popupMenuCanceled(e: PopupMenuEvent) { - } - - }) - - - popupMenu.show(this, event.x, event.y) - } - - private fun openHosts(evt: EventObject, openInNewWindow: Boolean) { - assertEventDispatchThread() - val nodes = getSelectionNodes().filter { it.protocol != Protocol.Folder } - if (nodes.isEmpty()) return - val openHostAction = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return - val source = if (openInNewWindow) - TermoraFrameManager.getInstance().createWindow().apply { isVisible = true } - else evt.source - - nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) } - } - - private fun openWithSFTP(evt: EventObject) { - val nodes = getSelectionNodes().filter { it.protocol == Protocol.SSH } - if (nodes.isEmpty()) return - - val sftpAction = ActionManager.getInstance().getAction(Actions.SFTP) as SFTPAction? ?: return - val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return - for (node in nodes) { - sftpAction.connectHost(node, tab) - } - } - - private fun openWithSFTPCommand(evt: EventObject) { - val nodes = getSelectionNodes().filter { it.protocol == Protocol.SSH } - if (nodes.isEmpty()) return - val action = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) ?: return - for (host in nodes) { - action.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt)) - } - } - - fun expandNode(node: Host, including: Boolean = false) { - expandPath(TreePath(model.getPathToRoot(node))) - if (including) { - model.getChildren(node).forEach { expandNode(it, true) } - } - } - - - private fun copyNode( - parent: Host, - host: Host, - idGenerator: () -> String = { UUID.randomUUID().toSimpleString() } - ): Host { - val now = System.currentTimeMillis() - val newHost = host.copy( - name = "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}", - id = idGenerator.invoke(), - parentId = parent.id, - updateDate = now, - createDate = now, - sort = now - ) - - runCatchingHost(newHost) - - if (host.protocol == Protocol.Folder) { - for (child in model.getChildren(host)) { - copyNode(newHost, child, idGenerator) - } - if (isExpanded(TreePath(model.getPathToRoot(host)))) { - expandNode(newHost) - } - } - - return newHost - - } - - private fun runCatchingHost(host: Host) { - hostManager.addHost(host) - } - - private fun collapseNode(node: Host) { - model.getChildren(node).forEach { collapseNode(it) } - collapsePath(TreePath(model.getPathToRoot(node))) - } - - fun getSelectionNodes(): List { - val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent } - .filterIsInstance() - - if (selectionNodes.isEmpty()) { - return emptyList() - } - - val nodes = mutableListOf() - val parents = mutableListOf() - - for (node in selectionNodes) { - if (node.protocol == Protocol.Folder) { - parents.add(node) - } - nodes.add(node) - } - - while (parents.isNotEmpty()) { - val p = parents.removeFirst() - for (i in 0 until getModel().getChildCount(p)) { - val child = getModel().getChild(p, i) as Host - nodes.add(child) - parents.add(child) - } - } - - // 确保是最新的 - for (i in 0 until nodes.size) { - nodes[i] = model.getHost(nodes[i].id) ?: continue - } - - return nodes - } - - override fun dispose() { - Database.getDatabase().properties.putString( - "HostTreeExpansionState", - TreeUtils.saveExpansionState(this) - ) - } - - private abstract class HostTreeNodeTransferable(val hosts: List) : - Transferable { - - override fun getTransferDataFlavors(): Array { - return arrayOf(getDataFlavor()) - } - - override fun isDataFlavorSupported(flavor: DataFlavor): Boolean { - return getDataFlavor() == flavor - } - - override fun getTransferData(flavor: DataFlavor): Any { - return hosts - } - - abstract fun getDataFlavor(): DataFlavor - } - - private class MoveHostTransferable(hosts: List) : HostTreeNodeTransferable(hosts) { - companion object { - val dataFlavor = - DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}") - } - - override fun getDataFlavor(): DataFlavor { - return dataFlavor - } - - } - - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTreeDialog.kt b/src/main/kotlin/app/termora/HostTreeDialog.kt deleted file mode 100644 index 46d5be9..0000000 --- a/src/main/kotlin/app/termora/HostTreeDialog.kt +++ /dev/null @@ -1,122 +0,0 @@ -package app.termora - -import java.awt.Dimension -import java.awt.Window -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import java.awt.event.WindowAdapter -import java.awt.event.WindowEvent -import javax.swing.* -import javax.swing.tree.TreeSelectionModel - -class HostTreeDialog( - owner: Window, - private val filter: (host: Host) -> Boolean = { true } -) : DialogWrapper(owner) { - - private val tree = HostTree() - - val hosts = mutableListOf() - - var allowMulti = true - set(value) { - field = value - if (value) { - tree.selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION - } else { - tree.selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION - } - } - - init { - size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150) - isModal = true - isResizable = false - controlsVisible = false - title = I18n.getString("termora.transport.sftp.select-host") - - tree.setModel(SearchableHostTreeModel(tree.model) { host -> - (host.protocol == Protocol.Folder || host.protocol == Protocol.SSH) && filter.invoke(host) - }) - tree.contextmenu = true - tree.doubleClickConnection = false - tree.dragEnabled = false - - initEvents() - - init() - setLocationRelativeTo(null) - - } - - private fun initEvents() { - addWindowListener(object : WindowAdapter() { - override fun windowActivated(e: WindowEvent) { - removeWindowListener(this) - val state = Database.getDatabase().properties.getString("HostTreeDialog.HostTreeExpansionState") - if (state != null) { - TreeUtils.loadExpansionState(tree, state) - } - } - }) - - tree.addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { - val node = tree.lastSelectedPathComponent ?: return - if (node is Host && node.protocol != Protocol.Folder) { - doOKAction() - } - } - } - }) - - addWindowListener(object : WindowAdapter() { - override fun windowClosed(e: WindowEvent) { - tree.setModel(null) - Database.getDatabase().properties.putString( - "HostTreeDialog.HostTreeExpansionState", - TreeUtils.saveExpansionState(tree) - ) - } - }) - } - - override fun createCenterPanel(): JComponent { - val scrollPane = JScrollPane(tree) - scrollPane.border = BorderFactory.createCompoundBorder( - BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor), - BorderFactory.createEmptyBorder(4, 6, 4, 6) - ) - - return scrollPane - } - - override fun doOKAction() { - - if (allowMulti) { - val nodes = tree.getSelectionNodes().filter { it.protocol == Protocol.SSH } - if (nodes.isEmpty()) { - return - } - hosts.clear() - hosts.addAll(nodes) - } else { - val node = tree.lastSelectedPathComponent ?: return - if (node !is Host || node.protocol != Protocol.SSH) { - return - } - hosts.clear() - hosts.add(node) - } - - - super.doOKAction() - } - - override fun doCancelAction() { - hosts.clear() - super.doCancelAction() - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTreeModel.kt b/src/main/kotlin/app/termora/HostTreeModel.kt deleted file mode 100644 index d323cea..0000000 --- a/src/main/kotlin/app/termora/HostTreeModel.kt +++ /dev/null @@ -1,159 +0,0 @@ -package app.termora - -import org.apache.commons.lang3.StringUtils -import javax.swing.event.TreeModelEvent -import javax.swing.event.TreeModelListener -import javax.swing.tree.TreeModel -import javax.swing.tree.TreePath - -class HostTreeModel : TreeModel { - - val listeners = mutableListOf() - - private val hostManager get() = HostManager.getInstance() - private val hosts = mutableMapOf() - private val myRoot by lazy { - Host( - id = "0", - protocol = Protocol.Folder, - name = I18n.getString("termora.welcome.my-hosts"), - host = StringUtils.EMPTY, - port = 0, - remark = StringUtils.EMPTY, - username = StringUtils.EMPTY - ) - } - - init { - - for (host in hostManager.hosts()) { - hosts[host.id] = host - } - - hostManager.addHostListener(object : HostListener { - override fun hostRemoved(id: String) { - val host = hosts[id] ?: return - removeNodeFromParent(host) - } - - override fun hostAdded(host: Host) { - // 如果已经存在,那么是修改 - if (hosts.containsKey(host.id)) { - val oldHost = hosts.getValue(host.id) - // 父级结构变了 - if (oldHost.parentId != host.parentId) { - hostRemoved(host.id) - hostAdded(host) - } else { - hosts[host.id] = host - val event = TreeModelEvent(this, getPathToRoot(host)) - listeners.forEach { it.treeStructureChanged(event) } - } - - } else { - hosts[host.id] = host - val parent = getParent(host) ?: return - val path = TreePath(getPathToRoot(parent)) - val event = TreeModelEvent(this, path, intArrayOf(getIndexOfChild(parent, host)), arrayOf(host)) - listeners.forEach { it.treeNodesInserted(event) } - } - } - - override fun hostsChanged() { - hosts.clear() - for (host in hostManager.hosts()) { - hosts[host.id] = host - } - val event = TreeModelEvent(this, getPathToRoot(root), null, null) - listeners.forEach { it.treeStructureChanged(event) } - } - - }) - } - - override fun getRoot(): Host { - return myRoot - } - - override fun getChild(parent: Any?, index: Int): Any { - return getChildren(parent)[index] - } - - override fun getChildCount(parent: Any?): Int { - return getChildren(parent).size - } - - override fun isLeaf(node: Any?): Boolean { - return getChildCount(node) == 0 - } - - fun getParent(node: Host): Host? { - if (node.parentId == root.id || root.id == node.id) { - return root - } - return hosts.values.firstOrNull { it.id == node.parentId } - } - - override fun valueForPathChanged(path: TreePath?, newValue: Any?) { - - } - - override fun getIndexOfChild(parent: Any?, child: Any?): Int { - return getChildren(parent).indexOf(child) - } - - override fun addTreeModelListener(listener: TreeModelListener) { - listeners.add(listener) - } - - override fun removeTreeModelListener(listener: TreeModelListener) { - listeners.remove(listener) - } - - /** - * 仅从结构中删除 - */ - fun removeNodeFromParent(host: Host) { - val parent = getParent(host) ?: return - val index = getIndexOfChild(parent, host) - val event = TreeModelEvent(this, TreePath(getPathToRoot(parent)), intArrayOf(index), arrayOf(host)) - hosts.remove(host.id) - listeners.forEach { it.treeNodesRemoved(event) } - } - - fun visit(host: Host, visitor: (host: Host) -> Unit) { - if (host.protocol == Protocol.Folder) { - getChildren(host).forEach { visit(it, visitor) } - visitor.invoke(host) - } else { - visitor.invoke(host) - } - } - - fun getHost(id: String): Host? { - return hosts[id] - } - - fun getPathToRoot(host: Host): Array { - - if (host.id == root.id) { - return arrayOf(root) - } - - val parents = mutableListOf(host) - var pId = host.parentId - while (pId != root.id) { - val e = hosts[(pId)] ?: break - parents.addFirst(e) - pId = e.parentId - } - parents.addFirst(root) - return parents.toTypedArray() - } - - fun getChildren(parent: Any?): List { - val pId = if (parent is Host) parent.id else root.id - return hosts.values.filter { it.parentId == pId } - .sortedWith(compareBy { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort }) - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTreeNode.kt b/src/main/kotlin/app/termora/HostTreeNode.kt new file mode 100644 index 0000000..64aa02d --- /dev/null +++ b/src/main/kotlin/app/termora/HostTreeNode.kt @@ -0,0 +1,48 @@ +package app.termora + +import javax.swing.tree.DefaultMutableTreeNode + +class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { + var host: Host + get() = userObject as Host + set(value) = setUserObject(value) + + val folderCount + get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false } + + override fun getParent(): HostTreeNode? { + return super.getParent() as HostTreeNode? + } + + fun getAllChildren(): List { + val children = mutableListOf() + for (child in children()) { + if (child is HostTreeNode) { + children.add(child) + children.addAll(child.getAllChildren()) + } + } + return children + } + + + override fun clone(): Any { + val newNode = HostTreeNode(host) + newNode.children = null + newNode.parent = null + return newNode + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HostTreeNode + + return host == other.host + } + + override fun hashCode(): Int { + return host.hashCode() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/NewHostTree.kt b/src/main/kotlin/app/termora/NewHostTree.kt new file mode 100644 index 0000000..2b05207 --- /dev/null +++ b/src/main/kotlin/app/termora/NewHostTree.kt @@ -0,0 +1,612 @@ +package app.termora + +import app.termora.actions.AnActionEvent +import app.termora.actions.OpenHostAction +import app.termora.transport.SFTPAction +import com.formdev.flatlaf.extras.components.FlatPopupMenu +import com.formdev.flatlaf.icons.FlatTreeClosedIcon +import com.formdev.flatlaf.icons.FlatTreeOpenIcon +import org.apache.commons.lang3.StringUtils +import org.jdesktop.swingx.JXTree +import org.jdesktop.swingx.action.ActionManager +import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer +import java.awt.Component +import java.awt.Dimension +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.util.* +import java.util.function.Function +import javax.swing.* +import javax.swing.event.CellEditorListener +import javax.swing.event.ChangeEvent +import javax.swing.event.PopupMenuEvent +import javax.swing.event.PopupMenuListener +import javax.swing.tree.TreePath +import javax.swing.tree.TreeSelectionModel +import kotlin.math.min + +class NewHostTree : JXTree() { + + private val tree = this + private val editor = OutlineTextField(64) + private val hostManager get() = HostManager.getInstance() + private val properties get() = Database.getDatabase().properties + private val owner get() = SwingUtilities.getWindowAncestor(this) + private val openHostAction get() = ActionManager.getInstance().getAction(OpenHostAction.OPEN_HOST) + private var isShowMoreInfo + get() = properties.getString("HostTree.showMoreInfo", "false").toBoolean() + set(value) = properties.putString("HostTree.showMoreInfo", value.toString()) + + private val model = NewHostTreeModel() + + /** + * 是否允许显示右键菜单 + */ + var contextmenu = true + + /** + * 是否允许双击连接 + */ + var doubleClickConnection = true + + init { + initViews() + initEvents() + } + + + private fun initViews() { + super.setModel(model) + isEditable = true + dragEnabled = true + isRootVisible = true + dropMode = DropMode.ON_OR_INSERT + selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION + editor.preferredSize = Dimension(220, 0) + + // renderer + setCellRenderer(object : DefaultXTreeCellRenderer() { + override fun getTreeCellRendererComponent( + tree: JTree, + value: Any, + sel: Boolean, + expanded: Boolean, + leaf: Boolean, + row: Int, + hasFocus: Boolean + ): Component { + val node = value as HostTreeNode + val host = node.host + var text = host.name + + // 是否显示更多信息 + if (isShowMoreInfo) { + val color = if (sel) { + if (tree.hasFocus()) { + UIManager.getColor("textHighlightText") + } else { + this.foreground + } + } else { + UIManager.getColor("textInactiveText") + } + + val fontTag = Function { + """${it}""" + } + + if (host.protocol == Protocol.SSH) { + text = + "${host.name}    ${fontTag.apply("${host.username}@${host.host}")}" + } else if (host.protocol == Protocol.Serial) { + text = + "${host.name}    ${fontTag.apply(host.options.serialComm.port)}" + } else if (host.protocol == Protocol.Folder) { + text = "${host.name}${fontTag.apply(" (${node.childCount})")}" + } + } + + val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus) + + icon = when (host.protocol) { + Protocol.Folder -> if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() + Protocol.Serial -> if (sel && tree.hasFocus()) Icons.plugin.dark else Icons.plugin + else -> if (sel && tree.hasFocus()) Icons.terminal.dark else Icons.terminal + } + return c + } + }) + + // rename + setCellEditor(object : DefaultCellEditor(editor) { + override fun isCellEditable(e: EventObject?): Boolean { + if (e is MouseEvent) { + return false + } + return super.isCellEditable(e) + } + + override fun getCellEditorValue(): Any { + val node = lastSelectedPathComponent as HostTreeNode + return node.host + } + }) + } + + private fun initEvents() { + // 右键选中 + addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + if (!SwingUtilities.isRightMouseButton(e)) { + return + } + + requestFocusInWindow() + + val selectionRows = selectionModel.selectionRows + + val selRow = getClosestRowForLocation(e.x, e.y) + if (selRow < 0) { + selectionModel.clearSelection() + return + } else if (selectionRows != null && selectionRows.contains(selRow)) { + return + } + + selectionPath = getPathForLocation(e.x, e.y) + + setSelectionRow(selRow) + } + + }) + + // contextmenu + addMouseListener(object : MouseAdapter() { + override fun mousePressed(e: MouseEvent) { + if (!(SwingUtilities.isRightMouseButton(e))) { + return + } + + if (Objects.isNull(lastSelectedPathComponent)) { + return + } + + if (contextmenu) { + SwingUtilities.invokeLater { showContextmenu(e) } + } + } + + override fun mouseClicked(e: MouseEvent) { + if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) { + val lastNode = lastSelectedPathComponent as? HostTreeNode ?: return + if (lastNode.host.protocol != Protocol.Folder) { + openHostAction?.actionPerformed(OpenHostActionEvent(e.source, lastNode.host, e)) + } + } + } + }) + + // rename + getCellEditor().addCellEditorListener(object : CellEditorListener { + override fun editingStopped(e: ChangeEvent) { + val lastHost = lastSelectedPathComponent + if (lastHost !is HostTreeNode || editor.text.isBlank() || editor.text == lastHost.host.name) { + return + } + lastHost.host = lastHost.host.copy(name = editor.text) + hostManager.addHost(lastHost.host) + } + + override fun editingCanceled(e: ChangeEvent) { + } + }) + + // drag + transferHandler = object : TransferHandler() { + + override fun createTransferable(c: JComponent): Transferable? { + val nodes = getSelectionHostTreeNodes().toMutableList() + if (nodes.isEmpty()) return null + if (nodes.contains(model.root)) return null + + val iterator = nodes.iterator() + while (iterator.hasNext()) { + val node = iterator.next() + val parents = model.getPathToRoot(node).filter { it != node } + if (parents.any { nodes.contains(it) }) { + iterator.remove() + } + } + + return MoveHostTransferable(nodes) + } + + override fun getSourceActions(c: JComponent?): Int { + return MOVE + } + + override fun canImport(support: TransferSupport): Boolean { + if (support.component != tree) return false + val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false + val node = dropLocation.path.lastPathComponent as? HostTreeNode ?: return false + if (!support.isDataFlavorSupported(MoveHostTransferable.dataFlavor)) return false + val nodes = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as? List<*>) + ?.filterIsInstance() ?: return false + if (nodes.isEmpty()) return false + if (node.host.protocol != Protocol.Folder) return false + + for (e in nodes) { + // 禁止拖拽到自己的子下面 + if (dropLocation.path.equals(TreePath(e.path)) || TreePath(e.path).isDescendant(dropLocation.path)) { + return false + } + + // 文件夹只能拖拽到文件夹的下面 + if (e.host.protocol == Protocol.Folder) { + if (dropLocation.childIndex > node.folderCount) { + return false + } + } else if (dropLocation.childIndex != -1) { + // 非文件夹也不能拖拽到文件夹的上面 + if (dropLocation.childIndex < node.folderCount) { + return false + } + } + + val p = e.parent ?: continue + // 如果是同级目录排序,那么判断是不是自己的上下,如果是的话也禁止 + if (p == node && dropLocation.childIndex != -1) { + val idx = p.getIndex(e) + if (dropLocation.childIndex in idx..idx + 1) { + return false + } + } + } + + support.setShowDropLocation(true) + + return true + } + + override fun importData(support: TransferSupport): Boolean { + val dropLocation = support.dropLocation as? JTree.DropLocation ?: return false + val node = dropLocation.path.lastPathComponent as? HostTreeNode ?: return false + val nodes = (support.transferable.getTransferData(MoveHostTransferable.dataFlavor) as? List<*>) + ?.filterIsInstance() ?: return false + + // 展开的 host id + val expanded = mutableSetOf(node.host.id) + for (e in nodes) { + e.getAllChildren().filter { isExpanded(TreePath(model.getPathToRoot(it))) } + .map { it.host.id }.forEach { expanded.add(it) } + } + + // 转移 + for (e in nodes) { + model.removeNodeFromParent(e) + e.host = e.host.copy(parentId = node.host.id) + hostManager.addHost(e.host) + + if (dropLocation.childIndex == -1) { + if (e.host.protocol == Protocol.Folder) { + model.insertNodeInto(e, node, node.folderCount) + } else { + model.insertNodeInto(e, node, node.childCount) + } + } else { + if (e.host.protocol == Protocol.Folder) { + model.insertNodeInto(e, node, min(node.folderCount, dropLocation.childIndex)) + } else { + model.insertNodeInto(e, node, min(node.childCount, dropLocation.childIndex)) + } + } + + selectionPath = TreePath(model.getPathToRoot(e)) + } + + // 先展开最顶级的 + expandPath(TreePath(model.getPathToRoot(node))) + + for (child in node.getAllChildren()) { + if (expanded.contains(child.host.id)) { + expandPath(TreePath(model.getPathToRoot(child))) + } + } + + + + return true + } + } + } + + + private fun showContextmenu(event: MouseEvent) { + val lastNode = lastSelectedPathComponent + if (lastNode !is HostTreeNode) return + + val lastNodeParent = lastNode.parent ?: model.root + val lastHost = lastNode.host + + val popupMenu = FlatPopupMenu() + val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new")) + val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder")) + val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host")) + + val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect")) + val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu + val openWithSFTP = openWith.add("SFTP") + val openWithSFTPCommand = openWith.add(I18n.getString("termora.tabbed.contextmenu.sftp-command")) + val openInNewWindow = popupMenu.add(I18n.getString("termora.welcome.contextmenu.open-in-new-window")) + popupMenu.addSeparator() + val copy = popupMenu.add(I18n.getString("termora.welcome.contextmenu.copy")) + val remove = popupMenu.add(I18n.getString("termora.welcome.contextmenu.remove")) + val rename = popupMenu.add(I18n.getString("termora.welcome.contextmenu.rename")) + popupMenu.addSeparator() + val refresh = popupMenu.add(I18n.getString("termora.welcome.contextmenu.refresh")) + val expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all")) + val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all")) + popupMenu.addSeparator() + popupMenu.add(newMenu) + popupMenu.addSeparator() + val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info")) + showMoreInfo.isSelected = isShowMoreInfo + showMoreInfo.addActionListener { + isShowMoreInfo = !isShowMoreInfo + SwingUtilities.updateComponentTreeUI(tree) + } + popupMenu.add(showMoreInfo) + val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property")) + + open.addActionListener { openHosts(it, false) } + openInNewWindow.addActionListener { openHosts(it, true) } + openWithSFTP.addActionListener { openWithSFTP(it) } + openWithSFTPCommand.addActionListener { openWithSFTPCommand(it) } + newFolder.addActionListener { + val host = Host( + id = UUID.randomUUID().toSimpleString(), + protocol = Protocol.Folder, + name = I18n.getString("termora.welcome.contextmenu.new.folder.name"), + sort = System.currentTimeMillis(), + parentId = lastHost.id + ) + hostManager.addHost(host) + val newNode = HostTreeNode(host) + model.insertNodeInto(newNode, lastNode, lastNode.folderCount) + selectionPath = TreePath(model.getPathToRoot(newNode)) + startEditingAtPath(selectionPath) + } + remove.addActionListener(object : ActionListener { + override fun actionPerformed(e: ActionEvent) { + val nodes = getSelectionHostTreeNodes() + if (nodes.isEmpty()) return + if (OptionPane.showConfirmDialog( + SwingUtilities.getWindowAncestor(tree), + I18n.getString("termora.keymgr.delete-warning"), + I18n.getString("termora.remove"), + JOptionPane.YES_NO_OPTION, + JOptionPane.QUESTION_MESSAGE + ) == JOptionPane.YES_OPTION + ) { + for (c in nodes) { + hostManager.addHost(c.host.copy(deleted = true, updateDate = System.currentTimeMillis())) + model.removeNodeFromParent(c) + } + } + } + }) + copy.addActionListener { + for (c in getSelectionHostTreeNodes()) { + val p = c.parent ?: continue + val newNode = copyNode(c, p.host.id) + model.insertNodeInto(newNode, p, lastNodeParent.getIndex(c) + 1) + selectionPath = TreePath(model.getPathToRoot(newNode)) + } + } + rename.addActionListener { startEditingAtPath(TreePath(model.getPathToRoot(lastNode))) } + expandAll.addActionListener { + for (node in getSelectionHostTreeNodes(true)) { + expandPath(TreePath(model.getPathToRoot(node))) + } + } + colspanAll.addActionListener { + for (node in getSelectionHostTreeNodes(true).reversed()) { + collapsePath(TreePath(model.getPathToRoot(node))) + } + } + newHost.addActionListener(object : ActionListener { + override fun actionPerformed(e: ActionEvent) { + val dialog = HostDialog(owner) + dialog.setLocationRelativeTo(owner) + dialog.isVisible = true + val host = (dialog.host ?: return).copy(parentId = lastHost.id) + hostManager.addHost(host) + val c = HostTreeNode(host) + val newNode = copyNode(c, lastHost.id) + model.insertNodeInto(newNode, lastNode, lastNode.childCount) + selectionPath = TreePath(model.getPathToRoot(newNode)) + } + }) + property.addActionListener(object : ActionListener { + override fun actionPerformed(e: ActionEvent) { + val dialog = HostDialog(owner, lastHost) + dialog.title = lastHost.name + dialog.setLocationRelativeTo(owner) + dialog.isVisible = true + val host = dialog.host ?: return + lastNode.host = host + hostManager.addHost(host) + model.nodeStructureChanged(lastNode) + } + }) + refresh.addActionListener { + val expanded = mutableSetOf(lastNode.host.id) + for (e in lastNode.getAllChildren()) { + if (e.host.protocol == Protocol.Folder && isExpanded(TreePath(model.getPathToRoot(e)))) { + expanded.add(e.host.id) + } + } + + // 刷新 + model.reload(lastNode) + + // 先展开最顶级的 + expandPath(TreePath(model.getPathToRoot(lastNode))) + + for (child in lastNode.getAllChildren()) { + if (expanded.contains(child.host.id)) { + expandPath(TreePath(model.getPathToRoot(child))) + } + } + } + + newFolder.isEnabled = lastHost.protocol == Protocol.Folder + newHost.isEnabled = newFolder.isEnabled + remove.isEnabled = getSelectionHostTreeNodes().none { it == model.root } + copy.isEnabled = remove.isEnabled + rename.isEnabled = remove.isEnabled + property.isEnabled = lastHost.protocol != Protocol.Folder + refresh.isEnabled = lastHost.protocol == Protocol.Folder + + + // 如果选中了 SSH 服务器,那么才启用 + openWithSFTP.isEnabled = getSelectionHostTreeNodes(true).map { it.host }.any { it.protocol == Protocol.SSH } + openWithSFTPCommand.isEnabled = openWithSFTP.isEnabled + openWith.isEnabled = openWith.menuComponents.any { it is JMenuItem && it.isEnabled } + + popupMenu.addPopupMenuListener(object : PopupMenuListener { + override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) { + tree.grabFocus() + } + + override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) { + tree.requestFocusInWindow() + } + + override fun popupMenuCanceled(e: PopupMenuEvent) { + } + + }) + + + popupMenu.show(this, event.x, event.y) + } + + private fun copyNode( + node: HostTreeNode, + parentId: String, + idGenerator: () -> String = { UUID.randomUUID().toSimpleString() }, + level: Int = 0 + ): HostTreeNode { + + val host = node.host + val now = host.sort + 1 + val name = if (level == 0) "${host.name} ${I18n.getString("termora.welcome.contextmenu.copy")}" + else host.name + + val newHost = host.copy( + id = idGenerator.invoke(), + name = name, + parentId = parentId, + updateDate = System.currentTimeMillis(), + createDate = System.currentTimeMillis(), + sort = now + ) + val newNode = HostTreeNode(newHost) + + hostManager.addHost(newHost) + + if (host.protocol == Protocol.Folder) { + for (child in node.children()) { + if (child is HostTreeNode) { + newNode.add(copyNode(child, newHost.id, idGenerator, level + 1)) + } + } + } + + return newNode + + } + + /** + * 包含孙子 + */ + fun getSelectionHostTreeNodes(include: Boolean = false): List { + val paths = selectionPaths ?: return emptyList() + if (paths.isEmpty()) return emptyList() + val nodes = mutableListOf() + val parents = paths.mapNotNull { it.lastPathComponent } + .filterIsInstance().toMutableList() + + if (include) { + while (parents.isNotEmpty()) { + val node = parents.removeFirst() + nodes.add(node) + parents.addAll(node.children().toList().filterIsInstance()) + } + } + + return if (include) nodes else parents + } + + private fun openHosts(evt: EventObject, openInNewWindow: Boolean) { + assertEventDispatchThread() + val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol != Protocol.Folder } + if (nodes.isEmpty()) return + val source = if (openInNewWindow) + TermoraFrameManager.getInstance().createWindow().apply { isVisible = true } + else evt.source + nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) } + } + + + private fun openWithSFTP(evt: EventObject) { + val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } + if (nodes.isEmpty()) return + + val sftpAction = ActionManager.getInstance().getAction(app.termora.Actions.SFTP) as SFTPAction? ?: return + val tab = sftpAction.openOrCreateSFTPTerminalTab(AnActionEvent(this, StringUtils.EMPTY, evt)) ?: return + for (node in nodes) { + sftpAction.connectHost(node, tab) + } + } + + private fun openWithSFTPCommand(evt: EventObject) { + val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } + if (nodes.isEmpty()) return + for (host in nodes) { + openHostAction.actionPerformed(OpenHostActionEvent(this, host.copy(protocol = Protocol.SFTPPty), evt)) + } + } + + + private class MoveHostTransferable(val nodes: List) : Transferable { + companion object { + val dataFlavor = + DataFlavor("${DataFlavor.javaJVMLocalObjectMimeType};class=${MoveHostTransferable::class.java.name}") + } + + + override fun getTransferDataFlavors(): Array { + return arrayOf(dataFlavor) + } + + override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean { + return dataFlavor == flavor + } + + override fun getTransferData(flavor: DataFlavor?): Any { + if (flavor == dataFlavor) { + return nodes + } + throw UnsupportedFlavorException(flavor) + } + + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/NewHostTreeDialog.kt b/src/main/kotlin/app/termora/NewHostTreeDialog.kt new file mode 100644 index 0000000..bb9e4cf --- /dev/null +++ b/src/main/kotlin/app/termora/NewHostTreeDialog.kt @@ -0,0 +1,87 @@ +package app.termora + +import org.apache.commons.lang3.StringUtils +import java.awt.Dimension +import java.awt.Window +import java.util.function.Function +import javax.swing.BorderFactory +import javax.swing.JComponent +import javax.swing.JScrollPane +import javax.swing.UIManager + +class NewHostTreeDialog( + owner: Window, +) : DialogWrapper(owner) { + var hosts = emptyList() + var allowMulti = true + + private var filter: Function = Function { true } + private val tree = NewHostTree() + + init { + size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150) + isModal = true + isResizable = false + controlsVisible = false + title = I18n.getString("termora.transport.sftp.select-host") + + tree.contextmenu = false + tree.doubleClickConnection = false + tree.dragEnabled = false + + + + init() + setLocationRelativeTo(null) + + } + + fun setFilter(filter: Function) { + tree.model = FilterableHostTreeModel(tree) { false }.apply { + addFilter(filter) + refresh() + } + } + + override fun createCenterPanel(): JComponent { + val scrollPane = JScrollPane(tree) + scrollPane.border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor), + BorderFactory.createEmptyBorder(4, 6, 4, 6) + ) + + return scrollPane + } + + + override fun doCancelAction() { + hosts = emptyList() + super.doCancelAction() + } + + override fun doOKAction() { + hosts = tree.getSelectionHostTreeNodes(true) + .filter { filter.apply(it) } + .map { it.host } + + if (hosts.isEmpty()) return + if (!allowMulti && hosts.size > 1) return + + super.doOKAction() + } + + fun setTreeName(treeName: String) { + Disposer.register(disposable, object : Disposable { + private val key = "${treeName}.state" + private val properties get() = Database.getDatabase().properties + + init { + TreeUtils.loadExpansionState(tree, properties.getString(key, StringUtils.EMPTY)) + } + + override fun dispose() { + properties.putString(key, TreeUtils.saveExpansionState(tree)) + } + }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/NewHostTreeModel.kt b/src/main/kotlin/app/termora/NewHostTreeModel.kt new file mode 100644 index 0000000..389fd9e --- /dev/null +++ b/src/main/kotlin/app/termora/NewHostTreeModel.kt @@ -0,0 +1,83 @@ +package app.termora + +import org.apache.commons.lang3.StringUtils +import javax.swing.tree.DefaultTreeModel +import javax.swing.tree.MutableTreeNode +import javax.swing.tree.TreeNode + + +class NewHostTreeModel : DefaultTreeModel( + HostTreeNode( + Host( + id = "0", + protocol = Protocol.Folder, + name = I18n.getString("termora.welcome.my-hosts"), + host = StringUtils.EMPTY, + port = 0, + remark = StringUtils.EMPTY, + username = StringUtils.EMPTY + ) + ) +) { + private val Host.isRoot get() = this.parentId == "0" || this.parentId.isBlank() + private val hostManager get() = HostManager.getInstance() + + init { + reload() + } + + + override fun getRoot(): HostTreeNode { + return super.getRoot() as HostTreeNode + } + + + override fun reload(parent: TreeNode) { + + if (parent !is HostTreeNode) { + super.reload(parent) + return + } + + parent.removeAllChildren() + + val hosts = hostManager.hosts() + val nodes = linkedMapOf() + + // 遍历 Host 列表,构建树节点 + for (host in hosts) { + val node = HostTreeNode(host) + nodes[host.id] = node + } + + for (host in hosts) { + val node = nodes[host.id] ?: continue + if (host.isRoot) continue + val p = nodes[host.parentId] ?: continue + p.add(node) + } + + for ((_, v) in nodes.entries) { + if (parent.host.id == v.host.parentId) { + parent.add(v) + } + } + + super.reload(parent) + } + + override fun insertNodeInto(newChild: MutableTreeNode, parent: MutableTreeNode, index: Int) { + super.insertNodeInto(newChild, parent, index) + // 重置所有排序 + if (parent is HostTreeNode) { + for ((i, c) in parent.children().toList().filterIsInstance().withIndex()) { + val sort = i.toLong() + if (c.host.sort == sort) continue + c.host = c.host.copy(sort = sort) + hostManager.addHost(c.host) + } + } + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SearchableHostTreeModel.kt b/src/main/kotlin/app/termora/SearchableHostTreeModel.kt deleted file mode 100644 index 7f8b5ed..0000000 --- a/src/main/kotlin/app/termora/SearchableHostTreeModel.kt +++ /dev/null @@ -1,70 +0,0 @@ -package app.termora - -import javax.swing.event.TreeModelEvent -import javax.swing.event.TreeModelListener -import javax.swing.tree.TreeModel -import javax.swing.tree.TreePath - -class SearchableHostTreeModel( - private val model: HostTreeModel, - private val filter: (host: Host) -> Boolean = { true } -) : TreeModel { - private var text = String() - - override fun getRoot(): Any { - return model.root - } - - override fun getChild(parent: Any?, index: Int): Any { - return getChildren(parent)[index] - } - - override fun getChildCount(parent: Any?): Int { - return getChildren(parent).size - } - - override fun isLeaf(node: Any?): Boolean { - return model.isLeaf(node) - } - - override fun valueForPathChanged(path: TreePath?, newValue: Any?) { - return model.valueForPathChanged(path, newValue) - } - - override fun getIndexOfChild(parent: Any?, child: Any?): Int { - return getChildren(parent).indexOf(child) - } - - override fun addTreeModelListener(l: TreeModelListener) { - model.addTreeModelListener(l) - } - - override fun removeTreeModelListener(l: TreeModelListener) { - model.removeTreeModelListener(l) - } - - - private fun getChildren(parent: Any?): List { - val children = model.getChildren(parent) - if (children.isEmpty()) return emptyList() - return children.filter { e -> - filter.invoke(e) - && e.name.contains(text, true) - || e.host.contains(text, true) - || TreeUtils.children(model, e, true).filterIsInstance().any { it.name.contains(text, true) || it.host.contains(text, true) } - } - } - - fun search(text: String) { - this.text = text - model.listeners.forEach { - it.treeStructureChanged( - TreeModelEvent( - this, TreePath(root), - null, null - ) - ) - } - } - -} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SettingsOptionsPane.kt b/src/main/kotlin/app/termora/SettingsOptionsPane.kt index bff43b4..5ecd48d 100644 --- a/src/main/kotlin/app/termora/SettingsOptionsPane.kt +++ b/src/main/kotlin/app/termora/SettingsOptionsPane.kt @@ -1595,7 +1595,7 @@ class SettingsOptionsPane : OptionsPane() { val key = doorman.work(passwordTextField.password) - hosts.forEach { hostManager.addHost(it, false) } + hosts.forEach { hostManager.addHost(it) } keyPairs.forEach { keyManager.addOhKeyPair(it) } for (e in properties) { for ((k, v) in e.second) { diff --git a/src/main/kotlin/app/termora/WelcomePanel.kt b/src/main/kotlin/app/termora/WelcomePanel.kt index d261022..aafa807 100644 --- a/src/main/kotlin/app/termora/WelcomePanel.kt +++ b/src/main/kotlin/app/termora/WelcomePanel.kt @@ -26,11 +26,15 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout() private val properties get() = Database.getDatabase().properties private val rootPanel = JPanel(BorderLayout()) private val searchTextField = FlatTextField() - private val hostTree = HostTree() + private val hostTree = NewHostTree() private val bannerPanel = BannerPanel() private val toggle = FlatButton() private var fullContent = properties.getString("WelcomeFullContent", "false").toBoolean() private val dataProviderSupport = DataProviderSupport() + private val hostTreeModel = hostTree.model as NewHostTreeModel + private val filterableHostTreeModel = FilterableHostTreeModel(hostTree) { + searchTextField.text.isBlank() + } init { initView() @@ -125,8 +129,6 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout() }) hostTree.showsRootHandles = true - Disposer.register(this, hostTree) - val scrollPane = JScrollPane(hostTree) scrollPane.verticalScrollBar.maximumSize = Dimension(0, 0) scrollPane.verticalScrollBar.preferredSize = Dimension(0, 0) @@ -137,6 +139,11 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout() panel.add(scrollPane, BorderLayout.CENTER) panel.border = BorderFactory.createEmptyBorder(10, 0, 0, 0) + hostTree.model = filterableHostTreeModel + TreeUtils.loadExpansionState( + hostTree, + properties.getString("Welcome.HostTree.state", StringUtils.EMPTY) + ) return panel } @@ -162,48 +169,49 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout() }) - FindEverywhereProvider.getFindEverywhereProviders(windowScope) - .add(object : FindEverywhereProvider { - override fun find(pattern: String): List { - var filter = TreeUtils.children(hostTree.model, hostTree.model.root) - .filterIsInstance() - .filter { it.protocol != Protocol.Folder } + FindEverywhereProvider.getFindEverywhereProviders(windowScope).add(object : FindEverywhereProvider { + override fun find(pattern: String): List { + var filter = hostTreeModel.root.getAllChildren() + .map { it.host } + .filter { it.protocol != Protocol.Folder } - if (pattern.isNotBlank()) { - filter = filter.filter { - if (it.protocol == Protocol.SSH) { - it.name.contains(pattern, true) || it.host.contains(pattern, true) - } else { - it.name.contains(pattern, true) - } + if (pattern.isNotBlank()) { + filter = filter.filter { + if (it.protocol == Protocol.SSH) { + it.name.contains(pattern, true) || it.host.contains(pattern, true) + } else { + it.name.contains(pattern, true) } } - - return filter.map { HostFindEverywhereResult(it) } } - override fun group(): String { - return I18n.getString("termora.find-everywhere.groups.open-new-hosts") - } + return filter.map { HostFindEverywhereResult(it) } + } - override fun order(): Int { - return Integer.MIN_VALUE + 2 - } - }) + override fun group(): String { + return I18n.getString("termora.find-everywhere.groups.open-new-hosts") + } + + override fun order(): Int { + return Integer.MIN_VALUE + 2 + } + }) + + + filterableHostTreeModel.addFilter { + val text = searchTextField.text + val host = it.host + text.isBlank() || host.name.contains(text, true) + || host.host.contains(text, true) + || host.username.contains(text, true) + } searchTextField.document.addDocumentListener(object : DocumentAdaptor() { - private var state = StringUtils.EMPTY override fun changedUpdate(e: DocumentEvent) { val text = searchTextField.text - if (text.isBlank()) { - hostTree.setModel(hostTree.model) - TreeUtils.loadExpansionState(hostTree, state) - state = String() - } else { - if (state.isBlank()) state = TreeUtils.saveExpansionState(hostTree) - hostTree.setModel(hostTree.searchableModel) - hostTree.searchableModel.search(text) - TreeUtils.expandAll(hostTree) + filterableHostTreeModel.refresh() + if (text.isNotBlank()) { + hostTree.expandAll() } } }) @@ -251,8 +259,8 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout() } override fun dispose() { - hostTree.setModel(null) properties.putString("WelcomeFullContent", fullContent.toString()) + properties.putString("Welcome.HostTree.state", TreeUtils.saveExpansionState(hostTree)) } private inner class HostFindEverywhereResult(val host: Host) : FindEverywhereResult { @@ -276,12 +284,10 @@ class WelcomePanel(private val windowScope: WindowScope) : JPanel(BorderLayout() override fun getText(isSelected: Boolean): String { if (showMoreInfo) { val color = UIManager.getColor(if (isSelected) "textHighlightText" else "textInactiveText") - val moreInfo = if (host.protocol == Protocol.SSH) { - "${host.username}@${host.host}" - } else if (host.protocol == Protocol.Serial) { - host.options.serialComm.port - } else { - StringUtils.EMPTY + val moreInfo = when (host.protocol) { + Protocol.SSH -> "${host.username}@${host.host}" + Protocol.Serial -> host.options.serialComm.port + else -> StringUtils.EMPTY } if (moreInfo.isNotBlank()) { return "${host.name}    ${moreInfo}" diff --git a/src/main/kotlin/app/termora/actions/DataProviders.kt b/src/main/kotlin/app/termora/actions/DataProviders.kt index 131ac77..22ff5e4 100644 --- a/src/main/kotlin/app/termora/actions/DataProviders.kt +++ b/src/main/kotlin/app/termora/actions/DataProviders.kt @@ -17,6 +17,6 @@ object DataProviders { object Welcome { - val HostTree = DataKey(app.termora.HostTree::class) + val HostTree = DataKey(app.termora.NewHostTree::class) } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/actions/NewHostAction.kt b/src/main/kotlin/app/termora/actions/NewHostAction.kt index ce1f5da..f6fad19 100644 --- a/src/main/kotlin/app/termora/actions/NewHostAction.kt +++ b/src/main/kotlin/app/termora/actions/NewHostAction.kt @@ -1,9 +1,6 @@ package app.termora.actions -import app.termora.Host -import app.termora.HostDialog -import app.termora.HostManager -import app.termora.Protocol +import app.termora.* import javax.swing.tree.TreePath class NewHostAction : AnAction() { @@ -20,27 +17,27 @@ class NewHostAction : AnAction() { override fun actionPerformed(evt: AnActionEvent) { val tree = evt.getData(DataProviders.Welcome.HostTree) ?: return - val model = tree.model - var lastHost = tree.lastSelectedPathComponent ?: model.root - if (lastHost !is Host) { - return - } - - if (lastHost.protocol != Protocol.Folder) { - val p = model.getParent(lastHost) ?: return - lastHost = p + var lastNode = (tree.lastSelectedPathComponent ?: tree.model.root) as? HostTreeNode ?: return + if (lastNode.host.protocol != Protocol.Folder) { + lastNode = lastNode.parent ?: return } + val lastHost = lastNode.host val dialog = HostDialog(evt.window) dialog.setLocationRelativeTo(evt.window) dialog.isVisible = true val host = (dialog.host ?: return).copy(parentId = lastHost.id) hostManager.addHost(host) + val newNode = HostTreeNode(host) - tree.expandNode(lastHost) + val model = if (tree.model is FilterableHostTreeModel) (tree.model as FilterableHostTreeModel).getModel() + else tree.model - tree.selectionPath = TreePath(model.getPathToRoot(host)) + if (model is NewHostTreeModel) { + model.insertNodeInto(newNode, lastNode, lastNode.childCount) + tree.selectionPath = TreePath(model.getPathToRoot(newNode)) + } } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/keymgr/KeyManagerPanel.kt b/src/main/kotlin/app/termora/keymgr/KeyManagerPanel.kt index 7921362..2ef74b1 100644 --- a/src/main/kotlin/app/termora/keymgr/KeyManagerPanel.kt +++ b/src/main/kotlin/app/termora/keymgr/KeyManagerPanel.kt @@ -211,9 +211,9 @@ class KeyManagerPanel : JPanel(BorderLayout()) { } val owner = SwingUtilities.getWindowAncestor(this) ?: return - val hostTreeDialog = HostTreeDialog(owner) { - it.protocol == Protocol.SSH - } + val hostTreeDialog = NewHostTreeDialog(owner) + hostTreeDialog.setFilter { it.host.protocol == Protocol.SSH } + hostTreeDialog.setTreeName("KeyManagerPanel.SSHCopyIdTree") hostTreeDialog.isVisible = true val hosts = hostTreeDialog.hosts if (hosts.isEmpty()) { diff --git a/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt b/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt index 3714aa7..4039d9e 100644 --- a/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt +++ b/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt @@ -42,6 +42,7 @@ class SSHCopyIdDialog( } private val terminalPanel by lazy { terminalPanelFactory.createTerminalPanel(terminal, PtyConnectorDelegate()) + .apply { enableFloatingToolbar = false } } private val coroutineScope = CoroutineScope(Job() + Dispatchers.IO) @@ -152,7 +153,7 @@ class SSHCopyIdDialog( val baos = ByteArrayOutputStream() channel.out = baos if (channel.open().verify(timeout).await(timeout)) { - channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout); + channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout) } if (channel.exitStatus != 0) { throw IllegalStateException("Server response: ${channel.exitStatus}") diff --git a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt index c52f34c..de374d9 100644 --- a/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt +++ b/src/main/kotlin/app/termora/terminal/panel/TerminalPanel.kt @@ -53,6 +53,15 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect private var visualWindows = emptyArray() val scrollBar = TerminalScrollBar(this@TerminalPanel, terminalFindPanel, terminal) + var enableFloatingToolbar = true + set(value) { + field = value + if (value) { + layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any) + } else { + layeredPane.remove(floatingToolbar) + } + } /** @@ -125,7 +134,9 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect layeredPane.add(terminalDisplay, JLayeredPane.DEFAULT_LAYER as Any) layeredPane.add(terminalFindPanel, JLayeredPane.POPUP_LAYER as Any) - layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any) + if (enableFloatingToolbar) { + layeredPane.add(floatingToolbar, JLayeredPane.POPUP_LAYER as Any) + } add(layeredPane, BorderLayout.CENTER) add(scrollBar, BorderLayout.EAST) diff --git a/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt b/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt index 5a2bc0d..beadffc 100644 --- a/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt +++ b/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt @@ -57,11 +57,13 @@ class FileSystemTabbed( private fun initEvents() { addBtn.addActionListener { - val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this)) + val dialog = NewHostTreeDialog(SwingUtilities.getWindowAncestor(this)) dialog.location = Point( max(0, addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2), addBtn.locationOnScreen.y + max(tabHeight, addBtn.height) ) + dialog.setFilter { it.host.protocol == Protocol.SSH } + dialog.setTreeName("FileSystemTabbed.Tree") dialog.isVisible = true for (host in dialog.hosts) { diff --git a/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt b/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt index c49dbf0..039daa8 100644 --- a/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt +++ b/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt @@ -1,6 +1,8 @@ package app.termora.transport import app.termora.* +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent import app.termora.keyboardinteractive.TerminalUserInteraction import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon @@ -291,9 +293,11 @@ class SftpFileSystemPanel( val builder = FormBuilder.create().layout(layout).debug(false) builder.add(FlatOptionPaneInformationIcon()).xy(2, 2) builder.add(errorInfo).xyw(1, 4, 3, "fill, center") - builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.select-host")) { - override fun actionPerformed(e: ActionEvent) { - val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel)) + builder.add(JXHyperlink(object : AnAction(I18n.getString("termora.transport.sftp.select-host")) { + override fun actionPerformed(evt: AnActionEvent) { + val dialog = NewHostTreeDialog(evt.window) + dialog.setFilter { it.host.protocol == Protocol.SSH } + dialog.setTreeName("SftpFileSystemPanel.SelectHostTree") dialog.allowMulti = false dialog.setLocationRelativeTo(this@SelectHostPanel) dialog.isVisible = true diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index b89138b..e69dce6 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -130,6 +130,7 @@ termora.welcome.my-hosts=My hosts termora.welcome.contextmenu.connect=Connect termora.welcome.contextmenu.connect-with=Connect with termora.welcome.contextmenu.open-in-new-window=${termora.tabbed.contextmenu.open-in-new-window} +termora.welcome.contextmenu.refresh=${termora.transport.table.contextmenu.refresh} termora.welcome.contextmenu.copy=${termora.copy} termora.welcome.contextmenu.remove=${termora.remove} termora.welcome.contextmenu.rename=Rename @@ -350,7 +351,7 @@ termora.visual-window.system-information.filesystem=Filesystem termora.visual-window.system-information.used-total=Used / Total -termora.visual-window.nvidia-smi=NVIDIA System Management Interface +termora.visual-window.nvidia-smi=NVIDIA SMI termora.floating-toolbar.not-supported=This action is not supported