feat: supports importing hosts from WindTerm (#289)

This commit is contained in:
hstyi
2025-02-21 21:44:51 +08:00
committed by GitHub
parent 18fe92cb11
commit 28fe4c725f
7 changed files with 222 additions and 36 deletions

View File

@@ -1,37 +1,50 @@
package app.termora package app.termora
import org.slf4j.LoggerFactory
import kotlin.system.measureTimeMillis
class HostManager private constructor() { class HostManager private constructor() {
companion object { companion object {
fun getInstance(): HostManager { fun getInstance(): HostManager {
return ApplicationScope.forApplicationScope().getOrCreate(HostManager::class) { HostManager() } return ApplicationScope.forApplicationScope().getOrCreate(HostManager::class) { HostManager() }
} }
private val log = LoggerFactory.getLogger(HostManager::class.java)
} }
private val database get() = Database.getDatabase() private val database get() = Database.getDatabase()
private var hosts = mutableMapOf<String, Host>()
/**
* 修改缓存并存入数据库
*/
fun addHost(host: Host) { fun addHost(host: Host) {
assertEventDispatchThread() assertEventDispatchThread()
database.addHost(host) database.addHost(host)
setHost(host)
} }
/**
* 第一次调用从数据库中获取,后续从缓存中获取
*/
fun hosts(): List<Host> { fun hosts(): List<Host> {
val hosts: List<Host> if (hosts.isEmpty()) {
measureTimeMillis { database.getHosts().filter { !it.deleted }
hosts = database.getHosts() .forEach { hosts[it.id] = it }
.filter { !it.deleted }
.sortedWith(compareBy<Host> { if (it.protocol == Protocol.Folder) 0 else 1 }.thenBy { it.sort })
}.let {
if (log.isDebugEnabled) {
log.debug("hosts: $it ms")
}
} }
return hosts return hosts.values.filter { !it.deleted }
.sortedWith(compareBy<Host> { 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
}
} }

View File

@@ -3,9 +3,16 @@ package app.termora
import javax.swing.tree.DefaultMutableTreeNode import javax.swing.tree.DefaultMutableTreeNode
class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) { class HostTreeNode(host: Host) : DefaultMutableTreeNode(host) {
companion object {
private val hostManager get() = HostManager.getInstance()
}
var host: Host var host: Host
get() = userObject as Host get() = hostManager.getHost((userObject as Host).id) ?: userObject as Host
set(value) = setUserObject(value) set(value) {
setUserObject(value)
hostManager.setHost(value)
}
val folderCount val folderCount
get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false } get() = children().toList().count { if (it is HostTreeNode) it.host.protocol == Protocol.Folder else false }

View File

@@ -1,12 +1,19 @@
package app.termora package app.termora
import app.termora.Application.ohMyJson
import app.termora.actions.AnActionEvent import app.termora.actions.AnActionEvent
import app.termora.actions.OpenHostAction import app.termora.actions.OpenHostAction
import app.termora.transport.SFTPAction import app.termora.transport.SFTPAction
import com.formdev.flatlaf.extras.components.FlatPopupMenu import com.formdev.flatlaf.extras.components.FlatPopupMenu
import com.formdev.flatlaf.icons.FlatTreeClosedIcon import com.formdev.flatlaf.icons.FlatTreeClosedIcon
import com.formdev.flatlaf.icons.FlatTreeOpenIcon 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.StringUtils
import org.apache.commons.lang3.exception.ExceptionUtils
import org.jdesktop.swingx.JXTree import org.jdesktop.swingx.JXTree
import org.jdesktop.swingx.action.ActionManager import org.jdesktop.swingx.action.ActionManager
import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer import org.jdesktop.swingx.tree.DefaultXTreeCellRenderer
@@ -19,6 +26,7 @@ import java.awt.event.ActionEvent
import java.awt.event.ActionListener import java.awt.event.ActionListener
import java.awt.event.MouseAdapter import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.io.File
import java.util.* import java.util.*
import java.util.function.Function import java.util.function.Function
import javax.swing.* import javax.swing.*
@@ -26,6 +34,7 @@ import javax.swing.event.CellEditorListener
import javax.swing.event.ChangeEvent import javax.swing.event.ChangeEvent
import javax.swing.event.PopupMenuEvent import javax.swing.event.PopupMenuEvent
import javax.swing.event.PopupMenuListener import javax.swing.event.PopupMenuListener
import javax.swing.filechooser.FileNameExtensionFilter
import javax.swing.tree.TreePath import javax.swing.tree.TreePath
import javax.swing.tree.TreeSelectionModel import javax.swing.tree.TreeSelectionModel
import kotlin.math.min import kotlin.math.min
@@ -337,6 +346,8 @@ class NewHostTree : JXTree() {
val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new")) val newMenu = JMenu(I18n.getString("termora.welcome.contextmenu.new"))
val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder")) val newFolder = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.folder"))
val newHost = newMenu.add(I18n.getString("termora.welcome.contextmenu.new.host")) 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 open = popupMenu.add(I18n.getString("termora.welcome.contextmenu.connect"))
val openWith = popupMenu.add(JMenu(I18n.getString("termora.welcome.contextmenu.connect-with"))) as JMenu 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 expandAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.expand-all"))
val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all")) val colspanAll = popupMenu.add(I18n.getString("termora.welcome.contextmenu.collapse-all"))
popupMenu.addSeparator() popupMenu.addSeparator()
popupMenu.add(importMenu)
popupMenu.add(newMenu) popupMenu.add(newMenu)
popupMenu.addSeparator() popupMenu.addSeparator()
val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info")) val showMoreInfo = JCheckBoxMenuItem(I18n.getString("termora.welcome.contextmenu.show-more-info"))
@@ -363,6 +375,7 @@ class NewHostTree : JXTree() {
popupMenu.add(showMoreInfo) popupMenu.add(showMoreInfo)
val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property")) val property = popupMenu.add(I18n.getString("termora.welcome.contextmenu.property"))
windTermMenu.addActionListener { importHosts(lastNode, ImportType.WindTerm) }
open.addActionListener { openHosts(it, false) } open.addActionListener { openHosts(it, false) }
openInNewWindow.addActionListener { openHosts(it, true) } openInNewWindow.addActionListener { openHosts(it, true) }
openWithSFTP.addActionListener { openWithSFTP(it) } openWithSFTP.addActionListener { openWithSFTP(it) }
@@ -396,6 +409,15 @@ class NewHostTree : JXTree() {
for (c in nodes) { for (c in nodes) {
hostManager.addHost(c.host.copy(deleted = true, updateDate = System.currentTimeMillis())) hostManager.addHost(c.host.copy(deleted = true, updateDate = System.currentTimeMillis()))
model.removeNodeFromParent(c) 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 dialog.isVisible = true
val host = (dialog.host ?: return).copy(parentId = lastHost.id) val host = (dialog.host ?: return).copy(parentId = lastHost.id)
hostManager.addHost(host) hostManager.addHost(host)
val c = HostTreeNode(host) val newNode = HostTreeNode(host)
val newNode = copyNode(c, lastHost.id)
model.insertNodeInto(newNode, lastNode, lastNode.childCount) model.insertNodeInto(newNode, lastNode, lastNode.childCount)
selectionPath = TreePath(model.getPathToRoot(newNode)) selectionPath = TreePath(model.getPathToRoot(newNode))
} }
@@ -465,14 +486,13 @@ class NewHostTree : JXTree() {
} }
} }
newFolder.isEnabled = lastHost.protocol == Protocol.Folder newMenu.isEnabled = lastHost.protocol == Protocol.Folder
newHost.isEnabled = newFolder.isEnabled
remove.isEnabled = getSelectionHostTreeNodes().none { it == model.root } remove.isEnabled = getSelectionHostTreeNodes().none { it == model.root }
copy.isEnabled = remove.isEnabled copy.isEnabled = remove.isEnabled
rename.isEnabled = remove.isEnabled rename.isEnabled = remove.isEnabled
property.isEnabled = lastHost.protocol != Protocol.Folder property.isEnabled = lastHost.protocol != Protocol.Folder
refresh.isEnabled = lastHost.protocol == Protocol.Folder refresh.isEnabled = lastHost.protocol == Protocol.Folder
importMenu.isEnabled = lastHost.protocol == Protocol.Folder
// 如果选中了 SSH 服务器,那么才启用 // 如果选中了 SSH 服务器,那么才启用
openWithSFTP.isEnabled = getSelectionHostTreeNodes(true).map { it.host }.any { it.protocol == Protocol.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)) } nodes.forEach { openHostAction.actionPerformed(OpenHostActionEvent(source, it, evt)) }
} }
private fun openWithSFTP(evt: EventObject) { private fun openWithSFTP(evt: EventObject) {
val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH } val nodes = getSelectionHostTreeNodes(true).map { it.host }.filter { it.protocol == Protocol.SSH }
if (nodes.isEmpty()) return 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<HostTreeNode> {
val sessions = ohMyJson.runCatching { ohMyJson.parseToJsonElement(file.readText()).jsonArray }
.onFailure { OptionPane.showMessageDialog(owner, ExceptionUtils.getMessage(it)) }
.getOrNull() ?: return emptyList()
val nodes = mutableListOf<HostTreeNode>()
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<HostTreeNode>()
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<HostTreeNode>) : Transferable { private class MoveHostTransferable(val nodes: List<HostTreeNode>) : Transferable {
companion object { companion object {

View File

@@ -13,12 +13,13 @@ import java.awt.event.ItemEvent
import javax.swing.* import javax.swing.*
import kotlin.math.max import kotlin.math.max
class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) { class RequestAuthenticationDialog(owner: Window, host: Host) : DialogWrapper(owner) {
private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>() private val authenticationTypeComboBox = FlatComboBox<AuthenticationType>()
private val rememberCheckBox = JCheckBox("Remember") private val rememberCheckBox = JCheckBox("Remember")
private val passwordPanel = JPanel(BorderLayout()) private val passwordPanel = JPanel(BorderLayout())
private val passwordPasswordField = OutlinePasswordField() private val passwordPasswordField = OutlinePasswordField()
private val usernameTextField = OutlineTextField()
private val publicKeyComboBox = OutlineComboBox<OhKeyPair>() private val publicKeyComboBox = OutlineComboBox<OhKeyPair>()
private val keyManager get() = KeyManager.getInstance() private val keyManager get() = KeyManager.getInstance()
private var authentication = Authentication.No private var authentication = Authentication.No
@@ -64,6 +65,8 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
} }
} }
usernameTextField.text = host.username
} }
override fun createCenterPanel(): JComponent { override fun createCenterPanel(): JComponent {
@@ -72,7 +75,7 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
val formMargin = "7dlu" val formMargin = "7dlu"
val layout = FormLayout( val layout = FormLayout(
"left:pref, $formMargin, default:grow", "left:pref, $formMargin, default:grow",
"pref, $formMargin, pref" "pref, $formMargin, pref, $formMargin, pref"
) )
switchPasswordComponent() switchPasswordComponent()
@@ -81,8 +84,10 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
.layout(layout) .layout(layout)
.add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1) .add("${I18n.getString("termora.new-host.general.authentication")}:").xy(1, 1)
.add(authenticationTypeComboBox).xy(3, 1) .add(authenticationTypeComboBox).xy(3, 1)
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 3) .add("${I18n.getString("termora.new-host.general.username")}:").xy(1, 3)
.add(passwordPanel).xy(3, 3) .add(usernameTextField).xy(3, 3)
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, 5)
.add(passwordPanel).xy(3, 5)
.build() .build()
} }
@@ -134,7 +139,13 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
fun getAuthentication(): Authentication { fun getAuthentication(): Authentication {
isModal = true isModal = true
SwingUtilities.invokeLater { passwordPasswordField.requestFocusInWindow() } SwingUtilities.invokeLater {
if (usernameTextField.text.isBlank()) {
usernameTextField.requestFocusInWindow()
} else {
passwordPasswordField.requestFocusInWindow()
}
}
isVisible = true isVisible = true
return authentication return authentication
} }
@@ -143,4 +154,8 @@ class RequestAuthenticationDialog(owner: Window) : DialogWrapper(owner) {
return rememberCheckBox.isSelected return rememberCheckBox.isSelected
} }
fun getUsername(): String {
return usernameTextField.text
}
} }

