diff --git a/README.md b/README.md index 79d000b..80ebd67 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/build.gradle.kts b/build.gradle.kts index 56e76f0..e4e3341 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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("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", diff --git a/docs/sftp-zh_CN.png b/docs/sftp-zh_CN.png new file mode 100644 index 0000000..2a1ded3 Binary files /dev/null and b/docs/sftp-zh_CN.png differ diff --git a/docs/sftp-zh_TW.png b/docs/sftp-zh_TW.png new file mode 100644 index 0000000..3f7df62 Binary files /dev/null and b/docs/sftp-zh_TW.png differ diff --git a/docs/sftp.png b/docs/sftp.png new file mode 100644 index 0000000..b06fd65 Binary files /dev/null and b/docs/sftp.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f02b792..0649ae0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/src/main/kotlin/app/termora/Application.kt b/src/main/kotlin/app/termora/Application.kt index 7e0e7e3..08a68ee 100644 --- a/src/main/kotlin/app/termora/Application.kt +++ b/src/main/kotlin/app/termora/Application.kt @@ -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, 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}秒" + } +} + diff --git a/src/main/kotlin/app/termora/HostTree.kt b/src/main/kotlin/app/termora/HostTree.kt index 519a41d..6b26e48 100644 --- a/src/main/kotlin/app/termora/HostTree.kt +++ b/src/main/kotlin/app/termora/HostTree.kt @@ -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 { + fun getSelectionNodes(): List { val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent } .filterIsInstance() diff --git a/src/main/kotlin/app/termora/HostTreeDialog.kt b/src/main/kotlin/app/termora/HostTreeDialog.kt new file mode 100644 index 0000000..8dabdfe --- /dev/null +++ b/src/main/kotlin/app/termora/HostTreeDialog.kt @@ -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() + + 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() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/Icons.kt b/src/main/kotlin/app/termora/Icons.kt index 042653c..78bb502 100644 --- a/src/main/kotlin/app/termora/Icons.kt +++ b/src/main/kotlin/app/termora/Icons.kt @@ -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") } diff --git a/src/main/kotlin/app/termora/SFTPTerminalTab.kt b/src/main/kotlin/app/termora/SFTPTerminalTab.kt new file mode 100644 index 0000000..5de5e93 --- /dev/null +++ b/src/main/kotlin/app/termora/SFTPTerminalTab.kt @@ -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 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SearchableHostTreeModel.kt b/src/main/kotlin/app/termora/SearchableHostTreeModel.kt index e8974f5..08bee70 100644 --- a/src/main/kotlin/app/termora/SearchableHostTreeModel.kt +++ b/src/main/kotlin/app/termora/SearchableHostTreeModel.kt @@ -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().any { + filter.invoke(e) && e.name.contains(text, true) || TreeUtils.children(model, e, true) + .filterIsInstance().any { it.name.contains(text, true) } } diff --git a/src/main/kotlin/app/termora/TerminalTab.kt b/src/main/kotlin/app/termora/TerminalTab.kt index 617935c..514ce0a 100644 --- a/src/main/kotlin/app/termora/TerminalTab.kt +++ b/src/main/kotlin/app/termora/TerminalTab.kt @@ -37,5 +37,10 @@ interface TerminalTab : Disposable { fun onLostFocus() {} fun onGrabFocus() {} + /** + * @return 返回 false 则不可关闭 + */ + fun canClose(): Boolean = true + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/TerminalTabDialog.kt b/src/main/kotlin/app/termora/TerminalTabDialog.kt index 8d2ad0d..a82077c 100644 --- a/src/main/kotlin/app/termora/TerminalTabDialog.kt +++ b/src/main/kotlin/app/termora/TerminalTabDialog.kt @@ -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) } diff --git a/src/main/kotlin/app/termora/TerminalTabbed.kt b/src/main/kotlin/app/termora/TerminalTabbed.kt index 219b9f7..1d4ad9b 100644 --- a/src/main/kotlin/app/termora/TerminalTabbed.kt +++ b/src/main/kotlin/app/termora/TerminalTabbed.kt @@ -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 + } + } + } + } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/TerminalTabbedManager.kt b/src/main/kotlin/app/termora/TerminalTabbedManager.kt index a9521c6..9ba4c2d 100644 --- a/src/main/kotlin/app/termora/TerminalTabbedManager.kt +++ b/src/main/kotlin/app/termora/TerminalTabbedManager.kt @@ -4,4 +4,5 @@ interface TerminalTabbedManager { fun addTerminalTab(tab: TerminalTab) fun getSelectedTerminalTab(): TerminalTab? fun getTerminalTabs(): List + fun setSelectedTerminalTab(tab: TerminalTab) } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/TermoraFrame.kt b/src/main/kotlin/app/termora/TermoraFrame.kt index 023e7b9..58c1fdd 100644 --- a/src/main/kotlin/app/termora/TermoraFrame.kt +++ b/src/main/kotlin/app/termora/TermoraFrame.kt @@ -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 } } diff --git a/src/main/kotlin/app/termora/findeverywhere/QuickCommandFindEverywhereProvider.kt b/src/main/kotlin/app/termora/findeverywhere/QuickCommandFindEverywhereProvider.kt index 3203e7d..ebe456b 100644 --- a/src/main/kotlin/app/termora/findeverywhere/QuickCommandFindEverywhereProvider.kt +++ b/src/main/kotlin/app/termora/findeverywhere/QuickCommandFindEverywhereProvider.kt @@ -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 } diff --git a/src/main/kotlin/app/termora/transport/BookmarkButton.kt b/src/main/kotlin/app/termora/transport/BookmarkButton.kt new file mode 100644 index 0000000..756807e --- /dev/null +++ b/src/main/kotlin/app/termora/transport/BookmarkButton.kt @@ -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 { + if (StringUtils.isBlank(name)) { + return emptyList() + } + + + val text = properties.getString(name, "[]") + if (StringUtils.isNotBlank(text)) { + runCatching { ohMyJson.decodeFromString>(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) { + + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/BookmarksDialog.kt b/src/main/kotlin/app/termora/transport/BookmarksDialog.kt new file mode 100644 index 0000000..fc48629 --- /dev/null +++ b/src/main/kotlin/app/termora/transport/BookmarksDialog.kt @@ -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 +) : DialogWrapper(owner) { + + private val model = DefaultListModel() + 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 { + isModal = true + isVisible = true + return model.elements().toList() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileSystemPanel.kt b/src/main/kotlin/app/termora/transport/FileSystemPanel.kt new file mode 100644 index 0000000..cb5b1d3 --- /dev/null +++ b/src/main/kotlin/app/termora/transport/FileSystemPanel.kt @@ -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().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) { + 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) { + 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) + } + } + } + } + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt b/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt new file mode 100644 index 0000000..9c4170a --- /dev/null +++ b/src/main/kotlin/app/termora/transport/FileSystemTabbed.kt @@ -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() + + 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() + 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) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileSystemTableModel.kt b/src/main/kotlin/app/termora/transport/FileSystemTableModel.kt new file mode 100644 index 0000000..0cc188d --- /dev/null +++ b/src/main/kotlin/app/termora/transport/FileSystemTableModel.kt @@ -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? = null + private val propertyChangeListeners = mutableListOf() + + 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() + 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 { + val result = mutableSetOf() + + // 将十进制权限转换为八进制字符串 + 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 + by lazy { fromSftpPermissions(attributes.permissions) } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileSystemTransportListener.kt b/src/main/kotlin/app/termora/transport/FileSystemTransportListener.kt new file mode 100644 index 0000000..1bc414d --- /dev/null +++ b/src/main/kotlin/app/termora/transport/FileSystemTransportListener.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileTransportPanel.kt b/src/main/kotlin/app/termora/transport/FileTransportPanel.kt new file mode 100644 index 0000000..bc137b5 --- /dev/null +++ b/src/main/kotlin/app/termora/transport/FileTransportPanel.kt @@ -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, 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) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/FileTransportTableModel.kt b/src/main/kotlin/app/termora/transport/FileTransportTableModel.kt new file mode 100644 index 0000000..0c33156 --- /dev/null +++ b/src/main/kotlin/app/termora/transport/FileTransportTableModel.kt @@ -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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/PosixFilePermissionDialog.kt b/src/main/kotlin/app/termora/transport/PosixFilePermissionDialog.kt new file mode 100644 index 0000000..f3dfce7 --- /dev/null +++ b/src/main/kotlin/app/termora/transport/PosixFilePermissionDialog.kt @@ -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 +) : 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? { + isModal = true + isVisible = true + + if (isCancelled) { + return null + } + + val permissions = mutableSetOf() + 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt b/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt new file mode 100644 index 0000000..a32b608 --- /dev/null +++ b/src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt @@ -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() + 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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/Transport.kt b/src/main/kotlin/app/termora/transport/Transport.kt new file mode 100644 index 0000000..4109b8a --- /dev/null +++ b/src/main/kotlin/app/termora/transport/Transport.kt @@ -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() + + @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>() + 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 + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/TransportListener.kt b/src/main/kotlin/app/termora/transport/TransportListener.kt new file mode 100644 index 0000000..735ccda --- /dev/null +++ b/src/main/kotlin/app/termora/transport/TransportListener.kt @@ -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) +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/TransportManager.kt b/src/main/kotlin/app/termora/transport/TransportManager.kt new file mode 100644 index 0000000..beec91c --- /dev/null +++ b/src/main/kotlin/app/termora/transport/TransportManager.kt @@ -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()) + private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) } + private val isProcessing = AtomicBoolean(false) + private val listeners = mutableListOf() + 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 = 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() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/transport/TransportPanel.kt b/src/main/kotlin/app/termora/transport/TransportPanel.kt new file mode 100644 index 0000000..1355a06 --- /dev/null +++ b/src/main/kotlin/app/termora/transport/TransportPanel.kt @@ -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") + } + } +} \ No newline at end of file diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 2fe7166..556df36 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -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} diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index ab2fccb..4f7a425 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -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=已复制 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index e1ea859..b3903a4 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -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=已複製 diff --git a/src/main/resources/icons/bookmarks.svg b/src/main/resources/icons/bookmarks.svg new file mode 100644 index 0000000..cbb7212 --- /dev/null +++ b/src/main/resources/icons/bookmarks.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/bookmarksOff.svg b/src/main/resources/icons/bookmarksOff.svg new file mode 100644 index 0000000..8c81521 --- /dev/null +++ b/src/main/resources/icons/bookmarksOff.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/src/main/resources/icons/bookmarksOff_dark.svg b/src/main/resources/icons/bookmarksOff_dark.svg new file mode 100644 index 0000000..a2dbda2 --- /dev/null +++ b/src/main/resources/icons/bookmarksOff_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/bookmarks_dark.svg b/src/main/resources/icons/bookmarks_dark.svg new file mode 100644 index 0000000..6fd28df --- /dev/null +++ b/src/main/resources/icons/bookmarks_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/bulletList.svg b/src/main/resources/icons/bulletList.svg new file mode 100644 index 0000000..ed33c60 --- /dev/null +++ b/src/main/resources/icons/bulletList.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/icons/bulletList_dark.svg b/src/main/resources/icons/bulletList_dark.svg new file mode 100644 index 0000000..5340135 --- /dev/null +++ b/src/main/resources/icons/bulletList_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/icons/errorIntroduction.svg b/src/main/resources/icons/errorIntroduction.svg new file mode 100644 index 0000000..c202817 --- /dev/null +++ b/src/main/resources/icons/errorIntroduction.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/errorIntroduction_dark.svg b/src/main/resources/icons/errorIntroduction_dark.svg new file mode 100644 index 0000000..841ab73 --- /dev/null +++ b/src/main/resources/icons/errorIntroduction_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/icons/fileTransfer.svg b/src/main/resources/icons/fileTransfer.svg new file mode 100644 index 0000000..ba0aaa1 --- /dev/null +++ b/src/main/resources/icons/fileTransfer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/fileTransfer_dark.svg b/src/main/resources/icons/fileTransfer_dark.svg new file mode 100644 index 0000000..999484f --- /dev/null +++ b/src/main/resources/icons/fileTransfer_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/icons/listFiles.svg b/src/main/resources/icons/listFiles.svg new file mode 100644 index 0000000..38ee91a --- /dev/null +++ b/src/main/resources/icons/listFiles.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/icons/listFiles_dark.svg b/src/main/resources/icons/listFiles_dark.svg new file mode 100644 index 0000000..798a2b3 --- /dev/null +++ b/src/main/resources/icons/listFiles_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/icons/refresh.svg b/src/main/resources/icons/refresh.svg new file mode 100644 index 0000000..7a4c73d --- /dev/null +++ b/src/main/resources/icons/refresh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/icons/refresh_dark.svg b/src/main/resources/icons/refresh_dark.svg new file mode 100644 index 0000000..3362992 --- /dev/null +++ b/src/main/resources/icons/refresh_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/test/kotlin/app/termora/SFTPTest.kt b/src/test/kotlin/app/termora/SFTPTest.kt new file mode 100644 index 0000000..ec6e2f6 --- /dev/null +++ b/src/test/kotlin/app/termora/SFTPTest.kt @@ -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) + } + } +} \ No newline at end of file