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
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<String, Host>()
/**
* 修改缓存并存入数据库
*/
fun addHost(host: Host) {
assertEventDispatchThread()
database.addHost(host)
setHost(host)
}
/**
* 第一次调用从数据库中获取,后续从缓存中获取
*/
fun hosts(): List<Host> {
val hosts: List<Host>
measureTimeMillis {
hosts = database.getHosts()
.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")
}
if (hosts.isEmpty()) {
database.getHosts().filter { !it.deleted }
.forEach { hosts[it.id] = it }
}
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
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 }

View File

@@ -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<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 {
companion object {

View File

@@ -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<AuthenticationType>()
private val rememberCheckBox = JCheckBox("Remember")
private val passwordPanel = JPanel(BorderLayout())
private val passwordPasswordField = OutlinePasswordField()
private val usernameTextField = OutlineTextField()
private val publicKeyComboBox = OutlineComboBox<OhKeyPair>()
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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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(),
)
)
}
}
}

View File

@@ -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