mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 02:12:58 +08:00
feat: supports importing hosts from WindTerm (#289)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user