@@ -11,6 +11,7 @@
|
||||
## 功能特性
|
||||
|
||||
- 支持 SSH 和本地终端
|
||||
- 支持 [SFTP](./docs/sftp-zh_CN.png) 文件传输
|
||||
- 支持 Windows、macOS、Linux 平台
|
||||
- 支持 Zmodem 协议
|
||||
- 支持 SSH 端口转发
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
BIN
docs/sftp-zh_CN.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/sftp-zh_TW.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/sftp.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -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" }
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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}秒"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -296,6 +303,8 @@ class HostTree : JTree(), Disposable {
|
||||
}
|
||||
|
||||
private fun showContextMenu(event: MouseEvent) {
|
||||
if (!contextmenu) return
|
||||
|
||||
val lastHost = lastSelectedPathComponent
|
||||
if (lastHost !is Host) {
|
||||
return
|
||||
@@ -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
|
||||
@@ -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>()
|
||||
|
||||
|
||||
119
src/main/kotlin/app/termora/HostTreeDialog.kt
Normal file
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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") }
|
||||
@@ -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") }
|
||||
@@ -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") }
|
||||
|
||||
53
src/main/kotlin/app/termora/SFTPTerminalTab.kt
Normal file
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,5 +37,10 @@ interface TerminalTab : Disposable {
|
||||
fun onLostFocus() {}
|
||||
fun onGrabFocus() {}
|
||||
|
||||
/**
|
||||
* @return 返回 false 则不可关闭
|
||||
*/
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package app.termora
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.swing.*
|
||||
|
||||
class TerminalTabDialog(
|
||||
owner: Window,
|
||||
@@ -19,10 +19,20 @@ class TerminalTabDialog(
|
||||
isAlwaysOnTop = false
|
||||
iconImages = owner.iconImages
|
||||
escapeDispose = false
|
||||
|
||||
|
||||
super.setSize(size)
|
||||
|
||||
init()
|
||||
|
||||
defaultCloseOperation = WindowConstants.DO_NOTHING_ON_CLOSE
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosing(e: WindowEvent) {
|
||||
if (terminalTab.canClose()) {
|
||||
SwingUtilities.invokeLater { doCancelAction() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setLocationRelativeTo(null)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ class TerminalTabbed(
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||
val index = tabbedPane.indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
if (index > 0) {
|
||||
tabbedPane.getComponentAt(index).requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,13 @@ class TerminalTabbed(
|
||||
private fun removeTabAt(index: Int, disposable: Boolean = true) {
|
||||
if (tabbedPane.isTabClosable(index)) {
|
||||
val tab = tabs[index]
|
||||
|
||||
if (disposable) {
|
||||
if (!tab.canClose()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tab.onLostFocus()
|
||||
tab.removePropertyChangeListener(iconListener)
|
||||
|
||||
@@ -244,6 +251,7 @@ class TerminalTabbed(
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
|
||||
// 修改名称
|
||||
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
|
||||
rename.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
@@ -261,6 +269,7 @@ class TerminalTabbed(
|
||||
|
||||
}
|
||||
|
||||
// 克隆
|
||||
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
|
||||
clone.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
@@ -272,9 +281,9 @@ class TerminalTabbed(
|
||||
.actionPerformed(OpenHostActionEvent(this, tab.host))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 在新窗口中打开
|
||||
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
|
||||
openInNewWindow.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
@@ -294,11 +303,13 @@ class TerminalTabbed(
|
||||
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 关闭
|
||||
val close = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close"))
|
||||
close.addActionListener {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabIndex)
|
||||
}
|
||||
|
||||
// 关闭其他标签页
|
||||
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-other-tabs")).addActionListener {
|
||||
for (i in tabbedPane.tabCount - 1 downTo tabIndex + 1) {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, i)
|
||||
@@ -308,6 +319,7 @@ class TerminalTabbed(
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭所有标签页
|
||||
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-all-tabs")).addActionListener {
|
||||
for (i in 0 until tabbedPane.tabCount) {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabbedPane.tabCount - 1)
|
||||
@@ -320,6 +332,11 @@ class TerminalTabbed(
|
||||
clone.isEnabled = close.isEnabled
|
||||
openInNewWindow.isEnabled = close.isEnabled
|
||||
|
||||
// SFTP不允许克隆
|
||||
if (clone.isEnabled && getSelectedTerminalTab() is SFTPTerminalTab) {
|
||||
clone.isEnabled = false
|
||||
}
|
||||
|
||||
|
||||
if (close.isEnabled) {
|
||||
popupMenu.addSeparator()
|
||||
@@ -400,5 +417,14 @@ class TerminalTabbed(
|
||||
return tabs
|
||||
}
|
||||
|
||||
override fun setSelectedTerminalTab(tab: TerminalTab) {
|
||||
for (index in tabs.indices) {
|
||||
if (tabs[index] == tab) {
|
||||
tabbedPane.selectedIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -4,4 +4,5 @@ interface TerminalTabbedManager {
|
||||
fun addTerminalTab(tab: TerminalTab)
|
||||
fun getSelectedTerminalTab(): TerminalTab?
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
}
|
||||
@@ -392,9 +392,6 @@ class TermoraFrame : JFrame() {
|
||||
if (e.source == tabbedPane) {
|
||||
val index = tabbedPane.indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
if (e.id == MouseEvent.MOUSE_CLICKED) {
|
||||
tabbedPane.getComponentAt(index)?.requestFocusInWindow()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package app.termora.findeverywhere
|
||||
|
||||
import app.termora.Actions
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import java.awt.event.ActionEvent
|
||||
import javax.swing.Icon
|
||||
|
||||
class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
|
||||
@@ -15,6 +14,24 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
|
||||
ActionManager.getInstance().getAction(Actions.ADD_HOST)?.let {
|
||||
list.add(CreateHostFindEverywhereResult())
|
||||
}
|
||||
|
||||
// SFTP
|
||||
list.add(ActionFindEverywhereResult(object : AnAction("SFTP", Icons.fileTransfer) {
|
||||
override fun actionPerformed(evt: ActionEvent) {
|
||||
val terminalTabbedManager = Application.getService(TerminalTabbedManager::class)
|
||||
val tabs = terminalTabbedManager.getTerminalTabs()
|
||||
for (i in tabs.indices) {
|
||||
val tab = tabs[i]
|
||||
if (tab is SFTPTerminalTab) {
|
||||
terminalTabbedManager.setSelectedTerminalTab(tab)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 创建一个新的
|
||||
terminalTabbedManager.addTerminalTab(SFTPTerminalTab())
|
||||
}
|
||||
}))
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
|
||||
164
src/main/kotlin/app/termora/transport/BookmarkButton.kt
Normal file
@@ -0,0 +1,164 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import app.termora.assertEventDispatchThread
|
||||
import app.termora.db.Database
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.ui.FlatUIUtils
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
private val properties by lazy { Database.instance.properties }
|
||||
private val arrowWidth = 16
|
||||
private val arrowSize = 6
|
||||
|
||||
/**
|
||||
* 为 true 表示在书签内
|
||||
*/
|
||||
var isBookmark = false
|
||||
set(value) {
|
||||
field = value
|
||||
icon = if (value) {
|
||||
Icons.bookmarksOff
|
||||
} else {
|
||||
Icons.bookmarks
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
val oldWidth = preferredSize.width
|
||||
|
||||
preferredSize = Dimension(oldWidth + arrowWidth, preferredSize.height)
|
||||
horizontalAlignment = SwingConstants.LEFT
|
||||
|
||||
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||
if (e.x < oldWidth) {
|
||||
super@BookmarkButton.fireActionPerformed(
|
||||
ActionEvent(
|
||||
this@BookmarkButton,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
StringUtils.EMPTY
|
||||
)
|
||||
)
|
||||
} else {
|
||||
showBookmarks(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
isBookmark = false
|
||||
}
|
||||
|
||||
private fun showBookmarks(e: MouseEvent) {
|
||||
if (StringUtils.isBlank(name)) return
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val bookmarks = getBookmarks()
|
||||
popupMenu.add(I18n.getString("termora.transport.bookmarks")).addActionListener {
|
||||
val list = BookmarksDialog(SwingUtilities.getWindowAncestor(this), bookmarks).open()
|
||||
properties.putString(name, ohMyJson.encodeToString(list))
|
||||
}
|
||||
|
||||
if (bookmarks.isNotEmpty()) {
|
||||
popupMenu.addSeparator()
|
||||
for (bookmark in bookmarks) {
|
||||
popupMenu.add(bookmark).addActionListener {
|
||||
super@BookmarkButton.fireActionPerformed(
|
||||
ActionEvent(
|
||||
this@BookmarkButton,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
bookmark
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
popupMenu.show(e.component, -(popupMenu.preferredSize.width / 2 - width / 2), height + 2)
|
||||
}
|
||||
|
||||
fun addBookmark(text: String) {
|
||||
assertEventDispatchThread()
|
||||
if (StringUtils.isBlank(name)) return
|
||||
val bookmarks = getBookmarks().toMutableList()
|
||||
bookmarks.add(text)
|
||||
properties.putString(name, ohMyJson.encodeToString(bookmarks))
|
||||
}
|
||||
|
||||
fun deleteBookmark(text: String) {
|
||||
assertEventDispatchThread()
|
||||
if (StringUtils.isBlank(name)) return
|
||||
val bookmarks = getBookmarks().toMutableList()
|
||||
bookmarks.removeIf { text == it }
|
||||
properties.putString(name, ohMyJson.encodeToString(bookmarks))
|
||||
}
|
||||
|
||||
fun getBookmarks(): List<String> {
|
||||
if (StringUtils.isBlank(name)) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
||||
val text = properties.getString(name, "[]")
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
runCatching { ohMyJson.decodeFromString<List<String>>(text) }.onSuccess {
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2d = g as Graphics2D
|
||||
super.paintComponent(g2d)
|
||||
|
||||
val x = preferredSize.width - arrowWidth
|
||||
|
||||
g.color = DynamicColor.BorderColor
|
||||
g.drawLine(x + 1, 4, x + 1, preferredSize.height - 2)
|
||||
|
||||
g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126)
|
||||
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
FlatUIUtils.paintArrow(
|
||||
g2d, x, preferredSize.height / 2 - arrowSize, arrowWidth, arrowWidth, SwingConstants.SOUTH,
|
||||
false, arrowSize, 0f, 0f, 0f
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
override fun isSelected(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 忽略默认的触发事件
|
||||
*/
|
||||
override fun fireActionPerformed(event: ActionEvent) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
157
src/main/kotlin/app/termora/transport/BookmarksDialog.kt
Normal file
@@ -0,0 +1,157 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.I18n
|
||||
import app.termora.OptionPane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import javax.swing.*
|
||||
import javax.swing.border.EmptyBorder
|
||||
|
||||
class BookmarksDialog(
|
||||
owner: Window,
|
||||
bookmarks: List<String>
|
||||
) : DialogWrapper(owner) {
|
||||
|
||||
private val model = DefaultListModel<String>()
|
||||
private val list = JList(model)
|
||||
|
||||
private val upBtn = JButton(I18n.getString("termora.transport.bookmarks.up"))
|
||||
private val downBtn = JButton(I18n.getString("termora.transport.bookmarks.down"))
|
||||
private val deleteBtn = JButton(I18n.getString("termora.remove"))
|
||||
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
|
||||
isModal = true
|
||||
title = I18n.getString("termora.transport.bookmarks")
|
||||
|
||||
initView()
|
||||
initEvents()
|
||||
|
||||
model.addAll(bookmarks)
|
||||
|
||||
|
||||
init()
|
||||
setLocationRelativeTo(null)
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
upBtn.isEnabled = false
|
||||
downBtn.isEnabled = false
|
||||
deleteBtn.isEnabled = false
|
||||
|
||||
upBtn.isFocusable = false
|
||||
downBtn.isFocusable = false
|
||||
deleteBtn.isFocusable = false
|
||||
|
||||
list.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
|
||||
list.selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
upBtn.addActionListener {
|
||||
val rows = list.selectedIndices.sorted()
|
||||
list.clearSelection()
|
||||
|
||||
for (row in rows) {
|
||||
val a = model.getElementAt(row - 1)
|
||||
val b = model.getElementAt(row)
|
||||
model.setElementAt(b, row - 1)
|
||||
model.setElementAt(a, row)
|
||||
list.selectionModel.addSelectionInterval(row - 1, row - 1)
|
||||
}
|
||||
}
|
||||
|
||||
downBtn.addActionListener {
|
||||
val rows = list.selectedIndices.sortedDescending()
|
||||
list.clearSelection()
|
||||
|
||||
for (row in rows) {
|
||||
val a = model.getElementAt(row + 1)
|
||||
val b = model.getElementAt(row)
|
||||
model.setElementAt(b, row + 1)
|
||||
model.setElementAt(a, row)
|
||||
list.selectionModel.addSelectionInterval(row + 1, row + 1)
|
||||
}
|
||||
}
|
||||
|
||||
deleteBtn.addActionListener {
|
||||
if (list.selectionModel.selectedItemsCount > 0) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
for (e in list.selectionModel.selectedIndices.sortedDescending()) {
|
||||
model.removeElementAt(e)
|
||||
}
|
||||
|
||||
if (model.size > 0) {
|
||||
list.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
list.selectionModel.addListSelectionListener {
|
||||
upBtn.isEnabled = list.selectionModel.selectedItemsCount > 0
|
||||
downBtn.isEnabled = upBtn.isEnabled
|
||||
deleteBtn.isEnabled = upBtn.isEnabled
|
||||
|
||||
upBtn.isEnabled = list.minSelectionIndex != 0
|
||||
downBtn.isEnabled = list.maxSelectionIndex != model.size - 1
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.add(JScrollPane(list).apply {
|
||||
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
|
||||
}, BorderLayout.CENTER)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
panel.add(
|
||||
FormBuilder.create().layout(layout).padding(EmptyBorder(0, 12, 0, 0))
|
||||
.add(upBtn).xy(1, rows).apply { rows += step }
|
||||
.add(downBtn).xy(1, rows).apply { rows += step }
|
||||
.add(deleteBtn).xy(1, rows).apply { rows += step }
|
||||
.build(),
|
||||
BorderLayout.EAST)
|
||||
|
||||
panel.border = BorderFactory.createEmptyBorder(
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0,
|
||||
12, 12, 12
|
||||
)
|
||||
|
||||
return panel
|
||||
}
|
||||
|
||||
override fun createSouthPanel(): JComponent? {
|
||||
return null
|
||||
}
|
||||
|
||||
fun open(): List<String> {
|
||||
isModal = true
|
||||
isVisible = true
|
||||
return model.elements().toList()
|
||||
}
|
||||
|
||||
}
|
||||
787
src/main/kotlin/app/termora/transport/FileSystemPanel.kt
Normal file
@@ -0,0 +1,787 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||
import com.formdev.flatlaf.icons.FlatFileViewDirectoryIcon
|
||||
import com.formdev.flatlaf.icons.FlatFileViewFileIcon
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.sftp.client.SftpClient
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.apache.sshd.sftp.client.fs.SftpPath
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Desktop
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.dnd.DnDConstants
|
||||
import java.awt.dnd.DropTarget
|
||||
import java.awt.dnd.DropTargetDropEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.io.File
|
||||
import java.nio.file.*
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.isDirectory
|
||||
|
||||
|
||||
/**
|
||||
* 文件系统面板
|
||||
*/
|
||||
class FileSystemPanel(
|
||||
private val fileSystem: FileSystem,
|
||||
private val transportManager: TransportManager,
|
||||
private val host: Host
|
||||
) : JPanel(BorderLayout()), Disposable,
|
||||
FileSystemTransportListener.Provider {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
|
||||
}
|
||||
|
||||
private val tableModel = FileSystemTableModel(fileSystem)
|
||||
private val table = JTable(tableModel)
|
||||
private val parentBtn = JButton(Icons.up)
|
||||
private val workdirTextField = OutlineTextField()
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val layeredPane = FileSystemLayeredPane()
|
||||
private val loadingPanel = LoadingPanel()
|
||||
private val bookmarkBtn = BookmarkButton()
|
||||
private val homeBtn = JButton(Icons.homeFolder)
|
||||
|
||||
val workdir get() = tableModel.workdir
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
// 设置书签名称
|
||||
bookmarkBtn.name = "Host.${host.id}.Bookmarks"
|
||||
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString())
|
||||
|
||||
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
|
||||
table.fillsViewportHeight = true
|
||||
table.putClientProperty(
|
||||
FlatClientProperties.STYLE, mapOf(
|
||||
"showHorizontalLines" to true,
|
||||
"showVerticalLines" to true,
|
||||
)
|
||||
)
|
||||
|
||||
table.setDefaultRenderer(
|
||||
Any::class.java,
|
||||
DefaultTableCellRenderer().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
)
|
||||
|
||||
val modifyDateColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_LAST_MODIFIED_TIME)
|
||||
modifyDateColumn.preferredWidth = 130
|
||||
|
||||
val nameColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_NAME)
|
||||
nameColumn.preferredWidth = 250
|
||||
nameColumn.setCellRenderer(object : DefaultTableCellRenderer() {
|
||||
private val b = BorderFactory.createEmptyBorder(0, 4, 0, 0)
|
||||
private val d = FlatFileViewDirectoryIcon()
|
||||
private val f = FlatFileViewFileIcon()
|
||||
|
||||
override fun getTableCellRendererComponent(
|
||||
table: JTable?,
|
||||
value: Any,
|
||||
isSelected: Boolean,
|
||||
hasFocus: Boolean,
|
||||
row: Int,
|
||||
column: Int
|
||||
): Component {
|
||||
var text = value.toString()
|
||||
// name
|
||||
if (value is FileSystemTableModel.CacheablePath) {
|
||||
text = value.fileName
|
||||
icon = if (value.isDirectory) d else f
|
||||
iconTextGap = 4
|
||||
}
|
||||
|
||||
val c = super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column)
|
||||
border = b
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
parentBtn.toolTipText = I18n.getString("termora.transport.parent-folder")
|
||||
|
||||
|
||||
val toolbar = FlatToolBar()
|
||||
toolbar.add(homeBtn)
|
||||
toolbar.add(Box.createHorizontalStrut(2))
|
||||
toolbar.add(workdirTextField)
|
||||
toolbar.add(bookmarkBtn)
|
||||
toolbar.add(parentBtn)
|
||||
toolbar.add(JButton(Icons.refresh).apply {
|
||||
addActionListener { reload() }
|
||||
toolTipText = I18n.getString("termora.transport.table.contextmenu.refresh")
|
||||
})
|
||||
toolbar.border = BorderFactory.createEmptyBorder(4, 2, 4, 2)
|
||||
|
||||
val scrollPane = JScrollPane(table)
|
||||
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||
layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any)
|
||||
layeredPane.add(loadingPanel, JLayeredPane.MODAL_LAYER as Any)
|
||||
|
||||
add(toolbar, BorderLayout.NORTH)
|
||||
add(layeredPane, BorderLayout.CENTER)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
homeBtn.addActionListener {
|
||||
if (tableModel.isLocalFileSystem) {
|
||||
tableModel.workdir(SystemUtils.USER_HOME)
|
||||
} else if (fileSystem is SftpFileSystem) {
|
||||
tableModel.workdir(fileSystem.defaultDir)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
bookmarkBtn.addActionListener { e ->
|
||||
if (e.actionCommand.isNullOrBlank()) {
|
||||
if (bookmarkBtn.isBookmark) {
|
||||
bookmarkBtn.deleteBookmark(workdir.toString())
|
||||
} else {
|
||||
bookmarkBtn.addBookmark(workdir.toString())
|
||||
}
|
||||
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
|
||||
} else if (!loadingPanel.isLoading) {
|
||||
tableModel.workdir(e.actionCommand)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
// contextmenu
|
||||
table.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isRightMouseButton(e)) {
|
||||
val r = table.rowAtPoint(e.point)
|
||||
if (r >= 0 && r < table.rowCount) {
|
||||
if (!table.isRowSelected(r)) {
|
||||
table.setRowSelectionInterval(r, r)
|
||||
}
|
||||
} else {
|
||||
table.clearSelection()
|
||||
}
|
||||
|
||||
val rows = table.selectedRows
|
||||
|
||||
if (!table.hasFocus()) {
|
||||
table.requestFocusInWindow()
|
||||
}
|
||||
|
||||
showContextMenu(rows.filter { it != 0 }.toIntArray(), e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// double click
|
||||
table.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val row = table.selectedRow
|
||||
if (row < 0) return
|
||||
val path = tableModel.getCacheablePath(row)
|
||||
if (path.isDirectory) {
|
||||
openFolder()
|
||||
} else {
|
||||
transport(listOf(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 本地文件系统不支持本地拖拽进去
|
||||
if (!tableModel.isLocalFileSystem) {
|
||||
table.dropTarget = object : DropTarget() {
|
||||
override fun drop(dtde: DropTargetDropEvent) {
|
||||
val transportPanel = getTransportPanel() ?: return
|
||||
val localFileSystemPanel = transportPanel.leftFileSystemTabbed.getFileSystemPanel(0) ?: return
|
||||
|
||||
dtde.acceptDrop(DnDConstants.ACTION_COPY)
|
||||
val files = dtde.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
|
||||
if (files.isEmpty()) return
|
||||
|
||||
val paths = files.filterIsInstance<File>().map { FileSystemTableModel.CacheablePath(it.toPath()) }
|
||||
for (path in paths) {
|
||||
if (path.isDirectory) {
|
||||
Files.walk(path.path).use {
|
||||
for (e in it) {
|
||||
transportPanel.transport(
|
||||
sourceWorkdir = path.path.parent,
|
||||
targetWorkdir = workdir,
|
||||
isSourceDirectory = e.isDirectory(),
|
||||
sourcePath = e,
|
||||
sourceHolder = localFileSystemPanel,
|
||||
targetHolder = this@FileSystemPanel
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
transportPanel.transport(
|
||||
sourceWorkdir = localFileSystemPanel.workdir,
|
||||
targetWorkdir = workdir,
|
||||
isSourceDirectory = false,
|
||||
sourcePath = path.path,
|
||||
sourceHolder = localFileSystemPanel,
|
||||
targetHolder = this@FileSystemPanel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
this.defaultActions = DnDConstants.ACTION_COPY
|
||||
}
|
||||
}
|
||||
|
||||
// 工作目录变动
|
||||
tableModel.addPropertyChangeListener {
|
||||
if (it.propertyName == "workdir") {
|
||||
workdirTextField.text = tableModel.workdir.toAbsolutePath().toString()
|
||||
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdirTextField.text)
|
||||
}
|
||||
}
|
||||
|
||||
// 修改工作目录
|
||||
workdirTextField.addActionListener {
|
||||
val text = workdirTextField.text
|
||||
if (text.isBlank()) {
|
||||
workdirTextField.text = tableModel.workdir.toAbsolutePath().toString()
|
||||
reload()
|
||||
} else {
|
||||
val path = fileSystem.getPath(workdirTextField.text)
|
||||
if (Files.exists(path)) {
|
||||
tableModel.workdir(path)
|
||||
reload()
|
||||
} else {
|
||||
workdirTextField.outline = "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一级目录
|
||||
parentBtn.addActionListener {
|
||||
if (tableModel.rowCount > 0) {
|
||||
val path = tableModel.getCacheablePath(0)
|
||||
if (path.isDirectory && path.fileName == "..") {
|
||||
tableModel.workdir(path.path)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun reload() {
|
||||
if (loadingPanel.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching { suspendReload() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun suspendReload() {
|
||||
if (loadingPanel.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
// reload
|
||||
loadingPanel.start()
|
||||
workdirTextField.text = workdir.toString()
|
||||
}
|
||||
|
||||
try {
|
||||
tableModel.reload()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
table.scrollRectToVisible(table.getCellRect(0, 0, true))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listenerList.add(FileSystemTransportListener::class.java, listener)
|
||||
}
|
||||
|
||||
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listenerList.remove(FileSystemTransportListener::class.java, listener)
|
||||
}
|
||||
|
||||
private fun openFolder() {
|
||||
val row = table.selectedRow
|
||||
if (row < 0) return
|
||||
val path = tableModel.getCacheablePath(row)
|
||||
if (path.isDirectory) {
|
||||
tableModel.workdir(path.path)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canTransfer(): Boolean {
|
||||
return getTransportPanel()?.getTargetFileSystemPanel(this) != null
|
||||
}
|
||||
|
||||
|
||||
private fun getTransportPanel(): TransportPanel? {
|
||||
var p = this as Component?
|
||||
while (p != null) {
|
||||
if (p is TransportPanel) {
|
||||
return p
|
||||
}
|
||||
p = p.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun showContextMenu(rows: IntArray, event: MouseEvent) {
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
|
||||
|
||||
// 创建文件夹
|
||||
newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder")).addActionListener {
|
||||
newFolderOrFile(file = false)
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file")).addActionListener {
|
||||
newFolderOrFile(file = true)
|
||||
}
|
||||
|
||||
|
||||
// 传输
|
||||
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
|
||||
transfer.addActionListener {
|
||||
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
|
||||
if (paths.isNotEmpty()) {
|
||||
transport(paths)
|
||||
}
|
||||
}
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 复制路径
|
||||
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
|
||||
copyPath.addActionListener {
|
||||
val row = table.selectedRow
|
||||
if (row > 0) {
|
||||
toolkit.systemClipboard.setContents(
|
||||
StringSelection(
|
||||
tableModel.getPath(row).toAbsolutePath().toString()
|
||||
), null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是本地,那么支持打开本地路径
|
||||
if (tableModel.isLocalFileSystem) {
|
||||
if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
|
||||
popupMenu.add(
|
||||
I18n.getString(
|
||||
"termora.transport.table.contextmenu.open-in-folder",
|
||||
if (SystemInfo.isMacOS) I18n.getString("termora.finder")
|
||||
else if (SystemInfo.isWindows) I18n.getString("termora.explorer")
|
||||
else I18n.getString("termora.folder")
|
||||
)
|
||||
).addActionListener {
|
||||
val row = table.selectedRow
|
||||
if (row > 0) {
|
||||
Desktop.getDesktop().browseFileDirectory(tableModel.getPath(row).toFile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 重命名
|
||||
val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename"))
|
||||
rename.addActionListener { renamePath(tableModel.getPath(rows.last())) }
|
||||
|
||||
// 删除
|
||||
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")).apply {
|
||||
addActionListener { deletePaths(rows) }
|
||||
}
|
||||
|
||||
// rm -rf
|
||||
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.errorIntroduction)).apply {
|
||||
addActionListener {
|
||||
deletePaths(rows, true)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有 SFTP 可以
|
||||
if (fileSystem !is SftpFileSystem) {
|
||||
rmrf.isVisible = false
|
||||
}
|
||||
|
||||
// 修改权限
|
||||
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
|
||||
permission.isEnabled = false
|
||||
|
||||
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
|
||||
if (!tableModel.isLocalFileSystem && rows.isNotEmpty()) {
|
||||
permission.isEnabled = true
|
||||
permission.addActionListener { changePermissions(tableModel.getCacheablePath(rows.last())) }
|
||||
}
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 刷新
|
||||
popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh"))
|
||||
.apply { addActionListener { reload() } }
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 新建
|
||||
popupMenu.add(newMenu)
|
||||
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
transfer.isEnabled = false
|
||||
rename.isEnabled = false
|
||||
delete.isEnabled = false
|
||||
rmrf.isEnabled = false
|
||||
copyPath.isEnabled = false
|
||||
permission.isEnabled = false
|
||||
} else {
|
||||
transfer.isEnabled = canTransfer()
|
||||
}
|
||||
|
||||
|
||||
popupMenu.show(table, event.x, event.y)
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun renamePath(path: Path) {
|
||||
val fileName = path.fileName.toString()
|
||||
val text = InputDialog(
|
||||
owner = owner,
|
||||
title = fileName,
|
||||
text = fileName,
|
||||
).getText() ?: return
|
||||
|
||||
if (fileName == text) return
|
||||
|
||||
loadingPanel.stop()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching {
|
||||
Files.move(path, path.parent.resolve(text), StandardCopyOption.ATOMIC_MOVE)
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, it.message ?: ExceptionUtils.getRootCauseMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun newFolderOrFile(file: Boolean = false) {
|
||||
val title = I18n.getString("termora.transport.table.contextmenu.new.${if (file) "file" else "folder"}")
|
||||
val text = InputDialog(
|
||||
owner = owner,
|
||||
title = title,
|
||||
).getText() ?: return
|
||||
|
||||
if (text.isEmpty()) return
|
||||
|
||||
loadingPanel.stop()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching {
|
||||
val path = workdir.resolve(text)
|
||||
if (path.exists()) {
|
||||
throw IllegalStateException(I18n.getString("termora.transport.file-already-exists", text))
|
||||
}
|
||||
if (file)
|
||||
Files.createFile(path)
|
||||
else
|
||||
Files.createDirectories(path)
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, it.message ?: ExceptionUtils.getRootCauseMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun deletePaths(rows: IntArray, rm: Boolean = false) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
|
||||
messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE
|
||||
) != JOptionPane.YES_OPTION
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingPanel.start()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
for (row in rows.sortedDescending()) {
|
||||
try {
|
||||
deleteRecursively(tableModel.getPath(row), rm)
|
||||
withContext(Dispatchers.Swing) {
|
||||
tableModel.removeRow(row)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(it.message, it)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteRecursively(path: Path, rm: Boolean) {
|
||||
if (path.fileSystem == FileSystems.getDefault()) {
|
||||
FileUtils.deleteDirectory(path.toFile())
|
||||
} else if (path.fileSystem is SftpFileSystem) {
|
||||
val fs = path.fileSystem as SftpFileSystem
|
||||
if (rm) {
|
||||
fs.session.executeRemoteCommand("rm -rf '$path'")
|
||||
} else {
|
||||
fs.client.use {
|
||||
deleteRecursivelySFTP(path as SftpPath, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化删除效率,采用一个连接
|
||||
*/
|
||||
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
|
||||
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
|
||||
if (isDirectory) {
|
||||
for (e in sftpClient.readDir(path.toString())) {
|
||||
if (e.filename == ".." || e.filename == ".") {
|
||||
continue
|
||||
}
|
||||
if (e.attributes.isDirectory) {
|
||||
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
|
||||
} else {
|
||||
sftpClient.remove(path.resolve(e.filename).toString())
|
||||
}
|
||||
}
|
||||
sftpClient.rmdir(path.toString())
|
||||
} else {
|
||||
sftpClient.remove(path.toString())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun changePermissions(cacheablePath: FileSystemTableModel.CacheablePath) {
|
||||
val dialog = PosixFilePermissionDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
cacheablePath.posixFilePermissions
|
||||
)
|
||||
val permissions = dialog.open() ?: return
|
||||
|
||||
loadingPanel.start()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching {
|
||||
Files.setPosixFilePermissions(cacheablePath.path, permissions)
|
||||
}
|
||||
|
||||
result.onFailure {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(it.message, it)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
SwingUtilities.getWindowAncestor(this@FileSystemPanel), ExceptionUtils.getRootCauseMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
reload()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun transport(paths: List<FileSystemTableModel.CacheablePath>) {
|
||||
assertEventDispatchThread()
|
||||
if (!canTransfer()) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingPanel.start()
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching { doTransport(paths) }
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
|
||||
if (paths.isEmpty()) return
|
||||
|
||||
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java)
|
||||
if (listeners.isEmpty()) return
|
||||
|
||||
|
||||
// 收集数据
|
||||
for (e in paths) {
|
||||
|
||||
if (!e.isDirectory) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
listeners.forEach { it.transport(this@FileSystemPanel, workdir, false, e.path) }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
Files.walk(e.path).use { walkPaths ->
|
||||
for (path in walkPaths) {
|
||||
if (path is SftpPath) {
|
||||
val isDirectory = if (path.attributes != null)
|
||||
path.attributes.isDirectory else path.isDirectory()
|
||||
withContext(Dispatchers.Swing) {
|
||||
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
} else {
|
||||
val isDirectory = path.isDirectory()
|
||||
withContext(Dispatchers.Swing) {
|
||||
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoadingPanel : JPanel() {
|
||||
private val busyLabel = JXBusyLabel()
|
||||
|
||||
val isLoading get() = busyLabel.isBusy
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
border = BorderFactory.createEmptyBorder(50, 0, 0, 0)
|
||||
|
||||
add(busyLabel, BorderLayout.CENTER)
|
||||
addMouseListener(object : MouseAdapter() {})
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
fun start() {
|
||||
busyLabel.isBusy = true
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
busyLabel.isBusy = false
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
private class FileSystemLayeredPane : JLayeredPane() {
|
||||
override fun doLayout() {
|
||||
synchronized(treeLock) {
|
||||
val w = width
|
||||
val h = height
|
||||
for (c in components) {
|
||||
if (c is JScrollPane) {
|
||||
c.setBounds(0, 0, w, h)
|
||||
} else if (c is LoadingPanel) {
|
||||
c.setBounds(0, 0, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
205
src/main/kotlin/app/termora/transport/FileSystemTabbed.kt
Normal file
@@ -0,0 +1,205 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Point
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
|
||||
class FileSystemTabbed(
|
||||
private val transportManager: TransportManager,
|
||||
private val isLeft: Boolean = false
|
||||
) : FlatTabbedPane(), FileSystemTransportListener.Provider, Disposable {
|
||||
private val addBtn = JButton(Icons.add)
|
||||
private val listeners = mutableListOf<FileSystemTransportListener>()
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
tabLayoutPolicy = SCROLL_TAB_LAYOUT
|
||||
isTabsClosable = true
|
||||
tabType = TabType.underlined
|
||||
styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
|
||||
)
|
||||
|
||||
|
||||
val toolbar = JToolBar()
|
||||
toolbar.add(addBtn)
|
||||
trailingComponent = toolbar
|
||||
|
||||
if (isLeft) {
|
||||
addFileSystemTransportProvider(
|
||||
I18n.getString("termora.transport.local"),
|
||||
FileSystemPanel(
|
||||
FileSystems.getDefault(),
|
||||
transportManager,
|
||||
host = Host(
|
||||
id = "local",
|
||||
name = I18n.getString("termora.transport.local"),
|
||||
protocol = Protocol.Local,
|
||||
)
|
||||
).apply { reload() }
|
||||
)
|
||||
setTabClosable(0, false)
|
||||
} else {
|
||||
addFileSystemTransportProvider(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SftpFileSystemPanel(transportManager)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener {
|
||||
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this))
|
||||
|
||||
dialog.location = Point(
|
||||
addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2,
|
||||
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
|
||||
)
|
||||
dialog.isVisible = true
|
||||
|
||||
for (host in dialog.hosts) {
|
||||
val panel = SftpFileSystemPanel(transportManager, host)
|
||||
addFileSystemTransportProvider(host.name, panel)
|
||||
panel.connect()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
setTabCloseCallback { _, index ->
|
||||
removeTabAt(index)
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeTabAt(index: Int) {
|
||||
|
||||
val fileSystemPanel = getFileSystemPanel(index)
|
||||
|
||||
// 取消进行中的任务
|
||||
if (fileSystemPanel != null) {
|
||||
val transports = mutableListOf<Transport>()
|
||||
for (transport in transportManager.getTransports()) {
|
||||
if (transport.targetHolder == fileSystemPanel || transport.sourceHolder == fileSystemPanel) {
|
||||
transports.add(transport)
|
||||
}
|
||||
}
|
||||
|
||||
if (transports.isNotEmpty()) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.transport.sftp.close-tab"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
) != JOptionPane.OK_OPTION
|
||||
) {
|
||||
return
|
||||
}
|
||||
transports.sortedBy { it.state == TransportState.Waiting }
|
||||
.forEach { transportManager.removeTransport(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val c = getComponentAt(index)
|
||||
if (c is Disposable) {
|
||||
Disposer.dispose(c)
|
||||
}
|
||||
|
||||
super.removeTabAt(index)
|
||||
|
||||
if (tabCount == 0) {
|
||||
if (!isLeft) {
|
||||
addFileSystemTransportProvider(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SftpFileSystemPanel(transportManager)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fun addFileSystemTransportProvider(title: String, provider: FileSystemTransportListener.Provider) {
|
||||
if (provider !is JComponent) {
|
||||
throw IllegalArgumentException("Provider is not an JComponent")
|
||||
}
|
||||
|
||||
provider.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
})
|
||||
|
||||
// 修改 Tab名称
|
||||
provider.addPropertyChangeListener("TabName") { e ->
|
||||
SwingUtilities.invokeLater {
|
||||
val name = StringUtils.defaultIfEmpty(
|
||||
e.newValue.toString(),
|
||||
I18n.getString("termora.transport.sftp.select-host")
|
||||
)
|
||||
for (i in 0 until tabCount) {
|
||||
if (getComponentAt(i) == provider) {
|
||||
setTitleAt(i, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addTab(title, provider)
|
||||
|
||||
if (tabCount > 0)
|
||||
selectedIndex = tabCount - 1
|
||||
}
|
||||
|
||||
fun getSelectedFileSystemPanel(): FileSystemPanel? {
|
||||
return getFileSystemPanel(selectedIndex)
|
||||
}
|
||||
|
||||
fun getFileSystemPanel(index: Int): FileSystemPanel? {
|
||||
if (index < 0) return null
|
||||
val c = getComponentAt(index)
|
||||
if (c is SftpFileSystemPanel) {
|
||||
val p = c.fileSystemPanel
|
||||
if (p != null) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
if (c is FileSystemPanel) {
|
||||
return c
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
while (tabCount > 0) {
|
||||
val c = getComponentAt(0)
|
||||
if (c is Disposable) {
|
||||
Disposer.dispose(c)
|
||||
}
|
||||
super.removeTabAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
232
src/main/kotlin/app/termora/transport/FileSystemTableModel.kt
Normal file
@@ -0,0 +1,232 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.formatBytes
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.apache.sshd.sftp.client.fs.SftpPath
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.beans.PropertyChangeEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
import java.util.*
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.table.DefaultTableModel
|
||||
import kotlin.io.path.*
|
||||
|
||||
|
||||
class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableModel() {
|
||||
|
||||
|
||||
companion object {
|
||||
const val COLUMN_NAME = 0
|
||||
const val COLUMN_TYPE = 1
|
||||
const val COLUMN_FILE_SIZE = 2
|
||||
const val COLUMN_LAST_MODIFIED_TIME = 3
|
||||
const val COLUMN_ATTRS = 4
|
||||
const val COLUMN_OWNER = 5
|
||||
}
|
||||
|
||||
private val root = fileSystem.rootDirectories.first()
|
||||
|
||||
var workdir: Path = if (fileSystem is SftpFileSystem) fileSystem.defaultDir
|
||||
else fileSystem.getPath(SystemUtils.USER_HOME)
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
private var files: MutableList<CacheablePath>? = null
|
||||
private val propertyChangeListeners = mutableListOf<PropertyChangeListener>()
|
||||
|
||||
val isLocalFileSystem by lazy { FileSystems.getDefault() == fileSystem }
|
||||
|
||||
override fun getRowCount(): Int {
|
||||
return files?.size ?: 0
|
||||
}
|
||||
|
||||
override fun getValueAt(row: Int, column: Int): Any {
|
||||
val path = files?.get(row) ?: return StringUtils.EMPTY
|
||||
|
||||
if (path.fileName == ".." && column != 0) {
|
||||
return StringUtils.EMPTY
|
||||
}
|
||||
|
||||
return try {
|
||||
when (column) {
|
||||
COLUMN_NAME -> path
|
||||
COLUMN_FILE_SIZE -> if (path.isDirectory) StringUtils.EMPTY else formatBytes(path.fileSize)
|
||||
COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder") else path.extension
|
||||
COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(path.lastModifiedTime), "yyyy/MM/dd HH:mm")
|
||||
|
||||
// 如果是本地的并且还是Windows系统
|
||||
COLUMN_ATTRS -> if (isLocalFileSystem && SystemUtils.IS_OS_WINDOWS) StringUtils.EMPTY else PosixFilePermissions.toString(
|
||||
path.posixFilePermissions
|
||||
)
|
||||
|
||||
COLUMN_OWNER -> path.owner
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return 6
|
||||
}
|
||||
|
||||
override fun getColumnName(column: Int): String {
|
||||
return when (column) {
|
||||
COLUMN_NAME -> I18n.getString("termora.transport.table.filename")
|
||||
COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size")
|
||||
COLUMN_TYPE -> I18n.getString("termora.transport.table.type")
|
||||
COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time")
|
||||
COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions")
|
||||
COLUMN_OWNER -> I18n.getString("termora.transport.table.owner")
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
fun getPath(index: Int): Path {
|
||||
return getCacheablePath(index).path
|
||||
}
|
||||
|
||||
fun getCacheablePath(index: Int): CacheablePath {
|
||||
return files?.get(index) ?: throw IndexOutOfBoundsException()
|
||||
}
|
||||
|
||||
override fun isCellEditable(row: Int, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun removeRow(row: Int) {
|
||||
files?.removeAt(row) ?: return
|
||||
fireTableRowsDeleted(row, row)
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
val files = mutableListOf<CacheablePath>()
|
||||
if (root != workdir) {
|
||||
files.add(CacheablePath(workdir.resolve("..")))
|
||||
}
|
||||
|
||||
Files.list(workdir).use {
|
||||
for (path in it) {
|
||||
if (path is SftpPath) {
|
||||
files.add(SftpCacheablePath(path))
|
||||
} else {
|
||||
files.add(CacheablePath(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sortWith(compareBy({ !it.isDirectory }, { it.fileName }))
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
this.files = files
|
||||
fireTableDataChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun workdir(absolutePath: String) {
|
||||
workdir(fileSystem.getPath(absolutePath))
|
||||
}
|
||||
|
||||
fun workdir(path: Path) {
|
||||
this.workdir = path.toAbsolutePath().normalize()
|
||||
propertyChangeListeners.forEach {
|
||||
it.propertyChange(
|
||||
PropertyChangeEvent(
|
||||
this,
|
||||
"workdir",
|
||||
this.workdir,
|
||||
this.workdir
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addPropertyChangeListener(propertyChangeListener: PropertyChangeListener) {
|
||||
propertyChangeListeners.add(propertyChangeListener)
|
||||
}
|
||||
|
||||
open class CacheablePath(val path: Path) {
|
||||
val fileName by lazy { path.fileName.toString() }
|
||||
val extension by lazy { path.extension }
|
||||
|
||||
open val isDirectory by lazy { path.isDirectory() }
|
||||
open val fileSize by lazy { path.fileSize() }
|
||||
open val lastModifiedTime by lazy { Files.getLastModifiedTime(path).toMillis() }
|
||||
open val owner by lazy { path.getOwner().toString() }
|
||||
open val posixFilePermissions by lazy {
|
||||
kotlin.runCatching { path.getPosixFilePermissions() }.getOrElse { emptySet() }
|
||||
}
|
||||
}
|
||||
|
||||
class SftpCacheablePath(sftpPath: SftpPath) : CacheablePath(sftpPath) {
|
||||
private val attributes = sftpPath.attributes
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SftpCacheablePath::class.java)
|
||||
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
|
||||
val result = mutableSetOf<PosixFilePermission>()
|
||||
|
||||
// 将十进制权限转换为八进制字符串
|
||||
val octalPermissions = sftpPermissions.toString(8)
|
||||
|
||||
// 仅取后三位权限部分
|
||||
if (octalPermissions.length < 3) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Invalid permission value: {}", sftpPermissions)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
val permissionBits = octalPermissions.takeLast(3)
|
||||
|
||||
// 解析每一部分的权限
|
||||
val owner = permissionBits[0].digitToInt()
|
||||
val group = permissionBits[1].digitToInt()
|
||||
val others = permissionBits[2].digitToInt()
|
||||
|
||||
// 处理所有者权限
|
||||
if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ)
|
||||
if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE)
|
||||
if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE)
|
||||
|
||||
// 处理组权限
|
||||
if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ)
|
||||
if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE)
|
||||
if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE)
|
||||
|
||||
// 处理其他用户权限
|
||||
if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ)
|
||||
if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE)
|
||||
if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
override val isDirectory: Boolean
|
||||
get() = attributes.isDirectory
|
||||
|
||||
override val fileSize: Long
|
||||
get() = attributes.size
|
||||
|
||||
override val lastModifiedTime: Long
|
||||
by lazy { attributes.modifyTime.toMillis() }
|
||||
|
||||
override val owner: String
|
||||
get() = attributes.owner
|
||||
|
||||
override val posixFilePermissions: Set<PosixFilePermission>
|
||||
by lazy { fromSftpPermissions(attributes.permissions) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package app.termora.transport
|
||||
|
||||
import java.nio.file.Path
|
||||
import java.util.*
|
||||
|
||||
interface FileSystemTransportListener : EventListener {
|
||||
/**
|
||||
* @param workdir 当前工作目录
|
||||
* @param isDirectory 要传输的是否是文件夹
|
||||
* @param path 要传输的文件/文件夹
|
||||
*/
|
||||
fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path)
|
||||
|
||||
|
||||
interface Provider {
|
||||
fun addFileSystemTransportListener(listener: FileSystemTransportListener)
|
||||
fun removeFileSystemTransportListener(listener: FileSystemTransportListener)
|
||||
}
|
||||
}
|
||||
162
src/main/kotlin/app/termora/transport/FileTransportPanel.kt
Normal file
@@ -0,0 +1,162 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.I18n
|
||||
import app.termora.OptionPane
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Insets
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
|
||||
class FileTransportPanel(
|
||||
private val transportManager: TransportManager
|
||||
) : JPanel(BorderLayout()), Disposable {
|
||||
|
||||
private val tableModel = FileTransportTableModel(transportManager)
|
||||
private val table = JTable(tableModel)
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
table.fillsViewportHeight = true
|
||||
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
|
||||
table.putClientProperty(
|
||||
FlatClientProperties.STYLE, mapOf(
|
||||
"showHorizontalLines" to true,
|
||||
"showVerticalLines" to true,
|
||||
"cellMargins" to Insets(2, 2, 2, 2)
|
||||
)
|
||||
)
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_NAME).preferredWidth = 200
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
|
||||
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).preferredWidth = 100
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).preferredWidth = 150
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).preferredWidth = 140
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).preferredWidth = 80
|
||||
|
||||
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer =
|
||||
centerTableCellRenderer
|
||||
|
||||
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).cellRenderer =
|
||||
object : DefaultTableCellRenderer() {
|
||||
init {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
|
||||
private var lastRow = -1
|
||||
|
||||
override fun getTableCellRendererComponent(
|
||||
table: JTable?,
|
||||
value: Any?,
|
||||
isSelected: Boolean,
|
||||
hasFocus: Boolean,
|
||||
row: Int,
|
||||
column: Int
|
||||
): Component {
|
||||
lastRow = row
|
||||
return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
if (lastRow != -1) {
|
||||
val row = tableModel.getTransport(lastRow)
|
||||
if (row.state == TransportState.Transporting) {
|
||||
g.color = UIManager.getColor("textHighlight")
|
||||
g.fillRect(0, 0, (width * row.progress).toInt(), height)
|
||||
}
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
add(JScrollPane(table).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
// contextmenu
|
||||
table.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isRightMouseButton(e)) {
|
||||
val r = table.rowAtPoint(e.point)
|
||||
if (r >= 0 && r < table.rowCount) {
|
||||
if (!table.isRowSelected(r)) {
|
||||
table.setRowSelectionInterval(r, r)
|
||||
}
|
||||
} else {
|
||||
table.clearSelection()
|
||||
}
|
||||
|
||||
val rows = table.selectedRows
|
||||
|
||||
if (!table.hasFocus()) {
|
||||
table.requestFocusInWindow()
|
||||
}
|
||||
|
||||
|
||||
showContextMenu(kotlin.runCatching {
|
||||
rows.map { tableModel.getTransport(it) }
|
||||
}.getOrElse { emptyList() }, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private fun showContextMenu(transports: List<Transport>, event: MouseEvent) {
|
||||
val popupMenu = FlatPopupMenu()
|
||||
|
||||
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete")).apply {
|
||||
addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
for (transport in transports) {
|
||||
transportManager.removeTransport(transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
|
||||
deleteAll.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
transportManager.removeAllTransports()
|
||||
}
|
||||
}
|
||||
|
||||
if (transports.isEmpty()) {
|
||||
delete.isEnabled = false
|
||||
deleteAll.isEnabled = transportManager.getTransports().isNotEmpty()
|
||||
}
|
||||
|
||||
popupMenu.show(table, event.x, event.y)
|
||||
}
|
||||
|
||||
}
|
||||
123
src/main/kotlin/app/termora/transport/FileTransportTableModel.kt
Normal file
@@ -0,0 +1,123 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.formatBytes
|
||||
import app.termora.formatSeconds
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.table.DefaultTableModel
|
||||
|
||||
|
||||
class FileTransportTableModel(transportManager: TransportManager) : DefaultTableModel() {
|
||||
private var isInitialized = false
|
||||
|
||||
private inline fun invokeLater(crossinline block: () -> Unit) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
block.invoke()
|
||||
} else {
|
||||
SwingUtilities.invokeLater { block.invoke() }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
transportManager.addTransportListener(object : TransportListener {
|
||||
override fun onTransportAdded(transport: Transport) {
|
||||
invokeLater { addRow(arrayOf(transport)) }
|
||||
}
|
||||
|
||||
override fun onTransportRemoved(transport: Transport) {
|
||||
invokeLater {
|
||||
val index = getDataVector().indexOfFirst { it.firstOrNull() == transport }
|
||||
if (index >= 0) {
|
||||
removeRow(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
invokeLater {
|
||||
for ((index, vector) in getDataVector().withIndex()) {
|
||||
if (vector.firstOrNull() == transport) {
|
||||
fireTableRowsUpdated(index, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val COLUMN_NAME = 0
|
||||
const val COLUMN_STATUS = 1
|
||||
const val COLUMN_PROGRESS = 2
|
||||
const val COLUMN_SIZE = 3
|
||||
const val COLUMN_SOURCE_PATH = 4
|
||||
const val COLUMN_TARGET_PATH = 5
|
||||
const val COLUMN_SPEED = 6
|
||||
const val COLUMN_ESTIMATED_TIME = 7
|
||||
}
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return 8
|
||||
}
|
||||
|
||||
fun getTransport(row: Int): Transport {
|
||||
return super.getValueAt(row, COLUMN_NAME) as Transport
|
||||
}
|
||||
|
||||
override fun getValueAt(row: Int, column: Int): Any {
|
||||
val transport = getTransport(row)
|
||||
val isTransporting = transport.state == TransportState.Transporting
|
||||
val speed = if (isTransporting) transport.speed else 0
|
||||
val estimatedTime = if (isTransporting && speed > 0)
|
||||
(transport.size - transport.transferredSize) / speed else 0
|
||||
|
||||
return when (column) {
|
||||
COLUMN_NAME -> " ${transport.name}"
|
||||
COLUMN_STATUS -> formatStatus(transport.state)
|
||||
COLUMN_PROGRESS -> String.format("%.0f%%", transport.progress * 100.0)
|
||||
|
||||
// 大小
|
||||
COLUMN_SIZE -> if (transport.size < 0) "-"
|
||||
else if (isTransporting) "${formatBytes(transport.transferredSize)}/${formatBytes(transport.size)}"
|
||||
else formatBytes(transport.size)
|
||||
|
||||
COLUMN_SOURCE_PATH -> " ${transport.getSourcePath}"
|
||||
COLUMN_TARGET_PATH -> " ${transport.getTargetPath}"
|
||||
COLUMN_SPEED -> if (isTransporting) formatBytes(speed) else "-"
|
||||
COLUMN_ESTIMATED_TIME -> if (isTransporting && speed > 0) formatSeconds(estimatedTime) else "-"
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatStatus(state: TransportState): String {
|
||||
return when (state) {
|
||||
TransportState.Transporting -> I18n.getString("termora.transport.sftp.status.transporting")
|
||||
TransportState.Waiting -> I18n.getString("termora.transport.sftp.status.waiting")
|
||||
TransportState.Done -> I18n.getString("termora.transport.sftp.status.done")
|
||||
TransportState.Failed -> I18n.getString("termora.transport.sftp.status.failed")
|
||||
TransportState.Cancelled -> I18n.getString("termora.transport.sftp.status.cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getColumnName(column: Int): String {
|
||||
return when (column) {
|
||||
COLUMN_NAME -> I18n.getString("termora.transport.jobs.table.name")
|
||||
COLUMN_STATUS -> I18n.getString("termora.transport.jobs.table.status")
|
||||
COLUMN_PROGRESS -> I18n.getString("termora.transport.jobs.table.progress")
|
||||
COLUMN_SIZE -> I18n.getString("termora.transport.jobs.table.size")
|
||||
COLUMN_SOURCE_PATH -> I18n.getString("termora.transport.jobs.table.source-path")
|
||||
COLUMN_TARGET_PATH -> I18n.getString("termora.transport.jobs.table.target-path")
|
||||
COLUMN_SPEED -> I18n.getString("termora.transport.jobs.table.speed")
|
||||
COLUMN_ESTIMATED_TIME -> I18n.getString("termora.transport.jobs.table.estimated-time")
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
override fun isCellEditable(row: Int, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.I18n
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
class PosixFilePermissionDialog(
|
||||
owner: Window,
|
||||
private val permissions: Set<PosixFilePermission>
|
||||
) : DialogWrapper(owner) {
|
||||
|
||||
|
||||
private val ownerRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
||||
private val ownerWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
|
||||
private val ownerExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||
private val groupRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
||||
private val groupWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
|
||||
private val groupExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||
private val otherRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
||||
private val otherWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
|
||||
private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||
|
||||
private var isCancelled = false
|
||||
|
||||
init {
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.transport.permissions")
|
||||
initView()
|
||||
init()
|
||||
pack()
|
||||
size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 300), size.height)
|
||||
setLocationRelativeTo(null)
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
ownerRead.isSelected = permissions.contains(PosixFilePermission.OWNER_READ)
|
||||
ownerWrite.isSelected = permissions.contains(PosixFilePermission.OWNER_WRITE)
|
||||
ownerExecute.isSelected = permissions.contains(PosixFilePermission.OWNER_EXECUTE)
|
||||
groupRead.isSelected = permissions.contains(PosixFilePermission.GROUP_READ)
|
||||
groupWrite.isSelected = permissions.contains(PosixFilePermission.GROUP_WRITE)
|
||||
groupExecute.isSelected = permissions.contains(PosixFilePermission.GROUP_EXECUTE)
|
||||
otherRead.isSelected = permissions.contains(PosixFilePermission.OTHERS_READ)
|
||||
otherWrite.isSelected = permissions.contains(PosixFilePermission.OTHERS_WRITE)
|
||||
otherExecute.isSelected = permissions.contains(PosixFilePermission.OTHERS_EXECUTE)
|
||||
|
||||
ownerRead.isFocusable = false
|
||||
ownerWrite.isFocusable = false
|
||||
ownerExecute.isFocusable = false
|
||||
groupRead.isFocusable = false
|
||||
groupWrite.isFocusable = false
|
||||
groupExecute.isFocusable = false
|
||||
otherRead.isFocusable = false
|
||||
otherWrite.isFocusable = false
|
||||
otherExecute.isFocusable = false
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, $formMargin, default:grow, $formMargin, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
|
||||
.layout(layout).debug(true)
|
||||
|
||||
builder.add("${I18n.getString("termora.transport.permissions.file-folder-permissions")}:").xyw(1, 1, 5)
|
||||
|
||||
val ownerBox = Box.createVerticalBox()
|
||||
ownerBox.add(ownerRead)
|
||||
ownerBox.add(ownerWrite)
|
||||
ownerBox.add(ownerExecute)
|
||||
ownerBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.owner"))
|
||||
builder.add(ownerBox).xy(1, 3)
|
||||
|
||||
val groupBox = Box.createVerticalBox()
|
||||
groupBox.add(groupRead)
|
||||
groupBox.add(groupWrite)
|
||||
groupBox.add(groupExecute)
|
||||
groupBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.group"))
|
||||
builder.add(groupBox).xy(3, 3)
|
||||
|
||||
val otherBox = Box.createVerticalBox()
|
||||
otherBox.add(otherRead)
|
||||
otherBox.add(otherWrite)
|
||||
otherBox.add(otherExecute)
|
||||
otherBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.others"))
|
||||
builder.add(otherBox).xy(5, 3)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
this.isCancelled = true
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 返回空表示取消了
|
||||
*/
|
||||
fun open(): Set<PosixFilePermission>? {
|
||||
isModal = true
|
||||
isVisible = true
|
||||
|
||||
if (isCancelled) {
|
||||
return null
|
||||
}
|
||||
|
||||
val permissions = mutableSetOf<PosixFilePermission>()
|
||||
if (ownerRead.isSelected) {
|
||||
permissions.add(PosixFilePermission.OWNER_READ)
|
||||
}
|
||||
if (ownerWrite.isSelected) {
|
||||
permissions.add(PosixFilePermission.OWNER_WRITE)
|
||||
}
|
||||
if (ownerExecute.isSelected) {
|
||||
permissions.add(PosixFilePermission.OWNER_EXECUTE)
|
||||
}
|
||||
if (groupRead.isSelected) {
|
||||
permissions.add(PosixFilePermission.GROUP_READ)
|
||||
}
|
||||
if (groupWrite.isSelected) {
|
||||
permissions.add(PosixFilePermission.GROUP_WRITE)
|
||||
}
|
||||
if (groupExecute.isSelected) {
|
||||
permissions.add(PosixFilePermission.GROUP_EXECUTE)
|
||||
}
|
||||
if (otherRead.isSelected) {
|
||||
permissions.add(PosixFilePermission.OTHERS_READ)
|
||||
}
|
||||
if (otherWrite.isSelected) {
|
||||
permissions.add(PosixFilePermission.OTHERS_WRITE)
|
||||
}
|
||||
if (otherExecute.isSelected) {
|
||||
permissions.add(PosixFilePermission.OTHERS_EXECUTE)
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
}
|
||||
316
src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt
Normal file
@@ -0,0 +1,316 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.sftp.client.SftpClientFactory
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import org.jdesktop.swingx.JXHyperlink
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.CardLayout
|
||||
import java.awt.event.ActionEvent
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.*
|
||||
|
||||
class SftpFileSystemPanel(
|
||||
private val transportManager: TransportManager,
|
||||
private var host: Host? = null
|
||||
) : JPanel(BorderLayout()), Disposable,
|
||||
FileSystemTransportListener.Provider {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
|
||||
|
||||
private enum class State {
|
||||
Initialized,
|
||||
Connecting,
|
||||
Connected,
|
||||
ConnectFailed,
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var state = State.Initialized
|
||||
private val cardLayout = CardLayout()
|
||||
private val cardPanel = JPanel(cardLayout)
|
||||
|
||||
private val connectingPanel = ConnectingPanel()
|
||||
private val selectHostPanel = SelectHostPanel()
|
||||
private val connectFailedPanel = ConnectFailedPanel()
|
||||
private val listeners = mutableListOf<FileSystemTransportListener>()
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
|
||||
private var client: SshClient? = null
|
||||
private var session: ClientSession? = null
|
||||
private var fileSystem: SftpFileSystem? = null
|
||||
var fileSystemPanel: FileSystemPanel? = null
|
||||
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
cardPanel.add(selectHostPanel, State.Initialized.name)
|
||||
cardPanel.add(connectingPanel, State.Connecting.name)
|
||||
cardPanel.add(connectFailedPanel, State.ConnectFailed.name)
|
||||
cardLayout.show(cardPanel, State.Initialized.name)
|
||||
add(cardPanel, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun connect() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (state != State.Connecting) {
|
||||
state = State.Connecting
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
connectingPanel.start()
|
||||
cardLayout.show(cardPanel, State.Connecting.name)
|
||||
}
|
||||
|
||||
runCatching { doConnect() }.onFailure {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(it.message, it)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
state = State.ConnectFailed
|
||||
connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(it)
|
||||
cardLayout.show(cardPanel, State.ConnectFailed.name)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
connectingPanel.stop()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doConnect() {
|
||||
|
||||
val host = this.host ?: return
|
||||
|
||||
closeIO()
|
||||
|
||||
try {
|
||||
val client = SshClients.openClient(host).apply { client = this }
|
||||
val session = SshClients.openSession(host, client).apply { session = this }
|
||||
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
|
||||
session.addCloseFutureListener { onClose() }
|
||||
} catch (e: Exception) {
|
||||
closeIO()
|
||||
throw e
|
||||
}
|
||||
|
||||
if (isDisposed.get()) {
|
||||
throw IllegalStateException("Closed")
|
||||
}
|
||||
|
||||
val fileSystem = this.fileSystem ?: return
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
state = State.Connected
|
||||
|
||||
val fileSystemPanel = FileSystemPanel(fileSystem, transportManager, host)
|
||||
fileSystemPanel.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(
|
||||
fileSystemPanel: FileSystemPanel,
|
||||
workdir: Path,
|
||||
isDirectory: Boolean,
|
||||
path: Path
|
||||
) {
|
||||
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
})
|
||||
|
||||
cardPanel.add(fileSystemPanel, State.Connected.name)
|
||||
cardLayout.show(cardPanel, State.Connected.name)
|
||||
|
||||
firePropertyChange("TabName", StringUtils.EMPTY, host.name)
|
||||
|
||||
this@SftpFileSystemPanel.fileSystemPanel = fileSystemPanel
|
||||
|
||||
// 立即加载
|
||||
fileSystemPanel.reload()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun onClose() {
|
||||
if (isDisposed.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
closeIO()
|
||||
state = State.ConnectFailed
|
||||
connectFailedPanel.errorLabel.text = I18n.getString("termora.transport.sftp.closed")
|
||||
cardLayout.show(cardPanel, State.ConnectFailed.name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeIO() {
|
||||
val host = host
|
||||
|
||||
fileSystemPanel?.let { Disposer.dispose(it) }
|
||||
fileSystemPanel = null
|
||||
|
||||
runCatching { IOUtils.closeQuietly(fileSystem) }
|
||||
runCatching { IOUtils.closeQuietly(session) }
|
||||
runCatching { IOUtils.closeQuietly(client) }
|
||||
|
||||
if (host != null && log.isInfoEnabled) {
|
||||
log.info("Sftp ${host.name} is closed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (isDisposed.compareAndSet(false, true)) {
|
||||
closeIO()
|
||||
}
|
||||
}
|
||||
|
||||
private class ConnectingPanel : JPanel(BorderLayout()) {
|
||||
private val busyLabel = JXBusyLabel()
|
||||
|
||||
init {
|
||||
initView()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, pref, default:grow",
|
||||
"40dlu, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val label = JLabel(I18n.getString("termora.transport.sftp.connecting"))
|
||||
label.horizontalAlignment = SwingConstants.CENTER
|
||||
|
||||
busyLabel.horizontalAlignment = SwingConstants.CENTER
|
||||
busyLabel.verticalAlignment = SwingConstants.CENTER
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add(busyLabel).xy(2, 2, "fill, center")
|
||||
builder.add(label).xy(2, 4)
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
busyLabel.isBusy = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
busyLabel.isBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ConnectFailedPanel : JPanel(BorderLayout()) {
|
||||
val errorLabel = JLabel()
|
||||
|
||||
init {
|
||||
initView()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, pref, default:grow",
|
||||
"40dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
errorLabel.horizontalAlignment = SwingConstants.CENTER
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add(FlatOptionPaneErrorIcon()).xy(2, 2)
|
||||
builder.add(errorLabel).xyw(1, 4, 3, "fill, center")
|
||||
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.retry")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
connect()
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
isFocusable = false
|
||||
}).xy(2, 6)
|
||||
builder.add(JXHyperlink(object :
|
||||
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
state = State.Initialized
|
||||
this@SftpFileSystemPanel.firePropertyChange("TabName", StringUtils.SPACE, StringUtils.EMPTY)
|
||||
cardLayout.show(cardPanel, State.Initialized.name)
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
isFocusable = false
|
||||
}).xy(2, 8)
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SelectHostPanel : JPanel(BorderLayout()) {
|
||||
init {
|
||||
initView()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, pref, default:grow",
|
||||
"40dlu, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
|
||||
val errorInfo = JLabel(I18n.getString("termora.transport.sftp.connect-a-host"))
|
||||
errorInfo.horizontalAlignment = SwingConstants.CENTER
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
|
||||
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
|
||||
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.select-host")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel))
|
||||
dialog.allowMulti = false
|
||||
dialog.setLocationRelativeTo(this@SelectHostPanel)
|
||||
dialog.isVisible = true
|
||||
this@SftpFileSystemPanel.host = dialog.hosts.firstOrNull() ?: return
|
||||
connect()
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
isFocusable = false
|
||||
}).xy(2, 6)
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
}
|
||||
274
src/main/kotlin/app/termora/transport/Transport.kt
Normal file
@@ -0,0 +1,274 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.net.io.CopyStreamEvent
|
||||
import org.apache.commons.net.io.CopyStreamListener
|
||||
import org.apache.commons.net.io.Util
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.exists
|
||||
|
||||
enum class TransportState {
|
||||
Waiting,
|
||||
Transporting,
|
||||
Done,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
abstract class Transport(
|
||||
val name: String,
|
||||
// 源路径
|
||||
val source: Path,
|
||||
// 目标路径
|
||||
val target: Path,
|
||||
val sourceHolder: Disposable,
|
||||
val targetHolder: Disposable,
|
||||
) : Disposable, Runnable {
|
||||
|
||||
private val listeners = ArrayList<TransportListener>()
|
||||
|
||||
@Volatile
|
||||
var state = TransportState.Waiting
|
||||
protected set(value) {
|
||||
field = value
|
||||
listeners.forEach { it.onTransportChanged(this) }
|
||||
}
|
||||
|
||||
// 0 - 1
|
||||
var progress = 0.0
|
||||
protected set(value) {
|
||||
field = value
|
||||
listeners.forEach { it.onTransportChanged(this) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 要传输的大小
|
||||
*/
|
||||
var size = -1L
|
||||
protected set
|
||||
|
||||
/**
|
||||
* 已经传输的大小
|
||||
*/
|
||||
var transferredSize = 0L
|
||||
protected set
|
||||
|
||||
/**
|
||||
* 传输速度
|
||||
*/
|
||||
open val speed get() = 0L
|
||||
|
||||
open val getSourcePath by lazy {
|
||||
getFileSystemName(source.fileSystem) + ":" + source.toAbsolutePath().normalize().toString()
|
||||
}
|
||||
open val getTargetPath by lazy {
|
||||
getFileSystemName(target.fileSystem) + ":" + target.toAbsolutePath().normalize().toString()
|
||||
}
|
||||
|
||||
|
||||
fun addTransportListener(listener: TransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeTransportListener(listener: TransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
if (state != TransportState.Waiting) {
|
||||
throw IllegalStateException("$name has already been started")
|
||||
}
|
||||
|
||||
state = TransportState.Transporting
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
if (state == TransportState.Waiting || state == TransportState.Transporting) {
|
||||
state = TransportState.Cancelled
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileSystemName(fileSystem: FileSystem): String {
|
||||
if (fileSystem is SftpFileSystem) {
|
||||
val clientSession = fileSystem.session
|
||||
if (clientSession is JGitClientSession) {
|
||||
return clientSession.hostConfigEntry.host
|
||||
}
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
private class SlidingWindowByteCounter {
|
||||
private val events = ConcurrentLinkedQueue<Pair<Long, Long>>()
|
||||
private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1)
|
||||
|
||||
fun addBytes(bytes: Long, time: Long) {
|
||||
|
||||
// 添加当前事件
|
||||
events.add(time to bytes)
|
||||
|
||||
// 移除过期事件(超过 1 秒的记录)
|
||||
while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) {
|
||||
events.poll()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun getLastSecondBytes(): Long {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 累加最近 1 秒内的字节数
|
||||
return events.filter { it.first >= currentTime - oneSecondInMillis }
|
||||
.sumOf { it.second }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
events.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 文件传输
|
||||
*/
|
||||
class FileTransport(
|
||||
name: String, source: Path, target: Path,
|
||||
sourceHolder: Disposable, targetHolder: Disposable,
|
||||
) : Transport(
|
||||
name, source, target, sourceHolder, targetHolder,
|
||||
), CopyStreamListener {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(FileTransport::class.java)
|
||||
}
|
||||
|
||||
private var lastVisitTime = 0L
|
||||
private val input by lazy { Files.newInputStream(source) }
|
||||
private val output by lazy { Files.newOutputStream(target) }
|
||||
private val counter = SlidingWindowByteCounter()
|
||||
|
||||
override val speed: Long
|
||||
get() = counter.getLastSecondBytes()
|
||||
|
||||
|
||||
override fun run() {
|
||||
|
||||
try {
|
||||
super.run()
|
||||
doTransport()
|
||||
state = TransportState.Done
|
||||
} catch (e: Exception) {
|
||||
if (state == TransportState.Cancelled) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Transport $name is canceled")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
state = TransportState.Failed
|
||||
} finally {
|
||||
counter.clear()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
|
||||
// 如果在传输中,那么直接关闭流
|
||||
if (state == TransportState.Transporting) {
|
||||
runCatching { IOUtils.closeQuietly(input) }
|
||||
runCatching { IOUtils.closeQuietly(output) }
|
||||
}
|
||||
|
||||
super.stop()
|
||||
|
||||
counter.clear()
|
||||
}
|
||||
|
||||
private fun doTransport() {
|
||||
size = Files.size(source)
|
||||
try {
|
||||
Util.copyStream(
|
||||
input,
|
||||
output,
|
||||
Util.DEFAULT_COPY_BUFFER_SIZE * 8,
|
||||
size,
|
||||
this
|
||||
)
|
||||
} finally {
|
||||
IOUtils.closeQuietly(input, output)
|
||||
}
|
||||
}
|
||||
|
||||
override fun bytesTransferred(event: CopyStreamEvent?) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun bytesTransferred(totalBytesTransferred: Long, bytesTransferred: Int, streamSize: Long) {
|
||||
|
||||
if (state == TransportState.Cancelled) {
|
||||
throw IllegalStateException("$name has already been cancelled")
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val progress = totalBytesTransferred * 1.0 / streamSize
|
||||
|
||||
counter.addBytes(bytesTransferred.toLong(), now)
|
||||
|
||||
if (now - lastVisitTime < 750) {
|
||||
if (progress < 1.0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.transferredSize = totalBytesTransferred
|
||||
this.progress = progress
|
||||
lastVisitTime = now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件夹
|
||||
*/
|
||||
class DirectoryTransport(
|
||||
name: String, source: Path, target: Path,
|
||||
sourceHolder: Disposable,
|
||||
targetHolder: Disposable,
|
||||
) : Transport(name, source, target, sourceHolder, targetHolder) {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(DirectoryTransport::class.java)
|
||||
}
|
||||
|
||||
|
||||
override fun run() {
|
||||
|
||||
try {
|
||||
super.run()
|
||||
if (!target.exists()) {
|
||||
Files.createDirectory(target)
|
||||
}
|
||||
state = TransportState.Done
|
||||
} catch (e: FileAlreadyExistsException) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Directory $name already exists")
|
||||
}
|
||||
state = TransportState.Done
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
state = TransportState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/main/kotlin/app/termora/transport/TransportListener.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package app.termora.transport
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface TransportListener : EventListener {
|
||||
/**
|
||||
* Added
|
||||
*/
|
||||
fun onTransportAdded(transport: Transport)
|
||||
|
||||
/**
|
||||
* Removed
|
||||
*/
|
||||
fun onTransportRemoved(transport: Transport)
|
||||
|
||||
/**
|
||||
* 状态变化
|
||||
*/
|
||||
fun onTransportChanged(transport: Transport)
|
||||
}
|
||||
129
src/main/kotlin/app/termora/transport/TransportManager.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class TransportManager : Disposable {
|
||||
private val transports = Collections.synchronizedList(mutableListOf<Transport>())
|
||||
private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
|
||||
private val isProcessing = AtomicBoolean(false)
|
||||
private val listeners = mutableListOf<TransportListener>()
|
||||
private val listener = object : TransportListener {
|
||||
override fun onTransportAdded(transport: Transport) {
|
||||
listeners.forEach { it.onTransportAdded(transport) }
|
||||
}
|
||||
|
||||
override fun onTransportRemoved(transport: Transport) {
|
||||
listeners.forEach { it.onTransportRemoved(transport) }
|
||||
}
|
||||
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
listeners.forEach { it.onTransportChanged(transport) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TransportManager::class.java)
|
||||
}
|
||||
|
||||
fun getTransports(): List<Transport> = transports
|
||||
|
||||
fun addTransport(transport: Transport) {
|
||||
synchronized(transports) {
|
||||
transport.addTransportListener(listener)
|
||||
if (transports.add(transport)) {
|
||||
listeners.forEach { it.onTransportAdded(transport) }
|
||||
if (isProcessing.compareAndSet(false, true)) {
|
||||
coroutineScope.launch(Dispatchers.IO) { process() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTransport(transport: Transport) {
|
||||
synchronized(transports) {
|
||||
transport.stop()
|
||||
if (transports.remove(transport)) {
|
||||
listeners.forEach { it.onTransportRemoved(transport) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllTransports() {
|
||||
synchronized(transports) {
|
||||
while (transports.isNotEmpty()) {
|
||||
removeTransport(transports.last())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addTransportListener(listener: TransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeTransportListener(listener: TransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
private suspend fun process() {
|
||||
var needDelay = false
|
||||
while (coroutineScope.isActive) {
|
||||
try {
|
||||
|
||||
// 如果为空或者其中一个正在传输中那么挑过
|
||||
if (needDelay || transports.isEmpty()) {
|
||||
needDelay = false
|
||||
delay(250.milliseconds)
|
||||
continue
|
||||
}
|
||||
|
||||
val transport = synchronized(transports) {
|
||||
var transport: Transport? = null
|
||||
for (e in transports) {
|
||||
if (e.state != TransportState.Waiting) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 遇到传输中,那么直接跳过
|
||||
if (e.state == TransportState.Transporting) {
|
||||
needDelay = true
|
||||
break
|
||||
}
|
||||
|
||||
transport = e
|
||||
break
|
||||
}
|
||||
return@synchronized transport
|
||||
}
|
||||
|
||||
if (transport == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
transport.run()
|
||||
|
||||
// 成功之后 删除
|
||||
if (transport.state == TransportState.Done) {
|
||||
// remove
|
||||
removeTransport(transport)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
transports.clear()
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
}
|
||||
194
src/main/kotlin/app/termora/transport/TransportPanel.kt
Normal file
@@ -0,0 +1,194 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.Disposer
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.assertEventDispatchThread
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.io.File
|
||||
import java.nio.file.Path
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JSplitPane
|
||||
|
||||
/**
|
||||
* 传输面板
|
||||
*/
|
||||
class TransportPanel : JPanel(BorderLayout()), Disposable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TransportPanel::class.java)
|
||||
}
|
||||
|
||||
val transportManager = TransportManager()
|
||||
|
||||
val leftFileSystemTabbed = FileSystemTabbed(transportManager, true)
|
||||
val rightFileSystemTabbed = FileSystemTabbed(transportManager, false)
|
||||
|
||||
private val fileTransportPanel = FileTransportPanel(transportManager)
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
Disposer.register(this, transportManager)
|
||||
Disposer.register(this, leftFileSystemTabbed)
|
||||
Disposer.register(this, rightFileSystemTabbed)
|
||||
Disposer.register(this, fileTransportPanel)
|
||||
|
||||
leftFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
|
||||
rightFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
|
||||
val splitPane = JSplitPane()
|
||||
splitPane.leftComponent = leftFileSystemTabbed
|
||||
splitPane.rightComponent = rightFileSystemTabbed
|
||||
splitPane.resizeWeight = 0.5
|
||||
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
|
||||
splitPane.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
splitPane.setDividerLocation(splitPane.resizeWeight)
|
||||
}
|
||||
})
|
||||
|
||||
fileTransportPanel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
val rootSplitPane = JSplitPane()
|
||||
rootSplitPane.orientation = JSplitPane.VERTICAL_SPLIT
|
||||
rootSplitPane.topComponent = splitPane
|
||||
rootSplitPane.bottomComponent = fileTransportPanel
|
||||
rootSplitPane.resizeWeight = 0.75
|
||||
rootSplitPane.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
|
||||
}
|
||||
})
|
||||
|
||||
add(rootSplitPane, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
private fun initEvents() {
|
||||
transportManager.addTransportListener(object : TransportListener {
|
||||
override fun onTransportAdded(transport: Transport) {
|
||||
}
|
||||
|
||||
override fun onTransportRemoved(transport: Transport) {
|
||||
|
||||
}
|
||||
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
if (transport.state == TransportState.Done) {
|
||||
val targetHolder = transport.targetHolder
|
||||
if (targetHolder is FileSystemPanel) {
|
||||
if (transport.target.parent == targetHolder.workdir) {
|
||||
targetHolder.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
leftFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
val target = rightFileSystemTabbed.getSelectedFileSystemPanel() ?: return
|
||||
transport(
|
||||
fileSystemPanel.workdir, target.workdir,
|
||||
isSourceDirectory = isDirectory,
|
||||
sourcePath = path,
|
||||
sourceHolder = fileSystemPanel,
|
||||
targetHolder = target,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
rightFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
val target = leftFileSystemTabbed.getSelectedFileSystemPanel() ?: return
|
||||
transport(
|
||||
fileSystemPanel.workdir, target.workdir,
|
||||
isSourceDirectory = isDirectory,
|
||||
sourcePath = path,
|
||||
sourceHolder = fileSystemPanel,
|
||||
targetHolder = target,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fun getTargetFileSystemPanel(fileSystemPanel: FileSystemPanel): FileSystemPanel? {
|
||||
|
||||
assertEventDispatchThread()
|
||||
|
||||
for (i in 0 until leftFileSystemTabbed.tabCount) {
|
||||
if (leftFileSystemTabbed.getFileSystemPanel(i) == fileSystemPanel) {
|
||||
return rightFileSystemTabbed.getSelectedFileSystemPanel()
|
||||
}
|
||||
}
|
||||
|
||||
for (i in 0 until rightFileSystemTabbed.tabCount) {
|
||||
if (rightFileSystemTabbed.getFileSystemPanel(i) == fileSystemPanel) {
|
||||
return leftFileSystemTabbed.getSelectedFileSystemPanel()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun transport(
|
||||
sourceWorkdir: Path,
|
||||
targetWorkdir: Path,
|
||||
isSourceDirectory: Boolean,
|
||||
sourcePath: Path,
|
||||
sourceHolder: Disposable,
|
||||
targetHolder: Disposable
|
||||
) {
|
||||
val relativizePath = sourceWorkdir.relativize(sourcePath).toString()
|
||||
if (StringUtils.isEmpty(relativizePath) || relativizePath == File.separator ||
|
||||
relativizePath == sourceWorkdir.fileSystem.separator ||
|
||||
relativizePath == targetWorkdir.fileSystem.separator
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val transport: Transport
|
||||
if (isSourceDirectory) {
|
||||
transport = DirectoryTransport(
|
||||
name = sourcePath.fileName.toString(),
|
||||
source = sourcePath,
|
||||
target = targetWorkdir.resolve(relativizePath),
|
||||
sourceHolder = sourceHolder,
|
||||
targetHolder = targetHolder,
|
||||
)
|
||||
} else {
|
||||
transport = FileTransport(
|
||||
name = sourcePath.fileName.toString(),
|
||||
source = sourcePath,
|
||||
target = targetWorkdir.resolve(relativizePath),
|
||||
sourceHolder = sourceHolder,
|
||||
targetHolder = targetHolder,
|
||||
)
|
||||
}
|
||||
|
||||
transportManager.addTransport(transport)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Transport is disposed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ termora.remove=Delete
|
||||
termora.yes=Yes
|
||||
termora.no=No
|
||||
termora.date-format=MM/dd/yyyy hh:mm:ss a
|
||||
termora.finder=Finder
|
||||
termora.folder=Folder
|
||||
termora.explorer=Explorer
|
||||
|
||||
# update
|
||||
termora.update.title=New version
|
||||
@@ -106,7 +109,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.new.folder=Folder
|
||||
termora.welcome.contextmenu.new.folder=${termora.folder}
|
||||
termora.welcome.contextmenu.new.host=Host
|
||||
termora.welcome.contextmenu.new.folder.name=New Folder
|
||||
termora.welcome.contextmenu.property=Properties
|
||||
@@ -187,12 +190,77 @@ termora.macro.playback=Playback
|
||||
termora.macro.manager=Manage Macros
|
||||
termora.macro.run=Run
|
||||
|
||||
|
||||
|
||||
|
||||
# Tools
|
||||
termora.tools.multiple=Send commands to multiple sessions
|
||||
|
||||
# Transport
|
||||
termora.transport.local=Local
|
||||
termora.transport.parent-folder=Parent Folder
|
||||
termora.transport.file-already-exists=The file {0} already exists
|
||||
|
||||
termora.transport.bookmarks=Bookmarks Manager
|
||||
termora.transport.bookmarks.up=Up
|
||||
termora.transport.bookmarks.down=Down
|
||||
|
||||
termora.transport.table.filename=Filename
|
||||
termora.transport.table.type=Type
|
||||
termora.transport.table.type.folder=${termora.welcome.contextmenu.new.folder}
|
||||
termora.transport.table.size=Size
|
||||
termora.transport.table.modified-time=Modified
|
||||
termora.transport.table.permissions=Permissions
|
||||
termora.transport.table.owner=Owner
|
||||
|
||||
# contextmenu
|
||||
termora.transport.table.contextmenu.transfer=Transfer
|
||||
termora.transport.table.contextmenu.copy-path=Copy Path
|
||||
termora.transport.table.contextmenu.open-in-folder=Open in {0}
|
||||
termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename}
|
||||
termora.transport.table.contextmenu.delete=${termora.remove}
|
||||
termora.transport.table.contextmenu.delete-warning=If the folder is too large, deleting it may take some time
|
||||
termora.transport.table.contextmenu.rm-warning=Using the rm -rf command to delete a folder is very dangerous
|
||||
termora.transport.table.contextmenu.change-permissions=Change Permissions...
|
||||
termora.transport.table.contextmenu.refresh=Refresh
|
||||
termora.transport.table.contextmenu.new=${termora.welcome.contextmenu.new}
|
||||
termora.transport.table.contextmenu.new.folder=${termora.welcome.contextmenu.new.folder.name}
|
||||
termora.transport.table.contextmenu.new.file=New File
|
||||
|
||||
# Permission
|
||||
termora.transport.permissions=Change Permissions
|
||||
termora.transport.permissions.file-folder-permissions=File/Folder Permissions
|
||||
termora.transport.permissions.read=Read
|
||||
termora.transport.permissions.write=Write
|
||||
termora.transport.permissions.execute=Execute
|
||||
termora.transport.permissions.owner=Owner
|
||||
termora.transport.permissions.group=Group
|
||||
termora.transport.permissions.others=Others
|
||||
|
||||
termora.transport.sftp.retry=Retry
|
||||
termora.transport.sftp.select-another-host=Select another host
|
||||
termora.transport.sftp.select-host=Select host
|
||||
termora.transport.sftp.connect-a-host=Connect to a Host
|
||||
termora.transport.sftp.connecting=Connecting...
|
||||
termora.transport.sftp.closed=The connection has been closed
|
||||
termora.transport.sftp.close-tab=Transfer is still in activated status. Are you sure you want to remove all jobs and close this session?
|
||||
termora.transport.sftp.status.transporting=Transporting
|
||||
termora.transport.sftp.status.waiting=Waiting
|
||||
termora.transport.sftp.status.done=Done
|
||||
termora.transport.sftp.status.failed=Failed
|
||||
termora.transport.sftp.status.cancelled=Cancelled
|
||||
|
||||
|
||||
# transport job
|
||||
termora.transport.jobs.table.name=Name
|
||||
termora.transport.jobs.table.status=Status
|
||||
termora.transport.jobs.table.progress=Progress
|
||||
termora.transport.jobs.table.size=Size
|
||||
termora.transport.jobs.table.source-path=Source Path
|
||||
termora.transport.jobs.table.target-path=Target Path
|
||||
termora.transport.jobs.table.speed=Speed
|
||||
termora.transport.jobs.table.estimated-time=Estimated time
|
||||
|
||||
termora.transport.jobs.contextmenu.delete=${termora.remove}
|
||||
termora.transport.jobs.contextmenu.delete-all=Delete All
|
||||
|
||||
|
||||
# Terminal
|
||||
termora.terminal.size=Size: {0} x {1}
|
||||
|
||||
@@ -7,6 +7,9 @@ termora.remove=删除
|
||||
termora.yes=是
|
||||
termora.no=否
|
||||
termora.date-format=yyyy-MM-dd HH:mm:ss
|
||||
termora.finder=访达
|
||||
termora.folder=文件夹
|
||||
termora.explorer=文件管理器
|
||||
|
||||
# update
|
||||
termora.update.title=新版本
|
||||
@@ -184,6 +187,70 @@ termora.macro.manager=管理宏
|
||||
termora.macro.run=运行
|
||||
|
||||
|
||||
|
||||
# Transport
|
||||
termora.transport.local=本机
|
||||
termora.transport.parent-folder=父文件夹
|
||||
termora.transport.file-already-exists=文件 {0} 已存在
|
||||
|
||||
termora.transport.bookmarks=书签管理
|
||||
termora.transport.bookmarks.up=上移
|
||||
termora.transport.bookmarks.down=下移
|
||||
|
||||
termora.transport.table.filename=文件名
|
||||
termora.transport.table.type=类型
|
||||
termora.transport.table.size=大小
|
||||
termora.transport.table.modified-time=修改时间
|
||||
termora.transport.table.permissions=权限
|
||||
termora.transport.table.owner=所有者
|
||||
|
||||
# contextmenu
|
||||
termora.transport.table.contextmenu.transfer=传输
|
||||
termora.transport.table.contextmenu.copy-path=复制路径
|
||||
termora.transport.table.contextmenu.open-in-folder=在{0}中打开
|
||||
termora.transport.table.contextmenu.change-permissions=更改权限...
|
||||
termora.transport.table.contextmenu.refresh=刷新
|
||||
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
|
||||
termora.transport.table.contextmenu.delete-warning=如果文件夹太大,删除可能需要耗费一定时间
|
||||
termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令删除文件夹存在很大风险
|
||||
|
||||
termora.transport.sftp.retry=重试
|
||||
termora.transport.sftp.select-another-host=选择其他主机
|
||||
termora.transport.sftp.select-host=选择主机
|
||||
termora.transport.sftp.connect-a-host=连接一个主机
|
||||
termora.transport.sftp.connecting=连接中...
|
||||
termora.transport.sftp.closed=连接已经关闭
|
||||
termora.transport.sftp.close-tab=传输还处于活动状态,是否删除所有传输任务并关闭此会话?
|
||||
|
||||
termora.transport.sftp.status.transporting=传输中
|
||||
termora.transport.sftp.status.waiting=等待中
|
||||
termora.transport.sftp.status.done=已完成
|
||||
termora.transport.sftp.status.failed=已失败
|
||||
termora.transport.sftp.status.cancelled=已取消
|
||||
|
||||
|
||||
# Permission
|
||||
termora.transport.permissions=更改权限
|
||||
termora.transport.permissions.file-folder-permissions=文件/文件夹权限
|
||||
termora.transport.permissions.read=读取
|
||||
termora.transport.permissions.write=写入
|
||||
termora.transport.permissions.execute=执行
|
||||
termora.transport.permissions.owner=所有者
|
||||
termora.transport.permissions.group=组
|
||||
termora.transport.permissions.others=其他
|
||||
|
||||
# transport job
|
||||
termora.transport.jobs.table.name=名称
|
||||
termora.transport.jobs.table.status=状态
|
||||
termora.transport.jobs.table.progress=进度
|
||||
termora.transport.jobs.table.size=大小
|
||||
termora.transport.jobs.table.source-path=源路径
|
||||
termora.transport.jobs.table.target-path=目标路径
|
||||
termora.transport.jobs.table.speed=速度
|
||||
termora.transport.jobs.table.estimated-time=剩余时间
|
||||
|
||||
termora.transport.jobs.contextmenu.delete-all=删除所有
|
||||
|
||||
termora.terminal.size=大小: {0} x {1}
|
||||
termora.terminal.copied=已复制
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ termora.remove=刪除
|
||||
termora.yes=是
|
||||
termora.no=否
|
||||
termora.date-format=yyyy/MM/dd HH:mm:ss
|
||||
termora.finder=訪達
|
||||
termora.folder=資料夾
|
||||
termora.explorer=檔案管理器
|
||||
|
||||
# update
|
||||
termora.update.title=新版本
|
||||
@@ -95,7 +98,7 @@ termora.welcome.contextmenu.rename=重新命名
|
||||
termora.welcome.contextmenu.expand-all=展開全部
|
||||
termora.welcome.contextmenu.collapse-all=全部收縮
|
||||
termora.welcome.contextmenu.new=新建
|
||||
termora.welcome.contextmenu.new.folder=資料夾
|
||||
termora.welcome.contextmenu.new.folder=${termora.folder}
|
||||
termora.welcome.contextmenu.new.host=主機
|
||||
termora.welcome.contextmenu.new.folder.name=新建資料夾
|
||||
termora.welcome.contextmenu.property=屬性
|
||||
@@ -177,6 +180,57 @@ termora.macro.playback=回放
|
||||
termora.macro.manager=管理宏
|
||||
termora.macro.run=運行
|
||||
|
||||
# Transport
|
||||
termora.transport.local=本機
|
||||
termora.transport.parent-folder=父資料夾
|
||||
termora.transport.file-already-exists=檔案 {0} 已存在
|
||||
|
||||
termora.transport.bookmarks=書籤管理
|
||||
termora.transport.bookmarks.up=上移
|
||||
termora.transport.bookmarks.down=下移
|
||||
|
||||
termora.transport.table.filename=檔名
|
||||
termora.transport.table.type=類型
|
||||
termora.transport.table.size=大小
|
||||
termora.transport.table.modified-time=修改時間
|
||||
termora.transport.table.permissions=權限
|
||||
termora.transport.table.owner=所有者
|
||||
|
||||
# contextmenu
|
||||
termora.transport.table.contextmenu.transfer=傳輸
|
||||
termora.transport.table.contextmenu.copy-path=複製路徑
|
||||
termora.transport.table.contextmenu.open-in-folder=在{0}中打開
|
||||
termora.transport.table.contextmenu.change-permissions=更改權限...
|
||||
termora.transport.table.contextmenu.refresh=刷新
|
||||
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
|
||||
termora.transport.table.contextmenu.delete-warning=如果資料夾太大,刪除可能需要耗費一定時間
|
||||
termora.transport.table.contextmenu.rm-warning=使用 rm -rf 命令刪除資料夾存在很大風險
|
||||
|
||||
termora.transport.sftp.retry=重試
|
||||
termora.transport.sftp.select-another-host=選擇其他主機
|
||||
termora.transport.sftp.select-host=選擇主機
|
||||
termora.transport.sftp.connect-a-host=連接一個主機
|
||||
termora.transport.sftp.connecting=連接中...
|
||||
termora.transport.sftp.closed=連線已經關閉
|
||||
termora.transport.sftp.close-tab=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話?
|
||||
termora.transport.sftp.status.transporting=傳輸中
|
||||
termora.transport.sftp.status.waiting=等待中
|
||||
termora.transport.sftp.status.done=已完成
|
||||
termora.transport.sftp.status.failed=已失敗
|
||||
termora.transport.sftp.status.cancelled=已取消
|
||||
|
||||
# transport job
|
||||
termora.transport.jobs.table.name=名稱
|
||||
termora.transport.jobs.table.status=狀態
|
||||
termora.transport.jobs.table.progress=進度
|
||||
termora.transport.jobs.table.size=大小
|
||||
termora.transport.jobs.table.source-path=來源路徑
|
||||
termora.transport.jobs.table.target-path=目標路徑
|
||||
termora.transport.jobs.table.speed=速度
|
||||
termora.transport.jobs.table.estimated-time=剩餘時間
|
||||
|
||||
termora.transport.jobs.contextmenu.delete-all=刪除所有
|
||||
|
||||
termora.terminal.size=大小: {0} x {1}
|
||||
termora.terminal.copied=已複製
|
||||
|
||||
|
||||
4
src/main/resources/icons/bookmarks.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
6
src/main/resources/icons/bookmarksOff.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z"
|
||||
stroke="#6C707E"/>
|
||||
<path d="M1.28258 1.98958L1.98969 1.28247L14.7176 14.0104L14.0105 14.7175L1.28258 1.98958Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
6
src/main/resources/icons/bookmarksOff_dark.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#CED0D6"/>
|
||||
<path d="M1.28258 1.98958L1.98969 1.28247L14.7176 14.0104L14.0105 14.7175L1.28258 1.98958Z" fill="#CED0D6"/>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
4
src/main/resources/icons/bookmarks_dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.31216 11.5622L7.99943 11.3115L7.6867 11.5622L4.31273 14.2669C3.98543 14.5292 3.5 14.2962 3.5 13.8767V3.5C3.5 2.67157 4.17157 2 5 2H10.9989C11.8273 2 12.4989 2.67157 12.4989 3.5V13.8767C12.4989 14.2962 12.0134 14.5292 11.6861 14.2669L8.31216 11.5622Z" stroke="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
8
src/main/resources/icons/bulletList.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 3.25C3.91421 3.25 4.25 2.91421 4.25 2.5C4.25 2.08579 3.91421 1.75 3.5 1.75C3.08579 1.75 2.75 2.08579 2.75 2.5C2.75 2.91421 3.08579 3.25 3.5 3.25Z" fill="#6C707E"/>
|
||||
<path d="M7.5 2C7.22386 2 7 2.22386 7 2.5C7 2.77614 7.22386 3 7.5 3H13.5C13.7761 3 14 2.77614 14 2.5C14 2.22386 13.7761 2 13.5 2H7.5Z" fill="#6C707E"/>
|
||||
<path d="M7.5 7C7.22386 7 7 7.22386 7 7.5C7 7.77614 7.22386 8 7.5 8H13.5C13.7761 8 14 7.77614 14 7.5C14 7.22386 13.7761 7 13.5 7H7.5Z" fill="#6C707E"/>
|
||||
<path d="M7.5 12C7.22386 12 7 12.2239 7 12.5C7 12.7761 7.22386 13 7.5 13H13.5C13.7761 13 14 12.7761 14 12.5C14 12.2239 13.7761 12 13.5 12H7.5Z" fill="#6C707E"/>
|
||||
<path d="M3.5 8.25C3.91421 8.25 4.25 7.91421 4.25 7.5C4.25 7.08579 3.91421 6.75 3.5 6.75C3.08579 6.75 2.75 7.08579 2.75 7.5C2.75 7.91421 3.08579 8.25 3.5 8.25Z" fill="#6C707E"/>
|
||||
<path d="M4.25 12.5C4.25 12.9142 3.91421 13.25 3.5 13.25C3.08579 13.25 2.75 12.9142 2.75 12.5C2.75 12.0858 3.08579 11.75 3.5 11.75C3.91421 11.75 4.25 12.0858 4.25 12.5Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
8
src/main/resources/icons/bulletList_dark.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.5 3.25C3.91421 3.25 4.25 2.91421 4.25 2.5C4.25 2.08579 3.91421 1.75 3.5 1.75C3.08579 1.75 2.75 2.08579 2.75 2.5C2.75 2.91421 3.08579 3.25 3.5 3.25Z" fill="#CED0D6"/>
|
||||
<path d="M7.5 2C7.22386 2 7 2.22386 7 2.5C7 2.77614 7.22386 3 7.5 3H13.5C13.7761 3 14 2.77614 14 2.5C14 2.22386 13.7761 2 13.5 2H7.5Z" fill="#CED0D6"/>
|
||||
<path d="M7.5 7C7.22386 7 7 7.22386 7 7.5C7 7.77614 7.22386 8 7.5 8H13.5C13.7761 8 14 7.77614 14 7.5C14 7.22386 13.7761 7 13.5 7H7.5Z" fill="#CED0D6"/>
|
||||
<path d="M7.5 12C7.22386 12 7 12.2239 7 12.5C7 12.7761 7.22386 13 7.5 13H13.5C13.7761 13 14 12.7761 14 12.5C14 12.2239 13.7761 12 13.5 12H7.5Z" fill="#CED0D6"/>
|
||||
<path d="M3.5 8.25C3.91421 8.25 4.25 7.91421 4.25 7.5C4.25 7.08579 3.91421 6.75 3.5 6.75C3.08579 6.75 2.75 7.08579 2.75 7.5C2.75 7.91421 3.08579 8.25 3.5 8.25Z" fill="#CED0D6"/>
|
||||
<path d="M4.25 12.5C4.25 12.9142 3.91421 13.25 3.5 13.25C3.08579 13.25 2.75 12.9142 2.75 12.5C2.75 12.0858 3.08579 11.75 3.5 11.75C3.91421 11.75 4.25 12.0858 4.25 12.5Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
6
src/main/resources/icons/errorIntroduction.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6.5" fill="#FFF7F7" stroke="#DB3B4B"/>
|
||||
<path d="M8 4.5L8 8.5" stroke="#DB3B4B" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<circle cx="8.0002" cy="10.8" r="0.5" fill="#DB3B4B" stroke="#DB3B4B" stroke-width="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
6
src/main/resources/icons/errorIntroduction_dark.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6.5" fill="#402929" stroke="#DB5C5C"/>
|
||||
<path d="M8 4.5L8 8.5" stroke="#DB5C5C" stroke-width="1.2" stroke-linecap="round"/>
|
||||
<circle cx="8.0002" cy="10.8" r="0.5" fill="#DB5C5C" stroke="#DB5C5C" stroke-width="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
4
src/main/resources/icons/fileTransfer.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.8536 6.85355C15.0488 6.65829 15.0488 6.34171 14.8536 6.14645L11.8536 3.14645C11.6583 2.95118 11.3417 2.95118 11.1464 3.14645L8.14645 6.14645C7.95118 6.34171 7.95118 6.65829 8.14645 6.85355C8.34171 7.04882 8.65829 7.04882 8.85355 6.85355L11 4.70711V11.5C11 11.7761 11.2239 12 11.5 12C11.7761 12 12 11.7761 12 11.5V4.70711L14.1464 6.85355C14.3417 7.04882 14.6583 7.04882 14.8536 6.85355Z" fill="#6C707E"/>
|
||||
<path d="M7.85355 9.14645C8.04882 9.34171 8.04882 9.65829 7.85355 9.85355L4.85355 12.8536C4.65829 13.0488 4.34171 13.0488 4.14645 12.8536L1.14645 9.85355C0.951184 9.65829 0.951184 9.34171 1.14645 9.14645C1.34171 8.95118 1.65829 8.95118 1.85355 9.14645L4 11.2929L4 4.5C4 4.22386 4.22386 4 4.5 4C4.77614 4 5 4.22386 5 4.5V11.2929L7.14645 9.14645C7.34171 8.95118 7.65829 8.95118 7.85355 9.14645Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 931 B |
4
src/main/resources/icons/fileTransfer_dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.8536 6.85355C15.0488 6.65829 15.0488 6.34171 14.8536 6.14645L11.8536 3.14645C11.6583 2.95118 11.3417 2.95118 11.1464 3.14645L8.14645 6.14645C7.95118 6.34171 7.95118 6.65829 8.14645 6.85355C8.34171 7.04882 8.65829 7.04882 8.85355 6.85355L11 4.70711V11.5C11 11.7761 11.2239 12 11.5 12C11.7761 12 12 11.7761 12 11.5V4.70711L14.1464 6.85355C14.3417 7.04882 14.6583 7.04882 14.8536 6.85355Z" fill="#CED0D6"/>
|
||||
<path d="M7.85355 9.14645C8.04882 9.34171 8.04882 9.65829 7.85355 9.85355L4.85355 12.8536C4.65829 13.0488 4.34171 13.0488 4.14645 12.8536L1.14645 9.85355C0.951184 9.65829 0.951184 9.34171 1.14645 9.14645C1.34171 8.95118 1.65829 8.95118 1.85355 9.14645L4 11.2929L4 4.5C4 4.22386 4.22386 4 4.5 4C4.77614 4 5 4.22386 5 4.5V11.2929L7.14645 9.14645C7.34171 8.95118 7.65829 8.95118 7.85355 9.14645Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 931 B |
7
src/main/resources/icons/listFiles.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#6C707E"/>
|
||||
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#6C707E"/>
|
||||
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#6C707E"/>
|
||||
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 501 B |
7
src/main/resources/icons/listFiles_dark.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#CED0D6"/>
|
||||
<rect x="5" y="5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
|
||||
<rect x="5" y="7.5" width="6" height="1" rx="0.5" fill="#CED0D6"/>
|
||||
<rect x="5" y="10" width="6" height="1" rx="0.5" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 501 B |
7
src/main/resources/icons/refresh.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 9V8C2.5 4.96243 4.96243 2.5 8 2.5C9.10679 2.5 10.1372 2.82692 11 3.38947" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M5 12.6105C5.86278 13.1731 6.89321 13.5 8 13.5C11.0376 13.5 13.5 11.0376 13.5 8V7" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M0.49997 7.50027L2.5 9.5L4.49998 7.50023" stroke="#6C707E" stroke-linecap="round"/>
|
||||
<path d="M11.5 8.49982L13.5 6.5L15.5 8.49982" stroke="#6C707E" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 675 B |
7
src/main/resources/icons/refresh_dark.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.5 9V8C2.5 4.96243 4.96243 2.5 8 2.5C9.10679 2.5 10.1372 2.82692 11 3.38947" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M5 12.6105C5.86278 13.1731 6.89321 13.5 8 13.5C11.0376 13.5 13.5 11.0376 13.5 8V7" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M0.49997 7.50027L2.5 9.5L4.49998 7.50023" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
<path d="M11.5 8.49982L13.5 6.5L15.5 8.49982" stroke="#CED0D6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 675 B |
54
src/test/kotlin/app/termora/SFTPTest.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package app.termora
|
||||
|
||||
import org.apache.sshd.sftp.client.impl.DefaultSftpClientFactory
|
||||
import org.testcontainers.containers.GenericContainer
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SFTPTest {
|
||||
private val sftpContainer = GenericContainer("linuxserver/openssh-server")
|
||||
.withEnv("PUID", "1000")
|
||||
.withEnv("PGID", "1000")
|
||||
.withEnv("TZ", "Etc/UTC")
|
||||
.withEnv("SUDO_ACCESS", "true")
|
||||
.withEnv("PASSWORD_ACCESS", "true")
|
||||
.withEnv("USER_NAME", "foo")
|
||||
.withEnv("USER_PASSWORD", "pass")
|
||||
.withEnv("SUDO_ACCESS", "true")
|
||||
.withExposedPorts(2222)
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
sftpContainer.start()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
sftpContainer.stop()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test() {
|
||||
val host = Host(
|
||||
name = sftpContainer.containerName,
|
||||
protocol = Protocol.SSH,
|
||||
host = "127.0.0.1",
|
||||
port = sftpContainer.getMappedPort(2222),
|
||||
username = "foo",
|
||||
authentication = Authentication.No.copy(type = AuthenticationType.Password, password = "pass"),
|
||||
)
|
||||
|
||||
val client = SshClients.openClient(host)
|
||||
val session = SshClients.openSession(host, client)
|
||||
assertTrue(session.isOpen)
|
||||
|
||||
|
||||
val fileSystem = DefaultSftpClientFactory.INSTANCE.createSftpFileSystem(session)
|
||||
for (path in Files.list(fileSystem.rootDirectories.first())) {
|
||||
println(path)
|
||||
}
|
||||
}
|
||||
}
|
||||