Skip to content

Commit

Permalink
feat: support SFTP
Browse files Browse the repository at this point in the history
Refs #10
Refs #9
Refs #6
  • Loading branch information
hstyi committed Jan 5, 2025
1 parent a8efde6 commit 2096a29
Show file tree
Hide file tree
Showing 50 changed files with 3,567 additions and 23 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
## 功能特性

- 支持 SSH 和本地终端
- 支持 [SFTP](./docs/sftp-zh_CN.png) 文件传输
- 支持 Windows、macOS、Linux 平台
- 支持 Zmodem 协议
- 支持 SSH 端口转发
Expand All @@ -33,7 +34,7 @@

## 开发

建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run`即可运行程序。
建议使用 [JetBrainsRuntime](https://github.com/JetBrains/JetBrainsRuntime) 的 JDK 版本,通过 `./gradlew :run` 即可运行程序。

通过 `./gradlew dist` 可以自动构建适用于本机的版本。在 macOS 上是:`dmg`,在 Windows 上是:`zip`,在 Linux 上是:`tar.gz`

Expand Down
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ dependencies {
testImplementation(libs.jsch)
testImplementation(libs.rhino)
testImplementation(libs.delight.rhino.sandbox)
testImplementation(platform(libs.testcontainers.bom))
testImplementation(libs.testcontainers)

implementation(libs.slf4j.api)
implementation(libs.pty4j)
Expand Down Expand Up @@ -137,6 +139,7 @@ tasks.register<Exec>("jpackage") {
val buildDir = layout.buildDirectory.get()
val options = mutableListOf(
"--add-exports java.base/sun.nio.ch=ALL-UNNAMED",
"-XX:+UseZGC", "-XX:+ZGenerational", "-XX:ZUncommit", "-XX:ZUncommitDelay=60",
"-Xmx2g",
"-XX:+HeapDumpOnOutOfMemoryError",
"-Dlogger.console.level=off",
Expand Down
Binary file added docs/sftp-zh_CN.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/sftp-zh_TW.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/sftp.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ bip39 = "1.0.8"
colorpicker = "2.0.1"
rhino = "1.7.15"
delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4"

[libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
Expand All @@ -57,6 +58,8 @@ flatlaf = { group = "com.formdev", name = "flatlaf", version.ref = "flatlaf" }
flatlaf-extras = { group = "com.formdev", name = "flatlaf-extras", version.ref = "flatlaf" }
trove4j = { group = "org.jetbrains.intellij.deps", name = "trove4j", version.ref = "trove4j" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" }
testcontainers = { module = "org.testcontainers:testcontainers" }
koin-core = { module = "io.insert-koin:koin-core" }
swingx = { module = "org.swinglabs.swingx:swingx-all", version.ref = "swingx" }
jgoodies-forms = { module = "com.jgoodies:jgoodies-forms", version.ref = "jgoodies-forms" }
Expand Down
32 changes: 32 additions & 0 deletions src/main/kotlin/app/termora/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import java.io.File
import java.net.URI
import java.time.Duration
import java.util.*
import kotlin.math.ln
import kotlin.math.pow
import kotlin.reflect.KClass


object Application {
private val services = Collections.synchronizedMap(mutableMapOf<KClass<*>, Any>())
private lateinit var baseDataDir: File
Expand Down Expand Up @@ -99,6 +102,10 @@ object Application {
return version
}

fun isUnknownVersion(): Boolean {
return getVersion().contains("unknown")
}

fun getAppPath(): String {
return StringUtils.defaultString(System.getProperty("jpackage.app-path"))
}
Expand Down Expand Up @@ -144,3 +151,28 @@ object Application {
}
}
}

fun formatBytes(bytes: Long): String {
if (bytes < 1024) return "$bytes B"

val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
val value = bytes / 1024.0.pow(exp.toDouble())

return String.format("%.2f %s", value, units[exp])
}

fun formatSeconds(seconds: Long): String {
val days = seconds / 86400
val hours = (seconds % 86400) / 3600
val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60

return when {
days > 0 -> "${days}${hours}小时${minutes}${remainingSeconds}"
hours > 0 -> "${hours}小时${minutes}${remainingSeconds}"
minutes > 0 -> "${minutes}${remainingSeconds}"
else -> "${remainingSeconds}"
}
}

15 changes: 12 additions & 3 deletions src/main/kotlin/app/termora/HostTree.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ class HostTree : JTree(), Disposable {
private val hostManager get() = HostManager.instance
private val editor = OutlineTextField(64)

var contextmenu = true

/**
* 双击是否打开连接
*/
var doubleClickConnection = true

val model = HostTreeModel()
val searchableModel = SearchableHostTreeModel(model)

Expand Down Expand Up @@ -122,7 +129,7 @@ class HostTree : JTree(), Disposable {
}

override fun mouseClicked(e: MouseEvent) {
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
val host = lastSelectedPathComponent
if (host is Host && host.protocol != Protocol.Folder) {
ActionManager.getInstance().getAction(Actions.OPEN_HOST)
Expand Down Expand Up @@ -296,6 +303,8 @@ class HostTree : JTree(), Disposable {
}

private fun showContextMenu(event: MouseEvent) {
if (!contextmenu) return

val lastHost = lastSelectedPathComponent
if (lastHost !is Host) {
return
Expand Down Expand Up @@ -356,7 +365,7 @@ class HostTree : JTree(), Disposable {
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
Expand Down Expand Up @@ -512,7 +521,7 @@ class HostTree : JTree(), Disposable {
collapsePath(TreePath(model.getPathToRoot(node)))
}

private fun getSelectionNodes(): List<Host> {
fun getSelectionNodes(): List<Host> {
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
.filterIsInstance<Host>()

Expand Down
119 changes: 119 additions & 0 deletions src/main/kotlin/app/termora/HostTreeDialog.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package app.termora

import app.termora.db.Database
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) : DialogWrapper(owner) {

private val tree = HostTree()

val hosts = mutableListOf<Host>()

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
})
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.instance.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) {
Database.instance.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()
}

}
7 changes: 7 additions & 0 deletions src/main/kotlin/app/termora/Icons.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package app.termora

object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
Expand All @@ -14,6 +15,7 @@ object Icons {
val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
val empty by lazy { DynamicIcon("icons/empty.svg") }
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
Expand All @@ -26,6 +28,8 @@ object Icons {
val rec by lazy { DynamicIcon("icons/rec.svg", "icons/rec_dark.svg") }
val stop by lazy { DynamicIcon("icons/stop.svg", "icons/stop_dark.svg") }
val find by lazy { DynamicIcon("icons/find.svg", "icons/find_dark.svg") }
val bookmarks by lazy { DynamicIcon("icons/bookmarks.svg", "icons/bookmarks_dark.svg") }
val bookmarksOff by lazy { DynamicIcon("icons/bookmarksOff.svg", "icons/bookmarksOff_dark.svg") }
val keyboard by lazy { DynamicIcon("icons/keyboard.svg", "icons/keyboard_dark.svg") }
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
Expand Down Expand Up @@ -64,9 +68,12 @@ object Icons {
val split by lazy { DynamicIcon("icons/split.svg", "icons/split_dark.svg") }
val setKey by lazy { DynamicIcon("icons/setKey.svg", "icons/setKey_dark.svg") }
val greyKey by lazy { DynamicIcon("icons/greyKey.svg", "icons/greyKey_dark.svg") }
val refresh by lazy { DynamicIcon("icons/refresh.svg", "icons/refresh_dark.svg") }
val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") }
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }
val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_dark.svg") }
Expand Down
53 changes: 53 additions & 0 deletions src/main/kotlin/app/termora/SFTPTerminalTab.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package app.termora

import app.termora.transport.TransportPanel
import java.beans.PropertyChangeListener
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.SwingUtilities

class SFTPTerminalTab : Disposable, TerminalTab {

private val transportPanel by lazy {
TransportPanel().apply {
Disposer.register(this@SFTPTerminalTab, this)
}
}

override fun getTitle(): String {
return "SFTP"
}

override fun getIcon(): Icon {
return Icons.fileTransfer
}

override fun addPropertyChangeListener(listener: PropertyChangeListener) {

}

override fun removePropertyChangeListener(listener: PropertyChangeListener) {
}

override fun getJComponent(): JComponent {
return transportPanel
}


override fun canClose(): Boolean {
assertEventDispatchThread()

if (transportPanel.transportManager.getTransports().isEmpty()) {
return true
}

return OptionPane.showConfirmDialog(
SwingUtilities.getWindowAncestor(getJComponent()),
I18n.getString("termora.transport.sftp.close-tab"),
messageType = JOptionPane.QUESTION_MESSAGE,
optionType = JOptionPane.OK_CANCEL_OPTION
) == JOptionPane.OK_OPTION
}

}
8 changes: 6 additions & 2 deletions src/main/kotlin/app/termora/SearchableHostTreeModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import javax.swing.event.TreeModelListener
import javax.swing.tree.TreeModel
import javax.swing.tree.TreePath

class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
class SearchableHostTreeModel(
private val model: HostTreeModel,
private val filter: (host: Host) -> Boolean = { true }
) : TreeModel {
private var text = String()

override fun getRoot(): Any {
Expand Down Expand Up @@ -45,7 +48,8 @@ class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
val children = model.getChildren(parent)
if (children.isEmpty()) return emptyList()
return children.filter { e ->
e.name.contains(text, true) || TreeUtils.children(model, e, true).filterIsInstance<Host>().any {
filter.invoke(e) && e.name.contains(text, true) || TreeUtils.children(model, e, true)
.filterIsInstance<Host>().any {
it.name.contains(text, true)
}
}
Expand Down
Loading

0 comments on commit 2096a29

Please sign in to comment.