View File

@@ -42,6 +42,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
} }
private val mutex = Mutex() private val mutex = Mutex()
private val tab = this
private var sshClient: SshClient? = null private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null private var sshSession: ClientSession? = null
@@ -97,12 +98,20 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
if (host.authentication.type == AuthenticationType.No) { if (host.authentication.type == AuthenticationType.No) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
val dialog = RequestAuthenticationDialog(owner) val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication() val authentication = dialog.getAuthentication()
host = host.copy(authentication = authentication) host = host.copy(
authentication = authentication,
username = dialog.getUsername(),
)
// save // save
if (dialog.isRemembered()) { 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) { if (Database.getDatabase().terminal.autoCloseTabWhenDisconnected) {
terminalTabbedManager?.let { manager -> terminalTabbedManager?.let { manager ->
SwingUtilities.invokeLater { SwingUtilities.invokeLater {
manager.closeTerminalTab(this@SSHTerminalTab, true) manager.closeTerminalTab(tab, true)
} }
} }
} }

View File

@@ -119,13 +119,20 @@ class SftpFileSystemPanel(
client.serverKeyVerifier = DialogServerKeyVerifier(owner) client.serverKeyVerifier = DialogServerKeyVerifier(owner)
// 弹出授权框 // 弹出授权框
if (host.authentication.type == AuthenticationType.No) { if (host.authentication.type == AuthenticationType.No) {
val dialog = RequestAuthenticationDialog(owner) val dialog = RequestAuthenticationDialog(owner, host)
val authentication = dialog.getAuthentication() val authentication = dialog.getAuthentication()
host = host.copy(authentication = authentication) host = host.copy(
authentication = authentication,
username = dialog.getUsername(),
)
// save // save
if (dialog.isRemembered()) { if (dialog.isRemembered()) {
HostManager.getInstance() HostManager.getInstance().addHost(
.addHost(host.copy(authentication = authentication)) host.copy(
authentication = authentication,
username = dialog.getUsername(),
)
)
} }
} }
} }

View File

@@ -138,6 +138,7 @@ termora.welcome.contextmenu.rename=Rename
termora.welcome.contextmenu.expand-all=Expand all termora.welcome.contextmenu.expand-all=Expand all
termora.welcome.contextmenu.collapse-all=Collapse all termora.welcome.contextmenu.collapse-all=Collapse all
termora.welcome.contextmenu.new=New termora.welcome.contextmenu.new=New
termora.welcome.contextmenu.import=${termora.keymgr.import}
termora.welcome.contextmenu.new.folder=${termora.folder} termora.welcome.contextmenu.new.folder=${termora.folder}
termora.welcome.contextmenu.new.host=Host termora.welcome.contextmenu.new.host=Host
termora.welcome.contextmenu.new.folder.name=New Folder termora.welcome.contextmenu.new.folder.name=New Folder