From 28fe4c725f83fd292123f8a7a9de99c0e6d836ab Mon Sep 17 00:00:00 2001 From: hstyi Date: Fri, 21 Feb 2025 21:44:51 +0800 Subject: [PATCH] feat: supports importing hosts from WindTerm (#289) --- src/main/kotlin/app/termora/HostManager.kt | 43 ++++-- src/main/kotlin/app/termora/HostTreeNode.kt | 11 +- src/main/kotlin/app/termora/NewHostTree.kt | 146 +++++++++++++++++- .../termora/RequestAuthenticationDialog.kt | 25 ++- src/main/kotlin/app/termora/SSHTerminalTab.kt | 17 +- .../termora/transport/SftpFileSystemPanel.kt | 15 +- src/main/resources/i18n/messages.properties | 1 + 7 files changed, 222 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/app/termora/HostManager.kt b/src/main/kotlin/app/termora/HostManager.kt index 87fc9fb..cdb249f 100644 --- a/src/main/kotlin/app/termora/HostManager.kt +++ b/src/main/kotlin/app/termora/HostManager.kt @@ -1,37 +1,50 @@ package app.termora -import org.slf4j.LoggerFactory -import kotlin.system.measureTimeMillis - class HostManager private constructor() { companion object { 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 var hosts = mutableMapOf() + /** + * 修改缓存并存入数据库 + */ fun addHost(host: Host) { assertEventDispatchThread() database.addHost(host) + setHost(host) } + /** + * 第一次调用从数据库中获取,后续从缓存中获取 + */ fun hosts(): List { - 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") - } + if (hosts.isEmpty()) { + database.getHosts().filter { !it.deleted } + .forEach { hosts[it.id] = it } } - return hosts + return hosts.values.filter { !it.deleted } + .sortedWith(compareBy { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort }) } + /** + * 从缓存中获取 + */ + fun getHost(id: String): Host? { + return hosts[id] + } + + + /** + * 仅修改缓存中的 + */ + fun setHost(host: Host) { + assertEventDispatchThread() + hosts[host.id] = host + } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/HostTreeNode.kt b/src/main/kotlin/app/termora/HostTreeNode.kt index 64aa02d..115d856 100644 --- a/src/main/kotlin/app/termora/HostTreeNode.kt +++ b/src/main/kotlin/app/termora/HostTreeNode.kt @@ -3,9 +3,16 @@ package app.termora import javax.swing.tree.DefaultMutableTreeNode class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { + companion object { + private val hostManager get() = HostManager.getInstance() + } + var host: Host - get() = userObject as Host - set(value) = setUserObject(value) + get() = hostManager.getHost((userObject as Host).id) ?: userObject as Host + set(value) { + setUserObject(value) + hostManager.setHost(value) + } val folderCount get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false } diff --git a/src/main/kotlin/app/termora/NewHostTree.kt b/src/main/kotlin/app/termora/NewHostTree.kt index 2b05207..92e73a8 100644 --- a/src/main/kotlin/app/termora/NewHostTree.kt +++ b/src/main/kotlin/app/termora/NewHostTree.kt @@ -1,12 +1,19 @@ package app.termora +import app.termora.Application.ohMyJson 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 kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.apache.commons.io.FileUtils import org.apache.commons.lang3.StringUtils +import org.apache.commons.lang3.exception.ExceptionUtils import org.jdesktop.swingx.JXTree import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer @@ -19,6 +26,7 @@ import java.awt.event.ActionEvent import java.awt.event.ActionListener import java.awt.event.MouseAdapter import java.awt.event.MouseEvent +import java.io.File import java.util.* import java.util.function.Function import javax.swing.* @@ -26,6 +34,7 @@ import javax.swing.event.CellEditorListener import javax.swing.event.ChangeEvent import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuListener +import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.tree.TreePath import javax.swing.tree.TreeSelectionModel import kotlin.math.min @@ -337,6 +346,8 @@ class NewHostTree : JXTree() { 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 importMenu = JMenu(I18n.getString("termora.welcome.contextmenu.import")) + val windTermMenu = importMenu.add("WindTerm") val open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect")) val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu @@ -352,6 +363,7 @@ class NewHostTree : JXTree() { 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(importMenu) popupMenu.add(newMenu) popupMenu.addSeparator() val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info")) @@ -363,6 +375,7 @@ class NewHostTree : JXTree() { popupMenu.add(showMoreInfo) val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property")) + windTermMenu.addActionListener { importHosts(lastNode, ImportType.WindTerm) } open.addActionListener { openHosts(it, false) } openInNewWindow.addActionListener { openHosts(it, true) } openWithSFTP.addActionListener { openWithSFTP(it) } @@ -396,6 +409,15 @@ class NewHostTree : JXTree() { for (c in nodes) { hostManager.addHost(c.host.copy(deleted = true, updateDate = System.currentTimeMillis())) model.removeNodeFromParent(c) + // 将所有子孙也删除 + for (child in c.getAllChildren()) { + hostManager.addHost( + child.host.copy( + deleted = true, + updateDate = System.currentTimeMillis() + ) + ) + } } } } @@ -426,8 +448,7 @@ class NewHostTree : JXTree() { 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) + val newNode = HostTreeNode(host) model.insertNodeInto(newNode, lastNode, lastNode.childCount) selectionPath = TreePath(model.getPathToRoot(newNode)) } @@ -465,14 +486,13 @@ class NewHostTree : JXTree() { } } - newFolder.isEnabled = lastHost.protocol == Protocol.Folder - newHost.isEnabled = newFolder.isEnabled + newMenu.isEnabled = lastHost.protocol == Protocol.Folder 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 - + importMenu.isEnabled = lastHost.protocol == Protocol.Folder // 如果选中了 SSH 服务器,那么才启用 openWithSFTP.isEnabled = getSelectionHostTreeNodes(true).map { it.host }.any { it.protocol == Protocol.SSH } @@ -564,7 +584,6 @@ class NewHostTree : JXTree() { 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 @@ -584,6 +603,121 @@ class NewHostTree : JXTree() { } } + private fun importHosts(folder: HostTreeNode, type: ImportType) { + val chooser = JFileChooser() + chooser.fileSelectionMode = JFileChooser.FILES_ONLY + chooser.isAcceptAllFileFilterUsed = false + chooser.isMultiSelectionEnabled = false + + if (type == ImportType.WindTerm) { + chooser.fileFilter = FileNameExtensionFilter("WindTerm(*.sessions)", "sessions") + } + + val dir = properties.getString("NewHostTree.ImportHosts.defaultDir", StringUtils.EMPTY) + if (dir.isNotBlank()) { + val file = FileUtils.getFile(dir) + if (file.exists()) { + chooser.currentDirectory = file + } + } + + val code = chooser.showOpenDialog(owner) + properties.putString("NewHostTree.ImportHosts.defaultDir", chooser.currentDirectory.absolutePath) + + if (code != JFileChooser.APPROVE_OPTION) { + return + } + + val file = chooser.selectedFile + + val nodes = if (type == ImportType.WindTerm) { + parseFromWindTerm(file) + } else { + emptyList() + } + + + for (node in nodes) { + node.host = node.host.copy(parentId = folder.host.id) + model.insertNodeInto( + node, + folder, + if (node.host.protocol == Protocol.Folder) folder.folderCount else folder.childCount + ) + } + + for (node in nodes) { + hostManager.addHost(node.host) + node.getAllChildren().forEach { hostManager.addHost(it.host) } + } + } + + private fun parseFromWindTerm(file: File): List { + val sessions = ohMyJson.runCatching { ohMyJson.parseToJsonElement(file.readText()).jsonArray } + .onFailure { OptionPane.showMessageDialog(owner, ExceptionUtils.getMessage(it)) } + .getOrNull() ?: return emptyList() + val nodes = mutableListOf() + + for (i in 0 until sessions.size) { + val json = sessions[i].jsonObject + val protocol = json["session.protocol"]?.jsonPrimitive?.content ?: StringUtils.EMPTY + if (protocol != "SSH") continue + val label = json["session.label"]?.jsonPrimitive?.content ?: StringUtils.EMPTY + val target = json["session.target"]?.jsonPrimitive?.content ?: StringUtils.EMPTY + val port = json["session.port"]?.jsonPrimitive?.intOrNull ?: 22 + val group = json["session.group"]?.jsonPrimitive?.content ?: StringUtils.EMPTY + val groups = group.split(">") + + var p: HostTreeNode? = null + if (group.isNotBlank()) { + for (j in groups.indices) { + val folders = if (j == 0 || p == null) nodes + else p.children().toList().filterIsInstance() + val n = HostTreeNode( + Host( + name = groups[j], protocol = Protocol.Folder, + parentId = p?.host?.id ?: StringUtils.EMPTY + ) + ) + val cp = folders.find { it.host.protocol == Protocol.Folder && it.host.name == groups[j] } + if (cp != null) { + p = cp + continue + } + if (p == null) { + p = n + nodes.add(n) + } else { + p.add(n) + p = n + } + } + } + + val n = HostTreeNode( + Host( + name = StringUtils.defaultIfBlank(label, target), + host = target, + port = port, + protocol = Protocol.SSH, + parentId = p?.host?.id ?: StringUtils.EMPTY, + ) + ) + + if (p == null) { + nodes.add(n) + } else { + p.add(n) + } + } + + return nodes + } + + + private enum class ImportType { + WindTerm + } private class MoveHostTransferable(val nodes: List) : Transferable { companion object { diff --git a/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt b/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt index 78e51a5..e2ca1b4 100644 --- a/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt +++ b/src/main/kotlin/app/termora/RequestAuthenticationDialog.kt @@ -13,12 +13,13 @@ import java.awt.event.ItemEvent import javax.swing.* import kotlin.math.max -class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) { +class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) { private val authenticationTypeComboBox = FlatComboBox() private val rememberCheckBox = JCheckBox("Remember") private val passwordPanel = JPanel(BorderLayout()) private val passwordPasswordField = OutlinePasswordField() + private val usernameTextField = OutlineTextField() private val publicKeyComboBox = OutlineComboBox() private val keyManager get() = KeyManager.getInstance() private var authentication = Authentication.No @@ -64,6 +65,8 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) { } } + usernameTextField.text = host.username + } override fun createCenterPanel(): JComponent { @@ -72,7 +75,7 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) { val formMargin = "7dlu" val layout = FormLayout( "left:pref, $formMargin, default:grow", - "pref, $formMargin, pref" + "pref, $formMargin, pref, $formMargin, pref" ) switchPasswordComponent() @@ -81,8 +84,10 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) { .layout(layout) .add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1) .add(authenticationTypeComboBox).xy(3, 1) - .add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 3) - .add(passwordPanel).xy(3, 3) + .add("${I18n.getString("termora.new-host.general.username")}:").xy(1, 3) + .add(usernameTextField).xy(3, 3) + .add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 5) + .add(passwordPanel).xy(3, 5) .build() } @@ -134,7 +139,13 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) { fun getAuthentication(): Authentication { isModal = true - SwingUtilities.invokeLater { passwordPasswordField.requestFocusInWindow() } + SwingUtilities.invokeLater { + if (usernameTextField.text.isBlank()) { + usernameTextField.requestFocusInWindow() + } else { + passwordPasswordField.requestFocusInWindow() + } + } isVisible = true return authentication } @@ -143,4 +154,8 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) { return rememberCheckBox.isSelected } + fun getUsername(): String { + return usernameTextField.text + } + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SSHTerminalTab.kt b/src/main/kotlin/app/termora/SSHTerminalTab.kt index d0a4cd2..40bce7a 100644 --- a/src/main/kotlin/app/termora/SSHTerminalTab.kt +++ b/src/main/kotlin/app/termora/SSHTerminalTab.kt @@ -42,6 +42,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : } private val mutex = Mutex() + private val tab = this private var sshClient: SshClient? = null private var sshSession: ClientSession? = null @@ -97,12 +98,20 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : if (host.authentication.type == AuthenticationType.No) { withContext(Dispatchers.Swing) { - val dialog = RequestAuthenticationDialog(owner) + val dialog = RequestAuthenticationDialog(owner, host) val authentication = dialog.getAuthentication() - host = host.copy(authentication = authentication) + host = host.copy( + authentication = authentication, + username = dialog.getUsername(), + ) // save if (dialog.isRemembered()) { - HostManager.getInstance().addHost(this@SSHTerminalTab.host.copy(authentication = authentication)) + HostManager.getInstance().addHost( + tab.host.copy( + authentication = authentication, + username = dialog.getUsername(), + ) + ) } } } @@ -157,7 +166,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) { terminalTabbedManager?.let { manager -> SwingUtilities.invokeLater { - manager.closeTerminalTab(this@SSHTerminalTab, true) + manager.closeTerminalTab(tab, true) } } } diff --git a/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt b/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt index 039daa8..93aec67 100644 --- a/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt +++ b/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt @@ -119,13 +119,20 @@ class SftpFileSystemPanel( client.serverKeyVerifier = DialogServerKeyVerifier(owner) // 弹出授权框 if (host.authentication.type == AuthenticationType.No) { - val dialog = RequestAuthenticationDialog(owner) + val dialog = RequestAuthenticationDialog(owner, host) val authentication = dialog.getAuthentication() - host = host.copy(authentication = authentication) + host = host.copy( + authentication = authentication, + username = dialog.getUsername(), + ) // save if (dialog.isRemembered()) { - HostManager.getInstance() - .addHost(host.copy(authentication = authentication)) + HostManager.getInstance().addHost( + host.copy( + authentication = authentication, + username = dialog.getUsername(), + ) + ) } } } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index abe39e1..e29fdc6 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -138,6 +138,7 @@ termora.welcome.contextmenu.rename=Rename termora.welcome.contextmenu.expand-all=Expand all termora.welcome.contextmenu.collapse-all=Collapse all termora.welcome.contextmenu.new=New +termora.welcome.contextmenu.import=${termora.keymgr.import} termora.welcome.contextmenu.new.folder=${termora.folder} termora.welcome.contextmenu.new.host=Host termora.welcome.contextmenu.new.folder.name=New Folder