mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-16 18:32:58 +08:00
@@ -17,8 +17,11 @@ import java.io.File
|
||||
import java.net.URI
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.pow
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
|
||||
object Application {
|
||||
private val services = Collections.synchronizedMap(mutableMapOf<KClass<*>, Any>())
|
||||
private lateinit var baseDataDir: File
|
||||
@@ -99,6 +102,10 @@ object Application {
|
||||
return version
|
||||
}
|
||||
|
||||
fun isUnknownVersion(): Boolean {
|
||||
return getVersion().contains("unknown")
|
||||
}
|
||||
|
||||
fun getAppPath(): String {
|
||||
return StringUtils.defaultString(System.getProperty("jpackage.app-path"))
|
||||
}
|
||||
@@ -144,3 +151,28 @@ object Application {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024) return "$bytes B"
|
||||
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB")
|
||||
val exp = (ln(bytes.toDouble()) / ln(1024.0)).toInt()
|
||||
val value = bytes / 1024.0.pow(exp.toDouble())
|
||||
|
||||
return String.format("%.2f %s", value, units[exp])
|
||||
}
|
||||
|
||||
fun formatSeconds(seconds: Long): String {
|
||||
val days = seconds / 86400
|
||||
val hours = (seconds % 86400) / 3600
|
||||
val minutes = (seconds % 3600) / 60
|
||||
val remainingSeconds = seconds % 60
|
||||
|
||||
return when {
|
||||
days > 0 -> "${days}天${hours}小时${minutes}分${remainingSeconds}秒"
|
||||
hours > 0 -> "${hours}小时${minutes}分${remainingSeconds}秒"
|
||||
minutes > 0 -> "${minutes}分${remainingSeconds}秒"
|
||||
else -> "${remainingSeconds}秒"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ class HostTree : JTree(), Disposable {
|
||||
private val hostManager get() = HostManager.instance
|
||||
private val editor = OutlineTextField(64)
|
||||
|
||||
var contextmenu = true
|
||||
|
||||
/**
|
||||
* 双击是否打开连接
|
||||
*/
|
||||
var doubleClickConnection = true
|
||||
|
||||
val model = HostTreeModel()
|
||||
val searchableModel = SearchableHostTreeModel(model)
|
||||
|
||||
@@ -122,7 +129,7 @@ class HostTree : JTree(), Disposable {
|
||||
}
|
||||
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
if (doubleClickConnection && SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val host = lastSelectedPathComponent
|
||||
if (host is Host && host.protocol != Protocol.Folder) {
|
||||
ActionManager.getInstance().getAction(Actions.OPEN_HOST)
|
||||
@@ -296,6 +303,8 @@ class HostTree : JTree(), Disposable {
|
||||
}
|
||||
|
||||
private fun showContextMenu(event: MouseEvent) {
|
||||
if (!contextmenu) return
|
||||
|
||||
val lastHost = lastSelectedPathComponent
|
||||
if (lastHost !is Host) {
|
||||
return
|
||||
@@ -356,7 +365,7 @@ class HostTree : JTree(), Disposable {
|
||||
remove.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
"删除后无法恢复,你确定要删除吗?",
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
I18n.getString("termora.remove"),
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.QUESTION_MESSAGE
|
||||
@@ -512,7 +521,7 @@ class HostTree : JTree(), Disposable {
|
||||
collapsePath(TreePath(model.getPathToRoot(node)))
|
||||
}
|
||||
|
||||
private fun getSelectionNodes(): List<Host> {
|
||||
fun getSelectionNodes(): List<Host> {
|
||||
val selectionNodes = selectionModel.selectionPaths.map { it.lastPathComponent }
|
||||
.filterIsInstance<Host>()
|
||||
|
||||
|
||||
119
src/main/kotlin/app/termora/HostTreeDialog.kt
Normal file
119
src/main/kotlin/app/termora/HostTreeDialog.kt
Normal file
@@ -0,0 +1,119 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.db.Database
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.swing.*
|
||||
import javax.swing.tree.TreeSelectionModel
|
||||
|
||||
class HostTreeDialog(owner: Window) : DialogWrapper(owner) {
|
||||
|
||||
private val tree = HostTree()
|
||||
|
||||
val hosts = mutableListOf<Host>()
|
||||
|
||||
var allowMulti = true
|
||||
set(value) {
|
||||
field = value
|
||||
if (value) {
|
||||
tree.selectionModel.selectionMode = TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION
|
||||
} else {
|
||||
tree.selectionModel.selectionMode = TreeSelectionModel.SINGLE_TREE_SELECTION
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width") - 200, UIManager.getInt("Dialog.height") - 150)
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.transport.sftp.select-host")
|
||||
|
||||
tree.setModel(SearchableHostTreeModel(tree.model) { host ->
|
||||
host.protocol == Protocol.Folder || host.protocol == Protocol.SSH
|
||||
})
|
||||
tree.contextmenu = true
|
||||
tree.doubleClickConnection = false
|
||||
tree.dragEnabled = false
|
||||
|
||||
initEvents()
|
||||
|
||||
init()
|
||||
setLocationRelativeTo(null)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowActivated(e: WindowEvent) {
|
||||
removeWindowListener(this)
|
||||
val state = Database.instance.properties.getString("HostTreeDialog.HostTreeExpansionState")
|
||||
if (state != null) {
|
||||
TreeUtils.loadExpansionState(tree, state)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tree.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val node = tree.lastSelectedPathComponent ?: return
|
||||
if (node is Host && node.protocol != Protocol.Folder) {
|
||||
doOKAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosed(e: WindowEvent) {
|
||||
Database.instance.properties.putString(
|
||||
"HostTreeDialog.HostTreeExpansionState",
|
||||
TreeUtils.saveExpansionState(tree)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val scrollPane = JScrollPane(tree)
|
||||
scrollPane.border = BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor),
|
||||
BorderFactory.createEmptyBorder(4, 6, 4, 6)
|
||||
)
|
||||
|
||||
return scrollPane
|
||||
}
|
||||
|
||||
override fun doOKAction() {
|
||||
|
||||
if (allowMulti) {
|
||||
val nodes = tree.getSelectionNodes().filter { it.protocol == Protocol.SSH }
|
||||
if (nodes.isEmpty()) {
|
||||
return
|
||||
}
|
||||
hosts.clear()
|
||||
hosts.addAll(nodes)
|
||||
} else {
|
||||
val node = tree.lastSelectedPathComponent ?: return
|
||||
if (node !is Host || node.protocol != Protocol.SSH) {
|
||||
return
|
||||
}
|
||||
hosts.clear()
|
||||
hosts.add(node)
|
||||
}
|
||||
|
||||
|
||||
super.doOKAction()
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
hosts.clear()
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.termora
|
||||
|
||||
object Icons {
|
||||
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
|
||||
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
|
||||
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
|
||||
val close by lazy { DynamicIcon("icons/close.svg", "icons/close_dark.svg") }
|
||||
@@ -14,6 +15,7 @@ object Icons {
|
||||
val pin by lazy { DynamicIcon("icons/pin.svg", "icons/pin_dark.svg") }
|
||||
val empty by lazy { DynamicIcon("icons/empty.svg") }
|
||||
val add by lazy { DynamicIcon("icons/add.svg", "icons/add_dark.svg") }
|
||||
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
|
||||
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
|
||||
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
|
||||
val daemonSets by lazy { DynamicIcon("icons/daemonSets.svg", "icons/daemonSets_dark.svg") }
|
||||
@@ -26,6 +28,8 @@ object Icons {
|
||||
val rec by lazy { DynamicIcon("icons/rec.svg", "icons/rec_dark.svg") }
|
||||
val stop by lazy { DynamicIcon("icons/stop.svg", "icons/stop_dark.svg") }
|
||||
val find by lazy { DynamicIcon("icons/find.svg", "icons/find_dark.svg") }
|
||||
val bookmarks by lazy { DynamicIcon("icons/bookmarks.svg", "icons/bookmarks_dark.svg") }
|
||||
val bookmarksOff by lazy { DynamicIcon("icons/bookmarksOff.svg", "icons/bookmarksOff_dark.svg") }
|
||||
val keyboard by lazy { DynamicIcon("icons/keyboard.svg", "icons/keyboard_dark.svg") }
|
||||
val moreVertical by lazy { DynamicIcon("icons/moreVertical.svg", "icons/moreVertical_dark.svg") }
|
||||
val colors by lazy { DynamicIcon("icons/colors.svg", "icons/colors_dark.svg") }
|
||||
@@ -64,9 +68,12 @@ object Icons {
|
||||
val split by lazy { DynamicIcon("icons/split.svg", "icons/split_dark.svg") }
|
||||
val setKey by lazy { DynamicIcon("icons/setKey.svg", "icons/setKey_dark.svg") }
|
||||
val greyKey by lazy { DynamicIcon("icons/greyKey.svg", "icons/greyKey_dark.svg") }
|
||||
val refresh by lazy { DynamicIcon("icons/refresh.svg", "icons/refresh_dark.svg") }
|
||||
val sortedSet by lazy { DynamicIcon("icons/sortedSet.svg", "icons/sortedSet_dark.svg") }
|
||||
val colorPicker by lazy { DynamicIcon("icons/colorPicker.svg", "icons/colorPicker_dark.svg") }
|
||||
val folder by lazy { DynamicIcon("icons/folder.svg", "icons/folder_dark.svg") }
|
||||
val listFiles by lazy { DynamicIcon("icons/listFiles.svg", "icons/listFiles_dark.svg") }
|
||||
val fileTransfer by lazy { DynamicIcon("icons/fileTransfer.svg", "icons/fileTransfer_dark.svg") }
|
||||
val help by lazy { DynamicIcon("icons/help.svg", "icons/help_dark.svg") }
|
||||
val expand by lazy { DynamicIcon("icons/expand.svg", "icons/expand_dark.svg") }
|
||||
val collapse by lazy { DynamicIcon("icons/collapse.svg", "icons/collapse_dark.svg") }
|
||||
|
||||
53
src/main/kotlin/app/termora/SFTPTerminalTab.kt
Normal file
53
src/main/kotlin/app/termora/SFTPTerminalTab.kt
Normal file
@@ -0,0 +1,53 @@
|
||||
package app.termora
|
||||
|
||||
import app.termora.transport.TransportPanel
|
||||
import java.beans.PropertyChangeListener
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class SFTPTerminalTab : Disposable, TerminalTab {
|
||||
|
||||
private val transportPanel by lazy {
|
||||
TransportPanel().apply {
|
||||
Disposer.register(this@SFTPTerminalTab, this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
return "SFTP"
|
||||
}
|
||||
|
||||
override fun getIcon(): Icon {
|
||||
return Icons.fileTransfer
|
||||
}
|
||||
|
||||
override fun addPropertyChangeListener(listener: PropertyChangeListener) {
|
||||
|
||||
}
|
||||
|
||||
override fun removePropertyChangeListener(listener: PropertyChangeListener) {
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return transportPanel
|
||||
}
|
||||
|
||||
|
||||
override fun canClose(): Boolean {
|
||||
assertEventDispatchThread()
|
||||
|
||||
if (transportPanel.transportManager.getTransports().isEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(getJComponent()),
|
||||
I18n.getString("termora.transport.sftp.close-tab"),
|
||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
) == JOptionPane.OK_OPTION
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import javax.swing.event.TreeModelListener
|
||||
import javax.swing.tree.TreeModel
|
||||
import javax.swing.tree.TreePath
|
||||
|
||||
class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
|
||||
class SearchableHostTreeModel(
|
||||
private val model: HostTreeModel,
|
||||
private val filter: (host: Host) -> Boolean = { true }
|
||||
) : TreeModel {
|
||||
private var text = String()
|
||||
|
||||
override fun getRoot(): Any {
|
||||
@@ -45,7 +48,8 @@ class SearchableHostTreeModel(private val model: HostTreeModel) : TreeModel {
|
||||
val children = model.getChildren(parent)
|
||||
if (children.isEmpty()) return emptyList()
|
||||
return children.filter { e ->
|
||||
e.name.contains(text, true) || TreeUtils.children(model, e, true).filterIsInstance<Host>().any {
|
||||
filter.invoke(e) && e.name.contains(text, true) || TreeUtils.children(model, e, true)
|
||||
.filterIsInstance<Host>().any {
|
||||
it.name.contains(text, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,5 +37,10 @@ interface TerminalTab : Disposable {
|
||||
fun onLostFocus() {}
|
||||
fun onGrabFocus() {}
|
||||
|
||||
/**
|
||||
* @return 返回 false 则不可关闭
|
||||
*/
|
||||
fun canClose(): Boolean = true
|
||||
|
||||
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package app.termora
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import java.awt.event.WindowAdapter
|
||||
import java.awt.event.WindowEvent
|
||||
import javax.swing.*
|
||||
|
||||
class TerminalTabDialog(
|
||||
owner: Window,
|
||||
@@ -19,10 +19,20 @@ class TerminalTabDialog(
|
||||
isAlwaysOnTop = false
|
||||
iconImages = owner.iconImages
|
||||
escapeDispose = false
|
||||
|
||||
|
||||
super.setSize(size)
|
||||
|
||||
init()
|
||||
|
||||
defaultCloseOperation = WindowConstants.DO_NOTHING_ON_CLOSE
|
||||
addWindowListener(object : WindowAdapter() {
|
||||
override fun windowClosing(e: WindowEvent) {
|
||||
if (terminalTab.canClose()) {
|
||||
SwingUtilities.invokeLater { doCancelAction() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setLocationRelativeTo(null)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ class TerminalTabbed(
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||
val index = tabbedPane.indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
if (index > 0) {
|
||||
tabbedPane.getComponentAt(index).requestFocusInWindow()
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,13 @@ class TerminalTabbed(
|
||||
private fun removeTabAt(index: Int, disposable: Boolean = true) {
|
||||
if (tabbedPane.isTabClosable(index)) {
|
||||
val tab = tabs[index]
|
||||
|
||||
if (disposable) {
|
||||
if (!tab.canClose()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tab.onLostFocus()
|
||||
tab.removePropertyChangeListener(iconListener)
|
||||
|
||||
@@ -244,6 +251,7 @@ class TerminalTabbed(
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
|
||||
// 修改名称
|
||||
val rename = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.rename"))
|
||||
rename.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
@@ -261,6 +269,7 @@ class TerminalTabbed(
|
||||
|
||||
}
|
||||
|
||||
// 克隆
|
||||
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
|
||||
clone.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
@@ -272,9 +281,9 @@ class TerminalTabbed(
|
||||
.actionPerformed(OpenHostActionEvent(this, tab.host))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 在新窗口中打开
|
||||
val openInNewWindow = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.open-in-new-window"))
|
||||
openInNewWindow.addActionListener {
|
||||
val index = tabbedPane.selectedIndex
|
||||
@@ -294,11 +303,13 @@ class TerminalTabbed(
|
||||
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 关闭
|
||||
val close = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close"))
|
||||
close.addActionListener {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabIndex)
|
||||
}
|
||||
|
||||
// 关闭其他标签页
|
||||
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-other-tabs")).addActionListener {
|
||||
for (i in tabbedPane.tabCount - 1 downTo tabIndex + 1) {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, i)
|
||||
@@ -308,6 +319,7 @@ class TerminalTabbed(
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭所有标签页
|
||||
popupMenu.add(I18n.getString("termora.tabbed.contextmenu.close-all-tabs")).addActionListener {
|
||||
for (i in 0 until tabbedPane.tabCount) {
|
||||
tabbedPane.tabCloseCallback?.accept(tabbedPane, tabbedPane.tabCount - 1)
|
||||
@@ -320,6 +332,11 @@ class TerminalTabbed(
|
||||
clone.isEnabled = close.isEnabled
|
||||
openInNewWindow.isEnabled = close.isEnabled
|
||||
|
||||
// SFTP不允许克隆
|
||||
if (clone.isEnabled && getSelectedTerminalTab() is SFTPTerminalTab) {
|
||||
clone.isEnabled = false
|
||||
}
|
||||
|
||||
|
||||
if (close.isEnabled) {
|
||||
popupMenu.addSeparator()
|
||||
@@ -400,5 +417,14 @@ class TerminalTabbed(
|
||||
return tabs
|
||||
}
|
||||
|
||||
override fun setSelectedTerminalTab(tab: TerminalTab) {
|
||||
for (index in tabs.indices) {
|
||||
if (tabs[index] == tab) {
|
||||
tabbedPane.selectedIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -4,4 +4,5 @@ interface TerminalTabbedManager {
|
||||
fun addTerminalTab(tab: TerminalTab)
|
||||
fun getSelectedTerminalTab(): TerminalTab?
|
||||
fun getTerminalTabs(): List<TerminalTab>
|
||||
fun setSelectedTerminalTab(tab: TerminalTab)
|
||||
}
|
||||
@@ -392,9 +392,6 @@ class TermoraFrame : JFrame() {
|
||||
if (e.source == tabbedPane) {
|
||||
val index = tabbedPane.indexAtLocation(e.x, e.y)
|
||||
if (index >= 0) {
|
||||
if (e.id == MouseEvent.MOUSE_CLICKED) {
|
||||
tabbedPane.getComponentAt(index)?.requestFocusInWindow()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package app.termora.findeverywhere
|
||||
|
||||
import app.termora.Actions
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import org.jdesktop.swingx.action.ActionManager
|
||||
import java.awt.event.ActionEvent
|
||||
import javax.swing.Icon
|
||||
|
||||
class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
|
||||
@@ -15,6 +14,24 @@ class QuickCommandFindEverywhereProvider : FindEverywhereProvider {
|
||||
ActionManager.getInstance().getAction(Actions.ADD_HOST)?.let {
|
||||
list.add(CreateHostFindEverywhereResult())
|
||||
}
|
||||
|
||||
// SFTP
|
||||
list.add(ActionFindEverywhereResult(object : AnAction("SFTP", Icons.fileTransfer) {
|
||||
override fun actionPerformed(evt: ActionEvent) {
|
||||
val terminalTabbedManager = Application.getService(TerminalTabbedManager::class)
|
||||
val tabs = terminalTabbedManager.getTerminalTabs()
|
||||
for (i in tabs.indices) {
|
||||
val tab = tabs[i]
|
||||
if (tab is SFTPTerminalTab) {
|
||||
terminalTabbedManager.setSelectedTerminalTab(tab)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 创建一个新的
|
||||
terminalTabbedManager.addTerminalTab(SFTPTerminalTab())
|
||||
}
|
||||
}))
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
|
||||
164
src/main/kotlin/app/termora/transport/BookmarkButton.kt
Normal file
164
src/main/kotlin/app/termora/transport/BookmarkButton.kt
Normal file
@@ -0,0 +1,164 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import app.termora.assertEventDispatchThread
|
||||
import app.termora.db.Database
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.ui.FlatUIUtils
|
||||
import kotlinx.serialization.encodeToString
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.*
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.JButton
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
private val properties by lazy { Database.instance.properties }
|
||||
private val arrowWidth = 16
|
||||
private val arrowSize = 6
|
||||
|
||||
/**
|
||||
* 为 true 表示在书签内
|
||||
*/
|
||||
var isBookmark = false
|
||||
set(value) {
|
||||
field = value
|
||||
icon = if (value) {
|
||||
Icons.bookmarksOff
|
||||
} else {
|
||||
Icons.bookmarks
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
val oldWidth = preferredSize.width
|
||||
|
||||
preferredSize = Dimension(oldWidth + arrowWidth, preferredSize.height)
|
||||
horizontalAlignment = SwingConstants.LEFT
|
||||
|
||||
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||
if (e.x < oldWidth) {
|
||||
super@BookmarkButton.fireActionPerformed(
|
||||
ActionEvent(
|
||||
this@BookmarkButton,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
StringUtils.EMPTY
|
||||
)
|
||||
)
|
||||
} else {
|
||||
showBookmarks(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
isBookmark = false
|
||||
}
|
||||
|
||||
private fun showBookmarks(e: MouseEvent) {
|
||||
if (StringUtils.isBlank(name)) return
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val bookmarks = getBookmarks()
|
||||
popupMenu.add(I18n.getString("termora.transport.bookmarks")).addActionListener {
|
||||
val list = BookmarksDialog(SwingUtilities.getWindowAncestor(this), bookmarks).open()
|
||||
properties.putString(name, ohMyJson.encodeToString(list))
|
||||
}
|
||||
|
||||
if (bookmarks.isNotEmpty()) {
|
||||
popupMenu.addSeparator()
|
||||
for (bookmark in bookmarks) {
|
||||
popupMenu.add(bookmark).addActionListener {
|
||||
super@BookmarkButton.fireActionPerformed(
|
||||
ActionEvent(
|
||||
this@BookmarkButton,
|
||||
ActionEvent.ACTION_PERFORMED,
|
||||
bookmark
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
popupMenu.show(e.component, -(popupMenu.preferredSize.width / 2 - width / 2), height + 2)
|
||||
}
|
||||
|
||||
fun addBookmark(text: String) {
|
||||
assertEventDispatchThread()
|
||||
if (StringUtils.isBlank(name)) return
|
||||
val bookmarks = getBookmarks().toMutableList()
|
||||
bookmarks.add(text)
|
||||
properties.putString(name, ohMyJson.encodeToString(bookmarks))
|
||||
}
|
||||
|
||||
fun deleteBookmark(text: String) {
|
||||
assertEventDispatchThread()
|
||||
if (StringUtils.isBlank(name)) return
|
||||
val bookmarks = getBookmarks().toMutableList()
|
||||
bookmarks.removeIf { text == it }
|
||||
properties.putString(name, ohMyJson.encodeToString(bookmarks))
|
||||
}
|
||||
|
||||
fun getBookmarks(): List<String> {
|
||||
if (StringUtils.isBlank(name)) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
||||
val text = properties.getString(name, "[]")
|
||||
if (StringUtils.isNotBlank(text)) {
|
||||
runCatching { ohMyJson.decodeFromString<List<String>>(text) }.onSuccess {
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2d = g as Graphics2D
|
||||
super.paintComponent(g2d)
|
||||
|
||||
val x = preferredSize.width - arrowWidth
|
||||
|
||||
g.color = DynamicColor.BorderColor
|
||||
g.drawLine(x + 1, 4, x + 1, preferredSize.height - 2)
|
||||
|
||||
g.color = if (FlatLaf.isLafDark()) Color(206, 208, 214) else Color(108, 112, 126)
|
||||
|
||||
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
FlatUIUtils.paintArrow(
|
||||
g2d, x, preferredSize.height / 2 - arrowSize, arrowWidth, arrowWidth, SwingConstants.SOUTH,
|
||||
false, arrowSize, 0f, 0f, 0f
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
|
||||
override fun isSelected(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 忽略默认的触发事件
|
||||
*/
|
||||
override fun fireActionPerformed(event: ActionEvent) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
157
src/main/kotlin/app/termora/transport/BookmarksDialog.kt
Normal file
157
src/main/kotlin/app/termora/transport/BookmarksDialog.kt
Normal file
@@ -0,0 +1,157 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.I18n
|
||||
import app.termora.OptionPane
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import javax.swing.*
|
||||
import javax.swing.border.EmptyBorder
|
||||
|
||||
class BookmarksDialog(
|
||||
owner: Window,
|
||||
bookmarks: List<String>
|
||||
) : DialogWrapper(owner) {
|
||||
|
||||
private val model = DefaultListModel<String>()
|
||||
private val list = JList(model)
|
||||
|
||||
private val upBtn = JButton(I18n.getString("termora.transport.bookmarks.up"))
|
||||
private val downBtn = JButton(I18n.getString("termora.transport.bookmarks.down"))
|
||||
private val deleteBtn = JButton(I18n.getString("termora.remove"))
|
||||
|
||||
|
||||
init {
|
||||
size = Dimension(UIManager.getInt("Dialog.width"), UIManager.getInt("Dialog.height"))
|
||||
isModal = true
|
||||
title = I18n.getString("termora.transport.bookmarks")
|
||||
|
||||
initView()
|
||||
initEvents()
|
||||
|
||||
model.addAll(bookmarks)
|
||||
|
||||
|
||||
init()
|
||||
setLocationRelativeTo(null)
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
upBtn.isEnabled = false
|
||||
downBtn.isEnabled = false
|
||||
deleteBtn.isEnabled = false
|
||||
|
||||
upBtn.isFocusable = false
|
||||
downBtn.isFocusable = false
|
||||
deleteBtn.isFocusable = false
|
||||
|
||||
list.fixedCellHeight = UIManager.getInt("Tree.rowHeight")
|
||||
list.selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
upBtn.addActionListener {
|
||||
val rows = list.selectedIndices.sorted()
|
||||
list.clearSelection()
|
||||
|
||||
for (row in rows) {
|
||||
val a = model.getElementAt(row - 1)
|
||||
val b = model.getElementAt(row)
|
||||
model.setElementAt(b, row - 1)
|
||||
model.setElementAt(a, row)
|
||||
list.selectionModel.addSelectionInterval(row - 1, row - 1)
|
||||
}
|
||||
}
|
||||
|
||||
downBtn.addActionListener {
|
||||
val rows = list.selectedIndices.sortedDescending()
|
||||
list.clearSelection()
|
||||
|
||||
for (row in rows) {
|
||||
val a = model.getElementAt(row + 1)
|
||||
val b = model.getElementAt(row)
|
||||
model.setElementAt(b, row + 1)
|
||||
model.setElementAt(a, row)
|
||||
list.selectionModel.addSelectionInterval(row + 1, row + 1)
|
||||
}
|
||||
}
|
||||
|
||||
deleteBtn.addActionListener {
|
||||
if (list.selectionModel.selectedItemsCount > 0) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
for (e in list.selectionModel.selectedIndices.sortedDescending()) {
|
||||
model.removeElementAt(e)
|
||||
}
|
||||
|
||||
if (model.size > 0) {
|
||||
list.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
list.selectionModel.addListSelectionListener {
|
||||
upBtn.isEnabled = list.selectionModel.selectedItemsCount > 0
|
||||
downBtn.isEnabled = upBtn.isEnabled
|
||||
deleteBtn.isEnabled = upBtn.isEnabled
|
||||
|
||||
upBtn.isEnabled = list.minSelectionIndex != 0
|
||||
downBtn.isEnabled = list.maxSelectionIndex != model.size - 1
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
|
||||
val panel = JPanel(BorderLayout())
|
||||
panel.add(JScrollPane(list).apply {
|
||||
border = BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.BorderColor)
|
||||
}, BorderLayout.CENTER)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
panel.add(
|
||||
FormBuilder.create().layout(layout).padding(EmptyBorder(0, 12, 0, 0))
|
||||
.add(upBtn).xy(1, rows).apply { rows += step }
|
||||
.add(downBtn).xy(1, rows).apply { rows += step }
|
||||
.add(deleteBtn).xy(1, rows).apply { rows += step }
|
||||
.build(),
|
||||
BorderLayout.EAST)
|
||||
|
||||
panel.border = BorderFactory.createEmptyBorder(
|
||||
if (SystemInfo.isWindows || SystemInfo.isLinux) 6 else 0,
|
||||
12, 12, 12
|
||||
)
|
||||
|
||||
return panel
|
||||
}
|
||||
|
||||
override fun createSouthPanel(): JComponent? {
|
||||
return null
|
||||
}
|
||||
|
||||
fun open(): List<String> {
|
||||
isModal = true
|
||||
isVisible = true
|
||||
return model.elements().toList()
|
||||
}
|
||||
|
||||
}
|
||||
787
src/main/kotlin/app/termora/transport/FileSystemPanel.kt
Normal file
787
src/main/kotlin/app/termora/transport/FileSystemPanel.kt
Normal file
@@ -0,0 +1,787 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||
import com.formdev.flatlaf.icons.FlatFileViewDirectoryIcon
|
||||
import com.formdev.flatlaf.icons.FlatFileViewFileIcon
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.sftp.client.SftpClient
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.apache.sshd.sftp.client.fs.SftpPath
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Desktop
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.dnd.DnDConstants
|
||||
import java.awt.dnd.DropTarget
|
||||
import java.awt.dnd.DropTargetDropEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.io.File
|
||||
import java.nio.file.*
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.isDirectory
|
||||
|
||||
|
||||
/**
|
||||
* 文件系统面板
|
||||
*/
|
||||
class FileSystemPanel(
|
||||
private val fileSystem: FileSystem,
|
||||
private val transportManager: TransportManager,
|
||||
private val host: Host
|
||||
) : JPanel(BorderLayout()), Disposable,
|
||||
FileSystemTransportListener.Provider {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(FileSystemPanel::class.java)
|
||||
}
|
||||
|
||||
private val tableModel = FileSystemTableModel(fileSystem)
|
||||
private val table = JTable(tableModel)
|
||||
private val parentBtn = JButton(Icons.up)
|
||||
private val workdirTextField = OutlineTextField()
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val layeredPane = FileSystemLayeredPane()
|
||||
private val loadingPanel = LoadingPanel()
|
||||
private val bookmarkBtn = BookmarkButton()
|
||||
private val homeBtn = JButton(Icons.homeFolder)
|
||||
|
||||
val workdir get() = tableModel.workdir
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
// 设置书签名称
|
||||
bookmarkBtn.name = "Host.${host.id}.Bookmarks"
|
||||
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdir.toString())
|
||||
|
||||
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
|
||||
table.fillsViewportHeight = true
|
||||
table.putClientProperty(
|
||||
FlatClientProperties.STYLE, mapOf(
|
||||
"showHorizontalLines" to true,
|
||||
"showVerticalLines" to true,
|
||||
)
|
||||
)
|
||||
|
||||
table.setDefaultRenderer(
|
||||
Any::class.java,
|
||||
DefaultTableCellRenderer().apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
)
|
||||
|
||||
val modifyDateColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_LAST_MODIFIED_TIME)
|
||||
modifyDateColumn.preferredWidth = 130
|
||||
|
||||
val nameColumn = table.columnModel.getColumn(FileSystemTableModel.COLUMN_NAME)
|
||||
nameColumn.preferredWidth = 250
|
||||
nameColumn.setCellRenderer(object : DefaultTableCellRenderer() {
|
||||
private val b = BorderFactory.createEmptyBorder(0, 4, 0, 0)
|
||||
private val d = FlatFileViewDirectoryIcon()
|
||||
private val f = FlatFileViewFileIcon()
|
||||
|
||||
override fun getTableCellRendererComponent(
|
||||
table: JTable?,
|
||||
value: Any,
|
||||
isSelected: Boolean,
|
||||
hasFocus: Boolean,
|
||||
row: Int,
|
||||
column: Int
|
||||
): Component {
|
||||
var text = value.toString()
|
||||
// name
|
||||
if (value is FileSystemTableModel.CacheablePath) {
|
||||
text = value.fileName
|
||||
icon = if (value.isDirectory) d else f
|
||||
iconTextGap = 4
|
||||
}
|
||||
|
||||
val c = super.getTableCellRendererComponent(table, text, isSelected, hasFocus, row, column)
|
||||
border = b
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
parentBtn.toolTipText = I18n.getString("termora.transport.parent-folder")
|
||||
|
||||
|
||||
val toolbar = FlatToolBar()
|
||||
toolbar.add(homeBtn)
|
||||
toolbar.add(Box.createHorizontalStrut(2))
|
||||
toolbar.add(workdirTextField)
|
||||
toolbar.add(bookmarkBtn)
|
||||
toolbar.add(parentBtn)
|
||||
toolbar.add(JButton(Icons.refresh).apply {
|
||||
addActionListener { reload() }
|
||||
toolTipText = I18n.getString("termora.transport.table.contextmenu.refresh")
|
||||
})
|
||||
toolbar.border = BorderFactory.createEmptyBorder(4, 2, 4, 2)
|
||||
|
||||
val scrollPane = JScrollPane(table)
|
||||
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||
layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any)
|
||||
layeredPane.add(loadingPanel, JLayeredPane.MODAL_LAYER as Any)
|
||||
|
||||
add(toolbar, BorderLayout.NORTH)
|
||||
add(layeredPane, BorderLayout.CENTER)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
homeBtn.addActionListener {
|
||||
if (tableModel.isLocalFileSystem) {
|
||||
tableModel.workdir(SystemUtils.USER_HOME)
|
||||
} else if (fileSystem is SftpFileSystem) {
|
||||
tableModel.workdir(fileSystem.defaultDir)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
bookmarkBtn.addActionListener { e ->
|
||||
if (e.actionCommand.isNullOrBlank()) {
|
||||
if (bookmarkBtn.isBookmark) {
|
||||
bookmarkBtn.deleteBookmark(workdir.toString())
|
||||
} else {
|
||||
bookmarkBtn.addBookmark(workdir.toString())
|
||||
}
|
||||
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
|
||||
} else if (!loadingPanel.isLoading) {
|
||||
tableModel.workdir(e.actionCommand)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
// contextmenu
|
||||
table.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isRightMouseButton(e)) {
|
||||
val r = table.rowAtPoint(e.point)
|
||||
if (r >= 0 && r < table.rowCount) {
|
||||
if (!table.isRowSelected(r)) {
|
||||
table.setRowSelectionInterval(r, r)
|
||||
}
|
||||
} else {
|
||||
table.clearSelection()
|
||||
}
|
||||
|
||||
val rows = table.selectedRows
|
||||
|
||||
if (!table.hasFocus()) {
|
||||
table.requestFocusInWindow()
|
||||
}
|
||||
|
||||
showContextMenu(rows.filter { it != 0 }.toIntArray(), e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// double click
|
||||
table.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
val row = table.selectedRow
|
||||
if (row < 0) return
|
||||
val path = tableModel.getCacheablePath(row)
|
||||
if (path.isDirectory) {
|
||||
openFolder()
|
||||
} else {
|
||||
transport(listOf(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 本地文件系统不支持本地拖拽进去
|
||||
if (!tableModel.isLocalFileSystem) {
|
||||
table.dropTarget = object : DropTarget() {
|
||||
override fun drop(dtde: DropTargetDropEvent) {
|
||||
val transportPanel = getTransportPanel() ?: return
|
||||
val localFileSystemPanel = transportPanel.leftFileSystemTabbed.getFileSystemPanel(0) ?: return
|
||||
|
||||
dtde.acceptDrop(DnDConstants.ACTION_COPY)
|
||||
val files = dtde.transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*>
|
||||
if (files.isEmpty()) return
|
||||
|
||||
val paths = files.filterIsInstance<File>().map { FileSystemTableModel.CacheablePath(it.toPath()) }
|
||||
for (path in paths) {
|
||||
if (path.isDirectory) {
|
||||
Files.walk(path.path).use {
|
||||
for (e in it) {
|
||||
transportPanel.transport(
|
||||
sourceWorkdir = path.path.parent,
|
||||
targetWorkdir = workdir,
|
||||
isSourceDirectory = e.isDirectory(),
|
||||
sourcePath = e,
|
||||
sourceHolder = localFileSystemPanel,
|
||||
targetHolder = this@FileSystemPanel
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
transportPanel.transport(
|
||||
sourceWorkdir = localFileSystemPanel.workdir,
|
||||
targetWorkdir = workdir,
|
||||
isSourceDirectory = false,
|
||||
sourcePath = path.path,
|
||||
sourceHolder = localFileSystemPanel,
|
||||
targetHolder = this@FileSystemPanel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
this.defaultActions = DnDConstants.ACTION_COPY
|
||||
}
|
||||
}
|
||||
|
||||
// 工作目录变动
|
||||
tableModel.addPropertyChangeListener {
|
||||
if (it.propertyName == "workdir") {
|
||||
workdirTextField.text = tableModel.workdir.toAbsolutePath().toString()
|
||||
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(workdirTextField.text)
|
||||
}
|
||||
}
|
||||
|
||||
// 修改工作目录
|
||||
workdirTextField.addActionListener {
|
||||
val text = workdirTextField.text
|
||||
if (text.isBlank()) {
|
||||
workdirTextField.text = tableModel.workdir.toAbsolutePath().toString()
|
||||
reload()
|
||||
} else {
|
||||
val path = fileSystem.getPath(workdirTextField.text)
|
||||
if (Files.exists(path)) {
|
||||
tableModel.workdir(path)
|
||||
reload()
|
||||
} else {
|
||||
workdirTextField.outline = "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 返回上一级目录
|
||||
parentBtn.addActionListener {
|
||||
if (tableModel.rowCount > 0) {
|
||||
val path = tableModel.getCacheablePath(0)
|
||||
if (path.isDirectory && path.fileName == "..") {
|
||||
tableModel.workdir(path.path)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun reload() {
|
||||
if (loadingPanel.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching { suspendReload() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun suspendReload() {
|
||||
if (loadingPanel.isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
// reload
|
||||
loadingPanel.start()
|
||||
workdirTextField.text = workdir.toString()
|
||||
}
|
||||
|
||||
try {
|
||||
tableModel.reload()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
table.scrollRectToVisible(table.getCellRect(0, 0, true))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listenerList.add(FileSystemTransportListener::class.java, listener)
|
||||
}
|
||||
|
||||
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listenerList.remove(FileSystemTransportListener::class.java, listener)
|
||||
}
|
||||
|
||||
private fun openFolder() {
|
||||
val row = table.selectedRow
|
||||
if (row < 0) return
|
||||
val path = tableModel.getCacheablePath(row)
|
||||
if (path.isDirectory) {
|
||||
tableModel.workdir(path.path)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canTransfer(): Boolean {
|
||||
return getTransportPanel()?.getTargetFileSystemPanel(this) != null
|
||||
}
|
||||
|
||||
|
||||
private fun getTransportPanel(): TransportPanel? {
|
||||
var p = this as Component?
|
||||
while (p != null) {
|
||||
if (p is TransportPanel) {
|
||||
return p
|
||||
}
|
||||
p = p.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun showContextMenu(rows: IntArray, event: MouseEvent) {
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
|
||||
|
||||
// 创建文件夹
|
||||
newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder")).addActionListener {
|
||||
newFolderOrFile(file = false)
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file")).addActionListener {
|
||||
newFolderOrFile(file = true)
|
||||
}
|
||||
|
||||
|
||||
// 传输
|
||||
val transfer = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.transfer"))
|
||||
transfer.addActionListener {
|
||||
val paths = rows.filter { it != 0 }.map { tableModel.getCacheablePath(it) }
|
||||
if (paths.isNotEmpty()) {
|
||||
transport(paths)
|
||||
}
|
||||
}
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 复制路径
|
||||
val copyPath = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.copy-path"))
|
||||
copyPath.addActionListener {
|
||||
val row = table.selectedRow
|
||||
if (row > 0) {
|
||||
toolkit.systemClipboard.setContents(
|
||||
StringSelection(
|
||||
tableModel.getPath(row).toAbsolutePath().toString()
|
||||
), null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是本地,那么支持打开本地路径
|
||||
if (tableModel.isLocalFileSystem) {
|
||||
if (Desktop.getDesktop().isSupported(Desktop.Action.BROWSE_FILE_DIR)) {
|
||||
popupMenu.add(
|
||||
I18n.getString(
|
||||
"termora.transport.table.contextmenu.open-in-folder",
|
||||
if (SystemInfo.isMacOS) I18n.getString("termora.finder")
|
||||
else if (SystemInfo.isWindows) I18n.getString("termora.explorer")
|
||||
else I18n.getString("termora.folder")
|
||||
)
|
||||
).addActionListener {
|
||||
val row = table.selectedRow
|
||||
if (row > 0) {
|
||||
Desktop.getDesktop().browseFileDirectory(tableModel.getPath(row).toFile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 重命名
|
||||
val rename = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.rename"))
|
||||
rename.addActionListener { renamePath(tableModel.getPath(rows.last())) }
|
||||
|
||||
// 删除
|
||||
val delete = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.delete")).apply {
|
||||
addActionListener { deletePaths(rows) }
|
||||
}
|
||||
|
||||
// rm -rf
|
||||
val rmrf = popupMenu.add(JMenuItem("rm -rf", Icons.errorIntroduction)).apply {
|
||||
addActionListener {
|
||||
deletePaths(rows, true)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有 SFTP 可以
|
||||
if (fileSystem !is SftpFileSystem) {
|
||||
rmrf.isVisible = false
|
||||
}
|
||||
|
||||
// 修改权限
|
||||
val permission = popupMenu.add(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
|
||||
permission.isEnabled = false
|
||||
|
||||
// 如果是本地系统文件,那么不允许修改权限,用户应该自己修改
|
||||
if (!tableModel.isLocalFileSystem && rows.isNotEmpty()) {
|
||||
permission.isEnabled = true
|
||||
permission.addActionListener { changePermissions(tableModel.getCacheablePath(rows.last())) }
|
||||
}
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 刷新
|
||||
popupMenu.add(I18n.getString("termora.transport.table.contextmenu.refresh"))
|
||||
.apply { addActionListener { reload() } }
|
||||
popupMenu.addSeparator()
|
||||
|
||||
// 新建
|
||||
popupMenu.add(newMenu)
|
||||
|
||||
|
||||
if (rows.isEmpty()) {
|
||||
transfer.isEnabled = false
|
||||
rename.isEnabled = false
|
||||
delete.isEnabled = false
|
||||
rmrf.isEnabled = false
|
||||
copyPath.isEnabled = false
|
||||
permission.isEnabled = false
|
||||
} else {
|
||||
transfer.isEnabled = canTransfer()
|
||||
}
|
||||
|
||||
|
||||
popupMenu.show(table, event.x, event.y)
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun renamePath(path: Path) {
|
||||
val fileName = path.fileName.toString()
|
||||
val text = InputDialog(
|
||||
owner = owner,
|
||||
title = fileName,
|
||||
text = fileName,
|
||||
).getText() ?: return
|
||||
|
||||
if (fileName == text) return
|
||||
|
||||
loadingPanel.stop()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching {
|
||||
Files.move(path, path.parent.resolve(text), StandardCopyOption.ATOMIC_MOVE)
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, it.message ?: ExceptionUtils.getRootCauseMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun newFolderOrFile(file: Boolean = false) {
|
||||
val title = I18n.getString("termora.transport.table.contextmenu.new.${if (file) "file" else "folder"}")
|
||||
val text = InputDialog(
|
||||
owner = owner,
|
||||
title = title,
|
||||
).getText() ?: return
|
||||
|
||||
if (text.isEmpty()) return
|
||||
|
||||
loadingPanel.stop()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching {
|
||||
val path = workdir.resolve(text)
|
||||
if (path.exists()) {
|
||||
throw IllegalStateException(I18n.getString("termora.transport.file-already-exists", text))
|
||||
}
|
||||
if (file)
|
||||
Files.createFile(path)
|
||||
else
|
||||
Files.createDirectories(path)
|
||||
}.onFailure {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, it.message ?: ExceptionUtils.getRootCauseMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun deletePaths(rows: IntArray, rm: Boolean = false) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString(if (rm) "termora.transport.table.contextmenu.rm-warning" else "termora.transport.table.contextmenu.delete-warning"),
|
||||
messageType = if (rm) JOptionPane.ERROR_MESSAGE else JOptionPane.WARNING_MESSAGE
|
||||
) != JOptionPane.YES_OPTION
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingPanel.start()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
for (row in rows.sortedDescending()) {
|
||||
try {
|
||||
deleteRecursively(tableModel.getPath(row), rm)
|
||||
withContext(Dispatchers.Swing) {
|
||||
tableModel.removeRow(row)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(it.message, it)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteRecursively(path: Path, rm: Boolean) {
|
||||
if (path.fileSystem == FileSystems.getDefault()) {
|
||||
FileUtils.deleteDirectory(path.toFile())
|
||||
} else if (path.fileSystem is SftpFileSystem) {
|
||||
val fs = path.fileSystem as SftpFileSystem
|
||||
if (rm) {
|
||||
fs.session.executeRemoteCommand("rm -rf '$path'")
|
||||
} else {
|
||||
fs.client.use {
|
||||
deleteRecursivelySFTP(path as SftpPath, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化删除效率,采用一个连接
|
||||
*/
|
||||
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
|
||||
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
|
||||
if (isDirectory) {
|
||||
for (e in sftpClient.readDir(path.toString())) {
|
||||
if (e.filename == ".." || e.filename == ".") {
|
||||
continue
|
||||
}
|
||||
if (e.attributes.isDirectory) {
|
||||
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
|
||||
} else {
|
||||
sftpClient.remove(path.resolve(e.filename).toString())
|
||||
}
|
||||
}
|
||||
sftpClient.rmdir(path.toString())
|
||||
} else {
|
||||
sftpClient.remove(path.toString())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun changePermissions(cacheablePath: FileSystemTableModel.CacheablePath) {
|
||||
val dialog = PosixFilePermissionDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
cacheablePath.posixFilePermissions
|
||||
)
|
||||
val permissions = dialog.open() ?: return
|
||||
|
||||
loadingPanel.start()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching {
|
||||
Files.setPosixFilePermissions(cacheablePath.path, permissions)
|
||||
}
|
||||
|
||||
result.onFailure {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(it.message, it)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
SwingUtilities.getWindowAncestor(this@FileSystemPanel), ExceptionUtils.getRootCauseMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
reload()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun transport(paths: List<FileSystemTableModel.CacheablePath>) {
|
||||
assertEventDispatchThread()
|
||||
if (!canTransfer()) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingPanel.start()
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching { doTransport(paths) }
|
||||
withContext(Dispatchers.Swing) {
|
||||
loadingPanel.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doTransport(paths: List<FileSystemTableModel.CacheablePath>) {
|
||||
if (paths.isEmpty()) return
|
||||
|
||||
val listeners = listenerList.getListeners(FileSystemTransportListener::class.java)
|
||||
if (listeners.isEmpty()) return
|
||||
|
||||
|
||||
// 收集数据
|
||||
for (e in paths) {
|
||||
|
||||
if (!e.isDirectory) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
listeners.forEach { it.transport(this@FileSystemPanel, workdir, false, e.path) }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
Files.walk(e.path).use { walkPaths ->
|
||||
for (path in walkPaths) {
|
||||
if (path is SftpPath) {
|
||||
val isDirectory = if (path.attributes != null)
|
||||
path.attributes.isDirectory else path.isDirectory()
|
||||
withContext(Dispatchers.Swing) {
|
||||
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
} else {
|
||||
val isDirectory = path.isDirectory()
|
||||
withContext(Dispatchers.Swing) {
|
||||
listeners.forEach { it.transport(this@FileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoadingPanel : JPanel() {
|
||||
private val busyLabel = JXBusyLabel()
|
||||
|
||||
val isLoading get() = busyLabel.isBusy
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
border = BorderFactory.createEmptyBorder(50, 0, 0, 0)
|
||||
|
||||
add(busyLabel, BorderLayout.CENTER)
|
||||
addMouseListener(object : MouseAdapter() {})
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
fun start() {
|
||||
busyLabel.isBusy = true
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
busyLabel.isBusy = false
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
private class FileSystemLayeredPane : JLayeredPane() {
|
||||
override fun doLayout() {
|
||||
synchronized(treeLock) {
|
||||
val w = width
|
||||
val h = height
|
||||
for (c in components) {
|
||||
if (c is JScrollPane) {
|
||||
c.setBounds(0, 0, w, h)
|
||||
} else if (c is LoadingPanel) {
|
||||
c.setBounds(0, 0, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
205
src/main/kotlin/app/termora/transport/FileSystemTabbed.kt
Normal file
205
src/main/kotlin/app/termora/transport/FileSystemTabbed.kt
Normal file
@@ -0,0 +1,205 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.awt.Point
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
|
||||
class FileSystemTabbed(
|
||||
private val transportManager: TransportManager,
|
||||
private val isLeft: Boolean = false
|
||||
) : FlatTabbedPane(), FileSystemTransportListener.Provider, Disposable {
|
||||
private val addBtn = JButton(Icons.add)
|
||||
private val listeners = mutableListOf<FileSystemTransportListener>()
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
tabLayoutPolicy = SCROLL_TAB_LAYOUT
|
||||
isTabsClosable = true
|
||||
tabType = TabType.underlined
|
||||
styleMap = mapOf(
|
||||
"focusColor" to UIManager.getColor("TabbedPane.selectedBackground"),
|
||||
)
|
||||
|
||||
|
||||
val toolbar = JToolBar()
|
||||
toolbar.add(addBtn)
|
||||
trailingComponent = toolbar
|
||||
|
||||
if (isLeft) {
|
||||
addFileSystemTransportProvider(
|
||||
I18n.getString("termora.transport.local"),
|
||||
FileSystemPanel(
|
||||
FileSystems.getDefault(),
|
||||
transportManager,
|
||||
host = Host(
|
||||
id = "local",
|
||||
name = I18n.getString("termora.transport.local"),
|
||||
protocol = Protocol.Local,
|
||||
)
|
||||
).apply { reload() }
|
||||
)
|
||||
setTabClosable(0, false)
|
||||
} else {
|
||||
addFileSystemTransportProvider(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SftpFileSystemPanel(transportManager)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener {
|
||||
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this))
|
||||
|
||||
dialog.location = Point(
|
||||
addBtn.locationOnScreen.x - dialog.width / 2 + addBtn.width / 2,
|
||||
addBtn.locationOnScreen.y + max(tabHeight, addBtn.height)
|
||||
)
|
||||
dialog.isVisible = true
|
||||
|
||||
for (host in dialog.hosts) {
|
||||
val panel = SftpFileSystemPanel(transportManager, host)
|
||||
addFileSystemTransportProvider(host.name, panel)
|
||||
panel.connect()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
setTabCloseCallback { _, index ->
|
||||
removeTabAt(index)
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeTabAt(index: Int) {
|
||||
|
||||
val fileSystemPanel = getFileSystemPanel(index)
|
||||
|
||||
// 取消进行中的任务
|
||||
if (fileSystemPanel != null) {
|
||||
val transports = mutableListOf<Transport>()
|
||||
for (transport in transportManager.getTransports()) {
|
||||
if (transport.targetHolder == fileSystemPanel || transport.sourceHolder == fileSystemPanel) {
|
||||
transports.add(transport)
|
||||
}
|
||||
}
|
||||
|
||||
if (transports.isNotEmpty()) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.transport.sftp.close-tab"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
) != JOptionPane.OK_OPTION
|
||||
) {
|
||||
return
|
||||
}
|
||||
transports.sortedBy { it.state == TransportState.Waiting }
|
||||
.forEach { transportManager.removeTransport(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val c = getComponentAt(index)
|
||||
if (c is Disposable) {
|
||||
Disposer.dispose(c)
|
||||
}
|
||||
|
||||
super.removeTabAt(index)
|
||||
|
||||
if (tabCount == 0) {
|
||||
if (!isLeft) {
|
||||
addFileSystemTransportProvider(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SftpFileSystemPanel(transportManager)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
fun addFileSystemTransportProvider(title: String, provider: FileSystemTransportListener.Provider) {
|
||||
if (provider !is JComponent) {
|
||||
throw IllegalArgumentException("Provider is not an JComponent")
|
||||
}
|
||||
|
||||
provider.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
})
|
||||
|
||||
// 修改 Tab名称
|
||||
provider.addPropertyChangeListener("TabName") { e ->
|
||||
SwingUtilities.invokeLater {
|
||||
val name = StringUtils.defaultIfEmpty(
|
||||
e.newValue.toString(),
|
||||
I18n.getString("termora.transport.sftp.select-host")
|
||||
)
|
||||
for (i in 0 until tabCount) {
|
||||
if (getComponentAt(i) == provider) {
|
||||
setTitleAt(i, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addTab(title, provider)
|
||||
|
||||
if (tabCount > 0)
|
||||
selectedIndex = tabCount - 1
|
||||
}
|
||||
|
||||
fun getSelectedFileSystemPanel(): FileSystemPanel? {
|
||||
return getFileSystemPanel(selectedIndex)
|
||||
}
|
||||
|
||||
fun getFileSystemPanel(index: Int): FileSystemPanel? {
|
||||
if (index < 0) return null
|
||||
val c = getComponentAt(index)
|
||||
if (c is SftpFileSystemPanel) {
|
||||
val p = c.fileSystemPanel
|
||||
if (p != null) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
if (c is FileSystemPanel) {
|
||||
return c
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
while (tabCount > 0) {
|
||||
val c = getComponentAt(0)
|
||||
if (c is Disposable) {
|
||||
Disposer.dispose(c)
|
||||
}
|
||||
super.removeTabAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
232
src/main/kotlin/app/termora/transport/FileSystemTableModel.kt
Normal file
232
src/main/kotlin/app/termora/transport/FileSystemTableModel.kt
Normal file
@@ -0,0 +1,232 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.formatBytes
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.apache.sshd.sftp.client.fs.SftpPath
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.beans.PropertyChangeEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
import java.util.*
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.table.DefaultTableModel
|
||||
import kotlin.io.path.*
|
||||
|
||||
|
||||
class FileSystemTableModel(private val fileSystem: FileSystem) : DefaultTableModel() {
|
||||
|
||||
|
||||
companion object {
|
||||
const val COLUMN_NAME = 0
|
||||
const val COLUMN_TYPE = 1
|
||||
const val COLUMN_FILE_SIZE = 2
|
||||
const val COLUMN_LAST_MODIFIED_TIME = 3
|
||||
const val COLUMN_ATTRS = 4
|
||||
const val COLUMN_OWNER = 5
|
||||
}
|
||||
|
||||
private val root = fileSystem.rootDirectories.first()
|
||||
|
||||
var workdir: Path = if (fileSystem is SftpFileSystem) fileSystem.defaultDir
|
||||
else fileSystem.getPath(SystemUtils.USER_HOME)
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
private var files: MutableList<CacheablePath>? = null
|
||||
private val propertyChangeListeners = mutableListOf<PropertyChangeListener>()
|
||||
|
||||
val isLocalFileSystem by lazy { FileSystems.getDefault() == fileSystem }
|
||||
|
||||
override fun getRowCount(): Int {
|
||||
return files?.size ?: 0
|
||||
}
|
||||
|
||||
override fun getValueAt(row: Int, column: Int): Any {
|
||||
val path = files?.get(row) ?: return StringUtils.EMPTY
|
||||
|
||||
if (path.fileName == ".." && column != 0) {
|
||||
return StringUtils.EMPTY
|
||||
}
|
||||
|
||||
return try {
|
||||
when (column) {
|
||||
COLUMN_NAME -> path
|
||||
COLUMN_FILE_SIZE -> if (path.isDirectory) StringUtils.EMPTY else formatBytes(path.fileSize)
|
||||
COLUMN_TYPE -> if (path.isDirectory) I18n.getString("termora.transport.table.type.folder") else path.extension
|
||||
COLUMN_LAST_MODIFIED_TIME -> DateFormatUtils.format(Date(path.lastModifiedTime), "yyyy/MM/dd HH:mm")
|
||||
|
||||
// 如果是本地的并且还是Windows系统
|
||||
COLUMN_ATTRS -> if (isLocalFileSystem && SystemUtils.IS_OS_WINDOWS) StringUtils.EMPTY else PosixFilePermissions.toString(
|
||||
path.posixFilePermissions
|
||||
)
|
||||
|
||||
COLUMN_OWNER -> path.owner
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return 6
|
||||
}
|
||||
|
||||
override fun getColumnName(column: Int): String {
|
||||
return when (column) {
|
||||
COLUMN_NAME -> I18n.getString("termora.transport.table.filename")
|
||||
COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size")
|
||||
COLUMN_TYPE -> I18n.getString("termora.transport.table.type")
|
||||
COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time")
|
||||
COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions")
|
||||
COLUMN_OWNER -> I18n.getString("termora.transport.table.owner")
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
fun getPath(index: Int): Path {
|
||||
return getCacheablePath(index).path
|
||||
}
|
||||
|
||||
fun getCacheablePath(index: Int): CacheablePath {
|
||||
return files?.get(index) ?: throw IndexOutOfBoundsException()
|
||||
}
|
||||
|
||||
override fun isCellEditable(row: Int, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun removeRow(row: Int) {
|
||||
files?.removeAt(row) ?: return
|
||||
fireTableRowsDeleted(row, row)
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
val files = mutableListOf<CacheablePath>()
|
||||
if (root != workdir) {
|
||||
files.add(CacheablePath(workdir.resolve("..")))
|
||||
}
|
||||
|
||||
Files.list(workdir).use {
|
||||
for (path in it) {
|
||||
if (path is SftpPath) {
|
||||
files.add(SftpCacheablePath(path))
|
||||
} else {
|
||||
files.add(CacheablePath(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sortWith(compareBy({ !it.isDirectory }, { it.fileName }))
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
this.files = files
|
||||
fireTableDataChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun workdir(absolutePath: String) {
|
||||
workdir(fileSystem.getPath(absolutePath))
|
||||
}
|
||||
|
||||
fun workdir(path: Path) {
|
||||
this.workdir = path.toAbsolutePath().normalize()
|
||||
propertyChangeListeners.forEach {
|
||||
it.propertyChange(
|
||||
PropertyChangeEvent(
|
||||
this,
|
||||
"workdir",
|
||||
this.workdir,
|
||||
this.workdir
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addPropertyChangeListener(propertyChangeListener: PropertyChangeListener) {
|
||||
propertyChangeListeners.add(propertyChangeListener)
|
||||
}
|
||||
|
||||
open class CacheablePath(val path: Path) {
|
||||
val fileName by lazy { path.fileName.toString() }
|
||||
val extension by lazy { path.extension }
|
||||
|
||||
open val isDirectory by lazy { path.isDirectory() }
|
||||
open val fileSize by lazy { path.fileSize() }
|
||||
open val lastModifiedTime by lazy { Files.getLastModifiedTime(path).toMillis() }
|
||||
open val owner by lazy { path.getOwner().toString() }
|
||||
open val posixFilePermissions by lazy {
|
||||
kotlin.runCatching { path.getPosixFilePermissions() }.getOrElse { emptySet() }
|
||||
}
|
||||
}
|
||||
|
||||
class SftpCacheablePath(sftpPath: SftpPath) : CacheablePath(sftpPath) {
|
||||
private val attributes = sftpPath.attributes
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SftpCacheablePath::class.java)
|
||||
private fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
|
||||
val result = mutableSetOf<PosixFilePermission>()
|
||||
|
||||
// 将十进制权限转换为八进制字符串
|
||||
val octalPermissions = sftpPermissions.toString(8)
|
||||
|
||||
// 仅取后三位权限部分
|
||||
if (octalPermissions.length < 3) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error("Invalid permission value: {}", sftpPermissions)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
val permissionBits = octalPermissions.takeLast(3)
|
||||
|
||||
// 解析每一部分的权限
|
||||
val owner = permissionBits[0].digitToInt()
|
||||
val group = permissionBits[1].digitToInt()
|
||||
val others = permissionBits[2].digitToInt()
|
||||
|
||||
// 处理所有者权限
|
||||
if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ)
|
||||
if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE)
|
||||
if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE)
|
||||
|
||||
// 处理组权限
|
||||
if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ)
|
||||
if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE)
|
||||
if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE)
|
||||
|
||||
// 处理其他用户权限
|
||||
if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ)
|
||||
if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE)
|
||||
if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
override val isDirectory: Boolean
|
||||
get() = attributes.isDirectory
|
||||
|
||||
override val fileSize: Long
|
||||
get() = attributes.size
|
||||
|
||||
override val lastModifiedTime: Long
|
||||
by lazy { attributes.modifyTime.toMillis() }
|
||||
|
||||
override val owner: String
|
||||
get() = attributes.owner
|
||||
|
||||
override val posixFilePermissions: Set<PosixFilePermission>
|
||||
by lazy { fromSftpPermissions(attributes.permissions) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package app.termora.transport
|
||||
|
||||
import java.nio.file.Path
|
||||
import java.util.*
|
||||
|
||||
interface FileSystemTransportListener : EventListener {
|
||||
/**
|
||||
* @param workdir 当前工作目录
|
||||
* @param isDirectory 要传输的是否是文件夹
|
||||
* @param path 要传输的文件/文件夹
|
||||
*/
|
||||
fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path)
|
||||
|
||||
|
||||
interface Provider {
|
||||
fun addFileSystemTransportListener(listener: FileSystemTransportListener)
|
||||
fun removeFileSystemTransportListener(listener: FileSystemTransportListener)
|
||||
}
|
||||
}
|
||||
162
src/main/kotlin/app/termora/transport/FileTransportPanel.kt
Normal file
162
src/main/kotlin/app/termora/transport/FileTransportPanel.kt
Normal file
@@ -0,0 +1,162 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.I18n
|
||||
import app.termora.OptionPane
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Insets
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
|
||||
class FileTransportPanel(
|
||||
private val transportManager: TransportManager
|
||||
) : JPanel(BorderLayout()), Disposable {
|
||||
|
||||
private val tableModel = FileTransportTableModel(transportManager)
|
||||
private val table = JTable(tableModel)
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
table.fillsViewportHeight = true
|
||||
table.autoResizeMode = JTable.AUTO_RESIZE_OFF
|
||||
table.putClientProperty(
|
||||
FlatClientProperties.STYLE, mapOf(
|
||||
"showHorizontalLines" to true,
|
||||
"showVerticalLines" to true,
|
||||
"cellMargins" to Insets(2, 2, 2, 2)
|
||||
)
|
||||
)
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_NAME).preferredWidth = 200
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
|
||||
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).preferredWidth = 100
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).preferredWidth = 150
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).preferredWidth = 140
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).preferredWidth = 80
|
||||
|
||||
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer =
|
||||
centerTableCellRenderer
|
||||
|
||||
|
||||
table.columnModel.getColumn(FileTransportTableModel.COLUMN_PROGRESS).cellRenderer =
|
||||
object : DefaultTableCellRenderer() {
|
||||
init {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
|
||||
private var lastRow = -1
|
||||
|
||||
override fun getTableCellRendererComponent(
|
||||
table: JTable?,
|
||||
value: Any?,
|
||||
isSelected: Boolean,
|
||||
hasFocus: Boolean,
|
||||
row: Int,
|
||||
column: Int
|
||||
): Component {
|
||||
lastRow = row
|
||||
return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
if (lastRow != -1) {
|
||||
val row = tableModel.getTransport(lastRow)
|
||||
if (row.state == TransportState.Transporting) {
|
||||
g.color = UIManager.getColor("textHighlight")
|
||||
g.fillRect(0, 0, (width * row.progress).toInt(), height)
|
||||
}
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
add(JScrollPane(table).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
// contextmenu
|
||||
table.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isRightMouseButton(e)) {
|
||||
val r = table.rowAtPoint(e.point)
|
||||
if (r >= 0 && r < table.rowCount) {
|
||||
if (!table.isRowSelected(r)) {
|
||||
table.setRowSelectionInterval(r, r)
|
||||
}
|
||||
} else {
|
||||
table.clearSelection()
|
||||
}
|
||||
|
||||
val rows = table.selectedRows
|
||||
|
||||
if (!table.hasFocus()) {
|
||||
table.requestFocusInWindow()
|
||||
}
|
||||
|
||||
|
||||
showContextMenu(kotlin.runCatching {
|
||||
rows.map { tableModel.getTransport(it) }
|
||||
}.getOrElse { emptyList() }, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private fun showContextMenu(transports: List<Transport>, event: MouseEvent) {
|
||||
val popupMenu = FlatPopupMenu()
|
||||
|
||||
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete")).apply {
|
||||
addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
for (transport in transports) {
|
||||
transportManager.removeTransport(transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
|
||||
deleteAll.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
transportManager.removeAllTransports()
|
||||
}
|
||||
}
|
||||
|
||||
if (transports.isEmpty()) {
|
||||
delete.isEnabled = false
|
||||
deleteAll.isEnabled = transportManager.getTransports().isNotEmpty()
|
||||
}
|
||||
|
||||
popupMenu.show(table, event.x, event.y)
|
||||
}
|
||||
|
||||
}
|
||||
123
src/main/kotlin/app/termora/transport/FileTransportTableModel.kt
Normal file
123
src/main/kotlin/app/termora/transport/FileTransportTableModel.kt
Normal file
@@ -0,0 +1,123 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.formatBytes
|
||||
import app.termora.formatSeconds
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.table.DefaultTableModel
|
||||
|
||||
|
||||
class FileTransportTableModel(transportManager: TransportManager) : DefaultTableModel() {
|
||||
private var isInitialized = false
|
||||
|
||||
private inline fun invokeLater(crossinline block: () -> Unit) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
block.invoke()
|
||||
} else {
|
||||
SwingUtilities.invokeLater { block.invoke() }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
transportManager.addTransportListener(object : TransportListener {
|
||||
override fun onTransportAdded(transport: Transport) {
|
||||
invokeLater { addRow(arrayOf(transport)) }
|
||||
}
|
||||
|
||||
override fun onTransportRemoved(transport: Transport) {
|
||||
invokeLater {
|
||||
val index = getDataVector().indexOfFirst { it.firstOrNull() == transport }
|
||||
if (index >= 0) {
|
||||
removeRow(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
invokeLater {
|
||||
for ((index, vector) in getDataVector().withIndex()) {
|
||||
if (vector.firstOrNull() == transport) {
|
||||
fireTableRowsUpdated(index, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
isInitialized = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val COLUMN_NAME = 0
|
||||
const val COLUMN_STATUS = 1
|
||||
const val COLUMN_PROGRESS = 2
|
||||
const val COLUMN_SIZE = 3
|
||||
const val COLUMN_SOURCE_PATH = 4
|
||||
const val COLUMN_TARGET_PATH = 5
|
||||
const val COLUMN_SPEED = 6
|
||||
const val COLUMN_ESTIMATED_TIME = 7
|
||||
}
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return 8
|
||||
}
|
||||
|
||||
fun getTransport(row: Int): Transport {
|
||||
return super.getValueAt(row, COLUMN_NAME) as Transport
|
||||
}
|
||||
|
||||
override fun getValueAt(row: Int, column: Int): Any {
|
||||
val transport = getTransport(row)
|
||||
val isTransporting = transport.state == TransportState.Transporting
|
||||
val speed = if (isTransporting) transport.speed else 0
|
||||
val estimatedTime = if (isTransporting && speed > 0)
|
||||
(transport.size - transport.transferredSize) / speed else 0
|
||||
|
||||
return when (column) {
|
||||
COLUMN_NAME -> " ${transport.name}"
|
||||
COLUMN_STATUS -> formatStatus(transport.state)
|
||||
COLUMN_PROGRESS -> String.format("%.0f%%", transport.progress * 100.0)
|
||||
|
||||
// 大小
|
||||
COLUMN_SIZE -> if (transport.size < 0) "-"
|
||||
else if (isTransporting) "${formatBytes(transport.transferredSize)}/${formatBytes(transport.size)}"
|
||||
else formatBytes(transport.size)
|
||||
|
||||
COLUMN_SOURCE_PATH -> " ${transport.getSourcePath}"
|
||||
COLUMN_TARGET_PATH -> " ${transport.getTargetPath}"
|
||||
COLUMN_SPEED -> if (isTransporting) formatBytes(speed) else "-"
|
||||
COLUMN_ESTIMATED_TIME -> if (isTransporting && speed > 0) formatSeconds(estimatedTime) else "-"
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatStatus(state: TransportState): String {
|
||||
return when (state) {
|
||||
TransportState.Transporting -> I18n.getString("termora.transport.sftp.status.transporting")
|
||||
TransportState.Waiting -> I18n.getString("termora.transport.sftp.status.waiting")
|
||||
TransportState.Done -> I18n.getString("termora.transport.sftp.status.done")
|
||||
TransportState.Failed -> I18n.getString("termora.transport.sftp.status.failed")
|
||||
TransportState.Cancelled -> I18n.getString("termora.transport.sftp.status.cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getColumnName(column: Int): String {
|
||||
return when (column) {
|
||||
COLUMN_NAME -> I18n.getString("termora.transport.jobs.table.name")
|
||||
COLUMN_STATUS -> I18n.getString("termora.transport.jobs.table.status")
|
||||
COLUMN_PROGRESS -> I18n.getString("termora.transport.jobs.table.progress")
|
||||
COLUMN_SIZE -> I18n.getString("termora.transport.jobs.table.size")
|
||||
COLUMN_SOURCE_PATH -> I18n.getString("termora.transport.jobs.table.source-path")
|
||||
COLUMN_TARGET_PATH -> I18n.getString("termora.transport.jobs.table.target-path")
|
||||
COLUMN_SPEED -> I18n.getString("termora.transport.jobs.table.speed")
|
||||
COLUMN_ESTIMATED_TIME -> I18n.getString("termora.transport.jobs.table.estimated-time")
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
override fun isCellEditable(row: Int, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.I18n
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Window
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
class PosixFilePermissionDialog(
|
||||
owner: Window,
|
||||
private val permissions: Set<PosixFilePermission>
|
||||
) : DialogWrapper(owner) {
|
||||
|
||||
|
||||
private val ownerRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
||||
private val ownerWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
|
||||
private val ownerExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||
private val groupRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
||||
private val groupWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
|
||||
private val groupExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||
private val otherRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
||||
private val otherWrite = JCheckBox(I18n.getString("termora.transport.permissions.write"))
|
||||
private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||
|
||||
private var isCancelled = false
|
||||
|
||||
init {
|
||||
isModal = true
|
||||
isResizable = false
|
||||
controlsVisible = false
|
||||
title = I18n.getString("termora.transport.permissions")
|
||||
initView()
|
||||
init()
|
||||
pack()
|
||||
size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 300), size.height)
|
||||
setLocationRelativeTo(null)
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
ownerRead.isSelected = permissions.contains(PosixFilePermission.OWNER_READ)
|
||||
ownerWrite.isSelected = permissions.contains(PosixFilePermission.OWNER_WRITE)
|
||||
ownerExecute.isSelected = permissions.contains(PosixFilePermission.OWNER_EXECUTE)
|
||||
groupRead.isSelected = permissions.contains(PosixFilePermission.GROUP_READ)
|
||||
groupWrite.isSelected = permissions.contains(PosixFilePermission.GROUP_WRITE)
|
||||
groupExecute.isSelected = permissions.contains(PosixFilePermission.GROUP_EXECUTE)
|
||||
otherRead.isSelected = permissions.contains(PosixFilePermission.OTHERS_READ)
|
||||
otherWrite.isSelected = permissions.contains(PosixFilePermission.OTHERS_WRITE)
|
||||
otherExecute.isSelected = permissions.contains(PosixFilePermission.OTHERS_EXECUTE)
|
||||
|
||||
ownerRead.isFocusable = false
|
||||
ownerWrite.isFocusable = false
|
||||
ownerExecute.isFocusable = false
|
||||
groupRead.isFocusable = false
|
||||
groupWrite.isFocusable = false
|
||||
groupExecute.isFocusable = false
|
||||
otherRead.isFocusable = false
|
||||
otherWrite.isFocusable = false
|
||||
otherExecute.isFocusable = false
|
||||
}
|
||||
|
||||
override fun createCenterPanel(): JComponent {
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, $formMargin, default:grow, $formMargin, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
|
||||
.layout(layout).debug(true)
|
||||
|
||||
builder.add("${I18n.getString("termora.transport.permissions.file-folder-permissions")}:").xyw(1, 1, 5)
|
||||
|
||||
val ownerBox = Box.createVerticalBox()
|
||||
ownerBox.add(ownerRead)
|
||||
ownerBox.add(ownerWrite)
|
||||
ownerBox.add(ownerExecute)
|
||||
ownerBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.owner"))
|
||||
builder.add(ownerBox).xy(1, 3)
|
||||
|
||||
val groupBox = Box.createVerticalBox()
|
||||
groupBox.add(groupRead)
|
||||
groupBox.add(groupWrite)
|
||||
groupBox.add(groupExecute)
|
||||
groupBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.group"))
|
||||
builder.add(groupBox).xy(3, 3)
|
||||
|
||||
val otherBox = Box.createVerticalBox()
|
||||
otherBox.add(otherRead)
|
||||
otherBox.add(otherWrite)
|
||||
otherBox.add(otherExecute)
|
||||
otherBox.border = BorderFactory.createTitledBorder(I18n.getString("termora.transport.permissions.others"))
|
||||
builder.add(otherBox).xy(5, 3)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
this.isCancelled = true
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 返回空表示取消了
|
||||
*/
|
||||
fun open(): Set<PosixFilePermission>? {
|
||||
isModal = true
|
||||
isVisible = true
|
||||
|
||||
if (isCancelled) {
|
||||
return null
|
||||
}
|
||||
|
||||
val permissions = mutableSetOf<PosixFilePermission>()
|
||||
if (ownerRead.isSelected) {
|
||||
permissions.add(PosixFilePermission.OWNER_READ)
|
||||
}
|
||||
if (ownerWrite.isSelected) {
|
||||
permissions.add(PosixFilePermission.OWNER_WRITE)
|
||||
}
|
||||
if (ownerExecute.isSelected) {
|
||||
permissions.add(PosixFilePermission.OWNER_EXECUTE)
|
||||
}
|
||||
if (groupRead.isSelected) {
|
||||
permissions.add(PosixFilePermission.GROUP_READ)
|
||||
}
|
||||
if (groupWrite.isSelected) {
|
||||
permissions.add(PosixFilePermission.GROUP_WRITE)
|
||||
}
|
||||
if (groupExecute.isSelected) {
|
||||
permissions.add(PosixFilePermission.GROUP_EXECUTE)
|
||||
}
|
||||
if (otherRead.isSelected) {
|
||||
permissions.add(PosixFilePermission.OTHERS_READ)
|
||||
}
|
||||
if (otherWrite.isSelected) {
|
||||
permissions.add(PosixFilePermission.OTHERS_WRITE)
|
||||
}
|
||||
if (otherExecute.isSelected) {
|
||||
permissions.add(PosixFilePermission.OTHERS_EXECUTE)
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
}
|
||||
316
src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt
Normal file
316
src/main/kotlin/app/termora/transport/SftpFileSystemPanel.kt
Normal file
@@ -0,0 +1,316 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.*
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneInformationIcon
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.sftp.client.SftpClientFactory
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import org.jdesktop.swingx.JXHyperlink
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.CardLayout
|
||||
import java.awt.event.ActionEvent
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.*
|
||||
|
||||
class SftpFileSystemPanel(
|
||||
private val transportManager: TransportManager,
|
||||
private var host: Host? = null
|
||||
) : JPanel(BorderLayout()), Disposable,
|
||||
FileSystemTransportListener.Provider {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SftpFileSystemPanel::class.java)
|
||||
|
||||
private enum class State {
|
||||
Initialized,
|
||||
Connecting,
|
||||
Connected,
|
||||
ConnectFailed,
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var state = State.Initialized
|
||||
private val cardLayout = CardLayout()
|
||||
private val cardPanel = JPanel(cardLayout)
|
||||
|
||||
private val connectingPanel = ConnectingPanel()
|
||||
private val selectHostPanel = SelectHostPanel()
|
||||
private val connectFailedPanel = ConnectFailedPanel()
|
||||
private val listeners = mutableListOf<FileSystemTransportListener>()
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
|
||||
private var client: SshClient? = null
|
||||
private var session: ClientSession? = null
|
||||
private var fileSystem: SftpFileSystem? = null
|
||||
var fileSystemPanel: FileSystemPanel? = null
|
||||
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
cardPanel.add(selectHostPanel, State.Initialized.name)
|
||||
cardPanel.add(connectingPanel, State.Connecting.name)
|
||||
cardPanel.add(connectFailedPanel, State.ConnectFailed.name)
|
||||
cardLayout.show(cardPanel, State.Initialized.name)
|
||||
add(cardPanel, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun connect() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (state != State.Connecting) {
|
||||
state = State.Connecting
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
connectingPanel.start()
|
||||
cardLayout.show(cardPanel, State.Connecting.name)
|
||||
}
|
||||
|
||||
runCatching { doConnect() }.onFailure {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(it.message, it)
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
state = State.ConnectFailed
|
||||
connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(it)
|
||||
cardLayout.show(cardPanel, State.ConnectFailed.name)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
connectingPanel.stop()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doConnect() {
|
||||
|
||||
val host = this.host ?: return
|
||||
|
||||
closeIO()
|
||||
|
||||
try {
|
||||
val client = SshClients.openClient(host).apply { client = this }
|
||||
val session = SshClients.openSession(host, client).apply { session = this }
|
||||
fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
|
||||
session.addCloseFutureListener { onClose() }
|
||||
} catch (e: Exception) {
|
||||
closeIO()
|
||||
throw e
|
||||
}
|
||||
|
||||
if (isDisposed.get()) {
|
||||
throw IllegalStateException("Closed")
|
||||
}
|
||||
|
||||
val fileSystem = this.fileSystem ?: return
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
state = State.Connected
|
||||
|
||||
val fileSystemPanel = FileSystemPanel(fileSystem, transportManager, host)
|
||||
fileSystemPanel.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(
|
||||
fileSystemPanel: FileSystemPanel,
|
||||
workdir: Path,
|
||||
isDirectory: Boolean,
|
||||
path: Path
|
||||
) {
|
||||
listeners.forEach { it.transport(fileSystemPanel, workdir, isDirectory, path) }
|
||||
}
|
||||
})
|
||||
|
||||
cardPanel.add(fileSystemPanel, State.Connected.name)
|
||||
cardLayout.show(cardPanel, State.Connected.name)
|
||||
|
||||
firePropertyChange("TabName", StringUtils.EMPTY, host.name)
|
||||
|
||||
this@SftpFileSystemPanel.fileSystemPanel = fileSystemPanel
|
||||
|
||||
// 立即加载
|
||||
fileSystemPanel.reload()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun onClose() {
|
||||
if (isDisposed.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
closeIO()
|
||||
state = State.ConnectFailed
|
||||
connectFailedPanel.errorLabel.text = I18n.getString("termora.transport.sftp.closed")
|
||||
cardLayout.show(cardPanel, State.ConnectFailed.name)
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeIO() {
|
||||
val host = host
|
||||
|
||||
fileSystemPanel?.let { Disposer.dispose(it) }
|
||||
fileSystemPanel = null
|
||||
|
||||
runCatching { IOUtils.closeQuietly(fileSystem) }
|
||||
runCatching { IOUtils.closeQuietly(session) }
|
||||
runCatching { IOUtils.closeQuietly(client) }
|
||||
|
||||
if (host != null && log.isInfoEnabled) {
|
||||
log.info("Sftp ${host.name} is closed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (isDisposed.compareAndSet(false, true)) {
|
||||
closeIO()
|
||||
}
|
||||
}
|
||||
|
||||
private class ConnectingPanel : JPanel(BorderLayout()) {
|
||||
private val busyLabel = JXBusyLabel()
|
||||
|
||||
init {
|
||||
initView()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, pref, default:grow",
|
||||
"40dlu, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val label = JLabel(I18n.getString("termora.transport.sftp.connecting"))
|
||||
label.horizontalAlignment = SwingConstants.CENTER
|
||||
|
||||
busyLabel.horizontalAlignment = SwingConstants.CENTER
|
||||
busyLabel.verticalAlignment = SwingConstants.CENTER
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add(busyLabel).xy(2, 2, "fill, center")
|
||||
builder.add(label).xy(2, 4)
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
busyLabel.isBusy = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
busyLabel.isBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ConnectFailedPanel : JPanel(BorderLayout()) {
|
||||
val errorLabel = JLabel()
|
||||
|
||||
init {
|
||||
initView()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, pref, default:grow",
|
||||
"40dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
errorLabel.horizontalAlignment = SwingConstants.CENTER
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add(FlatOptionPaneErrorIcon()).xy(2, 2)
|
||||
builder.add(errorLabel).xyw(1, 4, 3, "fill, center")
|
||||
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.retry")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
connect()
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
isFocusable = false
|
||||
}).xy(2, 6)
|
||||
builder.add(JXHyperlink(object :
|
||||
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
state = State.Initialized
|
||||
this@SftpFileSystemPanel.firePropertyChange("TabName", StringUtils.SPACE, StringUtils.EMPTY)
|
||||
cardLayout.show(cardPanel, State.Initialized.name)
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
isFocusable = false
|
||||
}).xy(2, 8)
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SelectHostPanel : JPanel(BorderLayout()) {
|
||||
init {
|
||||
initView()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
val formMargin = "4dlu"
|
||||
val layout = FormLayout(
|
||||
"default:grow, pref, default:grow",
|
||||
"40dlu, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
|
||||
val errorInfo = JLabel(I18n.getString("termora.transport.sftp.connect-a-host"))
|
||||
errorInfo.horizontalAlignment = SwingConstants.CENTER
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
builder.add(FlatOptionPaneInformationIcon()).xy(2, 2)
|
||||
builder.add(errorInfo).xyw(1, 4, 3, "fill, center")
|
||||
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.select-host")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val dialog = HostTreeDialog(SwingUtilities.getWindowAncestor(this@SftpFileSystemPanel))
|
||||
dialog.allowMulti = false
|
||||
dialog.setLocationRelativeTo(this@SelectHostPanel)
|
||||
dialog.isVisible = true
|
||||
this@SftpFileSystemPanel.host = dialog.hosts.firstOrNull() ?: return
|
||||
connect()
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
verticalAlignment = SwingConstants.CENTER
|
||||
isFocusable = false
|
||||
}).xy(2, 6)
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun addFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
override fun removeFileSystemTransportListener(listener: FileSystemTransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
}
|
||||
274
src/main/kotlin/app/termora/transport/Transport.kt
Normal file
274
src/main/kotlin/app/termora/transport/Transport.kt
Normal file
@@ -0,0 +1,274 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.net.io.CopyStreamEvent
|
||||
import org.apache.commons.net.io.CopyStreamListener
|
||||
import org.apache.commons.net.io.Util
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.exists
|
||||
|
||||
enum class TransportState {
|
||||
Waiting,
|
||||
Transporting,
|
||||
Done,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
abstract class Transport(
|
||||
val name: String,
|
||||
// 源路径
|
||||
val source: Path,
|
||||
// 目标路径
|
||||
val target: Path,
|
||||
val sourceHolder: Disposable,
|
||||
val targetHolder: Disposable,
|
||||
) : Disposable, Runnable {
|
||||
|
||||
private val listeners = ArrayList<TransportListener>()
|
||||
|
||||
@Volatile
|
||||
var state = TransportState.Waiting
|
||||
protected set(value) {
|
||||
field = value
|
||||
listeners.forEach { it.onTransportChanged(this) }
|
||||
}
|
||||
|
||||
// 0 - 1
|
||||
var progress = 0.0
|
||||
protected set(value) {
|
||||
field = value
|
||||
listeners.forEach { it.onTransportChanged(this) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 要传输的大小
|
||||
*/
|
||||
var size = -1L
|
||||
protected set
|
||||
|
||||
/**
|
||||
* 已经传输的大小
|
||||
*/
|
||||
var transferredSize = 0L
|
||||
protected set
|
||||
|
||||
/**
|
||||
* 传输速度
|
||||
*/
|
||||
open val speed get() = 0L
|
||||
|
||||
open val getSourcePath by lazy {
|
||||
getFileSystemName(source.fileSystem) + ":" + source.toAbsolutePath().normalize().toString()
|
||||
}
|
||||
open val getTargetPath by lazy {
|
||||
getFileSystemName(target.fileSystem) + ":" + target.toAbsolutePath().normalize().toString()
|
||||
}
|
||||
|
||||
|
||||
fun addTransportListener(listener: TransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeTransportListener(listener: TransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
if (state != TransportState.Waiting) {
|
||||
throw IllegalStateException("$name has already been started")
|
||||
}
|
||||
|
||||
state = TransportState.Transporting
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
if (state == TransportState.Waiting || state == TransportState.Transporting) {
|
||||
state = TransportState.Cancelled
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileSystemName(fileSystem: FileSystem): String {
|
||||
if (fileSystem is SftpFileSystem) {
|
||||
val clientSession = fileSystem.session
|
||||
if (clientSession is JGitClientSession) {
|
||||
return clientSession.hostConfigEntry.host
|
||||
}
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
private class SlidingWindowByteCounter {
|
||||
private val events = ConcurrentLinkedQueue<Pair<Long, Long>>()
|
||||
private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1)
|
||||
|
||||
fun addBytes(bytes: Long, time: Long) {
|
||||
|
||||
// 添加当前事件
|
||||
events.add(time to bytes)
|
||||
|
||||
// 移除过期事件(超过 1 秒的记录)
|
||||
while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) {
|
||||
events.poll()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun getLastSecondBytes(): Long {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
// 累加最近 1 秒内的字节数
|
||||
return events.filter { it.first >= currentTime - oneSecondInMillis }
|
||||
.sumOf { it.second }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
events.clear()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 文件传输
|
||||
*/
|
||||
class FileTransport(
|
||||
name: String, source: Path, target: Path,
|
||||
sourceHolder: Disposable, targetHolder: Disposable,
|
||||
) : Transport(
|
||||
name, source, target, sourceHolder, targetHolder,
|
||||
), CopyStreamListener {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(FileTransport::class.java)
|
||||
}
|
||||
|
||||
private var lastVisitTime = 0L
|
||||
private val input by lazy { Files.newInputStream(source) }
|
||||
private val output by lazy { Files.newOutputStream(target) }
|
||||
private val counter = SlidingWindowByteCounter()
|
||||
|
||||
override val speed: Long
|
||||
get() = counter.getLastSecondBytes()
|
||||
|
||||
|
||||
override fun run() {
|
||||
|
||||
try {
|
||||
super.run()
|
||||
doTransport()
|
||||
state = TransportState.Done
|
||||
} catch (e: Exception) {
|
||||
if (state == TransportState.Cancelled) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Transport $name is canceled")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
state = TransportState.Failed
|
||||
} finally {
|
||||
counter.clear()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
|
||||
// 如果在传输中,那么直接关闭流
|
||||
if (state == TransportState.Transporting) {
|
||||
runCatching { IOUtils.closeQuietly(input) }
|
||||
runCatching { IOUtils.closeQuietly(output) }
|
||||
}
|
||||
|
||||
super.stop()
|
||||
|
||||
counter.clear()
|
||||
}
|
||||
|
||||
private fun doTransport() {
|
||||
size = Files.size(source)
|
||||
try {
|
||||
Util.copyStream(
|
||||
input,
|
||||
output,
|
||||
Util.DEFAULT_COPY_BUFFER_SIZE * 8,
|
||||
size,
|
||||
this
|
||||
)
|
||||
} finally {
|
||||
IOUtils.closeQuietly(input, output)
|
||||
}
|
||||
}
|
||||
|
||||
override fun bytesTransferred(event: CopyStreamEvent?) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun bytesTransferred(totalBytesTransferred: Long, bytesTransferred: Int, streamSize: Long) {
|
||||
|
||||
if (state == TransportState.Cancelled) {
|
||||
throw IllegalStateException("$name has already been cancelled")
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val progress = totalBytesTransferred * 1.0 / streamSize
|
||||
|
||||
counter.addBytes(bytesTransferred.toLong(), now)
|
||||
|
||||
if (now - lastVisitTime < 750) {
|
||||
if (progress < 1.0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.transferredSize = totalBytesTransferred
|
||||
this.progress = progress
|
||||
lastVisitTime = now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文件夹
|
||||
*/
|
||||
class DirectoryTransport(
|
||||
name: String, source: Path, target: Path,
|
||||
sourceHolder: Disposable,
|
||||
targetHolder: Disposable,
|
||||
) : Transport(name, source, target, sourceHolder, targetHolder) {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(DirectoryTransport::class.java)
|
||||
}
|
||||
|
||||
|
||||
override fun run() {
|
||||
|
||||
try {
|
||||
super.run()
|
||||
if (!target.exists()) {
|
||||
Files.createDirectory(target)
|
||||
}
|
||||
state = TransportState.Done
|
||||
} catch (e: FileAlreadyExistsException) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Directory $name already exists")
|
||||
}
|
||||
state = TransportState.Done
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
state = TransportState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/main/kotlin/app/termora/transport/TransportListener.kt
Normal file
20
src/main/kotlin/app/termora/transport/TransportListener.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package app.termora.transport
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface TransportListener : EventListener {
|
||||
/**
|
||||
* Added
|
||||
*/
|
||||
fun onTransportAdded(transport: Transport)
|
||||
|
||||
/**
|
||||
* Removed
|
||||
*/
|
||||
fun onTransportRemoved(transport: Transport)
|
||||
|
||||
/**
|
||||
* 状态变化
|
||||
*/
|
||||
fun onTransportChanged(transport: Transport)
|
||||
}
|
||||
129
src/main/kotlin/app/termora/transport/TransportManager.kt
Normal file
129
src/main/kotlin/app/termora/transport/TransportManager.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import kotlinx.coroutines.*
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class TransportManager : Disposable {
|
||||
private val transports = Collections.synchronizedList(mutableListOf<Transport>())
|
||||
private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
|
||||
private val isProcessing = AtomicBoolean(false)
|
||||
private val listeners = mutableListOf<TransportListener>()
|
||||
private val listener = object : TransportListener {
|
||||
override fun onTransportAdded(transport: Transport) {
|
||||
listeners.forEach { it.onTransportAdded(transport) }
|
||||
}
|
||||
|
||||
override fun onTransportRemoved(transport: Transport) {
|
||||
listeners.forEach { it.onTransportRemoved(transport) }
|
||||
}
|
||||
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
listeners.forEach { it.onTransportChanged(transport) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TransportManager::class.java)
|
||||
}
|
||||
|
||||
fun getTransports(): List<Transport> = transports
|
||||
|
||||
fun addTransport(transport: Transport) {
|
||||
synchronized(transports) {
|
||||
transport.addTransportListener(listener)
|
||||
if (transports.add(transport)) {
|
||||
listeners.forEach { it.onTransportAdded(transport) }
|
||||
if (isProcessing.compareAndSet(false, true)) {
|
||||
coroutineScope.launch(Dispatchers.IO) { process() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTransport(transport: Transport) {
|
||||
synchronized(transports) {
|
||||
transport.stop()
|
||||
if (transports.remove(transport)) {
|
||||
listeners.forEach { it.onTransportRemoved(transport) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAllTransports() {
|
||||
synchronized(transports) {
|
||||
while (transports.isNotEmpty()) {
|
||||
removeTransport(transports.last())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addTransportListener(listener: TransportListener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun removeTransportListener(listener: TransportListener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
private suspend fun process() {
|
||||
var needDelay = false
|
||||
while (coroutineScope.isActive) {
|
||||
try {
|
||||
|
||||
// 如果为空或者其中一个正在传输中那么挑过
|
||||
if (needDelay || transports.isEmpty()) {
|
||||
needDelay = false
|
||||
delay(250.milliseconds)
|
||||
continue
|
||||
}
|
||||
|
||||
val transport = synchronized(transports) {
|
||||
var transport: Transport? = null
|
||||
for (e in transports) {
|
||||
if (e.state != TransportState.Waiting) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 遇到传输中,那么直接跳过
|
||||
if (e.state == TransportState.Transporting) {
|
||||
needDelay = true
|
||||
break
|
||||
}
|
||||
|
||||
transport = e
|
||||
break
|
||||
}
|
||||
return@synchronized transport
|
||||
}
|
||||
|
||||
if (transport == null) {
|
||||
continue
|
||||
}
|
||||
|
||||
transport.run()
|
||||
|
||||
// 成功之后 删除
|
||||
if (transport.state == TransportState.Done) {
|
||||
// remove
|
||||
removeTransport(transport)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun dispose() {
|
||||
transports.clear()
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
}
|
||||
194
src/main/kotlin/app/termora/transport/TransportPanel.kt
Normal file
194
src/main/kotlin/app/termora/transport/TransportPanel.kt
Normal file
@@ -0,0 +1,194 @@
|
||||
package app.termora.transport
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.Disposer
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.assertEventDispatchThread
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.io.File
|
||||
import java.nio.file.Path
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JSplitPane
|
||||
|
||||
/**
|
||||
* 传输面板
|
||||
*/
|
||||
class TransportPanel : JPanel(BorderLayout()), Disposable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TransportPanel::class.java)
|
||||
}
|
||||
|
||||
val transportManager = TransportManager()
|
||||
|
||||
val leftFileSystemTabbed = FileSystemTabbed(transportManager, true)
|
||||
val rightFileSystemTabbed = FileSystemTabbed(transportManager, false)
|
||||
|
||||
private val fileTransportPanel = FileTransportPanel(transportManager)
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
|
||||
Disposer.register(this, transportManager)
|
||||
Disposer.register(this, leftFileSystemTabbed)
|
||||
Disposer.register(this, rightFileSystemTabbed)
|
||||
Disposer.register(this, fileTransportPanel)
|
||||
|
||||
leftFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
|
||||
rightFileSystemTabbed.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
|
||||
val splitPane = JSplitPane()
|
||||
splitPane.leftComponent = leftFileSystemTabbed
|
||||
splitPane.rightComponent = rightFileSystemTabbed
|
||||
splitPane.resizeWeight = 0.5
|
||||
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
|
||||
splitPane.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
splitPane.setDividerLocation(splitPane.resizeWeight)
|
||||
}
|
||||
})
|
||||
|
||||
fileTransportPanel.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
val rootSplitPane = JSplitPane()
|
||||
rootSplitPane.orientation = JSplitPane.VERTICAL_SPLIT
|
||||
rootSplitPane.topComponent = splitPane
|
||||
rootSplitPane.bottomComponent = fileTransportPanel
|
||||
rootSplitPane.resizeWeight = 0.75
|
||||
rootSplitPane.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
|
||||
}
|
||||
})
|
||||
|
||||
add(rootSplitPane, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
private fun initEvents() {
|
||||
transportManager.addTransportListener(object : TransportListener {
|
||||
override fun onTransportAdded(transport: Transport) {
|
||||
}
|
||||
|
||||
override fun onTransportRemoved(transport: Transport) {
|
||||
|
||||
}
|
||||
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
if (transport.state == TransportState.Done) {
|
||||
val targetHolder = transport.targetHolder
|
||||
if (targetHolder is FileSystemPanel) {
|
||||
if (transport.target.parent == targetHolder.workdir) {
|
||||
targetHolder.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
leftFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
val target = rightFileSystemTabbed.getSelectedFileSystemPanel() ?: return
|
||||
transport(
|
||||
fileSystemPanel.workdir, target.workdir,
|
||||
isSourceDirectory = isDirectory,
|
||||
sourcePath = path,
|
||||
sourceHolder = fileSystemPanel,
|
||||
targetHolder = target,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
rightFileSystemTabbed.addFileSystemTransportListener(object : FileSystemTransportListener {
|
||||
override fun transport(fileSystemPanel: FileSystemPanel, workdir: Path, isDirectory: Boolean, path: Path) {
|
||||
val target = leftFileSystemTabbed.getSelectedFileSystemPanel() ?: return
|
||||
transport(
|
||||
fileSystemPanel.workdir, target.workdir,
|
||||
isSourceDirectory = isDirectory,
|
||||
sourcePath = path,
|
||||
sourceHolder = fileSystemPanel,
|
||||
targetHolder = target,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fun getTargetFileSystemPanel(fileSystemPanel: FileSystemPanel): FileSystemPanel? {
|
||||
|
||||
assertEventDispatchThread()
|
||||
|
||||
for (i in 0 until leftFileSystemTabbed.tabCount) {
|
||||
if (leftFileSystemTabbed.getFileSystemPanel(i) == fileSystemPanel) {
|
||||
return rightFileSystemTabbed.getSelectedFileSystemPanel()
|
||||
}
|
||||
}
|
||||
|
||||
for (i in 0 until rightFileSystemTabbed.tabCount) {
|
||||
if (rightFileSystemTabbed.getFileSystemPanel(i) == fileSystemPanel) {
|
||||
return leftFileSystemTabbed.getSelectedFileSystemPanel()
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun transport(
|
||||
sourceWorkdir: Path,
|
||||
targetWorkdir: Path,
|
||||
isSourceDirectory: Boolean,
|
||||
sourcePath: Path,
|
||||
sourceHolder: Disposable,
|
||||
targetHolder: Disposable
|
||||
) {
|
||||
val relativizePath = sourceWorkdir.relativize(sourcePath).toString()
|
||||
if (StringUtils.isEmpty(relativizePath) || relativizePath == File.separator ||
|
||||
relativizePath == sourceWorkdir.fileSystem.separator ||
|
||||
relativizePath == targetWorkdir.fileSystem.separator
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val transport: Transport
|
||||
if (isSourceDirectory) {
|
||||
transport = DirectoryTransport(
|
||||
name = sourcePath.fileName.toString(),
|
||||
source = sourcePath,
|
||||
target = targetWorkdir.resolve(relativizePath),
|
||||
sourceHolder = sourceHolder,
|
||||
targetHolder = targetHolder,
|
||||
)
|
||||
} else {
|
||||
transport = FileTransport(
|
||||
name = sourcePath.fileName.toString(),
|
||||
source = sourcePath,
|
||||
target = targetWorkdir.resolve(relativizePath),
|
||||
sourceHolder = sourceHolder,
|
||||
targetHolder = targetHolder,
|
||||
)
|
||||
}
|
||||
|
||||
transportManager.addTransport(transport)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (log.isInfoEnabled) {
|
||||
log.info("Transport is disposed")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user