refactor: transfer
@@ -17,6 +17,7 @@ import java.io.File
|
||||
import java.net.URI
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import kotlin.math.ln
|
||||
@@ -202,6 +203,42 @@ fun formatBytes(bytes: Long): String {
|
||||
return String.format("%.2f%s", value, units[exp])
|
||||
}
|
||||
|
||||
fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
|
||||
val result = mutableSetOf<PosixFilePermission>()
|
||||
|
||||
// 将十进制权限转换为八进制字符串
|
||||
val octalPermissions = sftpPermissions.toString(8)
|
||||
|
||||
// 仅取后三位权限部分
|
||||
if (octalPermissions.length < 3) {
|
||||
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
|
||||
}
|
||||
|
||||
fun formatSeconds(seconds: Long): String {
|
||||
val days = seconds / 86400
|
||||
val hours = (seconds % 86400) / 3600
|
||||
|
||||
@@ -5,8 +5,6 @@ import app.termora.database.DatabaseManager
|
||||
import app.termora.keymap.KeymapManager
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import app.termora.plugin.PluginManager
|
||||
import app.termora.protocol.ProtocolProvider
|
||||
import app.termora.protocol.TransferProtocolProvider
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.FlatSystemProperties
|
||||
import com.formdev.flatlaf.extras.FlatDesktop
|
||||
@@ -22,9 +20,6 @@ import kotlinx.coroutines.launch
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.lang3.LocaleUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.cache.WeakRefFilesCache
|
||||
import org.apache.commons.vfs2.impl.DefaultFileSystemManager
|
||||
import org.json.JSONObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.MenuItem
|
||||
@@ -77,15 +72,6 @@ class ApplicationRunner {
|
||||
// 等待插件加载完成
|
||||
loadPluginThread.join()
|
||||
|
||||
// 初始化 VFS
|
||||
val fileSystemManager = DefaultFileSystemManager()
|
||||
for (provider in ProtocolProvider.providers.filterIsInstance<TransferProtocolProvider>()) {
|
||||
fileSystemManager.addProvider(provider.getProtocol().lowercase(), provider.getFileProvider())
|
||||
}
|
||||
fileSystemManager.filesCache = WeakRefFilesCache()
|
||||
fileSystemManager.init()
|
||||
VFS.setManager(fileSystemManager)
|
||||
|
||||
// 准备就绪
|
||||
for (extension in ExtensionManager.getInstance().getExtensions(ApplicationRunnerExtension::class.java)) {
|
||||
extension.ready()
|
||||
@@ -206,11 +192,13 @@ class ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// init native icon
|
||||
NativeIcons.folderIcon
|
||||
|
||||
themeManager.change(theme, true)
|
||||
|
||||
|
||||
if (Application.isUnknownVersion())
|
||||
FlatInspector.install("ctrl shift alt X")
|
||||
FlatInspector.install("ctrl shift X")
|
||||
|
||||
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
|
||||
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
|
||||
@@ -218,7 +206,7 @@ class ApplicationRunner {
|
||||
|
||||
UIManager.put("Component.arc", 5)
|
||||
UIManager.put("TextComponent.arc", UIManager.getInt("Component.arc"))
|
||||
UIManager.put("Component.hideMnemonics", false)
|
||||
UIManager.put("Component.hideMnemonics", true)
|
||||
|
||||
UIManager.put("TitleBar.height", 36)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ object Icons {
|
||||
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
|
||||
val error by lazy { DynamicIcon("icons/error.svg", "icons/error_dark.svg") }
|
||||
val cwmUsers by lazy { DynamicIcon("icons/cwmUsers.svg", "icons/cwmUsers_dark.svg") }
|
||||
val cwmPermissions by lazy { DynamicIcon("icons/cwmPermissions.svg", "icons/cwmPermissions_dark.svg") }
|
||||
val warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_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") }
|
||||
@@ -61,6 +62,7 @@ object Icons {
|
||||
val chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") }
|
||||
val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_dark.svg") }
|
||||
val homeFolder by lazy { DynamicIcon("icons/homeFolder.svg", "icons/homeFolder_dark.svg") }
|
||||
val playForward by lazy { DynamicIcon("icons/playForward.svg", "icons/playForward_dark.svg") }
|
||||
val openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") }
|
||||
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_dark.svg") }
|
||||
val export by lazy { DynamicIcon("icons/export.svg", "icons/export_dark.svg") }
|
||||
@@ -142,5 +144,8 @@ object Icons {
|
||||
val forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
|
||||
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_dark.svg") }
|
||||
val nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") }
|
||||
|
||||
val desktop_windows by lazy { DynamicIcon("icons/desktop_windows.svg", "icons/desktop_windows_dark.svg") }
|
||||
val desktop_mac by lazy { DynamicIcon("icons/desktop_mac.svg", "icons/desktop_mac_dark.svg") }
|
||||
val desktop by lazy { DynamicIcon("icons/desktop.svg", "icons/desktop_dark.svg") }
|
||||
val moreHorizontal by lazy { DynamicIcon("icons/moreHorizontal.svg", "icons/moreHorizontal_dark.svg") }
|
||||
}
|
||||
33
src/main/kotlin/app/termora/NativeIcons.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package app.termora
|
||||
|
||||
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
|
||||
import com.formdev.flatlaf.icons.FlatTreeLeafIcon
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import java.nio.file.Files
|
||||
import javax.swing.Icon
|
||||
import javax.swing.UIManager
|
||||
import javax.swing.filechooser.FileSystemView
|
||||
import kotlin.io.path.createTempFile
|
||||
|
||||
object NativeIcons {
|
||||
|
||||
|
||||
val folderIcon: Icon = if (SystemUtils.IS_OS_LINUX) FlatTreeClosedIcon()
|
||||
else if (SystemUtils.IS_OS_MAC_OSX)
|
||||
UIManager.getIcon("FileView.directoryIcon") ?: FlatTreeClosedIcon()
|
||||
else if (SystemUtils.IS_OS_WINDOWS)
|
||||
FileSystemView.getFileSystemView().getSystemIcon(SystemUtils.getUserDir()) ?: FlatTreeClosedIcon()
|
||||
else FlatTreeClosedIcon()
|
||||
|
||||
|
||||
val fileIcon: Icon = if (SystemUtils.IS_OS_LINUX) FlatTreeLeafIcon()
|
||||
else if (SystemUtils.IS_OS_MAC_OSX)
|
||||
UIManager.getIcon("FileView.fileIcon") ?: FlatTreeLeafIcon()
|
||||
else if (SystemUtils.IS_OS_WINDOWS) {
|
||||
val file = createTempFile()
|
||||
val icon = FileSystemView.getFileSystemView().getSystemIcon(file.toFile()) ?: FlatTreeLeafIcon()
|
||||
Files.deleteIfExists(file)
|
||||
icon
|
||||
} else FlatTreeLeafIcon()
|
||||
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import app.termora.actions.DataProviders
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.keymap.KeymapPanel
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.terminal.CursorStyle
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||
import app.termora.terminal.panel.TerminalPanel
|
||||
import app.termora.transfer.TransportTerminalTab
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
@@ -670,8 +670,8 @@ class SettingsOptionsPane : OptionsPane() {
|
||||
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
|
||||
|
||||
if (sftp.pinTab) {
|
||||
if (manager.getTerminalTabs().none { it is SFTPTab }) {
|
||||
manager.addTerminalTab(1, SFTPTab(), false)
|
||||
if (manager.getTerminalTabs().none { it is TransportTerminalTab }) {
|
||||
manager.addTerminalTab(1, TransportTerminalTab(), false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ package app.termora
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import app.termora.sftp.SFTPTab
|
||||
import app.termora.terminal.DataKey
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.ui.FlatRootPaneUI
|
||||
@@ -23,7 +21,6 @@ import java.util.*
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.SwingUtilities.isEventDispatchThread
|
||||
import javax.swing.UIManager
|
||||
|
||||
@@ -43,7 +40,6 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val welcomePanel = WelcomePanel(windowScope)
|
||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||
private var notifyListeners = emptyArray<NotifyListener>()
|
||||
|
||||
|
||||
@@ -205,13 +201,6 @@ class TermoraFrame : JFrame(), DataProvider {
|
||||
minimumSize = Dimension(640, 400)
|
||||
terminalTabbed.addTerminalTab(welcomePanel)
|
||||
|
||||
// 下一次事件循环检测是否固定 SFTP
|
||||
if (sftp.pinTab) {
|
||||
SwingUtilities.invokeLater {
|
||||
terminalTabbed.addTerminalTab(SFTPTab(), false)
|
||||
}
|
||||
}
|
||||
|
||||
val glassPane = GlassPane()
|
||||
rootPane.glassPane = glassPane
|
||||
glassPane.isOpaque = false
|
||||
|
||||
@@ -6,9 +6,9 @@ import app.termora.findeverywhere.FindEverywhereAction
|
||||
import app.termora.highlight.KeywordHighlightAction
|
||||
import app.termora.keymgr.KeyManagerAction
|
||||
import app.termora.macro.MacroAction
|
||||
import app.termora.sftp.SFTPAction
|
||||
import app.termora.snippet.SnippetAction
|
||||
import app.termora.tlog.TerminalLoggerAction
|
||||
import app.termora.transfer.TransferAnAction
|
||||
import javax.swing.Action
|
||||
|
||||
class ActionManager : org.jdesktop.swingx.action.ActionManager() {
|
||||
@@ -32,7 +32,7 @@ class ActionManager : org.jdesktop.swingx.action.ActionManager() {
|
||||
addAction(Actions.APP_UPDATE, AppUpdateAction.getInstance())
|
||||
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
|
||||
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
|
||||
addAction(Actions.SFTP, SFTPAction())
|
||||
addAction(Actions.SFTP, TransferAnAction())
|
||||
addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction())
|
||||
addAction(SnippetAction.SNIPPET, SnippetAction.getInstance())
|
||||
addAction(Actions.MACRO, MacroAction())
|
||||
|
||||
@@ -3,7 +3,7 @@ package app.termora.actions
|
||||
import app.termora.*
|
||||
import app.termora.protocol.GenericProtocolProvider
|
||||
import app.termora.protocol.ProtocolProvider
|
||||
import app.termora.sftp.SFTPActionEvent
|
||||
import app.termora.transfer.TransferActionEvent
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import javax.swing.JOptionPane
|
||||
|
||||
@@ -38,7 +38,7 @@ class OpenHostAction : AnAction() {
|
||||
if (providers.first { StringUtils.equalsIgnoreCase(it.getProtocol(), host.protocol) }
|
||||
.isTransfer()) {
|
||||
ActionManager.getInstance().getAction(Actions.SFTP)
|
||||
.actionPerformed(SFTPActionEvent(evt.source, evt.host.id, evt.event))
|
||||
.actionPerformed(TransferActionEvent(evt.source, evt.host.id, evt.event))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ class KeymapManager private constructor() : Disposable {
|
||||
|
||||
if (component is JComponent) {
|
||||
// 如果这个键已经被组件注册了,那么忽略
|
||||
if (component.getConditionForKeyStroke(keyStroke) != JComponent.UNDEFINED_CONDITION) {
|
||||
if (getConditionForKeyStroke(component, keyStroke) != JComponent.UNDEFINED_CONDITION) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,21 @@ class KeymapManager private constructor() : Disposable {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getConditionForKeyStroke(c: JComponent, keyStroke: KeyStroke): Int {
|
||||
val condition = c.getConditionForKeyStroke(keyStroke)
|
||||
|
||||
// 如果这个键已经被组件注册了,那么忽略
|
||||
if (condition != JComponent.UNDEFINED_CONDITION) {
|
||||
return condition
|
||||
}
|
||||
|
||||
if (c.parent is JComponent) {
|
||||
return getConditionForKeyStroke(c.parent as JComponent, keyStroke)
|
||||
}
|
||||
|
||||
return JComponent.UNDEFINED_CONDITION
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
|
||||
@@ -2,7 +2,7 @@ package app.termora.plugin
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
import app.termora.transfer.ScaleIcon
|
||||
import org.semver4j.Semver
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
@@ -18,7 +18,7 @@ open class PluginDescriptor(
|
||||
val path: File? = null,
|
||||
) {
|
||||
companion object {
|
||||
val defaultIcon: Icon = FlatSVGIcon(Icons.plugin.name, 32, 32)
|
||||
val defaultIcon: Icon = ScaleIcon(Icons.plugin, 32)
|
||||
}
|
||||
|
||||
val description: String get() = getBestDescription()
|
||||
|
||||
@@ -11,9 +11,9 @@ import app.termora.plugin.internal.rdp.RDPInternalPlugin
|
||||
import app.termora.plugin.internal.serial.SerialInternalPlugin
|
||||
import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin
|
||||
import app.termora.plugin.internal.ssh.SSHInternalPlugin
|
||||
import app.termora.sftp.internal.local.LocalPlugin
|
||||
import app.termora.sftp.internal.sftp.SFTPPlugin
|
||||
import app.termora.swingCoroutineScope
|
||||
import app.termora.transfer.internal.local.LocalPlugin
|
||||
import app.termora.transfer.internal.sftp.SFTPPlugin
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -7,7 +7,6 @@ import app.termora.setupAntialiasing
|
||||
import com.formdev.flatlaf.FlatLaf
|
||||
import com.formdev.flatlaf.util.UIScale
|
||||
import com.github.weisj.jsvg.SVGDocument
|
||||
import com.github.weisj.jsvg.parser.LoaderContext
|
||||
import com.github.weisj.jsvg.parser.SVGLoader
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
@@ -22,8 +21,8 @@ class PluginSVGIcon(input: InputStream, dark: InputStream? = null) : Icon {
|
||||
|
||||
}
|
||||
|
||||
private val document = svgLoader.load(input, null, LoaderContext.createDefault())
|
||||
private val darkDocument = dark?.let { svgLoader.load(it, null, LoaderContext.createDefault()) }
|
||||
private val document = svgLoader.load(input)
|
||||
private val darkDocument = dark?.let { svgLoader.load(it) }
|
||||
|
||||
override fun getIconHeight(): Int {
|
||||
return 32
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package app.termora.protocol
|
||||
|
||||
import app.termora.Disposable
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
|
||||
open class FileObjectHandler(val file: FileObject) : Disposable {
|
||||
|
||||
}
|
||||
9
src/main/kotlin/app/termora/protocol/PathHandler.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package app.termora.protocol
|
||||
|
||||
import app.termora.Disposable
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.Path
|
||||
|
||||
open class PathHandler(val fileSystem: FileSystem, val path: Path) : Disposable {
|
||||
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package app.termora.protocol
|
||||
import app.termora.Host
|
||||
import java.awt.Window
|
||||
|
||||
class FileObjectRequest(
|
||||
class PathHandlerRequest(
|
||||
val host: Host,
|
||||
val owner: Window? = null,
|
||||
)
|
||||
@@ -4,10 +4,9 @@ import app.termora.plugin.internal.local.LocalProtocolProvider
|
||||
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
||||
import app.termora.protocol.ProtocolProvider.Companion.providers
|
||||
import app.termora.sftp.internal.local.LocalTransferProtocolProvider
|
||||
import app.termora.sftp.internal.sftp.SFTPTransferProtocolProvider
|
||||
import app.termora.transfer.internal.local.LocalTransferProtocolProvider
|
||||
import app.termora.transfer.internal.sftp.SFTPTransferProtocolProvider
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.vfs2.provider.FileProvider
|
||||
|
||||
interface TransferProtocolProvider : ProtocolProvider {
|
||||
|
||||
@@ -32,14 +31,9 @@ interface TransferProtocolProvider : ProtocolProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件提供者
|
||||
* 创建一个文件
|
||||
*/
|
||||
fun getFileProvider(): FileProvider
|
||||
|
||||
/**
|
||||
* 获取根文件
|
||||
*/
|
||||
fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler
|
||||
fun createPathHandler(requester: PathHandlerRequest): PathHandler
|
||||
|
||||
override fun isTransfer(): Boolean {
|
||||
return true
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import org.apache.commons.vfs2.FileSystem
|
||||
|
||||
|
||||
interface FileSystemProvider {
|
||||
fun getFileSystem(): FileSystem
|
||||
fun setFileSystem(fileSystem: FileSystem)
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.Icons
|
||||
import app.termora.assertEventDispatchThread
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatTextField
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Point
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.ActionListener
|
||||
import java.awt.event.ItemEvent
|
||||
import java.awt.event.ItemListener
|
||||
import java.nio.file.FileSystems
|
||||
import javax.swing.*
|
||||
import javax.swing.event.PopupMenuEvent
|
||||
import javax.swing.event.PopupMenuListener
|
||||
import javax.swing.filechooser.FileSystemView
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
class FileSystemViewNav(
|
||||
private val fileSystemProvider: FileSystemProvider,
|
||||
private val homeDirectory: FileObject
|
||||
) : JPanel(BorderLayout()) {
|
||||
|
||||
companion object {
|
||||
private const val PATH = "path"
|
||||
private val log = LoggerFactory.getLogger(FileSystemViewNav::class.java)
|
||||
}
|
||||
|
||||
private val fileSystemView = FileSystemView.getFileSystemView()
|
||||
private val textField = MyFlatTextField()
|
||||
private var popupLastTime = 0L
|
||||
private val history = linkedSetOf<String>()
|
||||
private val layeredPane = LayeredPane()
|
||||
private val downBtn = JButton(Icons.chevronDown)
|
||||
private val comboBox = object : JComboBox<FileObject>() {
|
||||
override fun getLocationOnScreen(): Point {
|
||||
val point = super.getLocationOnScreen()
|
||||
point.y -= 1
|
||||
return point
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
initViews()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
|
||||
comboBox.isEnabled = false
|
||||
comboBox.putClientProperty("JComboBox.isTableCellEditor", true)
|
||||
|
||||
textField.leadingIcon = NativeFileIcons.getFolderIcon()
|
||||
textField.trailingComponent = downBtn
|
||||
textField.text = homeDirectory.absolutePathString()
|
||||
textField.putClientProperty(PATH, homeDirectory)
|
||||
|
||||
downBtn.putClientProperty(
|
||||
FlatClientProperties.STYLE,
|
||||
mapOf(
|
||||
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
|
||||
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
|
||||
)
|
||||
)
|
||||
|
||||
comboBox.renderer = object : DefaultListCellRenderer() {
|
||||
private val indentIcon = IndentIcon()
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
val c = super.getListCellRendererComponent(
|
||||
list,
|
||||
if (value is FileObject) formatDisplayPath(value) else value.toString(),
|
||||
index,
|
||||
isSelected,
|
||||
cellHasFocus
|
||||
)
|
||||
|
||||
indentIcon.depth = 0
|
||||
indentIcon.icon = NativeFileIcons.getFolderIcon()
|
||||
|
||||
icon = indentIcon
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
layeredPane.add(comboBox, JLayeredPane.DEFAULT_LAYER as Any)
|
||||
layeredPane.add(textField, JLayeredPane.PALETTE_LAYER as Any)
|
||||
add(layeredPane, BorderLayout.CENTER)
|
||||
|
||||
|
||||
if (SystemInfo.isWindows && fileSystemProvider.getFileSystem() is LocalFileSystem) {
|
||||
try {
|
||||
for (root in fileSystemView.roots) {
|
||||
history.add(root.absolutePath)
|
||||
}
|
||||
for (rootDirectory in FileSystems.getDefault().rootDirectories) {
|
||||
history.add(rootDirectory.absolutePathString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDisplayPath(file: FileObject): String {
|
||||
return file.absolutePathString()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
val itemListener = ItemListener { e ->
|
||||
if (e.stateChange == ItemEvent.SELECTED) {
|
||||
val item = comboBox.selectedItem
|
||||
if (item is FileObject) {
|
||||
changeSelectedPath(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comboBox.addPopupMenuListener(object : PopupMenuListener {
|
||||
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
|
||||
comboBox.addItemListener(itemListener)
|
||||
}
|
||||
|
||||
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
|
||||
popupLastTime = System.currentTimeMillis()
|
||||
comboBox.removeItemListener(itemListener)
|
||||
comboBox.isEnabled = false
|
||||
textField.requestFocusInWindow()
|
||||
}
|
||||
|
||||
override fun popupMenuCanceled(e: PopupMenuEvent?) {
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
// 监听 Action
|
||||
addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val text = textField.text.trim()
|
||||
if (text.isBlank()) return
|
||||
if (history.contains(text)) return
|
||||
history.add(text)
|
||||
}
|
||||
})
|
||||
|
||||
downBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (System.currentTimeMillis() - popupLastTime < 250) return
|
||||
comboBox.isEnabled = true
|
||||
comboBox.requestFocusInWindow()
|
||||
showComboBoxPopup()
|
||||
}
|
||||
})
|
||||
|
||||
textField.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
val name = textField.text.trim()
|
||||
if (name.isBlank()) return
|
||||
val fileSystem = fileSystemProvider.getFileSystem()
|
||||
try {
|
||||
if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
|
||||
val file = VFS.getManager().resolveFile("file://${name}")
|
||||
if (!StringUtils.equals(file.fileSystem.rootURI, fileSystemProvider.getFileSystem().rootURI)) {
|
||||
fileSystemProvider.setFileSystem(file.fileSystem)
|
||||
}
|
||||
changeSelectedPath(file)
|
||||
} else {
|
||||
changeSelectedPath(fileSystem.resolveFile(name))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun showComboBoxPopup() {
|
||||
|
||||
comboBox.removeAllItems()
|
||||
val fileSystem = fileSystemProvider.getFileSystem()
|
||||
|
||||
for (text in history) {
|
||||
val path = if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
|
||||
VFS.getManager().resolveFile("file://${text}")
|
||||
} else {
|
||||
fileSystem.resolveFile(text)
|
||||
}
|
||||
comboBox.addItem(path)
|
||||
if (text == textField.text) {
|
||||
comboBox.selectedItem = path
|
||||
}
|
||||
}
|
||||
|
||||
comboBox.showPopup()
|
||||
}
|
||||
|
||||
fun addActionListener(l: ActionListener) {
|
||||
listenerList.add(ActionListener::class.java, l)
|
||||
}
|
||||
|
||||
class IndentIcon : Icon {
|
||||
val space = 10
|
||||
var depth: Int = 0
|
||||
var icon = NativeFileIcons.getFolderIcon()
|
||||
|
||||
override fun paintIcon(c: Component, g: Graphics, x: Int, y: Int) {
|
||||
if (c.componentOrientation.isLeftToRight) {
|
||||
icon.paintIcon(c, g, x + depth * space, y)
|
||||
} else {
|
||||
icon.paintIcon(c, g, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getIconWidth(): Int {
|
||||
return icon.iconWidth + depth * space
|
||||
}
|
||||
|
||||
override fun getIconHeight(): Int {
|
||||
return icon.iconHeight
|
||||
}
|
||||
}
|
||||
|
||||
fun getSelectedPath(): FileObject {
|
||||
return textField.getClientProperty(PATH) as FileObject
|
||||
}
|
||||
|
||||
fun changeSelectedPath(file: FileObject) {
|
||||
assertEventDispatchThread()
|
||||
|
||||
textField.text = formatDisplayPath(file)
|
||||
textField.putClientProperty(PATH, file)
|
||||
|
||||
val fileSystem = fileSystemProvider.getFileSystem()
|
||||
if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
|
||||
if (!StringUtils.equals(fileSystem.rootURI, file.fileSystem.rootURI)) {
|
||||
fileSystemProvider.setFileSystem(file.fileSystem)
|
||||
}
|
||||
}
|
||||
|
||||
for (listener in listenerList.getListeners(ActionListener::class.java)) {
|
||||
listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Suppress("UNNECESSARY_SAFE_CALL")
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
downBtn?.putClientProperty(
|
||||
FlatClientProperties.STYLE,
|
||||
mapOf(
|
||||
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
|
||||
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class MyFlatTextField : FlatTextField() {
|
||||
public override fun fireActionPerformed() {
|
||||
super.fireActionPerformed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class LayeredPane : JLayeredPane() {
|
||||
override fun doLayout() {
|
||||
synchronized(treeLock) {
|
||||
for (c in components) {
|
||||
c.setBounds(0, 0, width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.vfs2.sftp.MySftpFileSystem
|
||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.FileSystem
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.function.Consumer
|
||||
import javax.swing.*
|
||||
|
||||
class FileSystemViewPanel(
|
||||
val host: Host,
|
||||
private val homeDirectory: FileObject,
|
||||
private val transportManager: TransportManager,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) : JPanel(BorderLayout()), Disposable, DataProvider, FileSystemProvider {
|
||||
|
||||
private var fileSystem: FileSystem = homeDirectory.fileSystem
|
||||
private val properties get() = DatabaseManager.getInstance().properties
|
||||
private val table = FileSystemViewTable(this, transportManager, coroutineScope)
|
||||
private val disposed = AtomicBoolean(false)
|
||||
private var nextReloadTicks = emptyArray<Consumer<Unit>>()
|
||||
private val isLoading = AtomicBoolean(false)
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val loadingPanel = LoadingPanel()
|
||||
private val layeredPane = LayeredPane()
|
||||
private val nav = FileSystemViewNav(this, homeDirectory)
|
||||
private var workdir = homeDirectory
|
||||
private val model get() = table.model as FileSystemViewTableModel
|
||||
private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files"
|
||||
private var useFileHiding: Boolean
|
||||
get() = properties.getString(showHiddenFilesKey, "true").toBoolean()
|
||||
set(value) = properties.putString(showHiddenFilesKey, value.toString())
|
||||
|
||||
val isDisposed get() = disposed.get()
|
||||
|
||||
init {
|
||||
initViews()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
|
||||
val toolbar = FlatToolBar()
|
||||
toolbar.add(createHomeFolderButton())
|
||||
toolbar.add(Box.createHorizontalStrut(2))
|
||||
toolbar.add(nav)
|
||||
toolbar.add(createBookmarkButton())
|
||||
toolbar.add(createParentFolderButton())
|
||||
toolbar.add(createHiddenFilesButton())
|
||||
toolbar.add(createRefreshButton())
|
||||
|
||||
add(toolbar, BorderLayout.NORTH)
|
||||
add(layeredPane, BorderLayout.CENTER)
|
||||
|
||||
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.PALETTE_LAYER as Any)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
Disposer.register(this, table)
|
||||
|
||||
nav.addActionListener { reload() }
|
||||
|
||||
table.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
||||
enterTableSelectionFolder()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
table.addKeyListener(object : KeyAdapter() {
|
||||
override fun keyPressed(e: KeyEvent) {
|
||||
if (e.keyCode == KeyEvent.VK_ENTER) {
|
||||
enterTableSelectionFolder()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
val listener = object : TransportListener, Disposable {
|
||||
override fun onTransportChanged(transport: Transport) {
|
||||
val path = transport.target.parent ?: return
|
||||
if (path.fileSystem != fileSystem) return
|
||||
if (path.name.path != workdir.name.path) return
|
||||
// 立即刷新
|
||||
reload(true)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
transportManager.removeTransportListener(this)
|
||||
}
|
||||
}
|
||||
transportManager.addTransportListener(listener)
|
||||
Disposer.register(this, listener)
|
||||
|
||||
// 变更工作目录
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
changeWorkdir(homeDirectory)
|
||||
} else {
|
||||
SwingUtilities.invokeLater { changeWorkdir(homeDirectory) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun enterTableSelectionFolder(row: Int = table.selectedRow) {
|
||||
if (row < 0 || isLoading.get()) return
|
||||
val file = model.getFileObject(row)
|
||||
if (file.isFile) return
|
||||
|
||||
// 当前工作目录
|
||||
val workdir = getWorkdir()
|
||||
|
||||
// 返回上级之后,选中上级目录
|
||||
if (row == 0 && model.hasParent) {
|
||||
val workdirName = workdir.name
|
||||
nextReloadTickSelection(workdirName.baseName)
|
||||
}
|
||||
|
||||
changeWorkdir(file)
|
||||
|
||||
}
|
||||
|
||||
private fun createRefreshButton(): JButton {
|
||||
val button = JButton(Icons.refresh)
|
||||
button.addActionListener { reload(true) }
|
||||
return button
|
||||
}
|
||||
|
||||
private fun createHiddenFilesButton(): JButton {
|
||||
val button = JButton(if (useFileHiding) Icons.eyeClose else Icons.eye)
|
||||
button.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
useFileHiding = !useFileHiding
|
||||
button.icon = if (useFileHiding) Icons.eyeClose else Icons.eye
|
||||
reload(true)
|
||||
}
|
||||
})
|
||||
return button
|
||||
}
|
||||
|
||||
private fun createHomeFolderButton(): JButton {
|
||||
val button = JButton(Icons.homeFolder)
|
||||
button.addActionListener { nav.changeSelectedPath(homeDirectory) }
|
||||
return button
|
||||
}
|
||||
|
||||
private fun createBookmarkButton(): JButton {
|
||||
val bookmarkBtn = BookmarkButton()
|
||||
bookmarkBtn.name = "Host.${host.id}.Bookmarks"
|
||||
bookmarkBtn.addActionListener { e ->
|
||||
if (e.actionCommand.isNullOrBlank()) {
|
||||
if (bookmarkBtn.isBookmark) {
|
||||
bookmarkBtn.deleteBookmark(workdir.absolutePathString())
|
||||
} else {
|
||||
bookmarkBtn.addBookmark(workdir.absolutePathString())
|
||||
}
|
||||
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
|
||||
} else {
|
||||
if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
|
||||
val file = VFS.getManager().resolveFile("file://${e.actionCommand}")
|
||||
if (!StringUtils.equals(file.fileSystem.rootURI, fileSystem.rootURI)) {
|
||||
fileSystem = file.fileSystem
|
||||
}
|
||||
changeWorkdir(file)
|
||||
} else {
|
||||
changeWorkdir(fileSystem.resolveFile(e.actionCommand))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(nav.getSelectedPath().absolutePathString())
|
||||
}
|
||||
})
|
||||
|
||||
return bookmarkBtn
|
||||
}
|
||||
|
||||
|
||||
private fun createParentFolderButton(): AbstractButton {
|
||||
val button = JButton(Icons.up)
|
||||
button.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (model.rowCount < 1) return
|
||||
if (model.hasParent) enterTableSelectionFolder(0)
|
||||
}
|
||||
})
|
||||
|
||||
addPropertyChangeListener("workdir") {
|
||||
button.isEnabled = model.rowCount > 0 && model.hasParent
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
private fun nextReloadTickSelection(name: String, consumer: Consumer<Int> = Consumer { }) {
|
||||
// 创建成功之后需要修改和选中
|
||||
registerNextReloadTick {
|
||||
for (i in 0 until table.rowCount) {
|
||||
if (model.getFileObject(i).name.baseName == name) {
|
||||
table.addRowSelectionInterval(i, i)
|
||||
table.scrollRectToVisible(table.getCellRect(i, 0, true))
|
||||
consumer.accept(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeWorkdir(workdir: FileObject) {
|
||||
assertEventDispatchThread()
|
||||
nav.changeSelectedPath(workdir)
|
||||
}
|
||||
|
||||
fun renameTo(oldPath: FileObject, newPath: FileObject) {
|
||||
|
||||
// 新建文件夹
|
||||
coroutineScope.launch {
|
||||
|
||||
if (requestLoading()) {
|
||||
try {
|
||||
oldPath.moveTo(newPath)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
SwingUtilities.getWindowAncestor(owner),
|
||||
ExceptionUtils.getMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
stopLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// 创建成功之后需要选中
|
||||
nextReloadTickSelection(newPath.name.baseName)
|
||||
|
||||
// 立即刷新
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
fun newFolderOrFile(name: String, isFile: Boolean) {
|
||||
coroutineScope.launch {
|
||||
if (requestLoading()) {
|
||||
try {
|
||||
doNewFolderOrFile(getWorkdir().resolveFile(name), isFile)
|
||||
} finally {
|
||||
stopLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// 创建成功之后需要修改和选中
|
||||
nextReloadTickSelection(name)
|
||||
|
||||
// 立即刷新
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun doNewFolderOrFile(path: FileObject, isFile: Boolean) {
|
||||
|
||||
if (path.exists()) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.transport.file-already-exists", path.name),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 创建文件夹
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { if (isFile) path.createFile() else path.createFolder() }.onFailure {
|
||||
withContext(Dispatchers.Swing) {
|
||||
if (it is Exception) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
ExceptionUtils.getMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
fun requestLoading(): Boolean {
|
||||
if (isLoading.compareAndSet(false, true)) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
loadingPanel.start()
|
||||
} else {
|
||||
SwingUtilities.invokeLater { loadingPanel.start() }
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun stopLoading() {
|
||||
if (isLoading.compareAndSet(true, false)) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
loadingPanel.stop()
|
||||
} else {
|
||||
SwingUtilities.invokeLater { loadingPanel.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reload(rememberSelection: Boolean = false) {
|
||||
if (!requestLoading()) return
|
||||
if (fileSystem is MySftpFileSystem) loadingPanel.start()
|
||||
val oldWorkdir = workdir
|
||||
val path = nav.getSelectedPath()
|
||||
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
|
||||
if (rememberSelection) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
table.selectedRows.sortedDescending().map { model.getFileObject(it).name.baseName }
|
||||
.forEach { nextReloadTickSelection(it) }
|
||||
}
|
||||
}
|
||||
|
||||
runCatching { model.reload(path, useFileHiding) }.onFailure {
|
||||
if (it is Exception) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getRootCauseMessage(it),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onSuccess {
|
||||
withContext(Dispatchers.Swing) {
|
||||
workdir = path
|
||||
// 触发工作目录变动
|
||||
firePropertyChange("workdir", oldWorkdir, workdir)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
// 触发
|
||||
triggerNextReloadTicks()
|
||||
}
|
||||
|
||||
} finally {
|
||||
stopLoading()
|
||||
if (fileSystem is MySftpFileSystem) {
|
||||
withContext(Dispatchers.Swing) { loadingPanel.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getWorkdir(): FileObject {
|
||||
return workdir
|
||||
}
|
||||
|
||||
private fun registerNextReloadTick(consumer: Consumer<Unit>) {
|
||||
nextReloadTicks += Consumer<Unit> { t ->
|
||||
assertEventDispatchThread()
|
||||
consumer.accept(t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerNextReloadTicks() {
|
||||
for (nextReloadTick in nextReloadTicks) {
|
||||
nextReloadTick.accept(Unit)
|
||||
}
|
||||
nextReloadTicks = emptyArray()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (disposed.compareAndSet(false, true)) {
|
||||
val rootChildren = transportManager.getTransports(0L)
|
||||
for (child in rootChildren) {
|
||||
if (child.source.fileSystem == fileSystem ||
|
||||
child.target.fileSystem == fileSystem
|
||||
) {
|
||||
child.changeStatus(TransportStatus.Failed)
|
||||
}
|
||||
}
|
||||
fileSystem.fileSystemManager.filesCache.clear(fileSystem)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
return if (dataKey == SFTPDataProviders.FileSystemViewTable) table as T else null
|
||||
}
|
||||
|
||||
override fun getFileSystem(): FileSystem {
|
||||
return fileSystem
|
||||
}
|
||||
|
||||
override fun setFileSystem(fileSystem: FileSystem) {
|
||||
this.fileSystem = fileSystem
|
||||
}
|
||||
|
||||
private class LoadingPanel : JPanel() {
|
||||
private val busyLabel = JXBusyLabel()
|
||||
|
||||
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 LayeredPane : JLayeredPane() {
|
||||
override fun doLayout() {
|
||||
synchronized(treeLock) {
|
||||
val w = width
|
||||
val h = height
|
||||
for (c in components) {
|
||||
c.setBounds(0, 0, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.NativeStringComparator
|
||||
import app.termora.formatBytes
|
||||
import app.termora.vfs2.FileObjectDescriptor
|
||||
import app.termora.vfs2.sftp.MySftpFileObject
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.FileType
|
||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
import java.util.*
|
||||
import javax.swing.Icon
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.table.DefaultTableModel
|
||||
|
||||
class FileSystemViewTableModel : 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 log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java)
|
||||
|
||||
fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
|
||||
val result = mutableSetOf<PosixFilePermission>()
|
||||
|
||||
// 将十进制权限转换为八进制字符串
|
||||
val octalPermissions = sftpPermissions.toString(8)
|
||||
|
||||
// 仅取后三位权限部分
|
||||
if (octalPermissions.length < 3) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
var hasParent: Boolean = false
|
||||
private set
|
||||
|
||||
override fun getValueAt(row: Int, column: Int): Any {
|
||||
val file = getFileObject(row)
|
||||
val isParentRow = hasParent && row == 0
|
||||
|
||||
try {
|
||||
if (file.type == FileType.IMAGINARY) return StringUtils.EMPTY
|
||||
return when (column) {
|
||||
COLUMN_NAME -> if (isParentRow) ".." else file.name.baseName
|
||||
COLUMN_FILE_SIZE -> if (isParentRow || file.isFolder) StringUtils.EMPTY else formatBytes(file.content.size)
|
||||
COLUMN_TYPE -> if (isParentRow) StringUtils.EMPTY else getFileType(file)
|
||||
COLUMN_LAST_MODIFIED_TIME -> if (isParentRow) StringUtils.EMPTY else getLastModifiedTime(file)
|
||||
COLUMN_ATTRS -> if (isParentRow) StringUtils.EMPTY else getAttrs(file)
|
||||
COLUMN_OWNER -> StringUtils.EMPTY
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (file.fileSystem is LocalFileSystem) {
|
||||
if (ExceptionUtils.getRootCause(e) is java.nio.file.NoSuchFileException) {
|
||||
SwingUtilities.invokeLater { removeRow(row) }
|
||||
return StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
return StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileType(file: FileObject): String {
|
||||
if (file is FileObjectDescriptor) {
|
||||
val type = file.getTypeDescription()
|
||||
if (type != null) return type
|
||||
}
|
||||
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
|
||||
else if (file.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
|
||||
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
|
||||
}
|
||||
|
||||
fun getFileIcon(file: FileObject, width: Int = 16, height: Int = 16): Icon {
|
||||
if (file is FileObjectDescriptor) {
|
||||
val icon = file.getIcon(width, height)
|
||||
if (icon != null) return icon
|
||||
}
|
||||
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile, width, height).first
|
||||
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).first
|
||||
}
|
||||
|
||||
fun getFileIcon(row: Int): Icon {
|
||||
return getFileIcon(getFileObject(row))
|
||||
}
|
||||
|
||||
fun getLastModifiedTime(file: FileObject): String {
|
||||
var lastModified: Long = 0
|
||||
if (file is FileObjectDescriptor) {
|
||||
val time = file.getLastModified()
|
||||
if (time != null) lastModified = time
|
||||
} else {
|
||||
lastModified = file.content.lastModifiedTime
|
||||
}
|
||||
if (lastModified < 1) return "-"
|
||||
return DateFormatUtils.format(Date(lastModified), "yyyy/MM/dd HH:mm")
|
||||
}
|
||||
|
||||
private fun getAttrs(file: FileObject): String {
|
||||
if (file.fileSystem is LocalFileSystem) return StringUtils.EMPTY
|
||||
return PosixFilePermissions.toString(getFilePermissions(file))
|
||||
}
|
||||
|
||||
fun getFilePermissions(file: FileObject): Set<PosixFilePermission> {
|
||||
val permissions = file.content.getAttribute(MySftpFileObject.POSIX_FILE_PERMISSIONS)
|
||||
as Int? ?: return emptySet()
|
||||
return fromSftpPermissions(permissions)
|
||||
}
|
||||
|
||||
override fun getDataVector(): Vector<Vector<Any>> {
|
||||
return super.getDataVector()
|
||||
}
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return 6
|
||||
}
|
||||
|
||||
override fun getColumnClass(columnIndex: Int): Class<*> {
|
||||
return when (columnIndex) {
|
||||
COLUMN_NAME -> String::class.java
|
||||
else -> super.getColumnClass(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileObject(row: Int): FileObject {
|
||||
return super.getValueAt(row, 0) as FileObject
|
||||
}
|
||||
|
||||
fun getPathNames(): Set<String> {
|
||||
val names = linkedSetOf<String>()
|
||||
for (i in 0 until rowCount) {
|
||||
if (hasParent && i == 0) {
|
||||
names.add("..")
|
||||
} else {
|
||||
names.add(getFileObject(i).name.baseName)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override fun isCellEditable(row: Int, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
suspend fun reload(dir: FileObject, useFileHiding: Boolean) {
|
||||
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding)
|
||||
}
|
||||
|
||||
val files = mutableListOf<FileObject>()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
dir.refresh()
|
||||
for (file in dir.children) {
|
||||
if (useFileHiding && file.isHidden) continue
|
||||
files.add(file)
|
||||
}
|
||||
}
|
||||
|
||||
files.sortWith(compareBy<FileObject> { !it.isFolder }.thenComparing { a, b ->
|
||||
NativeStringComparator.getInstance().compare(
|
||||
a.name.baseName,
|
||||
b.name.baseName
|
||||
)
|
||||
})
|
||||
|
||||
hasParent = dir.parent != null
|
||||
if (hasParent) {
|
||||
files.addFirst(dir.parent)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
while (rowCount > 0) removeRow(0)
|
||||
files.forEach { addRow(arrayOf(it)) }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.Application
|
||||
import app.termora.I18n
|
||||
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
|
||||
import com.formdev.flatlaf.icons.FlatTreeLeafIcon
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.io.FileUtils
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.eclipse.jgit.util.LRUMap
|
||||
import java.util.*
|
||||
import javax.swing.Icon
|
||||
import javax.swing.filechooser.FileSystemView.getFileSystemView
|
||||
|
||||
object NativeFileIcons {
|
||||
|
||||
/**
|
||||
* key: filename , value: <icon,description>
|
||||
*/
|
||||
private val cache = LRUMap<String, Pair<Icon, String>>(16, 512)
|
||||
private val folderIcon = FlatTreeClosedIcon()
|
||||
private val fileIcon = FlatTreeLeafIcon()
|
||||
|
||||
init {
|
||||
if (SystemUtils.IS_OS_UNIX) {
|
||||
cache[SystemUtils.USER_HOME] = Pair(folderIcon, I18n.getString("termora.folder"))
|
||||
}
|
||||
}
|
||||
|
||||
fun getFolderIcon(): Icon {
|
||||
return getIcon(UUID.randomUUID().toString(), false).first
|
||||
}
|
||||
|
||||
fun getFileIcon(filename: String): Icon {
|
||||
return getIcon(filename, true).first
|
||||
}
|
||||
|
||||
fun getIcon(filename: String, isFile: Boolean = true, width: Int = 16, height: Int = 16): Pair<Icon, String> {
|
||||
val key = if (isFile) FilenameUtils.getExtension(filename) + "." + width + "@" + height
|
||||
else SystemUtils.USER_HOME + "." + width + "@" + height
|
||||
|
||||
if (cache.containsKey(key)) {
|
||||
return cache.getValue(key)
|
||||
}
|
||||
|
||||
val isDirectory = !isFile
|
||||
|
||||
if (SystemInfo.isWindows) {
|
||||
|
||||
val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else
|
||||
FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}")
|
||||
if (isFile && !file.exists()) {
|
||||
file.createNewFile()
|
||||
}
|
||||
|
||||
val icon = getFileSystemView().getSystemIcon(file, width, height) ?: if (isFile) fileIcon else folderIcon
|
||||
val description = getFileSystemView().getSystemTypeDescription(file)
|
||||
?: StringUtils.defaultString(file.extension)
|
||||
val pair = icon to description
|
||||
|
||||
cache[key] = pair
|
||||
|
||||
if (isFile) FileUtils.deleteQuietly(file)
|
||||
|
||||
return pair
|
||||
}
|
||||
|
||||
return Pair(
|
||||
if (isDirectory) folderIcon else fileIcon,
|
||||
if (isDirectory) I18n.getString("termora.folder") else FilenameUtils.getExtension(filename)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.terminal.DataKey
|
||||
|
||||
object SFTPDataProviders {
|
||||
val TransportManager = DataKey(app.termora.sftp.TransportManager::class)
|
||||
val FileSystemViewPanel = DataKey(app.termora.sftp.FileSystemViewPanel::class)
|
||||
val CoroutineScope = DataKey(kotlinx.coroutines.CoroutineScope::class)
|
||||
val FileSystemViewTable = DataKey(app.termora.sftp.FileSystemViewTable::class)
|
||||
val LeftSFTPTabbed = DataKey(SFTPTabbed::class)
|
||||
val RightSFTPTabbed = DataKey(SFTPTabbed::class)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.plugin.DispatchThread
|
||||
import app.termora.plugin.Extension
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import java.awt.Window
|
||||
|
||||
interface SFTPEditFileExtension : Extension {
|
||||
|
||||
/**
|
||||
* @return 当停止编辑后请销毁
|
||||
*/
|
||||
fun edit(owner: Window, file: FileObject): Disposable
|
||||
|
||||
override fun getDispatchThread(): DispatchThread {
|
||||
return DispatchThread.BGT
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.plugin.Extension
|
||||
|
||||
interface SFTPExtension : Extension {
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.provider.local.LocalFile
|
||||
import java.io.File
|
||||
|
||||
|
||||
fun FileObject.absolutePathString(): String {
|
||||
var text = name.path
|
||||
if (this is LocalFile && SystemUtils.IS_OS_WINDOWS) {
|
||||
text = this.name.toString()
|
||||
text = StringUtils.removeStart(text, "file:///")
|
||||
text = StringUtils.replace(text, "/", File.separator)
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.actions.DataProviderSupport
|
||||
import app.termora.findeverywhere.FindEverywhereProvider
|
||||
import app.termora.protocol.FileObjectRequest
|
||||
import app.termora.protocol.TransferProtocolProvider
|
||||
import app.termora.sftp.internal.local.LocalTransferProtocolProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import okio.withLock
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.nio.file.FileSystems
|
||||
import javax.swing.*
|
||||
|
||||
|
||||
class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val transportTable = TransportTable()
|
||||
private val transportManager get() = transportTable.model
|
||||
private val dataProviderSupport = DataProviderSupport()
|
||||
private val leftComponent = SFTPTabbed(transportManager)
|
||||
private val rightComponent = SFTPTabbed(transportManager)
|
||||
private val localHost = Host(
|
||||
id = "local",
|
||||
name = I18n.getString("termora.transport.local"),
|
||||
protocol = "Local",
|
||||
)
|
||||
|
||||
init {
|
||||
initViews()
|
||||
initEvents()
|
||||
FileSystems.getDefault()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
|
||||
putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true)
|
||||
|
||||
val splitPane = JSplitPane()
|
||||
splitPane.resizeWeight = 0.5
|
||||
splitPane.leftComponent = leftComponent
|
||||
splitPane.rightComponent = rightComponent
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
leftComponent.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
|
||||
rightComponent.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT)
|
||||
val scrollPane = JScrollPane(transportTable)
|
||||
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
rootSplitPane.resizeWeight = 0.7
|
||||
rootSplitPane.topComponent = splitPane
|
||||
rootSplitPane.bottomComponent = scrollPane
|
||||
rootSplitPane.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
|
||||
}
|
||||
})
|
||||
|
||||
add(rootSplitPane, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
Disposer.register(this, leftComponent)
|
||||
Disposer.register(this, rightComponent)
|
||||
Disposer.register(this, transportTable)
|
||||
|
||||
dataProviderSupport.addData(SFTPDataProviders.TransportManager, transportManager)
|
||||
dataProviderSupport.addData(SFTPDataProviders.LeftSFTPTabbed, leftComponent)
|
||||
dataProviderSupport.addData(SFTPDataProviders.RightSFTPTabbed, rightComponent)
|
||||
|
||||
|
||||
// default tab
|
||||
leftComponent.addTab(
|
||||
I18n.getString("termora.transport.local"),
|
||||
FileSystemViewPanel(
|
||||
localHost,
|
||||
TransferProtocolProvider.valueOf(LocalTransferProtocolProvider.PROTOCOL)!!
|
||||
.getRootFileObject(FileObjectRequest(localHost)).file,
|
||||
transportManager,
|
||||
coroutineScope
|
||||
)
|
||||
)
|
||||
leftComponent.setTabClosable(0, false)
|
||||
|
||||
|
||||
// default tab
|
||||
rightComponent.addTab(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SFTPFileSystemViewPanel(transportManager = transportManager)
|
||||
)
|
||||
|
||||
rightComponent.addChangeListener {
|
||||
if (rightComponent.tabCount == 0 && !rightComponent.isDisposed) {
|
||||
rightComponent.addTab(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SFTPFileSystemViewPanel(transportManager = transportManager)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
leftComponent.setTabCloseCallback { _, index -> tabCloseCallback(leftComponent, index) }
|
||||
rightComponent.setTabCloseCallback { _, index -> tabCloseCallback(rightComponent, index) }
|
||||
}
|
||||
|
||||
private fun tabCloseCallback(tabbed: SFTPTabbed, index: Int) {
|
||||
assertEventDispatchThread()
|
||||
|
||||
val c = tabbed.getFileSystemViewPanel(index)
|
||||
if (c == null) {
|
||||
tabbed.removeTabAt(index)
|
||||
return
|
||||
}
|
||||
|
||||
val fs = c.getFileSystem()
|
||||
val root = transportManager.root
|
||||
|
||||
transportManager.lock.withLock {
|
||||
val deletedIds = mutableListOf<Long>()
|
||||
for (i in 0 until root.childCount) {
|
||||
val child = root.getChildAt(i) as? TransportTreeTableNode ?: continue
|
||||
if (child.transport.source.fileSystem == fs ||
|
||||
child.transport.target.fileSystem == fs
|
||||
) {
|
||||
deletedIds.add(child.transport.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedIds.isNotEmpty()) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.transport.sftp.close-tab"),
|
||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
) != JOptionPane.OK_OPTION
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
deletedIds.forEach { transportManager.removeTransport(it) }
|
||||
}
|
||||
|
||||
|
||||
// 删除并销毁
|
||||
tabbed.removeTabAt(index)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回失败表示没有创建成功
|
||||
*/
|
||||
fun addTransport(
|
||||
source: JComponent,
|
||||
sourceWorkdir: FileObject?,
|
||||
target: FileSystemViewPanel,
|
||||
targetWorkdir: FileObject?,
|
||||
transport: Transport
|
||||
): Boolean {
|
||||
|
||||
val sourcePanel = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, source)
|
||||
as? FileSystemViewPanel ?: return false
|
||||
val targetPanel = target
|
||||
if (sourcePanel.isDisposed || targetPanel.isDisposed) return false
|
||||
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir())
|
||||
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir())
|
||||
val sourcePath = transport.source
|
||||
|
||||
val relativeName = mySourceWorkdir.name.getRelativeName(sourcePath.name)
|
||||
transport.target = myTargetWorkdir.resolveFile(relativeName)
|
||||
|
||||
return transportManager.addTransport(transport)
|
||||
|
||||
}
|
||||
|
||||
fun canTransfer(source: JComponent): Boolean {
|
||||
return getTarget(source) != null
|
||||
}
|
||||
|
||||
fun getTarget(source: JComponent): FileSystemViewPanel? {
|
||||
val sourceTabbed = SwingUtilities.getAncestorOfClass(SFTPTabbed::class.java, source)
|
||||
as? SFTPTabbed ?: return null
|
||||
val isLeft = sourceTabbed == leftComponent
|
||||
val targetTabbed = if (isLeft) rightComponent else leftComponent
|
||||
return targetTabbed.getSelectedFileSystemViewPanel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地文件系统面板
|
||||
*/
|
||||
fun getLocalTarget(): FileSystemViewPanel {
|
||||
return leftComponent.getFileSystemViewPanel(0) as FileSystemViewPanel
|
||||
}
|
||||
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
return dataProviderSupport.getData(dataKey)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.database.DatabaseChangedExtension
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JToolBar
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable {
|
||||
private val addBtn = JButton(Icons.add)
|
||||
private val tabbed = this
|
||||
private val disposed = AtomicBoolean(false)
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
|
||||
val isDisposed get() = disposed.get()
|
||||
|
||||
init {
|
||||
initViews()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
super.setTabLayoutPolicy(SCROLL_TAB_LAYOUT)
|
||||
super.setTabsClosable(true)
|
||||
super.setTabType(TabType.underlined)
|
||||
|
||||
val toolbar = JToolBar()
|
||||
toolbar.add(addBtn)
|
||||
super.setTrailingComponent(toolbar)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
for (i in 0 until tabCount) {
|
||||
val c = getComponentAt(i)
|
||||
if (c !is SFTPFileSystemViewPanel) continue
|
||||
if (c.state != SFTPFileSystemViewPanel.State.Initialized) continue
|
||||
selectedIndex = i
|
||||
return
|
||||
}
|
||||
|
||||
// 添加一个新的
|
||||
addTab(
|
||||
I18n.getString("termora.transport.sftp.select-host"),
|
||||
SFTPFileSystemViewPanel(transportManager = transportManager)
|
||||
)
|
||||
selectedIndex = tabCount - 1
|
||||
}
|
||||
})
|
||||
|
||||
// 右键菜单
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (!SwingUtilities.isRightMouseButton(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index < 0) return
|
||||
|
||||
showContextMenu(index, e)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
|
||||
val panel = getFileSystemViewPanel(tabIndex) ?: return
|
||||
val popupMenu = FlatPopupMenu()
|
||||
// 克隆
|
||||
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
|
||||
clone.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
val host = hostManager.getHost(panel.host.id) ?: return
|
||||
addSFTPFileSystemViewPanelTab(
|
||||
host.copy(
|
||||
options = host.options.copy(
|
||||
sftpDefaultDirectory = panel.getWorkdir().absolutePathString()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑
|
||||
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
|
||||
edit.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
if (panel.host.id == "local") {
|
||||
return
|
||||
}
|
||||
val host = hostManager.getHost(panel.host.id) ?: return
|
||||
val dialog = NewHostDialogV2(evt.window, host)
|
||||
dialog.setLocationRelativeTo(evt.window)
|
||||
dialog.isVisible = true
|
||||
hostManager.addHost(dialog.host ?: return, DatabaseChangedExtension.Source.Sync)
|
||||
}
|
||||
})
|
||||
|
||||
clone.isEnabled = panel.host.id != "local"
|
||||
edit.isEnabled = clone.isEnabled
|
||||
|
||||
popupMenu.show(this, e.x, e.y)
|
||||
}
|
||||
|
||||
fun addSFTPFileSystemViewPanelTab(host: Host) {
|
||||
val panel = SFTPFileSystemViewPanel(host, transportManager)
|
||||
addTab(host.name, panel)
|
||||
panel.connect()
|
||||
selectedIndex = tabCount - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前的 FileSystemViewPanel
|
||||
*/
|
||||
fun getSelectedFileSystemViewPanel(): FileSystemViewPanel? {
|
||||
return getFileSystemViewPanel(selectedIndex)
|
||||
}
|
||||
|
||||
|
||||
fun getFileSystemViewPanel(index: Int): FileSystemViewPanel? {
|
||||
if (tabCount < 1 || index < 0) return null
|
||||
|
||||
val c = getComponentAt(index)
|
||||
if (c is FileSystemViewPanel) {
|
||||
return c
|
||||
}
|
||||
|
||||
if (c is SFTPFileSystemViewPanel) {
|
||||
return c.getData(SFTPDataProviders.FileSystemViewPanel)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
styleMap = mapOf(
|
||||
"focusColor" to DynamicColor("TabbedPane.background"),
|
||||
"hoverColor" to DynamicColor("TabbedPane.background"),
|
||||
"tabHeight" to 30
|
||||
)
|
||||
super.updateUI()
|
||||
}
|
||||
|
||||
override fun removeTabAt(index: Int) {
|
||||
val c = getComponentAt(index)
|
||||
if (c is Disposable) {
|
||||
Disposer.dispose(c)
|
||||
}
|
||||
super.removeTabAt(index)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (disposed.compareAndSet(false, true)) {
|
||||
while (tabCount > 0) removeTabAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class SpeedReporter(private val coroutineScope: CoroutineScope) {
|
||||
companion object {
|
||||
val millis = TimeUnit.MILLISECONDS.toMillis(500)
|
||||
}
|
||||
|
||||
private val events = ConcurrentLinkedQueue<Triple<Transport, Long, Long>>()
|
||||
|
||||
init {
|
||||
collect()
|
||||
}
|
||||
|
||||
fun report(transport: Transport, bytes: Long, time: Long) {
|
||||
events.add(Triple(transport, bytes, time))
|
||||
}
|
||||
|
||||
private fun collect() {
|
||||
// 异步上报数据
|
||||
coroutineScope.launch {
|
||||
while (coroutineScope.isActive) {
|
||||
val time = System.currentTimeMillis()
|
||||
val map = linkedMapOf<Transport, Long>()
|
||||
|
||||
// 收集
|
||||
while (events.isNotEmpty() && events.peek().second < time) {
|
||||
val (a, b) = events.poll()
|
||||
map[a] = map.computeIfAbsent(a) { 0 } + b
|
||||
}
|
||||
|
||||
if (map.isNotEmpty()) {
|
||||
for ((a, b) in map) {
|
||||
if (b > 0) {
|
||||
reportTransferredFilesize(a, b, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delay(millis.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun reportTransferredFilesize(transport: Transport, bytes: Long, time: Long) {
|
||||
transport.reportTransferredFilesize(bytes, time)
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.database.DatabaseManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.net.io.Util
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
enum class TransportStatus {
|
||||
Ready,
|
||||
Processing,
|
||||
Failed,
|
||||
Done,
|
||||
}
|
||||
|
||||
/**
|
||||
* 传输单位:单个文件
|
||||
*/
|
||||
class Transport(
|
||||
/**
|
||||
* 唯一 ID
|
||||
*/
|
||||
val id: Long = idGenerator.incrementAndGet(),
|
||||
|
||||
/**
|
||||
* 是否是文件夹
|
||||
*/
|
||||
val isDirectory: Boolean = false,
|
||||
|
||||
/**
|
||||
* 父
|
||||
*/
|
||||
val parentId: Long = 0,
|
||||
|
||||
/**
|
||||
* 源
|
||||
*/
|
||||
val source: FileObject,
|
||||
|
||||
/**
|
||||
* 目标
|
||||
*/
|
||||
var target: FileObject,
|
||||
/**
|
||||
* 仅对文件生效,切只有两个选项
|
||||
*
|
||||
* 1. [StandardOpenOption.APPEND]
|
||||
* 2. [StandardOpenOption.TRUNCATE_EXISTING]
|
||||
*/
|
||||
var mode: StandardOpenOption = StandardOpenOption.TRUNCATE_EXISTING
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val idGenerator = AtomicLong(0)
|
||||
private val exception = RuntimeException("Nothing")
|
||||
private val log = LoggerFactory.getLogger(Transport::class.java)
|
||||
private val isPreserveModificationTime get() = DatabaseManager.getInstance().sftp.preserveModificationTime
|
||||
}
|
||||
|
||||
private val scanned by lazy { AtomicBoolean(false) }
|
||||
|
||||
/**
|
||||
* 计数器
|
||||
*/
|
||||
private val counter by lazy { SlidingWindowByteCounter() }
|
||||
|
||||
/**
|
||||
* 父
|
||||
*/
|
||||
var parent: Transport? = null
|
||||
set(value) {
|
||||
if (field != null) throw IllegalStateException("parent already exists")
|
||||
field = value
|
||||
// 上报大小
|
||||
reportFilesize(filesize.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小,对于文件夹来说,文件大小是不确定的,它取决于文件夹下的文件
|
||||
*/
|
||||
val filesize = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 已经传输完成的文件大小
|
||||
*/
|
||||
val transferredFilesize = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 如果是文件夹,是否已经扫描完毕。如果已经扫描完毕,那么该文件夹传输完成后可以立即删除
|
||||
*/
|
||||
val isScanned get() = scanned.get()
|
||||
|
||||
val isFile = !isDirectory
|
||||
val isRoot = parentId == 0L
|
||||
|
||||
/**
|
||||
* 获取最近一秒内的速度
|
||||
*/
|
||||
val speed get() = counter.getLastSecondBytes()
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
@Volatile
|
||||
var status: TransportStatus = TransportStatus.Ready
|
||||
private set
|
||||
|
||||
/**
|
||||
* 失败异常
|
||||
*/
|
||||
var exception: Throwable = Transport.exception
|
||||
|
||||
|
||||
fun scanned() {
|
||||
scanned.compareAndSet(false, true)
|
||||
}
|
||||
|
||||
fun changeStatus(status: TransportStatus): Boolean {
|
||||
synchronized(this) {
|
||||
if (status == TransportStatus.Processing) {
|
||||
if (this.status != TransportStatus.Ready) {
|
||||
return false
|
||||
}
|
||||
} else if (status == TransportStatus.Failed || status == TransportStatus.Done) {
|
||||
if (this.status != TransportStatus.Ready && this.status != TransportStatus.Processing) {
|
||||
return false
|
||||
}
|
||||
} else if (status == TransportStatus.Ready) {
|
||||
if (this.status != TransportStatus.Ready) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
this.status = status
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private val c = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 开始传输
|
||||
*/
|
||||
suspend fun transport(reporter: SpeedReporter) {
|
||||
|
||||
if (isDirectory) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!target.exists()) {
|
||||
target.createFolder()
|
||||
}
|
||||
} catch (_: FileAlreadyExistsException) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("Directory ${target.name} already exists")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
exception = e
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val input = source.content.inputStream
|
||||
val output = target.content.getOutputStream(mode == StandardOpenOption.APPEND)
|
||||
|
||||
try {
|
||||
|
||||
val buff = ByteArray(Util.DEFAULT_COPY_BUFFER_SIZE)
|
||||
var len: Int
|
||||
while (input.read(buff).also { len = it } != -1 && this.isActive) {
|
||||
|
||||
// 写入
|
||||
output.write(buff, 0, len)
|
||||
|
||||
val size = len.toLong()
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// 上报传输的字节数量
|
||||
reporter.report(this@Transport, size, now)
|
||||
|
||||
// 如果状态错误,那么可能已经取消了
|
||||
if (status != TransportStatus.Processing) {
|
||||
throw TransportStatusException("status is $status")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} finally {
|
||||
IOUtils.closeQuietly(input, output)
|
||||
}
|
||||
|
||||
// 尝试修改时间
|
||||
preserveModificationTime()
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun preserveModificationTime() {
|
||||
// 设置修改时间
|
||||
if (isPreserveModificationTime) {
|
||||
target.content.lastModifiedTime = source.content.lastModifiedTime
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一层层上报文件大小
|
||||
*/
|
||||
fun reportFilesize(bytes: Long) {
|
||||
val p = parent ?: return
|
||||
if (isRoot) return
|
||||
// 父状态不正常
|
||||
if (p.status == TransportStatus.Failed) return
|
||||
// 父的文件大小就是自己的文件大小
|
||||
p.filesize.addAndGet(bytes)
|
||||
// 递归上报
|
||||
p.reportFilesize(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 一层层上报传输大小
|
||||
*/
|
||||
fun reportTransferredFilesize(bytes: Long, time: Long) {
|
||||
var p = this as Transport?
|
||||
while (p != null) {
|
||||
// 记录上报的数量,用于统计速度
|
||||
if (bytes > 0) p.counter.addBytes(bytes, time)
|
||||
// 状态不正常
|
||||
if (p.status == TransportStatus.Failed) return
|
||||
// 父的传输文件大小就是自己的传输文件大小
|
||||
p.transferredFilesize.addAndGet(bytes)
|
||||
p = p.parent
|
||||
c.incrementAndGet()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface TransportListener : EventListener {
|
||||
/**
|
||||
* 状态变化
|
||||
*/
|
||||
fun onTransportChanged(transport: Transport) {}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
interface TransportManager {
|
||||
fun addTransport(transport: Transport): Boolean
|
||||
fun getTransport(id: Long): Transport?
|
||||
fun getTransports(pId: Long): List<Transport>
|
||||
fun getTransportCount(): Int
|
||||
fun removeTransport(id: Long)
|
||||
fun addTransportListener(listener: TransportListener)
|
||||
fun removeTransportListener(listener: TransportListener)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
class TransportStatusException(message: String) : RuntimeException(message)
|
||||
@@ -1,261 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.Disposer
|
||||
import app.termora.I18n
|
||||
import app.termora.OptionPane
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.jdesktop.swingx.JXTreeTable
|
||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Insets
|
||||
import java.awt.event.KeyAdapter
|
||||
import java.awt.event.KeyEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.*
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
import javax.swing.tree.DefaultTreeCellRenderer
|
||||
import kotlin.math.floor
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
class TransportTable : JXTreeTable(), Disposable {
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val model = TransportTableModel(coroutineScope)
|
||||
|
||||
|
||||
private val table = this
|
||||
private val transportManager = model as TransportManager
|
||||
|
||||
init {
|
||||
initViews()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
super.getTableHeader().setReorderingAllowed(false)
|
||||
super.setTreeTableModel(model)
|
||||
super.setRowHeight(UIManager.getInt("Table.rowHeight"))
|
||||
super.setAutoResizeMode(JTable.AUTO_RESIZE_OFF)
|
||||
super.setFillsViewportHeight(true)
|
||||
super.putClientProperty(
|
||||
FlatClientProperties.STYLE, mapOf(
|
||||
"cellMargins" to Insets(0, 4, 0, 4),
|
||||
"selectionArc" to 0,
|
||||
)
|
||||
)
|
||||
|
||||
super.setTreeCellRenderer(object : DefaultTreeCellRenderer() {
|
||||
override fun getTreeCellRendererComponent(
|
||||
tree: JTree?,
|
||||
value: Any?,
|
||||
sel: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean
|
||||
): Component {
|
||||
val node = value as DefaultMutableTreeTableNode
|
||||
val transport = node.userObject as? Transport
|
||||
val text = Objects.toString(node.getValueAt(TransportTableModel.COLUMN_NAME))
|
||||
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
|
||||
icon = if (transport?.isDirectory == true) NativeFileIcons.getFolderIcon()
|
||||
else NativeFileIcons.getFileIcon(text)
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_NAME).preferredWidth = 300
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
|
||||
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_STATUS).preferredWidth = 100
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).preferredWidth = 150
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_SIZE).preferredWidth = 140
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_SPEED).preferredWidth = 80
|
||||
|
||||
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = centerTableCellRenderer
|
||||
columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).cellRenderer =
|
||||
object : DefaultTableCellRenderer() {
|
||||
private var progress = 0.0
|
||||
private var progressInt = 0
|
||||
private val padding = 4
|
||||
|
||||
init {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
|
||||
override fun getTableCellRendererComponent(
|
||||
table: JTable?,
|
||||
value: Any?,
|
||||
isSelected: Boolean,
|
||||
hasFocus: Boolean,
|
||||
row: Int,
|
||||
column: Int
|
||||
): Component {
|
||||
|
||||
this.progress = 0.0
|
||||
this.progressInt = 0
|
||||
|
||||
if (value is Transport) {
|
||||
if (value.status == TransportStatus.Processing) {
|
||||
this.progress = value.transferredFilesize.get() * 1.0 / value.filesize.get()
|
||||
this.progressInt = floor(progress * 100.0).toInt()
|
||||
// 因为有一些 0B 大小的文件,所以如果在进行中,那么最大就是99
|
||||
if (this.progress >= 1 && value.status == TransportStatus.Processing) {
|
||||
this.progress = 0.99
|
||||
this.progressInt = floor(progress * 100.0).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.getTableCellRendererComponent(
|
||||
table,
|
||||
"${progressInt}%",
|
||||
isSelected,
|
||||
hasFocus,
|
||||
row,
|
||||
column
|
||||
)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
// 原始背景
|
||||
g.color = background
|
||||
g.fillRect(0, 0, width, height)
|
||||
|
||||
// 进度条背景
|
||||
g.color = UIManager.getColor("Table.selectionInactiveBackground")
|
||||
g.fillRect(0, padding, width, height - padding * 2)
|
||||
|
||||
// 进度条颜色
|
||||
g.color = UIManager.getColor("ProgressBar.foreground")
|
||||
g.fillRect(0, padding, (width * progress).toInt(), height - padding * 2)
|
||||
|
||||
// 大于某个阀值的时候,就要改变颜色
|
||||
if (progress >= 0.45) {
|
||||
foreground = selectionForeground
|
||||
}
|
||||
|
||||
// 绘制文字
|
||||
ui.paint(g, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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(rows, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 刷新状态
|
||||
coroutineScope.launch(Dispatchers.Swing) { refreshView() }
|
||||
|
||||
|
||||
// Delete key
|
||||
table.addKeyListener(object : KeyAdapter() {
|
||||
override fun keyPressed(e: KeyEvent) {
|
||||
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
|
||||
val transports = selectedRows.map { getPathForRow(it).lastPathComponent }
|
||||
.filterIsInstance<TransportTreeTableNode>().map { it.transport }
|
||||
if (transports.isEmpty()) return
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(table),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
transports.forEach { transportManager.removeTransport(it.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Disposer.register(this, model)
|
||||
|
||||
}
|
||||
|
||||
private fun showContextMenu(rows: IntArray, e: MouseEvent) {
|
||||
val transports = rows.map { getPathForRow(it).lastPathComponent }
|
||||
.filterIsInstance<TransportTreeTableNode>().map { it.transport }
|
||||
val popupMenu = FlatPopupMenu()
|
||||
|
||||
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete"))
|
||||
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
|
||||
delete.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.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
deleteAll.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
transportManager.removeTransport(0)
|
||||
}
|
||||
}
|
||||
|
||||
delete.isEnabled = transports.isNotEmpty()
|
||||
|
||||
popupMenu.show(this, e.x, e.y)
|
||||
}
|
||||
|
||||
private suspend fun refreshView() {
|
||||
while (coroutineScope.isActive) {
|
||||
for (row in 0 until rowCount) {
|
||||
val treePath = getPathForRow(row) ?: continue
|
||||
val node = treePath.lastPathComponent as? TransportTreeTableNode ?: continue
|
||||
model.valueForPathChanged(treePath, node.transport)
|
||||
}
|
||||
delay(SpeedReporter.millis.milliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.I18n
|
||||
import app.termora.assertEventDispatchThread
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import okio.withLock
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||
import org.jdesktop.swingx.treetable.DefaultTreeTableModel
|
||||
import org.jdesktop.swingx.treetable.MutableTreeTableNode
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlin.collections.ArrayDeque
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
class TransportTableModel(private val coroutineScope: CoroutineScope) :
|
||||
DefaultTreeTableModel(DefaultMutableTreeTableNode()), TransportManager, Disposable {
|
||||
|
||||
val lock = ReentrantLock()
|
||||
|
||||
private val transports = Collections.synchronizedMap(linkedMapOf<Long, TransportTreeTableNode>())
|
||||
private val reporter = SpeedReporter(coroutineScope)
|
||||
private var listeners = emptyArray<TransportListener>()
|
||||
private val activeTransports = linkedMapOf<Long, Job>()
|
||||
|
||||
/**
|
||||
* 最多的平行任务
|
||||
*/
|
||||
private val maxParallels = max(min(Runtime.getRuntime().availableProcessors(), 4), 1)
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TransportTableModel::class.java)
|
||||
|
||||
const val COLUMN_COUNT = 8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
init {
|
||||
setColumnIdentifiers(
|
||||
listOf(
|
||||
I18n.getString("termora.transport.jobs.table.name"),
|
||||
I18n.getString("termora.transport.jobs.table.status"),
|
||||
I18n.getString("termora.transport.jobs.table.progress"),
|
||||
I18n.getString("termora.transport.jobs.table.size"),
|
||||
I18n.getString("termora.transport.jobs.table.source-path"),
|
||||
I18n.getString("termora.transport.jobs.table.target-path"),
|
||||
I18n.getString("termora.transport.jobs.table.speed"),
|
||||
I18n.getString("termora.transport.jobs.table.estimated-time")
|
||||
)
|
||||
)
|
||||
coroutineScope.launch { run() }
|
||||
}
|
||||
|
||||
|
||||
override fun getRoot(): DefaultMutableTreeTableNode {
|
||||
return super.getRoot() as DefaultMutableTreeTableNode
|
||||
}
|
||||
|
||||
override fun isCellEditable(node: Any?, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun addTransport(transport: Transport): Boolean {
|
||||
return lock.withLock {
|
||||
if (!transport.isRoot) {
|
||||
|
||||
// 判断父是否存在
|
||||
if (!transports.containsKey(transport.parentId)) {
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
// 检测状态
|
||||
if (!validGrandfatherStatus(transport)) {
|
||||
changeStatus(transport, TransportStatus.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
val newNode = TransportTreeTableNode(transport)
|
||||
val parentId = transport.parentId
|
||||
val root = getRoot()
|
||||
val p = if (parentId == 0L || !transports.contains(parentId)) {
|
||||
root
|
||||
} else {
|
||||
transports.getValue(transport.parentId).apply { transport.parent = this.transport }
|
||||
}
|
||||
|
||||
transports[transport.id] = newNode
|
||||
|
||||
if ((transports.containsKey(parentId) || p == root) && transports.containsKey(transport.id)) {
|
||||
// 主线程加入节点
|
||||
SwingUtilities.invokeLater {
|
||||
// 因为是异步的,父节点此时可能已经被移除了
|
||||
if (p == root || transports.containsKey(parentId)) {
|
||||
insertNodeInto(newNode, p, p.childCount)
|
||||
} else {
|
||||
removeTransport(transport.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return@withLock true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
override fun getTransport(id: Long): Transport? {
|
||||
return transports[id]?.transport
|
||||
}
|
||||
|
||||
override fun getTransports(pId: Long): List<Transport> {
|
||||
lock.withLock {
|
||||
if (pId == 0L) {
|
||||
return getRoot().children().toList().filterIsInstance<TransportTreeTableNode>()
|
||||
.map { it.transport }
|
||||
}
|
||||
val p = transports[pId] ?: return emptyList()
|
||||
return p.children().toList().filterIsInstance<TransportTreeTableNode>()
|
||||
.map { it.transport }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getTransportCount(): Int {
|
||||
return transports.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取祖先的状态,如果祖先状态不正常,那么子直接定义为失败
|
||||
*
|
||||
* @return true 正常
|
||||
*/
|
||||
private fun validGrandfatherStatus(transport: Transport): Boolean {
|
||||
lock.withLock {
|
||||
// 如果自己/父不正常,那么失败
|
||||
if (transport.isRoot) return transport.status != TransportStatus.Failed
|
||||
|
||||
// 父不存在,那么直接定义失败
|
||||
val p = transports[transport.parentId] ?: return false
|
||||
|
||||
// 父状态不正常,那么失败
|
||||
if (p.transport.status == TransportStatus.Failed) return false
|
||||
|
||||
return validGrandfatherStatus(p.transport)
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeTransport(id: Long) {
|
||||
assertEventDispatchThread()
|
||||
|
||||
lock.withLock {
|
||||
|
||||
// ID 为空就是清空
|
||||
if (id <= 0) {
|
||||
|
||||
// 定义为失败
|
||||
transports.forEach { changeStatus(it.value.transport, TransportStatus.Failed) }
|
||||
// 清除所有任务
|
||||
transports.clear()
|
||||
|
||||
// 取消任务
|
||||
activeTransports.forEach { it.value.cancel() }
|
||||
activeTransports.clear()
|
||||
|
||||
val root = getRoot()
|
||||
while (root.childCount > 0) {
|
||||
val c = root.getChildAt(0)
|
||||
if (c is MutableTreeTableNode) {
|
||||
removeNodeFromParent(c)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val n = transports[id] ?: return
|
||||
val deletedIds = mutableListOf<Long>()
|
||||
n.visit { deletedIds.add(it.transport.id) }
|
||||
deletedIds.add(id)
|
||||
|
||||
for (deletedId in deletedIds) {
|
||||
val node = transports[deletedId] ?: continue
|
||||
|
||||
// 定义为失败
|
||||
changeStatus(node.transport, TransportStatus.Failed)
|
||||
if (deletedId == id) {
|
||||
val p = if (node.transport.isRoot) root else transports[node.transport.parentId]
|
||||
if (p != null) {
|
||||
removeNodeFromParent(node)
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试取消
|
||||
activeTransports[deletedId]?.cancel()
|
||||
|
||||
transports.remove(deletedId)
|
||||
}
|
||||
|
||||
// 如果不是成功,那么就是人工手动删除
|
||||
if (n.transport.status != TransportStatus.Done) {
|
||||
// 文件大小减去尚未传输的
|
||||
n.transport.reportFilesize(-abs((n.transport.filesize.get() - n.transport.transferredFilesize.get())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun addTransportListener(listener: TransportListener) {
|
||||
listeners += listener
|
||||
}
|
||||
|
||||
override fun removeTransportListener(listener: TransportListener) {
|
||||
listeners = ArrayUtils.removeElement(listeners, listener)
|
||||
}
|
||||
|
||||
private suspend fun run() {
|
||||
while (coroutineScope.isActive) {
|
||||
val nodes = getReadyTransport()
|
||||
if (nodes.isEmpty()) {
|
||||
delay((Random.nextInt(100, 250)).milliseconds)
|
||||
continue
|
||||
}
|
||||
|
||||
// pre process
|
||||
val readyNodes = mutableListOf<TransportTreeTableNode>()
|
||||
for (node in nodes) {
|
||||
val transport = node.transport
|
||||
|
||||
// 因为有可能返回刚刚清理的 Transport,如果不返回清理的 Transport 那么就只能返回 null,返回null就要等待 N 毫秒
|
||||
if (transport.status != TransportStatus.Ready) continue
|
||||
|
||||
// 如果祖先状态异常,那么直接定义为失败
|
||||
if (!validGrandfatherStatus(transport)) {
|
||||
changeStatus(transport, TransportStatus.Failed)
|
||||
continue
|
||||
}
|
||||
|
||||
// 进行中
|
||||
if (!changeStatus(transport, TransportStatus.Processing)) continue
|
||||
|
||||
// 能走到这里表示准备好的任务
|
||||
readyNodes.add(node)
|
||||
}
|
||||
|
||||
// 如果没有准备好的节点,那么跳过
|
||||
if (readyNodes.isEmpty()) continue
|
||||
|
||||
// 激活中的任务
|
||||
val activeTransports = mutableMapOf<Long, Job>()
|
||||
|
||||
// 同步传输
|
||||
for (node in readyNodes) {
|
||||
val transport = node.transport
|
||||
activeTransports[transport.id] = coroutineScope.launch { doTransport(node) }
|
||||
}
|
||||
|
||||
// 设置为全局的
|
||||
lock.withLock {
|
||||
this.activeTransports.forEach { it.value.cancel() }
|
||||
this.activeTransports.clear()
|
||||
this.activeTransports.putAll(activeTransports)
|
||||
}
|
||||
|
||||
try {
|
||||
// 等待所有任务
|
||||
activeTransports.values.joinAll()
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doTransport(node: TransportTreeTableNode) {
|
||||
val transport = node.transport
|
||||
|
||||
try {
|
||||
// 传输
|
||||
transport.transport(reporter)
|
||||
// 变更状态,文件夹不需要变更状态,因为当文件夹下所有文件都成功时,文件夹自然会成功
|
||||
if (transport.isFile) {
|
||||
changeStatus(transport, TransportStatus.Done)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
// 记录异常
|
||||
transport.exception = ExceptionUtils.getRootCause(e)
|
||||
|
||||
if (e is TransportStatusException) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn("{}: {}", transport.source.name, e.message)
|
||||
}
|
||||
} else if (log.isErrorEnabled) {
|
||||
log.error(e.message, e)
|
||||
}
|
||||
|
||||
// 定义为失败
|
||||
changeStatus(transport, TransportStatus.Failed)
|
||||
|
||||
} finally {
|
||||
|
||||
// 从激活中移除
|
||||
if (lock.tryLock()) {
|
||||
try {
|
||||
activeTransports.remove(transport.id)
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// 安全删除
|
||||
if (transport.status == TransportStatus.Done) {
|
||||
safeRemoveTransport(node)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun fireTransportEvent(transport: Transport) {
|
||||
for (listener in listeners) {
|
||||
listener.onTransportChanged(transport)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun safeRemoveTransport(node: TransportTreeTableNode) {
|
||||
withContext(Dispatchers.Swing) {
|
||||
lock.withLock {
|
||||
var n = node as TransportTreeTableNode?
|
||||
while (n != null) {
|
||||
// 如果还有子,跳过
|
||||
if (n.childCount != 0) break
|
||||
// 如果文件夹还没扫描完,那么不处理
|
||||
if (n.transport.isDirectory && !n.transport.isScanned) break
|
||||
// 提前保存一下父
|
||||
val p = n.parent as? TransportTreeTableNode
|
||||
// 设置成功
|
||||
changeStatus(n.transport, TransportStatus.Done)
|
||||
// 删除
|
||||
removeTransport(n.transport.id)
|
||||
// 继续向上查找
|
||||
n = p
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getReadyTransport(): List<TransportTreeTableNode> {
|
||||
val nodes = mutableListOf<TransportTreeTableNode>()
|
||||
val removeNodes = mutableListOf<TransportTreeTableNode>()
|
||||
|
||||
lock.withLock {
|
||||
|
||||
val stack = ArrayDeque<TransportTreeTableNode>()
|
||||
val root = getRoot()
|
||||
for (i in root.childCount - 1 downTo 0) {
|
||||
val child = root.getChildAt(i)
|
||||
if (child is TransportTreeTableNode) {
|
||||
stack.addLast(child)
|
||||
}
|
||||
}
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val node = stack.removeLast()
|
||||
val transport = node.transport
|
||||
|
||||
// 如果父已经失败,那么自己也定义为失败,之所以定义失败要走下去是因为它的子也要定义为失败
|
||||
if (transport.parent?.status == TransportStatus.Failed) {
|
||||
changeStatus(transport, TransportStatus.Failed)
|
||||
}
|
||||
|
||||
// 这是一个比较特殊的情况,因为传输任务和文件夹扫描并不是一个线程。
|
||||
// 如果该文件夹最后一个任务传输任务完成后(已经尝试清理)这时候
|
||||
// 因为还没有“定义为扫描完毕”那么清理任务就会认为还在扫描,但是已经
|
||||
// 扫描完了,所以这里要执行一次清理。
|
||||
if (transport.isDirectory && transport.status == TransportStatus.Processing) {
|
||||
if (node.childCount == 0 && transport.isScanned) {
|
||||
removeNodes.add(node)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (transport.status == TransportStatus.Ready) {
|
||||
if (transport.isDirectory) {
|
||||
// 文件夹不允许和文件作为并行任务
|
||||
if (nodes.isNotEmpty()) break
|
||||
// 加入任务立即退出
|
||||
nodes.add(node)
|
||||
break
|
||||
} else if (transport.isFile) {
|
||||
// 如果要准备加入的并行任务不是一个父,那么不允许
|
||||
if (nodes.isNotEmpty() && nodes.last().transport.parentId != transport.parentId) break
|
||||
// 加入任务
|
||||
nodes.add(node)
|
||||
// 如果超出了最大
|
||||
if (nodes.size >= maxParallels) break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 文件不可能有子
|
||||
if (transport.isFile) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
for (i in node.childCount - 1 downTo 0) {
|
||||
val child = node.getChildAt(i)
|
||||
if (child is TransportTreeTableNode) {
|
||||
stack.addLast(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 如果有要清理的节点,那么直接返回清理的节点
|
||||
if (removeNodes.isNotEmpty()) {
|
||||
removeNodes.forEach { safeRemoveTransport(it) }
|
||||
return removeNodes
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
private fun changeStatus(transport: Transport, status: TransportStatus): Boolean {
|
||||
return transport.changeStatus(status).apply { if (this) fireTransportEvent(transport) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
lock.withLock {
|
||||
// remove all
|
||||
removeTransport(0L)
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package app.termora.sftp
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.formatBytes
|
||||
import app.termora.formatSeconds
|
||||
import app.termora.vfs2.sftp.MySftpFileSystem
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||
|
||||
class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) {
|
||||
val transport get() = userObject as Transport
|
||||
|
||||
override fun getValueAt(column: Int): Any {
|
||||
val isProcessing = transport.status == TransportStatus.Processing
|
||||
val speed = if (isProcessing) transport.speed else 0
|
||||
val estimatedTime = if (isProcessing && speed > 0)
|
||||
(transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0
|
||||
|
||||
return when (column) {
|
||||
TransportTableModel.COLUMN_NAME -> transport.source.name.baseName
|
||||
TransportTableModel.COLUMN_STATUS -> formatStatus(transport)
|
||||
TransportTableModel.COLUMN_SIZE -> size()
|
||||
TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-"
|
||||
TransportTableModel.COLUMN_ESTIMATED_TIME -> if (isProcessing) formatSeconds(estimatedTime) else "-"
|
||||
TransportTableModel.COLUMN_SOURCE_PATH -> formatPath(transport.source)
|
||||
TransportTableModel.COLUMN_TARGET_PATH -> formatPath(transport.target)
|
||||
else -> super.getValueAt(column)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPath(file: FileObject): String {
|
||||
val fileSystem = file.fileSystem
|
||||
if (fileSystem is MySftpFileSystem) {
|
||||
val session = fileSystem.getClientSession() as JGitClientSession
|
||||
val hostname = session.hostConfigEntry.hostName
|
||||
return hostname + ":" + file.name.path
|
||||
}
|
||||
return file.name.toString()
|
||||
}
|
||||
|
||||
private fun formatStatus(transport: Transport): String {
|
||||
return when (transport.status) {
|
||||
TransportStatus.Processing -> I18n.getString("termora.transport.sftp.status.transporting")
|
||||
TransportStatus.Ready -> I18n.getString("termora.transport.sftp.status.waiting")
|
||||
TransportStatus.Done -> I18n.getString("termora.transport.sftp.status.done")
|
||||
TransportStatus.Failed -> I18n.getString("termora.transport.sftp.status.failed") + ": " + transport.exception.message
|
||||
}
|
||||
}
|
||||
|
||||
private fun size(): String {
|
||||
val transferredFilesize = transport.transferredFilesize.get()
|
||||
val filesize = transport.filesize.get()
|
||||
if (transferredFilesize <= 0) return formatBytes(filesize)
|
||||
return "${formatBytes(transferredFilesize)}/${formatBytes(filesize)}"
|
||||
}
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return TransportTableModel.COLUMN_COUNT
|
||||
}
|
||||
|
||||
fun visit(consumer: (TransportTreeTableNode) -> Unit) {
|
||||
if (childCount == 0) return
|
||||
for (child in children()) {
|
||||
if (child is TransportTreeTableNode) {
|
||||
child.visit(consumer)
|
||||
consumer.invoke(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package app.termora.sftp.internal.sftp
|
||||
|
||||
import app.termora.Disposer
|
||||
import app.termora.protocol.FileObjectHandler
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
|
||||
class SFTPFileObjectHandler(
|
||||
file: FileObject,
|
||||
val client: SshClient,
|
||||
val session: ClientSession,
|
||||
val sftpFileSystem: SftpFileSystem,
|
||||
) : FileObjectHandler(file) {
|
||||
init {
|
||||
session.addCloseFutureListener { Disposer.dispose(this) }
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
IOUtils.closeQuietly(sftpFileSystem)
|
||||
IOUtils.closeQuietly(session)
|
||||
IOUtils.closeQuietly(client)
|
||||
}
|
||||
}
|
||||
56
src/main/kotlin/app/termora/transfer/AbstractTransfer.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
abstract class AbstractTransfer(
|
||||
private val parentId: String,
|
||||
private val source: Path,
|
||||
private val target: Path,
|
||||
private val isDirectory: Boolean,
|
||||
private val priority: Transfer.Priority = Transfer.Priority.Normal,
|
||||
) : Transfer {
|
||||
|
||||
companion object {
|
||||
private val ID = AtomicLong()
|
||||
}
|
||||
|
||||
private val id = ID.incrementAndGet().toString()
|
||||
private val handler: TransferHandler = object : TransferHandler {
|
||||
override fun isDisposed(): Boolean {
|
||||
return source.fileSystem.isOpen.not() || target.fileSystem.isOpen.not()
|
||||
}
|
||||
}
|
||||
|
||||
override fun source(): Path {
|
||||
return source
|
||||
}
|
||||
|
||||
override fun target(): Path {
|
||||
return target
|
||||
}
|
||||
|
||||
override fun isDirectory(): Boolean {
|
||||
return isDirectory
|
||||
}
|
||||
|
||||
override fun parentId(): String {
|
||||
return parentId
|
||||
}
|
||||
|
||||
override fun id(): String {
|
||||
return id
|
||||
}
|
||||
|
||||
override fun scanning(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun handler(): TransferHandler {
|
||||
return handler
|
||||
}
|
||||
|
||||
final override fun priority(): Transfer.Priority {
|
||||
return priority
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.sftp
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Application.ohMyJson
|
||||
import app.termora.DynamicColor
|
||||
@@ -28,7 +28,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
* 为 true 表示在书签内
|
||||
*/
|
||||
var isBookmark = false
|
||||
set(value) {
|
||||
set(value) {
|
||||
field = value
|
||||
icon = if (value) {
|
||||
Icons.bookmarksOff
|
||||
@@ -42,7 +42,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
||||
val oldWidth = preferredSize.width
|
||||
|
||||
preferredSize = Dimension(oldWidth + arrowWidth, preferredSize.height)
|
||||
horizontalAlignment = SwingConstants.LEFT
|
||||
horizontalAlignment = LEFT
|
||||
|
||||
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.sftp
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.DynamicColor
|
||||
@@ -0,0 +1,36 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import kotlin.math.max
|
||||
|
||||
class ChangePermissionTransfer(
|
||||
parentId: String, path: Path, val permissions: Set<PosixFilePermission>,
|
||||
isDirectory: Boolean, private val size: Long,
|
||||
) : AbstractTransfer(parentId, path, path, isDirectory, Transfer.Priority.Normal), TransferScanner {
|
||||
|
||||
private var changed = false
|
||||
private var scanned = false
|
||||
|
||||
override suspend fun transfer(bufferSize: Int): Long {
|
||||
if (changed) return 0
|
||||
Files.setPosixFilePermissions(source(), permissions)
|
||||
changed = true
|
||||
return size()
|
||||
}
|
||||
|
||||
override fun scanning(): Boolean {
|
||||
return if (isDirectory()) scanned.not() else false
|
||||
}
|
||||
|
||||
override fun scanned() {
|
||||
scanned = true
|
||||
}
|
||||
|
||||
override fun size(): Long {
|
||||
return max(size, 1)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
36
src/main/kotlin/app/termora/transfer/DeleteTransfer.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.math.max
|
||||
|
||||
class DeleteTransfer(
|
||||
parentId: String,
|
||||
path: Path,
|
||||
isDirectory: Boolean,
|
||||
private val size: Long,
|
||||
) : AbstractTransfer(parentId, path, path, isDirectory), TransferScanner {
|
||||
|
||||
private var scanned = false
|
||||
private var deleted = false
|
||||
|
||||
override suspend fun transfer(bufferSize: Int): Long {
|
||||
if (deleted) return 0
|
||||
Files.deleteIfExists(source())
|
||||
deleted = true
|
||||
return this.size()
|
||||
}
|
||||
|
||||
override fun scanning(): Boolean {
|
||||
return if (isDirectory()) scanned.not() else false
|
||||
}
|
||||
|
||||
override fun scanned() {
|
||||
scanned = true
|
||||
}
|
||||
|
||||
override fun size(): Long {
|
||||
return max(size, 1)
|
||||
}
|
||||
|
||||
}
|
||||
31
src/main/kotlin/app/termora/transfer/DirectoryTransfer.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.createDirectories
|
||||
|
||||
class DirectoryTransfer(
|
||||
parentId: String,
|
||||
source: Path,
|
||||
target: Path,
|
||||
) : AbstractTransfer(parentId, source, target, true), TransferScanner {
|
||||
|
||||
@Volatile
|
||||
private var scanned = false
|
||||
|
||||
override suspend fun transfer(bufferSize: Int): Long {
|
||||
target().createDirectories()
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun size(): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun scanning(): Boolean {
|
||||
return scanned.not()
|
||||
}
|
||||
|
||||
override fun scanned() {
|
||||
scanned = true
|
||||
}
|
||||
}
|
||||
60
src/main/kotlin/app/termora/transfer/FileTransfer.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import org.apache.commons.io.IOUtils
|
||||
import java.io.Closeable
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardOpenOption
|
||||
import kotlin.io.path.inputStream
|
||||
import kotlin.io.path.outputStream
|
||||
|
||||
class FileTransfer(
|
||||
parentId: String, source: Path, target: Path, private val size: Long,
|
||||
private val action: TransferAction,
|
||||
priority: Transfer.Priority = Transfer.Priority.Normal,
|
||||
) : AbstractTransfer(parentId, source, target, false, priority), Closeable {
|
||||
|
||||
private lateinit var input: InputStream
|
||||
private lateinit var output: OutputStream
|
||||
|
||||
override suspend fun transfer(bufferSize: Int): Long {
|
||||
|
||||
if (::input.isInitialized.not()) {
|
||||
input = source().inputStream(StandardOpenOption.READ)
|
||||
}
|
||||
|
||||
if (::output.isInitialized.not()) {
|
||||
output = if (action == TransferAction.Overwrite) {
|
||||
target().outputStream(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)
|
||||
} else {
|
||||
target().outputStream(StandardOpenOption.WRITE, StandardOpenOption.APPEND)
|
||||
}
|
||||
}
|
||||
|
||||
val buffer = ByteArray(bufferSize)
|
||||
val len = input.read(buffer)
|
||||
if (len <= 0) return 0
|
||||
output.write(buffer, 0, len)
|
||||
return len.toLong()
|
||||
}
|
||||
|
||||
override fun scanning(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun size(): Long {
|
||||
return size
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (::input.isInitialized) {
|
||||
IOUtils.closeQuietly(input)
|
||||
}
|
||||
|
||||
if (::output.isInitialized) {
|
||||
IOUtils.closeQuietly(output)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Disposable
|
||||
import java.nio.file.Path
|
||||
import java.util.concurrent.CompletableFuture
|
||||
|
||||
interface InternalTransferManager {
|
||||
enum class TransferMode {
|
||||
Delete,
|
||||
Transfer,
|
||||
ChangePermission,
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否允许传输,添加任务之前请调用
|
||||
*/
|
||||
fun canTransfer(paths: List<Path>): Boolean
|
||||
|
||||
/**
|
||||
* 添加任务,如果是文件夹会递归查询子然后传递
|
||||
*/
|
||||
fun addTransfer(
|
||||
paths: List<Pair<Path, TransportTableModel.Attributes>>,
|
||||
mode: TransferMode
|
||||
): CompletableFuture<Unit>
|
||||
|
||||
/**
|
||||
* 手动指定传输到哪个目录
|
||||
*/
|
||||
fun addTransfer(
|
||||
paths: List<Pair<Path, TransportTableModel.Attributes>>,
|
||||
targetWorkdir: Path,
|
||||
mode: TransferMode
|
||||
): CompletableFuture<Unit>
|
||||
|
||||
/**
|
||||
* 添加高优先级的传输,当有多个高优先级起的时候则有序传输,该方法通常用于编辑目的
|
||||
*
|
||||
* @return id
|
||||
*/
|
||||
fun addHighTransfer(source: Path, target: Path): String
|
||||
|
||||
fun addTransferListener(listener: TransferListener): Disposable
|
||||
}
|
||||
91
src/main/kotlin/app/termora/transfer/PathWalker.kt
Normal file
@@ -0,0 +1,91 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.transfer.PathWalker.EmptyBasicFileAttributes.Companion.INSTANCE
|
||||
import org.apache.sshd.sftp.client.SftpClient
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.FileVisitor
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.nio.file.attribute.FileTime
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
object PathWalker {
|
||||
|
||||
fun walkFileTree(path: Path, visitor: FileVisitor<Path>) {
|
||||
if (path.fileSystem is SftpFileSystem) {
|
||||
val fileSystem = path.fileSystem as SftpFileSystem
|
||||
fileSystem.client.use { walkFileTree(path, it, visitor) }
|
||||
} else {
|
||||
Files.walkFileTree(path, visitor)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun walkFileTree(path: Path, sftpClient: SftpClient, visitor: FileVisitor<Path>): Boolean {
|
||||
if (visitor.preVisitDirectory(path, INSTANCE) == FileVisitResult.TERMINATE) {
|
||||
return false
|
||||
}
|
||||
for (e in sftpClient.readDir(path.absolutePathString())) {
|
||||
if (e.filename == ".." || e.filename == ".") {
|
||||
continue
|
||||
}
|
||||
if (e.attributes.isDirectory) {
|
||||
if (walkFileTree(path.resolve(e.filename), sftpClient, visitor).not()) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if (visitor.visitFile(path.resolve(e.filename), INSTANCE) == FileVisitResult.TERMINATE) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return visitor.postVisitDirectory(path, null) == FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
|
||||
private class EmptyBasicFileAttributes : BasicFileAttributes {
|
||||
companion object {
|
||||
val INSTANCE = EmptyBasicFileAttributes()
|
||||
}
|
||||
|
||||
override fun lastModifiedTime(): FileTime {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun lastAccessTime(): FileTime {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun creationTime(): FileTime {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun isRegularFile(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun isDirectory(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun isSymbolicLink(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun isOther(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun size(): Long {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun fileKey(): Any {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
package app.termora.sftp
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.DialogWrapper
|
||||
import app.termora.I18n
|
||||
import app.termora.OptionsPane.Companion.FORM_MARGIN
|
||||
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 java.nio.file.attribute.PosixFilePermission
|
||||
import javax.swing.*
|
||||
import kotlin.math.max
|
||||
|
||||
class PosixFilePermissionDialog(
|
||||
owner: Window,
|
||||
private val permissions: Set<PosixFilePermission>
|
||||
) : DialogWrapper(owner) {
|
||||
class PosixFilePermissionPanel(private val permissions: Set<PosixFilePermission>) : JPanel(BorderLayout()) {
|
||||
|
||||
|
||||
private val ownerRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
||||
@@ -27,18 +24,9 @@ class PosixFilePermissionDialog(
|
||||
private val otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||
private val includeSubFolder = JCheckBox(I18n.getString("termora.transport.permissions.include-subfolder"))
|
||||
|
||||
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() {
|
||||
@@ -62,17 +50,24 @@ class PosixFilePermissionDialog(
|
||||
otherWrite.isFocusable = false
|
||||
otherExecute.isFocusable = false
|
||||
includeSubFolder.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, $formMargin, pref"
|
||||
add(createCenterPanel(), BorderLayout.CENTER)
|
||||
|
||||
preferredSize = Dimension(
|
||||
max(preferredSize.width, UIManager.getInt("Dialog.width") - 350),
|
||||
preferredSize.height
|
||||
)
|
||||
|
||||
val builder = FormBuilder.create().padding("0, $formMargin, $formMargin, $formMargin")
|
||||
.layout(layout).debug(false)
|
||||
}
|
||||
|
||||
private fun createCenterPanel(): JComponent {
|
||||
val formMargin = FORM_MARGIN
|
||||
val layout = FormLayout(
|
||||
"default:grow, $formMargin, default:grow, $formMargin, default:grow",
|
||||
"pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val builder = FormBuilder.create().layout(layout).debug(false)
|
||||
|
||||
builder.add("${I18n.getString("termora.transport.permissions.file-folder-permissions")}:").xyw(1, 1, 5)
|
||||
|
||||
@@ -102,25 +97,12 @@ class PosixFilePermissionDialog(
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun doCancelAction() {
|
||||
this.isCancelled = true
|
||||
super.doCancelAction()
|
||||
}
|
||||
|
||||
fun isIncludeSubdirectories(): Boolean {
|
||||
return includeSubFolder.isSelected
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 返回空表示取消了
|
||||
*/
|
||||
fun open(): Set<PosixFilePermission>? {
|
||||
isModal = true
|
||||
isVisible = true
|
||||
|
||||
if (isCancelled) {
|
||||
return null
|
||||
}
|
||||
fun getPermissions(): Set<PosixFilePermission> {
|
||||
|
||||
val permissions = mutableSetOf<PosixFilePermission>()
|
||||
if (ownerRead.isSelected) {
|
||||
29
src/main/kotlin/app/termora/transfer/ScaleIcon.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.restore
|
||||
import app.termora.save
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import javax.swing.Icon
|
||||
|
||||
class ScaleIcon(private val icon: Icon, private val size: Int) : Icon {
|
||||
override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
|
||||
if (g is Graphics2D) {
|
||||
g.save()
|
||||
val iconWidth = icon.iconWidth.toDouble()
|
||||
val iconHeight = icon.iconHeight.toDouble()
|
||||
g.scale(getIconWidth() / iconWidth, getIconHeight() / iconHeight)
|
||||
icon.paintIcon(c, g, x, y)
|
||||
g.restore()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getIconWidth(): Int {
|
||||
return size
|
||||
}
|
||||
|
||||
override fun getIconHeight(): Int {
|
||||
return size
|
||||
}
|
||||
}
|
||||
60
src/main/kotlin/app/termora/transfer/Transfer.kt
Normal file
@@ -0,0 +1,60 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import org.apache.commons.net.io.Util
|
||||
import java.nio.file.Path
|
||||
|
||||
interface Transfer {
|
||||
enum class Priority {
|
||||
High,
|
||||
Normal,
|
||||
}
|
||||
|
||||
/**
|
||||
* 每调用一次,传输一次
|
||||
*
|
||||
*/
|
||||
suspend fun transfer(bufferSize: Int = Util.DEFAULT_COPY_BUFFER_SIZE): Long
|
||||
|
||||
/**
|
||||
* 源
|
||||
*/
|
||||
fun source(): Path
|
||||
|
||||
/**
|
||||
* 目标
|
||||
*/
|
||||
fun target(): Path
|
||||
|
||||
fun size(): Long
|
||||
|
||||
/**
|
||||
* 是否是文件夹
|
||||
*/
|
||||
fun isDirectory(): Boolean
|
||||
|
||||
/**
|
||||
* 如果是文件夹,可能正在扫描中
|
||||
*/
|
||||
fun scanning(): Boolean
|
||||
|
||||
/**
|
||||
* 任务 ID
|
||||
*/
|
||||
fun id(): String
|
||||
|
||||
/**
|
||||
* 父任务 ID,为空则没有
|
||||
*/
|
||||
fun parentId(): String
|
||||
|
||||
/**
|
||||
* 持有者
|
||||
*/
|
||||
fun handler(): TransferHandler = TransferHandler.EMPTY
|
||||
|
||||
/**
|
||||
* 优先级,此优先级只对根目录下的文件有效
|
||||
*/
|
||||
fun priority(): Priority = Priority.Normal
|
||||
|
||||
}
|
||||
7
src/main/kotlin/app/termora/transfer/TransferAction.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package app.termora.transfer
|
||||
|
||||
enum class TransferAction {
|
||||
Overwrite,
|
||||
Append,
|
||||
Skip,
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package app.termora.sftp
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.actions.AnActionEvent
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
|
||||
class SFTPActionEvent(
|
||||
class TransferActionEvent(
|
||||
source: Any,
|
||||
val hostId: String,
|
||||
event: EventObject
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.sftp
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.HostManager
|
||||
import app.termora.HostTerminalTab
|
||||
@@ -7,19 +7,17 @@ import app.termora.Icons
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
||||
import app.termora.protocol.TransferProtocolProvider
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
|
||||
class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.folder) {
|
||||
class TransferAnAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.folder) {
|
||||
private val hostManager get() = HostManager.getInstance()
|
||||
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
|
||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
||||
var sftpTab: SFTPTab? = null
|
||||
|
||||
var sftpTab: TransportTerminalTab? = null
|
||||
for (tab in terminalTabbedManager.getTerminalTabs()) {
|
||||
if (tab is SFTPTab) {
|
||||
if (tab is TransportTerminalTab) {
|
||||
sftpTab = tab
|
||||
break
|
||||
}
|
||||
@@ -27,17 +25,17 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold
|
||||
|
||||
// 创建一个新的
|
||||
if (sftpTab == null) {
|
||||
sftpTab = SFTPTab()
|
||||
sftpTab = TransportTerminalTab()
|
||||
terminalTabbedManager.addTerminalTab(sftpTab, false)
|
||||
}
|
||||
|
||||
var hostId = if (evt is SFTPActionEvent) evt.hostId else StringUtils.EMPTY
|
||||
var hostId = if (evt is TransferActionEvent) evt.hostId else StringUtils.EMPTY
|
||||
|
||||
// 如果不是特定事件,那么尝试获取选中的Tab,如果是一个 Host 并且是 SSH 协议那么直接打开
|
||||
if (hostId.isBlank()) {
|
||||
val tab = terminalTabbedManager.getSelectedTerminalTab()
|
||||
if (tab is HostTerminalTab) {
|
||||
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) {
|
||||
if (TransferProtocolProvider.valueOf(tab.host.protocol) != null) {
|
||||
hostId = tab.host.id
|
||||
}
|
||||
}
|
||||
@@ -47,11 +45,11 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold
|
||||
|
||||
if (hostId.isBlank()) return
|
||||
|
||||
val tabbed = sftpTab.getData(SFTPDataProviders.RightSFTPTabbed) ?: return
|
||||
val tabbed = sftpTab.rightTabbed
|
||||
// 如果已经打开了 那么直接选中
|
||||
for (i in 0 until tabbed.tabCount) {
|
||||
val fileSystemViewPanel = tabbed.getFileSystemViewPanel(i) ?: continue
|
||||
if (fileSystemViewPanel.host.id == hostId) {
|
||||
val panel = tabbed.getTransportPanel(i) ?: continue
|
||||
if (panel.host.id == hostId) {
|
||||
tabbed.selectedIndex = i
|
||||
return
|
||||
}
|
||||
@@ -60,15 +58,15 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold
|
||||
val host = hostManager.getHost(hostId) ?: return
|
||||
for (i in 0 until tabbed.tabCount) {
|
||||
val c = tabbed.getComponentAt(i)
|
||||
if (c is SFTPFileSystemViewPanel) {
|
||||
if (c.state == SFTPFileSystemViewPanel.State.Initialized) {
|
||||
c.selectHost(host)
|
||||
if (c is TransportSelectionPanel) {
|
||||
if (c.state == TransportSelectionPanel.State.Initialized) {
|
||||
c.connect(host)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tabbed.addSFTPFileSystemViewPanelTab(host)
|
||||
tabbed.addSelectionTab()
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Disposable
|
||||
|
||||
class TransferDisposable(val id: String) : Disposable {
|
||||
}
|
||||
14
src/main/kotlin/app/termora/transfer/TransferHandler.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package app.termora.transfer
|
||||
|
||||
interface TransferHandler {
|
||||
companion object {
|
||||
val EMPTY: TransferHandler = object : TransferHandler {
|
||||
override fun isDisposed() = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 持有者已经销毁
|
||||
*/
|
||||
fun isDisposed(): Boolean
|
||||
}
|
||||
10
src/main/kotlin/app/termora/transfer/TransferListener.kt
Normal file
@@ -0,0 +1,10 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import java.util.*
|
||||
|
||||
interface TransferListener : EventListener {
|
||||
/**
|
||||
* 状态变化
|
||||
*/
|
||||
fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State)
|
||||
}
|
||||
33
src/main/kotlin/app/termora/transfer/TransferManager.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Disposable
|
||||
|
||||
interface TransferManager {
|
||||
|
||||
|
||||
/**
|
||||
* 添加传输任务
|
||||
*/
|
||||
fun addTransfer(transfer: Transfer): Boolean
|
||||
|
||||
/**
|
||||
* 移除传输任务
|
||||
*/
|
||||
fun removeTransfer(id: String)
|
||||
|
||||
/**
|
||||
* 获取任务
|
||||
*/
|
||||
fun getTransfers(): Collection<Transfer>
|
||||
|
||||
/**
|
||||
* 任务数量
|
||||
*/
|
||||
fun getTransferCount(): Int
|
||||
|
||||
/**
|
||||
* 传输监听器
|
||||
*/
|
||||
fun addTransferListener(listener: TransferListener): Disposable
|
||||
|
||||
}
|
||||
6
src/main/kotlin/app/termora/transfer/TransferScanner.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package app.termora.transfer
|
||||
|
||||
interface TransferScanner {
|
||||
fun scanning(): Boolean
|
||||
fun scanned()
|
||||
}
|
||||
233
src/main/kotlin/app/termora/transfer/TransferTable.kt
Normal file
@@ -0,0 +1,233 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.I18n
|
||||
import app.termora.NativeIcons
|
||||
import app.termora.OptionPane
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.JXTreeTable
|
||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Insets
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import javax.swing.*
|
||||
import javax.swing.table.DefaultTableCellRenderer
|
||||
import javax.swing.tree.DefaultTreeCellRenderer
|
||||
import kotlin.io.path.name
|
||||
import kotlin.math.floor
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
class TransferTable(private val coroutineScope: CoroutineScope, private val tableModel: TransferTableModel) :
|
||||
JXTreeTable(), Disposable {
|
||||
|
||||
private val table get() = this
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
refreshView()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
super.setTreeTableModel(tableModel)
|
||||
super.getTableHeader().setReorderingAllowed(false)
|
||||
super.setRowHeight(UIManager.getInt("Table.rowHeight"))
|
||||
super.setAutoResizeMode(AUTO_RESIZE_OFF)
|
||||
super.setFillsViewportHeight(true)
|
||||
super.putClientProperty(
|
||||
FlatClientProperties.STYLE, mapOf(
|
||||
"cellMargins" to Insets(0, 4, 0, 4),
|
||||
"selectionArc" to 0,
|
||||
)
|
||||
)
|
||||
super.setTreeCellRenderer(object : DefaultTreeCellRenderer() {
|
||||
override fun getTreeCellRendererComponent(
|
||||
tree: JTree?,
|
||||
value: Any?,
|
||||
sel: Boolean,
|
||||
expanded: Boolean,
|
||||
leaf: Boolean,
|
||||
row: Int,
|
||||
hasFocus: Boolean
|
||||
): Component {
|
||||
val node = value as DefaultMutableTreeTableNode
|
||||
val transfer = node.userObject as? Transfer
|
||||
val text = transfer?.source()?.name ?: StringUtils.EMPTY
|
||||
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
|
||||
icon = if (transfer?.isDirectory() == true) NativeIcons.folderIcon else NativeIcons.fileIcon
|
||||
return c
|
||||
}
|
||||
})
|
||||
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_NAME).preferredWidth = 300
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
|
||||
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_STATUS).preferredWidth = 100
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_PROGRESS).preferredWidth = 150
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_SIZE).preferredWidth = 140
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_SPEED).preferredWidth = 80
|
||||
|
||||
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = centerTableCellRenderer
|
||||
columnModel.getColumn(TransferTableModel.COLUMN_PROGRESS).cellRenderer = ProgressTableCellRenderer()
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
// contextmenu
|
||||
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, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun showContextmenu(rows: IntArray, e: MouseEvent) {
|
||||
val transfers = rows.map { getPathForRow(it).lastPathComponent }
|
||||
.filterIsInstance<DefaultMutableTreeTableNode>().map { it.userObject }
|
||||
.filterIsInstance<Transfer>()
|
||||
if (transfers.isEmpty()) return
|
||||
|
||||
val popupMenu = FlatPopupMenu()
|
||||
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete"))
|
||||
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
|
||||
delete.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) != JOptionPane.YES_OPTION
|
||||
) return
|
||||
for (transfer in transfers) {
|
||||
tableModel.removeTransfer(transfer.id())
|
||||
}
|
||||
}
|
||||
})
|
||||
deleteAll.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
tableModel.removeTransfer(StringUtils.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
delete.isEnabled = transfers.isNotEmpty()
|
||||
|
||||
popupMenu.show(this, e.x, e.y)
|
||||
}
|
||||
|
||||
private fun refreshView() {
|
||||
coroutineScope.launch(Dispatchers.Swing) {
|
||||
val timeout = 500
|
||||
while (coroutineScope.isActive) {
|
||||
for (row in 0 until rowCount) {
|
||||
val treePath = getPathForRow(row) ?: continue
|
||||
val node = treePath.lastPathComponent as? DefaultMutableTreeTableNode ?: continue
|
||||
tableModel.valueForPathChanged(treePath, node.userObject)
|
||||
}
|
||||
delay(timeout.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ProgressTableCellRenderer : DefaultTableCellRenderer() {
|
||||
private var progress = 0.0
|
||||
private var progressInt = 0
|
||||
private val padding = 4
|
||||
|
||||
init {
|
||||
horizontalAlignment = CENTER
|
||||
}
|
||||
|
||||
override fun getTableCellRendererComponent(
|
||||
table: JTable?,
|
||||
value: Any?,
|
||||
isSelected: Boolean,
|
||||
hasFocus: Boolean,
|
||||
row: Int,
|
||||
column: Int
|
||||
): Component {
|
||||
|
||||
this.progress = 0.0
|
||||
this.progressInt = 0
|
||||
|
||||
if (value is TransferTreeTableNode) {
|
||||
if (value.state() == TransferTreeTableNode.State.Processing || value.waitingChildrenCompleted() || value.transfer is DeleteTransfer) {
|
||||
this.progress = value.transferred.get() * 1.0 / value.filesize.get()
|
||||
this.progressInt = floor(progress * 100.0).toInt()
|
||||
// 因为有一些 0B 大小的文件,所以如果在进行中,那么最大就是99
|
||||
if (this.progress >= 1 && value.state() == TransferTreeTableNode.State.Processing) {
|
||||
this.progress = 0.99
|
||||
this.progressInt = floor(progress * 100.0).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.getTableCellRendererComponent(
|
||||
table,
|
||||
"${progressInt}%",
|
||||
isSelected,
|
||||
hasFocus,
|
||||
row,
|
||||
column
|
||||
)
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
// 原始背景
|
||||
g.color = background
|
||||
g.fillRect(0, 0, width, height)
|
||||
|
||||
// 进度条背景
|
||||
g.color = UIManager.getColor("Table.selectionInactiveBackground")
|
||||
g.fillRect(0, padding, width, height - padding * 2)
|
||||
|
||||
// 进度条颜色
|
||||
g.color = UIManager.getColor("ProgressBar.foreground")
|
||||
g.fillRect(0, padding, (width * progress).toInt(), height - padding * 2)
|
||||
|
||||
// 大于某个阀值的时候,就要改变颜色
|
||||
if (progress >= 0.45) {
|
||||
foreground = selectionForeground
|
||||
}
|
||||
|
||||
// 绘制文字
|
||||
ui.paint(g, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
543
src/main/kotlin/app/termora/transfer/TransferTableModel.kt
Normal file
@@ -0,0 +1,543 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.I18n
|
||||
import app.termora.assertEventDispatchThread
|
||||
import app.termora.transfer.TransferTreeTableNode.State
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import okio.withLock
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||
import org.jdesktop.swingx.treetable.DefaultTreeTableModel
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.Closeable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.locks.Condition
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.EventListenerList
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
class TransferTableModel(private val coroutineScope: CoroutineScope) :
|
||||
DefaultTreeTableModel(DefaultMutableTreeTableNode()), Disposable, TransferManager {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TransferTableModel::class.java)
|
||||
|
||||
const val COLUMN_COUNT = 8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private val maxParallels = max(min(Runtime.getRuntime().availableProcessors(), 6), 1)
|
||||
private val map = ConcurrentHashMap<String, TransferTreeTableNode>()
|
||||
private val reporter = SizeReporter(coroutineScope)
|
||||
private val lock = ReentrantLock()
|
||||
private val normalCondition = lock.newCondition()
|
||||
private val highCondition = lock.newCondition()
|
||||
private val eventListener = EventListenerList()
|
||||
|
||||
init {
|
||||
setColumnIdentifiers(
|
||||
listOf(
|
||||
I18n.getString("termora.transport.jobs.table.name"),
|
||||
I18n.getString("termora.transport.jobs.table.status"),
|
||||
I18n.getString("termora.transport.jobs.table.progress"),
|
||||
I18n.getString("termora.transport.jobs.table.size"),
|
||||
I18n.getString("termora.transport.jobs.table.source-path"),
|
||||
I18n.getString("termora.transport.jobs.table.target-path"),
|
||||
I18n.getString("termora.transport.jobs.table.speed"),
|
||||
I18n.getString("termora.transport.jobs.table.estimated-time")
|
||||
)
|
||||
)
|
||||
|
||||
consume()
|
||||
}
|
||||
|
||||
|
||||
override fun getRoot(): DefaultMutableTreeTableNode {
|
||||
return super.getRoot() as DefaultMutableTreeTableNode
|
||||
}
|
||||
|
||||
override fun isCellEditable(node: Any?, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return COLUMN_COUNT
|
||||
}
|
||||
|
||||
override fun addTransferListener(listener: TransferListener): Disposable {
|
||||
eventListener.add(TransferListener::class.java, listener)
|
||||
return object : Disposable {
|
||||
override fun dispose() {
|
||||
eventListener.remove(TransferListener::class.java, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun addTransfer(transfer: Transfer): Boolean {
|
||||
val node = TransferTreeTableNode(transfer)
|
||||
val parent = if (transfer.parentId().isBlank()) getRoot() else map[transfer.parentId()] ?: return false
|
||||
|
||||
// EDT 线程操作
|
||||
if (insertNode(node, parent).not()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 文件立即计算大小
|
||||
if (transfer.isDirectory().not() || transfer is DeleteTransfer) {
|
||||
computeFilesize(node, transfer.size(), 0, setOf(ComputeField.Filesize))
|
||||
}
|
||||
|
||||
lock.withLock { normalCondition.signalAll();highCondition.signalAll() }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun insertNode(node: TransferTreeTableNode, parent: DefaultMutableTreeTableNode): Boolean {
|
||||
val result = AtomicBoolean(false)
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
if (validGrandfather(node.transfer.parentId())) {
|
||||
map[node.transfer.id()] = node
|
||||
insertNodeInto(node, parent, parent.childCount)
|
||||
result.set(true)
|
||||
}
|
||||
} else {
|
||||
SwingUtilities.invokeAndWait { result.set(insertNode(node, parent)) }
|
||||
}
|
||||
return result.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取祖先的状态,如果祖先状态不正常,那么子直接定义为失败
|
||||
*
|
||||
* @return true 正常
|
||||
*/
|
||||
private fun validGrandfather(parentId: String): Boolean {
|
||||
if (parentId.isBlank()) return true
|
||||
|
||||
var parent = map[parentId]
|
||||
if (parent == null) return false
|
||||
|
||||
while (parent != null) {
|
||||
if (map.containsKey(parent.transfer.id()).not()) return false
|
||||
if (parent.state() == State.Failed) return false
|
||||
if (parent == getRoot()) return true
|
||||
if (parent.transfer.parentId().isBlank()) return true
|
||||
parent = parent.parent as? TransferTreeTableNode
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getTransferCount(): Int {
|
||||
return map.size
|
||||
}
|
||||
|
||||
override fun getTransfers(): Collection<Transfer> {
|
||||
return map.values.map { it.transfer }
|
||||
}
|
||||
|
||||
override fun removeTransfer(id: String) {
|
||||
assertEventDispatchThread()
|
||||
|
||||
val stack = ArrayDeque<Pair<TransferTreeTableNode, Boolean>>()
|
||||
if (id.isNotBlank()) {
|
||||
val rootNode = map[id] ?: return
|
||||
stack.addLast(rootNode to false)
|
||||
} else {
|
||||
for (i in 0 until getRoot().childCount) {
|
||||
val child = getRoot().getChildAt(i)
|
||||
if (child is TransferTreeTableNode) {
|
||||
stack.addLast(child to false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val (node, visitedChildren) = stack.removeLast()
|
||||
if (visitedChildren || node.childCount == 0) {
|
||||
val failed = node.state() != State.Done
|
||||
val transfer = node.transfer
|
||||
|
||||
// 定义为失败
|
||||
node.tryChangeState(State.Failed)
|
||||
// 移除
|
||||
map.remove(node.transfer.id())
|
||||
removeNodeFromParent(node)
|
||||
|
||||
// 如果删除时还在传输,那么需要减去大小
|
||||
// 如果是传输任务,文件夹是不处理的,因为文件夹的大小来自文件
|
||||
// 如果是删除任务,需要减去大小,删除任务的文件大小最小的:1
|
||||
if ((failed && transfer.isDirectory().not()) || (failed && transfer is DeleteTransfer)) {
|
||||
// 收集一次,确保数据实时
|
||||
reporter.collect()
|
||||
// 该文件已传输的大小
|
||||
val transferred = node.transferred.get()
|
||||
// 减去总大小,总大小就是减去尚未传输的数量
|
||||
computeFilesize(node, -abs(node.transfer.size() - transferred), 0, setOf(ComputeField.Filesize))
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
stack.addLast(node to true)
|
||||
for (i in node.childCount - 1 downTo 0) {
|
||||
val child = node.getChildAt(i)
|
||||
if (child is TransferTreeTableNode) {
|
||||
stack.addLast(child to false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeFilesize(
|
||||
node: TransferTreeTableNode,
|
||||
size: Long,
|
||||
time: Long,
|
||||
fields: Set<ComputeField>
|
||||
) {
|
||||
if (fields.contains(ComputeField.Counter)) {
|
||||
node.counter.addBytes(size, time)
|
||||
}
|
||||
|
||||
if (fields.contains(ComputeField.Transferred)) {
|
||||
node.transferred.addAndGet(size)
|
||||
}
|
||||
|
||||
var p = map[node.transfer.parentId()]
|
||||
while (p != null) {
|
||||
for (field in fields) {
|
||||
when (field) {
|
||||
ComputeField.Filesize -> p.filesize.addAndGet(size)
|
||||
ComputeField.Transferred -> p.transferred.addAndGet(size)
|
||||
ComputeField.Counter -> p.counter.addBytes(size, time)
|
||||
}
|
||||
}
|
||||
p = map[p.transfer.parentId()]
|
||||
}
|
||||
}
|
||||
|
||||
private fun canTransfer(node: TransferTreeTableNode): Boolean {
|
||||
var p: TransferTreeTableNode? = node
|
||||
while (p != null) {
|
||||
if (map.containsKey(p.transfer.id()).not()) {
|
||||
return false
|
||||
}
|
||||
p = map[p.transfer.parentId()]
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun consume() {
|
||||
// 普通级别的,如果空闲时也会传输高优先级的
|
||||
repeat(maxParallels) { coroutineScope.launch { transfer(Transfer.Priority.Normal, normalCondition) } }
|
||||
// 专门用户高优先级下载,高优先级的单独一个线程去处理
|
||||
coroutineScope.launch { transfer(Transfer.Priority.High, highCondition) }
|
||||
}
|
||||
|
||||
|
||||
private fun getReadyTransfer(priority: Transfer.Priority): TransferTreeTableNode? {
|
||||
assertEventDispatchThread()
|
||||
|
||||
val stack = ArrayDeque<TransferTreeTableNode>()
|
||||
val root = getRoot()
|
||||
|
||||
for (i in root.childCount - 1 downTo 0) {
|
||||
val child = root.getChildAt(i)
|
||||
if (child is TransferTreeTableNode) {
|
||||
if (priority == Transfer.Priority.High) {
|
||||
if (child.transfer.priority() == Transfer.Priority.High) {
|
||||
if (child.state() == State.Ready) {
|
||||
changeState(child, State.Processing)
|
||||
return child
|
||||
}
|
||||
}
|
||||
}
|
||||
stack.addLast(child)
|
||||
}
|
||||
}
|
||||
|
||||
if (priority == Transfer.Priority.High) return null
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val node = stack.removeLast()
|
||||
val transfer = node.transfer
|
||||
val parent = node.parent as? TransferTreeTableNode
|
||||
|
||||
// 删除文件和传输文件完全相反,传输文件是先创建文件夹后传输文件
|
||||
// 删除文件,是先删除文件后删除文件夹
|
||||
if (transfer is DeleteTransfer) {
|
||||
if (node.state() != State.Failed) {
|
||||
val c = getReadyDeleteTransfer(node)
|
||||
if (c != null) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果父文件夹正在创建,那么等待创建完毕
|
||||
// 顺序一定是先创建文件夹后传输文件
|
||||
if (parent != null) {
|
||||
if (parent.state() == State.Processing) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 父亲失败则子失败
|
||||
if (parent.state() == State.Failed && node.state() != State.Failed) {
|
||||
changeState(node, State.Failed)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 如果是文件夹并且已经创建,那么尝试去删除
|
||||
if (transfer.isDirectory() && node.state() == State.Done && node.waitingChildrenCompleted().not()) {
|
||||
removeCompleted(node)
|
||||
}
|
||||
|
||||
if (node.state() == State.Ready) {
|
||||
changeState(node, State.Processing)
|
||||
return node
|
||||
}
|
||||
|
||||
for (i in node.childCount - 1 downTo 0) {
|
||||
val child = node.getChildAt(i)
|
||||
if (child is TransferTreeTableNode) {
|
||||
stack.addLast(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度优先
|
||||
*/
|
||||
private fun getReadyDeleteTransfer(
|
||||
treeNode: TransferTreeTableNode,
|
||||
): TransferTreeTableNode? {
|
||||
val stack = ArrayDeque<TransferTreeTableNode>()
|
||||
stack.addLast(treeNode)
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val node = stack.removeLast()
|
||||
val transfer = node.transfer
|
||||
if (transfer.isDirectory().not()) {
|
||||
if (node.state() == State.Ready) {
|
||||
changeState(node, State.Processing)
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是文件夹并且已经扫描完毕
|
||||
if (transfer.isDirectory() && transfer.scanning().not() && node.childCount < 1) {
|
||||
if (node.state() == State.Ready) {
|
||||
changeState(node, State.Processing)
|
||||
return node
|
||||
}
|
||||
}
|
||||
|
||||
for (i in node.childCount - 1 downTo 0) {
|
||||
val child = node.getChildAt(i)
|
||||
if (child is TransferTreeTableNode) {
|
||||
stack.addLast(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun transfer(priority: Transfer.Priority, condition: Condition) {
|
||||
while (coroutineScope.isActive) {
|
||||
try {
|
||||
val node = withContext(Dispatchers.Swing) { getReadyTransfer(priority) }
|
||||
if (node == null) {
|
||||
if (map.isEmpty()) {
|
||||
lock.withLock { condition.await() }
|
||||
} else {
|
||||
lock.withLock { condition.await(1, TimeUnit.SECONDS) }
|
||||
}
|
||||
continue
|
||||
} else if (canTransfer(node)) {
|
||||
doTransfer(node)
|
||||
}
|
||||
lock.withLock { condition.signalAll() }
|
||||
} catch (_: CancellationException) {
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) log.error(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doTransfer(node: TransferTreeTableNode) {
|
||||
|
||||
val transfer = node.transfer
|
||||
|
||||
try {
|
||||
var len = 0L
|
||||
while (continueTransfer(node) && transfer.transfer().also { len = it } > 0) {
|
||||
// 异步上报,因为数据量非常大,所以采用异步
|
||||
reporter.report(node, len, System.currentTimeMillis())
|
||||
}
|
||||
withContext(Dispatchers.Swing) {
|
||||
if (continueTransfer(node)) {
|
||||
changeState(node, State.Done)
|
||||
removeCompleted(node)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
node.tryChangeState(State.Failed)
|
||||
if (e !is UserCanceledException) {
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
if (transfer is Closeable) IOUtils.closeQuietly(transfer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun continueTransfer(node: TransferTreeTableNode, throws: Boolean = true): Boolean {
|
||||
val transfer = node.transfer
|
||||
// 如果不存在则表示已经被删除了
|
||||
if (map.containsKey(transfer.id()).not()) if (throws) throw UserCanceledException() else return false
|
||||
// 状态突然变更
|
||||
if (node.state() != State.Processing) if (throws) throw UserCanceledException() else return false
|
||||
// 持有者已经销毁,和平结束
|
||||
if (transfer.handler().isDisposed()) if (throws) throw UserCanceledException() else return false
|
||||
return true
|
||||
}
|
||||
|
||||
private fun fireTransferChanged(node: TransferTreeTableNode) {
|
||||
try {
|
||||
for (listener in eventListener.getListeners(TransferListener::class.java)) {
|
||||
listener.onTransferChanged(node.transfer, node.state())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (log.isWarnEnabled) {
|
||||
log.warn(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeState(node: TransferTreeTableNode, state: State) {
|
||||
node.changeState(state)
|
||||
fireTransferChanged(node)
|
||||
}
|
||||
|
||||
private fun removeCompleted(node: TransferTreeTableNode) {
|
||||
|
||||
if (node == getRoot()) return
|
||||
if (node.transfer.isDirectory() && node.childCount > 0) return
|
||||
if (node.transfer.scanning()) return
|
||||
if (node.parent == null) return
|
||||
if (node.state() != State.Done) return
|
||||
|
||||
assertEventDispatchThread()
|
||||
|
||||
removeTransfer(node.transfer.id())
|
||||
|
||||
}
|
||||
|
||||
private class UserCanceledException : RuntimeException()
|
||||
|
||||
|
||||
private enum class ComputeField {
|
||||
Filesize,
|
||||
Transferred,
|
||||
Counter
|
||||
}
|
||||
|
||||
|
||||
private inner class SizeReporter(private val coroutineScope: CoroutineScope) {
|
||||
|
||||
private val events = ConcurrentLinkedQueue<Triple<TransferTreeTableNode, Long, Long>>()
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
init {
|
||||
scheduleCollect()
|
||||
}
|
||||
|
||||
fun report(node: TransferTreeTableNode, bytes: Long, time: Long) {
|
||||
events.add(Triple(node, bytes, time))
|
||||
}
|
||||
|
||||
private fun scheduleCollect() {
|
||||
// 异步上报数据
|
||||
coroutineScope.launch {
|
||||
while (coroutineScope.isActive) {
|
||||
collect()
|
||||
delay(500.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun collect() {
|
||||
lock.withLock {
|
||||
val time = System.currentTimeMillis()
|
||||
val map = linkedMapOf<TransferTreeTableNode, Long>()
|
||||
|
||||
// 收集
|
||||
while (events.isNotEmpty() && events.peek().second < time) {
|
||||
val (a, b) = events.poll()
|
||||
map[a] = map.computeIfAbsent(a) { 0 } + b
|
||||
}
|
||||
|
||||
if (map.isNotEmpty()) {
|
||||
for ((a, b) in map) {
|
||||
if (b > 0) {
|
||||
computeFilesize(a, b, time, setOf(ComputeField.Counter, ComputeField.Transferred))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
148
src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt
Normal file
@@ -0,0 +1,148 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.I18n
|
||||
import app.termora.formatBytes
|
||||
import app.termora.formatSeconds
|
||||
import app.termora.transfer.TransferTableModel.Companion.COLUMN_COUNT
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.io.path.name
|
||||
import kotlin.math.max
|
||||
|
||||
class TransferTreeTableNode(transfer: Transfer) : DefaultMutableTreeTableNode(transfer) {
|
||||
|
||||
enum class State {
|
||||
Ready,
|
||||
Processing,
|
||||
Failed,
|
||||
Done,
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件总大小,删除时文件夹也算数量
|
||||
*/
|
||||
val filesize = AtomicLong(transfer.size())
|
||||
|
||||
/**
|
||||
* 文件传输的大小
|
||||
*/
|
||||
val transferred = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 速率计数
|
||||
*/
|
||||
val counter = TransferTableModel.SlidingWindowByteCounter()
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
private var state = State.Ready
|
||||
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return COLUMN_COUNT
|
||||
}
|
||||
|
||||
val transfer get() = getUserObject() as Transfer
|
||||
|
||||
override fun getValueAt(column: Int): Any? {
|
||||
val filesize = if (transfer.isDirectory()) filesize.get() else transfer.size()
|
||||
val totalBytesTransferred = transferred.get()
|
||||
val state = if (waitingChildrenCompleted()) State.Processing else state()
|
||||
val isProcessing = state == State.Processing ||
|
||||
(transfer is DeleteTransfer && transfer.isDirectory() && (state() == State.Processing || state() == State.Ready))
|
||||
val speed = counter.getLastSecondBytes()
|
||||
val estimatedTime = max(if (isProcessing && speed > 0) (filesize - totalBytesTransferred) / speed else 0, 0)
|
||||
|
||||
return when (column) {
|
||||
TransferTableModel.COLUMN_NAME -> transfer.source().name
|
||||
TransferTableModel.COLUMN_STATUS -> formatStatus(state)
|
||||
TransferTableModel.COLUMN_SOURCE_PATH -> formatPath(transfer.source(), false)
|
||||
TransferTableModel.COLUMN_TARGET_PATH -> formatPath(transfer.target(), true)
|
||||
TransferTableModel.COLUMN_SIZE -> "${formatBytes(totalBytesTransferred)} / ${formatBytes(filesize)}"
|
||||
TransferTableModel.COLUMN_SPEED -> if (isProcessing) "${formatBytes(speed)}/s" else "-"
|
||||
TransferTableModel.COLUMN_ESTIMATED_TIME -> if (isProcessing) formatSeconds(estimatedTime) else "-"
|
||||
TransferTableModel.COLUMN_PROGRESS -> this
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPath(path: Path, target: Boolean): String {
|
||||
if (target) {
|
||||
if (transfer is DeleteTransfer) {
|
||||
return I18n.getString("termora.transport.sftp.status.deleting")
|
||||
} else if (transfer is ChangePermissionTransfer) {
|
||||
val permissions = (transfer as ChangePermissionTransfer).permissions
|
||||
// @formatter:off
|
||||
return "${I18n.getString("termora.transport.table.permissions")} -> ${PosixFilePermissions.toString(permissions)}"
|
||||
// @formatter:on
|
||||
}
|
||||
}
|
||||
|
||||
if (path.fileSystem == FileSystems.getDefault()) {
|
||||
return path.absolutePathString()
|
||||
}
|
||||
|
||||
return path.absolutePathString()
|
||||
}
|
||||
|
||||
private fun formatStatus(state: State): String {
|
||||
if (transfer is DeleteTransfer && state == State.Processing) {
|
||||
return I18n.getString("termora.transport.sftp.status.deleting")
|
||||
}
|
||||
|
||||
return when (state) {
|
||||
State.Processing -> I18n.getString("termora.transport.sftp.status.transporting")
|
||||
State.Ready -> I18n.getString("termora.transport.sftp.status.waiting")
|
||||
State.Done -> I18n.getString("termora.transport.sftp.status.done")
|
||||
State.Failed -> I18n.getString("termora.transport.sftp.status.failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun state(): State {
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待子完成
|
||||
*/
|
||||
fun waitingChildrenCompleted(): Boolean {
|
||||
if (transfer.isDirectory().not()) return false
|
||||
if (state == State.Processing) return true
|
||||
return state == State.Done && (transfer.scanning() || childCount > 0)
|
||||
}
|
||||
|
||||
fun changeState(state: State) {
|
||||
if (this.state == State.Done || this.state == State.Failed) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
|
||||
if (this.state == State.Processing && state == State.Ready) {
|
||||
throw IllegalStateException()
|
||||
}
|
||||
|
||||
this.state = state
|
||||
}
|
||||
|
||||
|
||||
fun tryChangeState(state: State): Boolean {
|
||||
if (this.state == State.Done || this.state == State.Failed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.state == State.Processing && state == State.Ready) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.state = state
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.plugin.Extension
|
||||
import java.awt.Window
|
||||
import java.nio.file.Path
|
||||
|
||||
interface TransportEditFileExtension : Extension {
|
||||
fun edit(owner: Window, path: Path): Disposable
|
||||
}
|
||||
390
src/main/kotlin/app/termora/transfer/TransportNavigationPanel.kt
Normal file
@@ -0,0 +1,390 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.DynamicColor
|
||||
import app.termora.Icons
|
||||
import app.termora.OptionPane
|
||||
import app.termora.transfer.TransportPanel.Companion.isWindowsFileSystem
|
||||
import com.formdev.flatlaf.FlatClientProperties
|
||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatTextField
|
||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||
import com.formdev.flatlaf.ui.FlatLineBorder
|
||||
import com.formdev.flatlaf.util.SystemInfo
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.CardLayout
|
||||
import java.awt.Dimension
|
||||
import java.awt.Insets
|
||||
import java.awt.Point
|
||||
import java.awt.event.*
|
||||
import java.beans.PropertyChangeEvent
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.nio.file.Path
|
||||
import java.util.function.Supplier
|
||||
import javax.swing.*
|
||||
import javax.swing.event.PopupMenuEvent
|
||||
import javax.swing.event.PopupMenuListener
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.pathString
|
||||
import kotlin.math.round
|
||||
|
||||
class TransportNavigationPanel(
|
||||
private val support: Supplier<TransportSupport>,
|
||||
private val navigator: TransportNavigator
|
||||
) : JPanel() {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TransportNavigationPanel::class.java)
|
||||
private const val TEXT_FIELD = "TextField"
|
||||
private const val SEGMENTS = "Segments"
|
||||
|
||||
private val ICON_SIZE = if (SystemInfo.isMacOS) 14 else 16
|
||||
private val icon = FlatSVGIcon(Icons.playForward.name, ICON_SIZE, ICON_SIZE)
|
||||
private val moreHorizontal = FlatSVGIcon(Icons.moreHorizontal.name, ICON_SIZE, ICON_SIZE)
|
||||
private val computerIcon = FlatSVGIcon(Icons.desktop.name, ICON_SIZE, ICON_SIZE)
|
||||
|
||||
}
|
||||
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val layeredPane = LayeredPane()
|
||||
private val textField = FlatTextField()
|
||||
private val downBtn = JButton(Icons.chevronDown)
|
||||
private val comboBox = object : JComboBox<Path>() {
|
||||
override fun getLocationOnScreen(): Point {
|
||||
val point = super.getLocationOnScreen()
|
||||
point.y -= 1
|
||||
return point
|
||||
}
|
||||
}
|
||||
private val segmentPanel = object : FlatToolBar() {
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
border = FlatLineBorder(
|
||||
Insets(1, 0, 1, 0), DynamicColor.BorderColor,
|
||||
1f, UIManager.getInt("TextComponent.arc")
|
||||
)
|
||||
}
|
||||
}
|
||||
private val cardLayout = CardLayout()
|
||||
private val that get() = this
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
super.setLayout(cardLayout)
|
||||
comboBox.isEnabled = false
|
||||
comboBox.putClientProperty("JComboBox.isTableCellEditor", true)
|
||||
comboBox.border = BorderFactory.createEmptyBorder()
|
||||
|
||||
textField.trailingComponent = downBtn
|
||||
downBtn.putClientProperty(
|
||||
FlatClientProperties.STYLE,
|
||||
mapOf(
|
||||
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
|
||||
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
|
||||
)
|
||||
)
|
||||
|
||||
segmentPanel.layout = BoxLayout(segmentPanel, BoxLayout.X_AXIS)
|
||||
segmentPanel.putClientProperty(
|
||||
FlatClientProperties.STYLE,
|
||||
mapOf("background" to DynamicColor("TextField.background"))
|
||||
)
|
||||
segmentPanel.isFocusable = true
|
||||
|
||||
layeredPane.add(comboBox, JLayeredPane.DEFAULT_LAYER as Any)
|
||||
layeredPane.add(textField, JLayeredPane.PALETTE_LAYER as Any)
|
||||
|
||||
add(layeredPane, TEXT_FIELD)
|
||||
add(segmentPanel, SEGMENTS)
|
||||
|
||||
cardLayout.show(this, SEGMENTS)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
|
||||
val itemListener = object : ItemListener {
|
||||
override fun itemStateChanged(e: ItemEvent) {
|
||||
val path = comboBox.selectedItem as Path? ?: return
|
||||
if (navigator.loading) return
|
||||
navigator.navigateTo(path)
|
||||
}
|
||||
}
|
||||
|
||||
segmentPanel.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
cardLayout.show(that, TEXT_FIELD)
|
||||
textField.requestFocusInWindow()
|
||||
}
|
||||
})
|
||||
|
||||
segmentPanel.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
val workdir = navigator.workdir ?: return
|
||||
repack(workdir)
|
||||
}
|
||||
})
|
||||
|
||||
textField.addFocusListener(object : FocusAdapter() {
|
||||
override fun focusLost(e: FocusEvent) {
|
||||
if (comboBox.isPopupVisible) return
|
||||
cardLayout.show(that, SEGMENTS)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
downBtn.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (comboBox.isPopupVisible) return
|
||||
comboBox.isEnabled = true
|
||||
comboBox.removeAllItems()
|
||||
for (path in navigator.getHistory()) {
|
||||
comboBox.addItem(path)
|
||||
}
|
||||
comboBox.selectedItem = navigator.workdir
|
||||
comboBox.requestFocusInWindow()
|
||||
comboBox.showPopup()
|
||||
}
|
||||
})
|
||||
|
||||
comboBox.addPopupMenuListener(object : PopupMenuListener {
|
||||
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent?) {
|
||||
comboBox.addItemListener(itemListener)
|
||||
}
|
||||
|
||||
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
|
||||
textField.requestFocusInWindow()
|
||||
comboBox.isEnabled = false
|
||||
comboBox.removeItemListener(itemListener)
|
||||
}
|
||||
|
||||
override fun popupMenuCanceled(e: PopupMenuEvent?) {
|
||||
textField.requestFocusInWindow()
|
||||
comboBox.isEnabled = false
|
||||
comboBox.removeItemListener(itemListener)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
textField.addActionListener(object : AbstractAction() {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
if (navigator.loading) return
|
||||
if (textField.text.isBlank()) return
|
||||
|
||||
try {
|
||||
val path = support.get().fileSystem.getPath(textField.text)
|
||||
navigator.navigateTo(path)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) log.error(e.message, e)
|
||||
OptionPane.showMessageDialog(
|
||||
owner, ExceptionUtils.getRootCauseMessage(e),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
navigator.addPropertyChangeListener("workdir", object : PropertyChangeListener {
|
||||
override fun propertyChange(evt: PropertyChangeEvent) {
|
||||
val path = evt.newValue as? Path ?: return
|
||||
setTextFieldText(path)
|
||||
repack(path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun setTextFieldText(path: Path) {
|
||||
if (path.fileSystem.isWindowsFileSystem() && path.pathString == path.fileSystem.separator) {
|
||||
textField.text = StringUtils.EMPTY
|
||||
} else {
|
||||
textField.text = path.absolutePathString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun repack(workdir: Path) {
|
||||
segmentPanel.removeAll()
|
||||
|
||||
var parent: Path? = workdir
|
||||
val fileSystem = workdir.fileSystem
|
||||
val parents = mutableListOf<Path>()
|
||||
|
||||
while (parent != null) {
|
||||
parents.addFirst(parent)
|
||||
parent = parent.parent
|
||||
// Windows 比较特殊,因为它有盘符
|
||||
if (parent == null && fileSystem.isWindowsFileSystem()) {
|
||||
parents.addFirst(fileSystem.getPath(fileSystem.separator))
|
||||
}
|
||||
}
|
||||
|
||||
// 预留点击空间
|
||||
val width = segmentPanel.width - 100
|
||||
val children = mutableListOf<JComponent>()
|
||||
|
||||
for (i in 0 until parents.size) {
|
||||
val path = parents[i]
|
||||
val button = if (i == 0) JLabel(computerIcon)
|
||||
else if (fileSystem.isWindowsFileSystem() && path.root == path)
|
||||
JButton(path.toString().replace(fileSystem.separator, StringUtils.EMPTY))
|
||||
else JButton(path.name)
|
||||
// JLabel 与 JButton 对齐
|
||||
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||
if (button is JLabel)
|
||||
button.border = BorderFactory.createEmptyBorder(2, 4, 2, 4)
|
||||
else
|
||||
button.putClientProperty(FlatClientProperties.STYLE, mapOf("margin" to Insets(1, 2, 1, 2)))
|
||||
} else if (SystemUtils.IS_OS_LINUX) {
|
||||
if (button is JLabel)
|
||||
button.border = BorderFactory.createEmptyBorder(0, 4, 0, 4)
|
||||
else
|
||||
button.putClientProperty(FlatClientProperties.STYLE, mapOf("margin" to Insets(0, 0, 0, 0)))
|
||||
} else {
|
||||
if (button is JLabel)
|
||||
button.border = BorderFactory.createEmptyBorder(3, 4, 3, 4)
|
||||
else
|
||||
button.putClientProperty(FlatClientProperties.STYLE, mapOf("margin" to Insets(1, 2, 1, 2)))
|
||||
}
|
||||
button.isFocusable = false
|
||||
button.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON)
|
||||
button.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (navigator.loading) return
|
||||
if (path == navigator.workdir) {
|
||||
setTextFieldText(path)
|
||||
} else {
|
||||
navigator.navigateTo(path)
|
||||
}
|
||||
}
|
||||
})
|
||||
button.putClientProperty("Path", path)
|
||||
children.add(button)
|
||||
|
||||
}
|
||||
|
||||
if (children.isEmpty()) {
|
||||
revalidate()
|
||||
repaint()
|
||||
return
|
||||
}
|
||||
|
||||
val moreChildren = mutableListOf<Path>()
|
||||
val rightBtnWidth = createRightLabel().preferredSize.width
|
||||
var childrenWidth = children.first().preferredSize.width - rightBtnWidth
|
||||
|
||||
var i = 1
|
||||
while (i < children.size) {
|
||||
val child = children[i]
|
||||
if (child.preferredSize.width + childrenWidth <= width) {
|
||||
childrenWidth += (child.preferredSize.width + rightBtnWidth)
|
||||
} else {
|
||||
i--
|
||||
if (children.size < 2 || i < 0) break
|
||||
val c = children.removeAt(1)
|
||||
val path = c.getClientProperty("Path") as Path
|
||||
moreChildren.add(path)
|
||||
childrenWidth -= (c.preferredSize.width + rightBtnWidth)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
for (n in 0 until children.size) {
|
||||
val child = children[n]
|
||||
segmentPanel.add(child)
|
||||
if (n != children.size - 1 || (moreChildren.isNotEmpty() && n == 0)) {
|
||||
segmentPanel.add(createRightLabel())
|
||||
}
|
||||
|
||||
if (moreChildren.isNotEmpty()) {
|
||||
val button = JButton(moreHorizontal)
|
||||
// JLabel 与 JButton 对齐
|
||||
button.putClientProperty(FlatClientProperties.STYLE, mapOf("margin" to Insets(1, 2, 1, 2)))
|
||||
button.isFocusable = false
|
||||
button.putClientProperty(
|
||||
FlatClientProperties.BUTTON_TYPE,
|
||||
FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON
|
||||
)
|
||||
val paths = moreChildren.toTypedArray()
|
||||
button.addActionListener { showMoreContextmenu(button, paths) }
|
||||
segmentPanel.add(button)
|
||||
|
||||
if (n != children.size - 1) {
|
||||
segmentPanel.add(createRightLabel())
|
||||
}
|
||||
|
||||
moreChildren.clear()
|
||||
}
|
||||
}
|
||||
|
||||
segmentPanel.add(Box.createHorizontalGlue())
|
||||
val downBtn = JLabel(Icons.chevronDown)
|
||||
downBtn.border = BorderFactory.createEmptyBorder(2, 2, 2, 3)
|
||||
downBtn.addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||
cardLayout.show(that, TEXT_FIELD)
|
||||
SwingUtilities.invokeLater { that.downBtn.doClick() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
segmentPanel.add(downBtn)
|
||||
|
||||
revalidate()
|
||||
repaint()
|
||||
|
||||
}
|
||||
|
||||
private fun showMoreContextmenu(button: JButton, paths: Array<Path>) {
|
||||
val popupMenu = FlatPopupMenu()
|
||||
for (item in paths) {
|
||||
var text = item.name
|
||||
if (item.fileSystem.isWindowsFileSystem()) {
|
||||
if (item.root == item) {
|
||||
text = item.pathString
|
||||
}
|
||||
}
|
||||
popupMenu.add(text).addActionListener { navigator.navigateTo(item) }
|
||||
}
|
||||
popupMenu.show(
|
||||
button,
|
||||
button.x - button.width / 2 - popupMenu.preferredSize.width / 2,
|
||||
button.y + button.height + 1
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRightLabel(): JLabel {
|
||||
val rightBtn = JLabel(icon)
|
||||
rightBtn.preferredSize = Dimension(
|
||||
round(rightBtn.preferredSize.width / 1.5).toInt(),
|
||||
rightBtn.preferredSize.height
|
||||
)
|
||||
rightBtn.maximumSize = rightBtn.preferredSize
|
||||
rightBtn.isFocusable = false
|
||||
rightBtn.putClientProperty(
|
||||
FlatClientProperties.BUTTON_TYPE,
|
||||
FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON
|
||||
)
|
||||
rightBtn.addMouseListener(object : MouseAdapter() {})
|
||||
return rightBtn
|
||||
}
|
||||
|
||||
private class LayeredPane : JLayeredPane() {
|
||||
override fun doLayout() {
|
||||
synchronized(treeLock) {
|
||||
for (c in components) {
|
||||
c.setBounds(0, 0, width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
22
src/main/kotlin/app/termora/transfer/TransportNavigator.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.nio.file.Path
|
||||
|
||||
interface TransportNavigator {
|
||||
|
||||
val loading: Boolean
|
||||
val workdir: Path?
|
||||
|
||||
fun navigateTo(destination: Path): Boolean
|
||||
|
||||
fun addPropertyChangeListener(propertyName: String, listener: PropertyChangeListener)
|
||||
fun removePropertyChangeListener(propertyName: String, listener: PropertyChangeListener)
|
||||
|
||||
fun getHistory(): List<Path>
|
||||
|
||||
fun canRedo(): Boolean
|
||||
fun canUndo(): Boolean
|
||||
fun back()
|
||||
fun forward()
|
||||
}
|
||||
1220
src/main/kotlin/app/termora/transfer/TransportPanel.kt
Normal file
217
src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt
Normal file
@@ -0,0 +1,217 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Application
|
||||
import app.termora.I18n
|
||||
import app.termora.Icons
|
||||
import app.termora.OptionPane
|
||||
import app.termora.transfer.TransportPanel.Companion.isLocallyFileSystem
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import java.awt.Window
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.ActionListener
|
||||
import java.awt.event.KeyEvent
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.util.*
|
||||
import javax.swing.JMenu
|
||||
import javax.swing.JMenuItem
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
import javax.swing.event.EventListenerList
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.io.path.name
|
||||
|
||||
|
||||
class TransportPopupMenu(
|
||||
private val owner: Window,
|
||||
private val model: TransportTableModel,
|
||||
private val transferManager: InternalTransferManager,
|
||||
private val fileSystem: FileSystem,
|
||||
private val files: List<Pair<Path, TransportTableModel.Attributes>>
|
||||
) : FlatPopupMenu() {
|
||||
private val paths = files.map { it.first }
|
||||
private val hasParent = files.any { it.second.isParent }
|
||||
|
||||
private val transferMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.transfer"))
|
||||
private val editMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.edit"))
|
||||
private val copyPathMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.copy-path"))
|
||||
private val openInFinderMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.open-in-folder"))
|
||||
private val renameMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.rename"))
|
||||
private val deleteMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.delete"))
|
||||
private val rmrfMenu = JMenuItem("rm -rf", Icons.warningIntroduction)
|
||||
|
||||
// @formatter:off
|
||||
private val changePermissionsMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
|
||||
// @formatter:on
|
||||
private val refreshMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.refresh"))
|
||||
private val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
|
||||
private val newFolderMenu = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder"))
|
||||
private val newFileMenu = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file"))
|
||||
|
||||
private val eventListeners = EventListenerList()
|
||||
private val mnemonics = mapOf(
|
||||
refreshMenu to KeyEvent.VK_R,
|
||||
newMenu to KeyEvent.VK_W,
|
||||
newFolderMenu to KeyEvent.VK_F,
|
||||
renameMenu to KeyEvent.VK_M,
|
||||
deleteMenu to KeyEvent.VK_D,
|
||||
editMenu to KeyEvent.VK_E,
|
||||
transferMenu to KeyEvent.VK_T,
|
||||
)
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
inheritsPopupMenu = false
|
||||
|
||||
add(transferMenu)
|
||||
add(editMenu)
|
||||
addSeparator()
|
||||
add(copyPathMenu)
|
||||
if (fileSystem.isLocallyFileSystem()) add(openInFinderMenu)
|
||||
addSeparator()
|
||||
add(renameMenu)
|
||||
add(deleteMenu)
|
||||
if (fileSystem is SftpFileSystem) add(rmrfMenu)
|
||||
add(changePermissionsMenu)
|
||||
addSeparator()
|
||||
add(refreshMenu)
|
||||
addSeparator()
|
||||
add(newMenu)
|
||||
|
||||
transferMenu.isEnabled = hasParent.not() && files.isNotEmpty() && transferManager.canTransfer(paths)
|
||||
copyPathMenu.isEnabled = files.isNotEmpty()
|
||||
openInFinderMenu.isEnabled = files.isNotEmpty() && fileSystem.isLocallyFileSystem()
|
||||
editMenu.isEnabled = files.isNotEmpty() && fileSystem.isLocallyFileSystem().not()
|
||||
&& files.all { it.second.isFile && it.second.isSymbolicLink.not() }
|
||||
renameMenu.isEnabled = hasParent.not() && files.size == 1
|
||||
deleteMenu.isEnabled = hasParent.not() && files.isNotEmpty()
|
||||
rmrfMenu.isEnabled = hasParent.not() && files.isNotEmpty()
|
||||
changePermissionsMenu.isEnabled = hasParent.not() && fileSystem is SftpFileSystem && files.size == 1
|
||||
|
||||
for ((item, mnemonic) in mnemonics) {
|
||||
item.text = "${item.text}(${KeyEvent.getKeyText(mnemonic)})"
|
||||
item.setMnemonic(mnemonic)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
transferMenu.addActionListener { fireActionPerformed(it, ActionCommand.Transfer) }
|
||||
deleteMenu.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner,
|
||||
I18n.getString("termora.keymgr.delete-warning"),
|
||||
messageType = JOptionPane.WARNING_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
fireActionPerformed(it, ActionCommand.Delete)
|
||||
}
|
||||
}
|
||||
rmrfMenu.addActionListener {
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.transport.table.contextmenu.rm-warning"),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
) == JOptionPane.YES_OPTION
|
||||
) {
|
||||
fireActionPerformed(it, ActionCommand.Rmrf)
|
||||
}
|
||||
}
|
||||
renameMenu.addActionListener { newFolderOrNewFile(it, ActionCommand.Rename) }
|
||||
editMenu.addActionListener { fireActionPerformed(it, ActionCommand.Edit) }
|
||||
newFolderMenu.addActionListener { newFolderOrNewFile(it, ActionCommand.NewFolder) }
|
||||
newFileMenu.addActionListener { newFolderOrNewFile(it, ActionCommand.NewFile) }
|
||||
refreshMenu.addActionListener { fireActionPerformed(it, ActionCommand.Refresh) }
|
||||
openInFinderMenu.addActionListener { for (path in paths) Application.browseInFolder(path.toFile()) }
|
||||
changePermissionsMenu.addActionListener { changePosixFilePermission(it) }
|
||||
copyPathMenu.addActionListener {
|
||||
val sb = StringBuilder()
|
||||
paths.forEach { sb.append(it.absolutePathString()).appendLine() }
|
||||
sb.deleteCharAt(sb.length - 1)
|
||||
toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fireActionPerformed(evt: ActionEvent, command: ActionCommand) {
|
||||
for (listener in eventListeners.getListeners(ActionListener::class.java)) {
|
||||
listener.actionPerformed(ActionEvent(evt.source, evt.id, command.name))
|
||||
}
|
||||
}
|
||||
|
||||
private fun changePosixFilePermission(evt: ActionEvent) {
|
||||
val panel = PosixFilePermissionPanel(files.first().second.permissions)
|
||||
if (OptionPane.showConfirmDialog(
|
||||
owner, panel,
|
||||
messageType = JOptionPane.PLAIN_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
) != JOptionPane.OK_OPTION
|
||||
) return
|
||||
|
||||
if (panel.isIncludeSubdirectories().not()) {
|
||||
if (Objects.deepEquals(panel.getPermissions(), files.first().second.permissions)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fireActionPerformed(
|
||||
ActionEvent(
|
||||
ChangePermission(panel.getPermissions(), panel.isIncludeSubdirectories()),
|
||||
evt.id,
|
||||
evt.actionCommand
|
||||
),
|
||||
ActionCommand.ChangePermissions
|
||||
)
|
||||
}
|
||||
|
||||
private fun newFolderOrNewFile(evt: ActionEvent, actionCommand: ActionCommand) {
|
||||
val title = when (actionCommand) {
|
||||
ActionCommand.NewFile -> I18n.getString("termora.transport.table.contextmenu.new.file")
|
||||
ActionCommand.NewFolder -> I18n.getString("termora.welcome.contextmenu.new.folder.name")
|
||||
ActionCommand.Rename -> I18n.getString("termora.transport.table.contextmenu.rename")
|
||||
else -> StringUtils.EMPTY
|
||||
}
|
||||
val defaultValue = if (actionCommand == ActionCommand.Rename) paths.first().name else title
|
||||
val text = OptionPane.showInputDialog(owner, title = title, value = defaultValue) ?: return
|
||||
if (text.isBlank()) return
|
||||
for (i in 0 until model.rowCount) {
|
||||
if (model.getPath(i).name == text) {
|
||||
OptionPane.showMessageDialog(
|
||||
owner,
|
||||
I18n.getString("termora.transport.file-already-exists", text),
|
||||
messageType = JOptionPane.ERROR_MESSAGE
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
fireActionPerformed(ActionEvent(text, evt.id, evt.actionCommand), actionCommand)
|
||||
}
|
||||
|
||||
fun addActionListener(listener: ActionListener) {
|
||||
eventListeners.add(ActionListener::class.java, listener)
|
||||
}
|
||||
|
||||
fun removeActionListener(listener: ActionListener) {
|
||||
eventListeners.remove(ActionListener::class.java, listener)
|
||||
}
|
||||
|
||||
enum class ActionCommand {
|
||||
Transfer,
|
||||
Delete,
|
||||
Edit,
|
||||
Rename,
|
||||
NewFolder,
|
||||
NewFile,
|
||||
Refresh,
|
||||
ChangePermissions,
|
||||
Rmrf,
|
||||
}
|
||||
|
||||
data class ChangePermission(val permissions: Set<PosixFilePermission>, val includeSubFolder: Boolean)
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
package app.termora.sftp
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.Disposable
|
||||
import app.termora.Disposer
|
||||
import app.termora.Host
|
||||
import app.termora.I18n
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.*
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.protocol.FileObjectHandler
|
||||
import app.termora.protocol.FileObjectRequest
|
||||
import app.termora.protocol.PathHandlerRequest
|
||||
import app.termora.protocol.TransferProtocolProvider
|
||||
import app.termora.terminal.DataKey
|
||||
import app.termora.tree.*
|
||||
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
@@ -18,7 +12,6 @@ import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.jdesktop.swingx.JXBusyLabel
|
||||
import org.jdesktop.swingx.JXHyperlink
|
||||
import org.slf4j.LoggerFactory
|
||||
@@ -27,43 +20,42 @@ import java.awt.CardLayout
|
||||
import java.awt.event.ActionEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.Executors
|
||||
import javax.swing.*
|
||||
import javax.swing.event.TreeExpansionEvent
|
||||
import javax.swing.event.TreeExpansionListener
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
class SFTPFileSystemViewPanel(
|
||||
var host: Host? = null,
|
||||
private val transportManager: TransportManager,
|
||||
) : JPanel(BorderLayout()), Disposable, DataProvider {
|
||||
class TransportSelectionPanel(
|
||||
private val tabbed: TransportTabbed,
|
||||
private val transferManager: InternalTransferManager,
|
||||
) : JPanel(BorderLayout()), Disposable {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java)
|
||||
private val log = LoggerFactory.getLogger(TransportSelectionPanel::class.java)
|
||||
}
|
||||
|
||||
enum class State {
|
||||
Initialized,
|
||||
Connecting,
|
||||
Connected,
|
||||
ConnectFailed,
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var state = State.Initialized
|
||||
private set
|
||||
private val cardLayout = CardLayout()
|
||||
private val cardPanel = JPanel(cardLayout)
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val connectingPanel = ConnectingPanel()
|
||||
private val selectHostPanel = SelectHostPanel()
|
||||
private val connectFailedPanel = ConnectFailedPanel()
|
||||
private val isDisposed = AtomicBoolean(false)
|
||||
private val that = this
|
||||
private val properties get() = DatabaseManager.getInstance().properties
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
private val executorService = Executors.newVirtualThreadPerTaskExecutor()
|
||||
private val coroutineDispatcher = executorService.asCoroutineDispatcher()
|
||||
private val coroutineScope = CoroutineScope(coroutineDispatcher)
|
||||
|
||||
private var handler: FileObjectHandler? = null
|
||||
private var fileSystemPanel: FileSystemViewPanel? = null
|
||||
|
||||
private val that get() = this
|
||||
private var host: Host? = null
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -71,6 +63,7 @@ class SFTPFileSystemViewPanel(
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
isFocusable = false
|
||||
cardPanel.add(selectHostPanel, State.Initialized.name)
|
||||
cardPanel.add(connectingPanel, State.Connecting.name)
|
||||
cardPanel.add(connectFailedPanel, State.ConnectFailed.name)
|
||||
@@ -82,110 +75,67 @@ class SFTPFileSystemViewPanel(
|
||||
Disposer.register(this, selectHostPanel)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
fun connect(host: Host) {
|
||||
if (state == State.Connecting) return
|
||||
state = State.Connecting
|
||||
this.host = host
|
||||
|
||||
connectingPanel.busyLabel.isBusy = true
|
||||
cardLayout.show(cardPanel, State.Connecting.name)
|
||||
|
||||
coroutineScope.launch {
|
||||
if (state != State.Connecting) {
|
||||
state = State.Connecting
|
||||
|
||||
try {
|
||||
doConnect(host)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) log.error(e.message, e)
|
||||
withContext(Dispatchers.Swing) {
|
||||
connectingPanel.start()
|
||||
cardLayout.show(cardPanel, State.Connecting.name)
|
||||
state = State.ConnectFailed
|
||||
connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(e)
|
||||
cardLayout.show(cardPanel, State.ConnectFailed.name)
|
||||
}
|
||||
}
|
||||
}.invokeOnCompletion { swingCoroutineScope.launch { connectingPanel.busyLabel.isBusy = false } }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
private suspend fun doConnect(host: Host) {
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
connectingPanel.stop()
|
||||
val provider = TransferProtocolProvider.valueOf(host.protocol)
|
||||
if (provider == null) {
|
||||
throw IllegalStateException("Protocol ${host.protocol} not supported")
|
||||
}
|
||||
|
||||
val handler = provider.createPathHandler(PathHandlerRequest(host, owner))
|
||||
val support = TransportSupport(handler.fileSystem, handler.path.absolutePathString())
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
val panel = TransportPanel(transferManager, host, TransportSupportLoader { support })
|
||||
Disposer.register(panel, object : Disposable {
|
||||
override fun dispose() {
|
||||
Disposer.dispose(handler)
|
||||
}
|
||||
})
|
||||
swingCoroutineScope.launch {
|
||||
tabbed.remove(that)
|
||||
tabbed.addTab(host.name, panel)
|
||||
tabbed.selectedIndex = tabbed.tabCount - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doConnect() {
|
||||
val thisHost = this.host ?: return
|
||||
|
||||
closeIO()
|
||||
|
||||
val file: FileObject
|
||||
val provider = TransferProtocolProvider.valueOf(thisHost.protocol)
|
||||
?: throw IllegalStateException("Protocol ${thisHost.protocol} not supported")
|
||||
|
||||
try {
|
||||
val owner = SwingUtilities.getWindowAncestor(that)
|
||||
val requester = FileObjectRequest(host = thisHost, owner = owner)
|
||||
provider.getRootFileObject(requester)
|
||||
val handler = provider.getRootFileObject(requester).apply { handler = this }
|
||||
file = handler.file
|
||||
Disposer.register(handler, object : Disposable {
|
||||
override fun dispose() {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
closeIO()
|
||||
throw e
|
||||
}
|
||||
|
||||
if (isDisposed.get()) {
|
||||
throw IllegalStateException("Closed")
|
||||
}
|
||||
|
||||
|
||||
withContext(Dispatchers.Swing) {
|
||||
state = State.Connected
|
||||
val fileSystemPanel = FileSystemViewPanel(thisHost, file, transportManager, coroutineScope)
|
||||
cardPanel.add(fileSystemPanel, State.Connected.name)
|
||||
cardLayout.show(cardPanel, State.Connected.name)
|
||||
that.fileSystemPanel = fileSystemPanel
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
handler?.let { Disposer.dispose(it) }
|
||||
handler = null
|
||||
|
||||
if (host != null && log.isInfoEnabled) {
|
||||
log.info("Sftp ${host.name} is closed")
|
||||
}
|
||||
override fun requestFocusInWindow(): Boolean {
|
||||
return selectHostPanel.tree.requestFocusInWindow()
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
if (isDisposed.compareAndSet(false, true)) {
|
||||
closeIO()
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
coroutineScope.cancel()
|
||||
coroutineDispatcher.close()
|
||||
executorService.shutdownNow()
|
||||
connectingPanel.busyLabel.isBusy = false
|
||||
}
|
||||
|
||||
|
||||
private class ConnectingPanel : JPanel(BorderLayout()) {
|
||||
private val busyLabel = JXBusyLabel()
|
||||
val busyLabel = JXBusyLabel()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -210,13 +160,6 @@ class SFTPFileSystemViewPanel(
|
||||
add(builder.build(), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
busyLabel.isBusy = true
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
busyLabel.isBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ConnectFailedPanel : JPanel(BorderLayout()) {
|
||||
@@ -240,7 +183,7 @@ class SFTPFileSystemViewPanel(
|
||||
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()
|
||||
host?.let { connect(it) }
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
@@ -251,8 +194,8 @@ class SFTPFileSystemViewPanel(
|
||||
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
|
||||
override fun actionPerformed(e: ActionEvent) {
|
||||
state = State.Initialized
|
||||
that.setTabTitle(I18n.getString("termora.transport.sftp.select-host"))
|
||||
cardLayout.show(cardPanel, State.Initialized.name)
|
||||
selectHostPanel.tree.requestFocusInWindow()
|
||||
}
|
||||
}).apply {
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
@@ -264,7 +207,7 @@ class SFTPFileSystemViewPanel(
|
||||
}
|
||||
|
||||
private inner class SelectHostPanel : JPanel(BorderLayout()), Disposable {
|
||||
private val tree = NewHostTree()
|
||||
val tree = NewHostTree()
|
||||
|
||||
init {
|
||||
initView()
|
||||
@@ -306,7 +249,7 @@ class SFTPFileSystemViewPanel(
|
||||
val node = tree.getLastSelectedPathNode() ?: return
|
||||
if (node.isFolder) return
|
||||
val host = node.data as Host
|
||||
selectHost(host)
|
||||
connect(host)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -324,31 +267,5 @@ class SFTPFileSystemViewPanel(
|
||||
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
return when (dataKey) {
|
||||
SFTPDataProviders.FileSystemViewPanel -> fileSystemPanel as T?
|
||||
SFTPDataProviders.CoroutineScope -> coroutineScope as T?
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun selectHost(host: Host) {
|
||||
that.setTabTitle(host.name)
|
||||
that.host = host
|
||||
that.connect()
|
||||
}
|
||||
|
||||
private fun setTabTitle(title: String) {
|
||||
val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that)
|
||||
if (tabbed is JTabbedPane) {
|
||||
for (i in 0 until tabbed.tabCount) {
|
||||
if (tabbed.getComponentAt(i) == that) {
|
||||
tabbed.setTitleAt(i, title)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
9
src/main/kotlin/app/termora/transfer/TransportSupport.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import java.nio.file.FileSystem
|
||||
|
||||
|
||||
class TransportSupport(
|
||||
val fileSystem: FileSystem,
|
||||
val path: String
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import okio.withLock
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import java.util.function.Supplier
|
||||
|
||||
class TransportSupportLoader(private val support: Supplier<TransportSupport>) : Supplier<TransportSupport> {
|
||||
private val loading = AtomicBoolean(false)
|
||||
private lateinit var mySupport: TransportSupport
|
||||
private val lock = ReentrantLock()
|
||||
private val condition = lock.newCondition()
|
||||
private val exceptionReference = AtomicReference<Exception>(null)
|
||||
|
||||
val isLoaded get() = ::mySupport.isInitialized
|
||||
|
||||
|
||||
override fun get(): TransportSupport {
|
||||
if (isLoaded) return mySupport
|
||||
|
||||
if (loading.compareAndSet(false, true)) {
|
||||
try {
|
||||
mySupport = support.get()
|
||||
} catch (e: Exception) {
|
||||
exceptionReference.set(e)
|
||||
throw e
|
||||
} finally {
|
||||
lock.withLock {
|
||||
loading.set(false)
|
||||
condition.signalAll()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lock.lock()
|
||||
try {
|
||||
condition.await()
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
val exception = exceptionReference.get()
|
||||
if (exception != null) {
|
||||
throw exception
|
||||
}
|
||||
|
||||
return get()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
197
src/main/kotlin/app/termora/transfer/TransportTabbed.kt
Normal file
@@ -0,0 +1,197 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.AnAction
|
||||
import app.termora.actions.AnActionEvent
|
||||
import app.termora.plugin.internal.local.LocalProtocolProvider
|
||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.nio.file.FileSystems
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.JToolBar
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
@Suppress("DuplicatedCode")
|
||||
class TransportTabbed(
|
||||
private val transferManager: TransferManager,
|
||||
private val internalTransferManager: InternalTransferManager
|
||||
) : FlatTabbedPane(), Disposable {
|
||||
private val addBtn = JButton(Icons.add)
|
||||
private val tabbed get() = this
|
||||
|
||||
init {
|
||||
initViews()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initViews() {
|
||||
super.setTabLayoutPolicy(SCROLL_TAB_LAYOUT)
|
||||
super.setTabsClosable(true)
|
||||
super.setTabType(TabType.underlined)
|
||||
super.setFocusable(false)
|
||||
|
||||
|
||||
val toolbar = JToolBar()
|
||||
toolbar.add(addBtn)
|
||||
super.setTrailingComponent(toolbar)
|
||||
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
addBtn.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
for (i in 0 until tabCount) {
|
||||
val c = getComponentAt(i)
|
||||
if (c !is TransportSelectionPanel) continue
|
||||
if (c.state != TransportSelectionPanel.State.Initialized) continue
|
||||
selectedIndex = i
|
||||
SwingUtilities.invokeLater { c.requestFocusInWindow() }
|
||||
return
|
||||
}
|
||||
|
||||
// 添加一个新的
|
||||
addSelectionTab()
|
||||
}
|
||||
})
|
||||
|
||||
// 右键菜单
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
if (!SwingUtilities.isRightMouseButton(e)) {
|
||||
return
|
||||
}
|
||||
|
||||
val index = indexAtLocation(e.x, e.y)
|
||||
if (index < 0) return
|
||||
|
||||
showContextMenu(index, e)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 关闭 tab
|
||||
setTabCloseCallback { _, i -> tabCloseCallback(i) }
|
||||
}
|
||||
|
||||
fun tabCloseCallback(index: Int) {
|
||||
assertEventDispatchThread()
|
||||
|
||||
if (isTabClosable(index).not()) return
|
||||
|
||||
val c = tabbed.getComponentAt(index)
|
||||
if (c == null) {
|
||||
tabbed.removeTabAt(index)
|
||||
return
|
||||
}
|
||||
|
||||
if (c is TransportPanel) {
|
||||
if (tabClose(c).not()) return
|
||||
}
|
||||
|
||||
// 删除并销毁
|
||||
tabbed.removeTabAt(index)
|
||||
|
||||
if (tabbed.tabCount < 1) {
|
||||
addSelectionTab()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tabClose(c: TransportPanel): Boolean {
|
||||
if (transferManager.getTransferCount() < 1) return true
|
||||
if (c.loader.isLoaded.not()) return false
|
||||
val fileSystem = c.getFileSystem()
|
||||
val transfers = transferManager.getTransfers()
|
||||
.filter { it.source().fileSystem == fileSystem || it.target().fileSystem == fileSystem }
|
||||
if (transfers.isEmpty()) return true
|
||||
|
||||
if (OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(this),
|
||||
I18n.getString("termora.transport.sftp.close-tab"),
|
||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||
) != JOptionPane.OK_OPTION
|
||||
) return false
|
||||
|
||||
// 删除所有关联任务
|
||||
for (transfer in transfers) {
|
||||
transferManager.removeTransfer(transfer.id())
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun addSelectionTab() {
|
||||
val c = TransportSelectionPanel(tabbed, internalTransferManager)
|
||||
addTab(I18n.getString("termora.transport.sftp.select-host"), c)
|
||||
selectedIndex = tabCount - 1
|
||||
SwingUtilities.invokeLater { c.requestFocusInWindow() }
|
||||
}
|
||||
|
||||
fun addLocalTab() {
|
||||
val host = Host(name = "Local", protocol = LocalProtocolProvider.PROTOCOL)
|
||||
val support = TransportSupport(FileSystems.getDefault(), SystemUtils.USER_HOME)
|
||||
val panel = TransportPanel(internalTransferManager, host, TransportSupportLoader { support })
|
||||
addTab(I18n.getString("termora.transport.local"), panel)
|
||||
super.setTabClosable(0, false)
|
||||
}
|
||||
|
||||
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
|
||||
val popupMenu = FlatPopupMenu()
|
||||
// 克隆
|
||||
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
|
||||
clone.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑
|
||||
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
|
||||
edit.addActionListener(object : AnAction() {
|
||||
override fun actionPerformed(evt: AnActionEvent) {
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
edit.isEnabled = clone.isEnabled
|
||||
|
||||
popupMenu.show(this, e.x, e.y)
|
||||
}
|
||||
|
||||
fun getSelectedTransportPanel(): TransportPanel? {
|
||||
val index = selectedIndex
|
||||
if (index < 0) return null
|
||||
return getTransportPanel(index)
|
||||
}
|
||||
|
||||
fun getTransportPanel(index: Int): TransportPanel? {
|
||||
return getComponentAt(index) as? TransportPanel
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
styleMap = mapOf(
|
||||
"focusColor" to DynamicColor("TabbedPane.background"),
|
||||
"hoverColor" to DynamicColor("TabbedPane.background"),
|
||||
"tabHeight" to 30,
|
||||
"showTabSeparators" to true,
|
||||
"tabSeparatorsFullHeight" to true,
|
||||
)
|
||||
super.updateUI()
|
||||
}
|
||||
|
||||
override fun removeTabAt(index: Int) {
|
||||
val c = getComponentAt(index)
|
||||
if (c is Disposable) {
|
||||
Disposer.dispose(c)
|
||||
}
|
||||
super.removeTabAt(index)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
while (tabCount > 0) removeTabAt(0)
|
||||
}
|
||||
}
|
||||
89
src/main/kotlin/app/termora/transfer/TransportTableModel.kt
Normal file
@@ -0,0 +1,89 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.I18n
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import javax.swing.table.DefaultTableModel
|
||||
|
||||
class TransportTableModel() : 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
|
||||
}
|
||||
|
||||
override fun getColumnCount(): Int {
|
||||
return 6
|
||||
}
|
||||
|
||||
fun getPath(row: Int): Path {
|
||||
return super.getValueAt(row, 0) as Path
|
||||
}
|
||||
|
||||
fun getAttributes(row: Int): Attributes {
|
||||
return super.getValueAt(row, 1) as Attributes
|
||||
}
|
||||
|
||||
override fun getColumnClass(columnIndex: Int): Class<*> {
|
||||
return Attributes::class.java
|
||||
}
|
||||
|
||||
override fun getValueAt(row: Int, column: Int): Any? {
|
||||
return getAttributes(row)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
override fun isCellEditable(row: Int, column: Int): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
while (rowCount > 0) {
|
||||
removeRow(rowCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class Attributes(
|
||||
val name: String,
|
||||
val type: String,
|
||||
val isDirectory: Boolean,
|
||||
val isFile: Boolean,
|
||||
val isSymbolicLink: Boolean,
|
||||
val fileSize: Long,
|
||||
val permissions: Set<PosixFilePermission>,
|
||||
val owner: String,
|
||||
val lastModifiedTime: Long
|
||||
) {
|
||||
companion object {
|
||||
fun computeType(isSymbolicLink: Boolean, isDirectory: Boolean, name: String): String {
|
||||
if (isSymbolicLink) {
|
||||
return I18n.getString("termora.transport.table.type.symbolic-link")
|
||||
} else if (isDirectory) {
|
||||
return I18n.getString("termora.folder")
|
||||
}
|
||||
if (name == "..") return StringUtils.EMPTY
|
||||
return FilenameUtils.getExtension(name)
|
||||
}
|
||||
}
|
||||
|
||||
val isParent get() = name == ".."
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
package app.termora.sftp
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.terminal.DataKey
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.nio.file.FileSystems
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JOptionPane
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class SFTPTab : RememberFocusTerminalTab() {
|
||||
private val sftpPanel = SFTPPanel()
|
||||
class TransportTerminalTab : RememberFocusTerminalTab() {
|
||||
private val transportViewer = TransportViewer()
|
||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||
private val transferManager get() = transportViewer.getTransferManager()
|
||||
val leftTabbed get() = transportViewer.getLeftTabbed()
|
||||
val rightTabbed get() = transportViewer.getRightTabbed()
|
||||
|
||||
init {
|
||||
Disposer.register(this, sftpPanel)
|
||||
Disposer.register(this, transportViewer)
|
||||
}
|
||||
|
||||
override fun getTitle(): String {
|
||||
@@ -32,14 +36,13 @@ class SFTPTab : RememberFocusTerminalTab() {
|
||||
}
|
||||
|
||||
override fun canClose(): Boolean {
|
||||
return !sftp.pinTab
|
||||
return sftp.pinTab.not()
|
||||
}
|
||||
|
||||
override fun willBeClose(): Boolean {
|
||||
if (!canClose()) return false
|
||||
if (canClose().not()) return false
|
||||
|
||||
val transportManager = sftpPanel.getData(SFTPDataProviders.TransportManager) ?: return true
|
||||
if (transportManager.getTransportCount() > 0) {
|
||||
if (transferManager.getTransferCount() > 0) {
|
||||
return OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(getJComponent()),
|
||||
I18n.getString("termora.transport.sftp.close-tab"),
|
||||
@@ -48,8 +51,6 @@ class SFTPTab : RememberFocusTerminalTab() {
|
||||
) == JOptionPane.OK_OPTION
|
||||
}
|
||||
|
||||
val leftTabbed = sftpPanel.getData(SFTPDataProviders.LeftSFTPTabbed) ?: return true
|
||||
val rightTabbed = sftpPanel.getData(SFTPDataProviders.RightSFTPTabbed) ?: return true
|
||||
if (hasActiveTab(leftTabbed) || hasActiveTab(rightTabbed)) {
|
||||
return OptionPane.showConfirmDialog(
|
||||
SwingUtilities.getWindowAncestor(getJComponent()),
|
||||
@@ -59,25 +60,26 @@ class SFTPTab : RememberFocusTerminalTab() {
|
||||
) == JOptionPane.OK_OPTION
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun hasActiveTab(tabbed: SFTPTabbed): Boolean {
|
||||
private fun hasActiveTab(tabbed: TransportTabbed): Boolean {
|
||||
for (i in 0 until tabbed.tabCount) {
|
||||
val c = tabbed.getFileSystemViewPanel(i) ?: continue
|
||||
if (c.host.id != "local") {
|
||||
return true
|
||||
val c = tabbed.getComponentAt(i) ?: continue
|
||||
if (c is TransportPanel && c.loader.isLoaded) {
|
||||
if (c.getFileSystem() != FileSystems.getDefault()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun getJComponent(): JComponent {
|
||||
return sftpPanel
|
||||
return transportViewer
|
||||
}
|
||||
|
||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||
return sftpPanel.getData(dataKey)
|
||||
return null
|
||||
}
|
||||
}
|
||||
489
src/main/kotlin/app/termora/transfer/TransportViewer.kt
Normal file
@@ -0,0 +1,489 @@
|
||||
package app.termora.transfer
|
||||
|
||||
import app.termora.*
|
||||
import app.termora.actions.DataProvider
|
||||
import app.termora.transfer.InternalTransferManager.TransferMode
|
||||
import com.jgoodies.forms.builder.FormBuilder
|
||||
import com.jgoodies.forms.layout.FormLayout
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.swing.Swing
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.time.DateFormatUtils
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.io.IOException
|
||||
import java.nio.file.*
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.util.Date
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.swing.*
|
||||
import kotlin.collections.ArrayDeque
|
||||
import kotlin.collections.List
|
||||
import kotlin.collections.Set
|
||||
import kotlin.collections.isNotEmpty
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.pathString
|
||||
import kotlin.math.max
|
||||
|
||||
|
||||
class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(TransportViewer::class.java)
|
||||
}
|
||||
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val splitPane = JSplitPane()
|
||||
private val transferManager = TransferTableModel(coroutineScope)
|
||||
private val transferTable = TransferTable(coroutineScope, transferManager)
|
||||
private val leftTransferManager = MyInternalTransferManager()
|
||||
private val rightTransferManager = MyInternalTransferManager()
|
||||
private val leftTabbed = TransportTabbed(transferManager, leftTransferManager)
|
||||
private val rightTabbed = TransportTabbed(transferManager, rightTransferManager)
|
||||
private val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT)
|
||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||
|
||||
init {
|
||||
initView()
|
||||
initEvents()
|
||||
}
|
||||
|
||||
private fun initView() {
|
||||
isFocusable = false
|
||||
|
||||
leftTabbed.addLocalTab()
|
||||
rightTabbed.addSelectionTab()
|
||||
|
||||
leftTransferManager.source = leftTabbed
|
||||
leftTransferManager.target = rightTabbed
|
||||
|
||||
rightTransferManager.source = rightTabbed
|
||||
rightTransferManager.target = leftTabbed
|
||||
|
||||
|
||||
val scrollPane = JScrollPane(transferTable)
|
||||
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
leftTabbed.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
|
||||
rightTabbed.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
|
||||
|
||||
splitPane.resizeWeight = 0.5
|
||||
splitPane.leftComponent = leftTabbed
|
||||
splitPane.rightComponent = rightTabbed
|
||||
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
|
||||
|
||||
rootSplitPane.resizeWeight = 0.7
|
||||
rootSplitPane.topComponent = splitPane
|
||||
rootSplitPane.bottomComponent = scrollPane
|
||||
|
||||
add(rootSplitPane, BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
private fun initEvents() {
|
||||
splitPane.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
splitPane.setDividerLocation(splitPane.resizeWeight)
|
||||
}
|
||||
})
|
||||
|
||||
rootSplitPane.addComponentListener(object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
removeComponentListener(this)
|
||||
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
|
||||
}
|
||||
})
|
||||
|
||||
Disposer.register(this, leftTabbed)
|
||||
Disposer.register(this, rightTabbed)
|
||||
}
|
||||
|
||||
fun getTransferManager(): TransferManager {
|
||||
return transferManager
|
||||
}
|
||||
|
||||
fun getLeftTabbed(): TransportTabbed {
|
||||
return leftTabbed
|
||||
}
|
||||
|
||||
fun getRightTabbed(): TransportTabbed {
|
||||
return rightTabbed
|
||||
}
|
||||
|
||||
private data class AskTransfer(
|
||||
val option: Int,
|
||||
val action: TransferAction,
|
||||
val applyAll: Boolean
|
||||
)
|
||||
|
||||
private data class AskTransferContext(var action: TransferAction, var applyAll: Boolean)
|
||||
|
||||
private inner class MyInternalTransferManager() : InternalTransferManager {
|
||||
lateinit var source: TransportTabbed
|
||||
lateinit var target: TransportTabbed
|
||||
|
||||
override fun canTransfer(paths: List<Path>): Boolean {
|
||||
return target.getSelectedTransportPanel()?.workdir != null
|
||||
}
|
||||
|
||||
override fun addTransfer(
|
||||
paths: List<Pair<Path, TransportTableModel.Attributes>>,
|
||||
mode: TransferMode
|
||||
): CompletableFuture<Unit> {
|
||||
val workdir = (if (mode == TransferMode.Delete || mode == TransferMode.ChangePermission)
|
||||
source.getSelectedTransportPanel()?.workdir else target.getSelectedTransportPanel()?.workdir)
|
||||
?: throw IllegalStateException()
|
||||
return addTransfer(paths, workdir, mode)
|
||||
}
|
||||
|
||||
override fun addTransfer(
|
||||
paths: List<Pair<Path, TransportTableModel.Attributes>>,
|
||||
targetWorkdir: Path,
|
||||
mode: TransferMode
|
||||
): CompletableFuture<Unit> {
|
||||
assertEventDispatchThread()
|
||||
|
||||
if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit)
|
||||
|
||||
val future = CompletableFuture<Unit>()
|
||||
val panel = getTransportPanel(targetWorkdir.fileSystem, leftTabbed)
|
||||
?: getTransportPanel(targetWorkdir.fileSystem, rightTabbed)
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val context = AskTransferContext(TransferAction.Overwrite, false)
|
||||
for (pair in paths) {
|
||||
if (mode == TransferMode.Transfer && panel != null) {
|
||||
val action = withContext(Dispatchers.Swing) {
|
||||
getTransferAction(context, panel, pair.second)
|
||||
}
|
||||
if (action == null) {
|
||||
break
|
||||
} else if (context.applyAll) {
|
||||
if (action == TransferAction.Skip) {
|
||||
break
|
||||
}
|
||||
} else if (action == TransferAction.Skip) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
val flag = doAddTransfer(targetWorkdir, pair, mode, context.action, future)
|
||||
if (flag != FileVisitResult.CONTINUE) break
|
||||
}
|
||||
future.complete(Unit)
|
||||
} catch (e: Exception) {
|
||||
if (log.isErrorEnabled) log.error(e.message, e)
|
||||
future.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
return future
|
||||
}
|
||||
|
||||
override fun addHighTransfer(source: Path, target: Path): String {
|
||||
val transfer = FileTransfer(
|
||||
parentId = StringUtils.EMPTY,
|
||||
source = source,
|
||||
target = target,
|
||||
size = Files.size(source),
|
||||
action = TransferAction.Overwrite,
|
||||
priority = Transfer.Priority.High
|
||||
)
|
||||
if (transferManager.addTransfer(transfer)) {
|
||||
return transfer.id()
|
||||
} else {
|
||||
throw IllegalStateException("Cannot add high transfer.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun addTransferListener(listener: TransferListener): Disposable {
|
||||
return transferManager.addTransferListener(listener)
|
||||
}
|
||||
|
||||
private fun getTransferAction(
|
||||
context: AskTransferContext,
|
||||
panel: TransportPanel,
|
||||
source: TransportTableModel.Attributes
|
||||
): TransferAction? {
|
||||
if (context.applyAll) return context.action
|
||||
|
||||
val model = panel.getTableModel()
|
||||
for (i in 0 until model.rowCount) {
|
||||
val c = model.getAttributes(i)
|
||||
if (c.name != source.name) continue
|
||||
val transfer = askTransfer(source, c)
|
||||
context.action = transfer.action
|
||||
context.applyAll = transfer.applyAll
|
||||
if (transfer.option != JOptionPane.OK_OPTION) return null
|
||||
}
|
||||
|
||||
return context.action
|
||||
}
|
||||
|
||||
|
||||
fun getTransportPanel(fileSystem: FileSystem, tabbed: TransportTabbed): TransportPanel? {
|
||||
for (i in 0 until tabbed.tabCount) {
|
||||
val c = tabbed.getComponentAt(i)
|
||||
if (c is TransportPanel) {
|
||||
if (c.loader.isLoaded) {
|
||||
if (c.loader.get().fileSystem == fileSystem) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun askTransfer(
|
||||
source: TransportTableModel.Attributes,
|
||||
target: TransportTableModel.Attributes
|
||||
): AskTransfer {
|
||||
val formMargin = "7dlu"
|
||||
val layout = FormLayout(
|
||||
"left:pref, $formMargin, default:grow, 2dlu, left:pref",
|
||||
"pref, 12dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||
)
|
||||
|
||||
val iconSize = 36
|
||||
// @formatter:off
|
||||
val targetIcon = ScaleIcon(if(target.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize)
|
||||
val sourceIcon = ScaleIcon(if(source.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize)
|
||||
val sourceModified= DateFormatUtils.format(Date(source.lastModifiedTime), I18n.getString("termora.date-format"))
|
||||
val targetModified= DateFormatUtils.format(Date(target.lastModifiedTime), I18n.getString("termora.date-format"))
|
||||
// @formatter:on
|
||||
|
||||
|
||||
val actionsComBoBox = JComboBox<TransferAction>()
|
||||
actionsComBoBox.addItem(TransferAction.Overwrite)
|
||||
actionsComBoBox.addItem(TransferAction.Append)
|
||||
actionsComBoBox.addItem(TransferAction.Skip)
|
||||
actionsComBoBox.renderer = object : DefaultListCellRenderer() {
|
||||
override fun getListCellRendererComponent(
|
||||
list: JList<*>?,
|
||||
value: Any?,
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
cellHasFocus: Boolean
|
||||
): Component {
|
||||
var text = value?.toString() ?: StringUtils.EMPTY
|
||||
if (value == TransferAction.Overwrite) {
|
||||
text = I18n.getString("termora.transport.sftp.already-exists.overwrite")
|
||||
} else if (value == TransferAction.Skip) {
|
||||
text = I18n.getString("termora.transport.sftp.already-exists.skip")
|
||||
} else if (value == TransferAction.Append) {
|
||||
text = I18n.getString("termora.transport.sftp.already-exists.append")
|
||||
}
|
||||
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
|
||||
}
|
||||
}
|
||||
val applyAllCheckbox = JCheckBox(I18n.getString("termora.transport.sftp.already-exists.apply-all"))
|
||||
val box = Box.createHorizontalBox()
|
||||
box.add(actionsComBoBox)
|
||||
box.add(Box.createHorizontalStrut(8))
|
||||
box.add(applyAllCheckbox)
|
||||
box.add(Box.createHorizontalGlue())
|
||||
|
||||
val ttBox = Box.createVerticalBox()
|
||||
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message1")))
|
||||
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message2")))
|
||||
|
||||
val warningIcon = ScaleIcon(Icons.warningIntroduction, iconSize)
|
||||
|
||||
var rows = 1
|
||||
val step = 2
|
||||
val panel = FormBuilder.create().layout(layout)
|
||||
// tip
|
||||
.add(JLabel(warningIcon)).xy(1, rows)
|
||||
.add(ttBox).xyw(3, rows, 3).apply { rows += step }
|
||||
// name
|
||||
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows)
|
||||
.add(source.name).xyw(3, rows, 3).apply { rows += step }
|
||||
// separator
|
||||
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
|
||||
// Destination
|
||||
.add("${I18n.getString("termora.transport.sftp.already-exists.destination")}:").xy(1, rows)
|
||||
.apply { rows += step }
|
||||
// Folder
|
||||
.add(JLabel(targetIcon)).xy(1, rows, "center, fill")
|
||||
.add(targetModified).xyw(3, rows, 3).apply { rows += step }
|
||||
// Source
|
||||
.add("${I18n.getString("termora.transport.sftp.already-exists.source")}:").xy(1, rows)
|
||||
.apply { rows += step }
|
||||
// Folder
|
||||
.add(JLabel(sourceIcon)).xy(1, rows, "center, fill")
|
||||
.add(sourceModified).xyw(3, rows, 3).apply { rows += step }
|
||||
// separator
|
||||
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
|
||||
// name
|
||||
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.actions")}:")).xy(1, rows)
|
||||
.add(box).xyw(3, rows, 3).apply { rows += step }
|
||||
.build()
|
||||
panel.putClientProperty("SKIP_requestFocusInWindow", true)
|
||||
|
||||
return AskTransfer(
|
||||
option = OptionPane.showConfirmDialog(
|
||||
owner, panel,
|
||||
messageType = JOptionPane.PLAIN_MESSAGE,
|
||||
optionType = JOptionPane.OK_CANCEL_OPTION,
|
||||
title = source.name,
|
||||
initialValue = JOptionPane.OK_OPTION,
|
||||
) {
|
||||
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height)
|
||||
it.setLocationRelativeTo(it.owner)
|
||||
},
|
||||
action = actionsComBoBox.selectedItem as TransferAction,
|
||||
applyAll = applyAllCheckbox.isSelected
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
private fun doAddTransfer(
|
||||
workdir: Path,
|
||||
pair: Pair<Path, TransportTableModel.Attributes>,
|
||||
mode: TransferMode,
|
||||
action: TransferAction,
|
||||
future: CompletableFuture<Unit>
|
||||
): FileVisitResult {
|
||||
|
||||
val isDirectory = pair.second.isDirectory
|
||||
val path = pair.first
|
||||
if (isDirectory.not()) {
|
||||
val transfer = createTransfer(path, workdir.resolve(path.name), false, StringUtils.EMPTY, mode, action)
|
||||
return if (transferManager.addTransfer(transfer)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
val continued = AtomicBoolean(true)
|
||||
val queue = ArrayDeque<Transfer>()
|
||||
val isCancelled =
|
||||
{ (future.isCancelled || future.isCompletedExceptionally).apply { continued.set(this.not()) } }
|
||||
val basedir = if (isDirectory) workdir.resolve(path.name) else workdir
|
||||
val visitor = object : FileVisitor<Path> {
|
||||
override fun preVisitDirectory(
|
||||
dir: Path,
|
||||
attrs: BasicFileAttributes
|
||||
): FileVisitResult {
|
||||
val parentId = queue.lastOrNull()?.id() ?: StringUtils.EMPTY
|
||||
// @formatter:off
|
||||
val transfer = when (mode) {
|
||||
TransferMode.Delete -> createTransfer(dir, dir, true, parentId, mode, action)
|
||||
TransferMode.ChangePermission -> createTransfer(path, dir, true, parentId, mode, action, pair.second.permissions)
|
||||
else -> createTransfer(dir, basedir.resolve(path.relativize(dir).pathString), true, parentId, mode, action)
|
||||
}
|
||||
// @formatter:on
|
||||
|
||||
queue.addLast(transfer)
|
||||
|
||||
if (transferManager.addTransfer(transfer).not()) {
|
||||
continued.set(false)
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun visitFile(
|
||||
file: Path,
|
||||
attrs: BasicFileAttributes
|
||||
): FileVisitResult {
|
||||
|
||||
val parentId = queue.last().id()
|
||||
// @formatter:off
|
||||
val transfer = when (mode) {
|
||||
TransferMode.Delete -> createTransfer(file, file, false, parentId, mode, action)
|
||||
TransferMode.ChangePermission -> createTransfer(file, file, false, parentId, mode, action, pair.second.permissions)
|
||||
else -> createTransfer(file, basedir.resolve(path.relativize(file).pathString), false, parentId, mode, action)
|
||||
}
|
||||
|
||||
if (transferManager.addTransfer(transfer).not()) {
|
||||
continued.set(false)
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
override fun visitFileFailed(
|
||||
file: Path?,
|
||||
exc: IOException
|
||||
): FileVisitResult {
|
||||
if (log.isErrorEnabled) log.error(exc.message, exc)
|
||||
future.completeExceptionally(exc)
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
override fun postVisitDirectory(
|
||||
dir: Path?,
|
||||
exc: IOException?
|
||||
): FileVisitResult {
|
||||
val c = queue.removeLast()
|
||||
if (c is TransferScanner) c.scanned()
|
||||
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PathWalker.walkFileTree(path, visitor)
|
||||
|
||||
// 已经添加的则继续传输
|
||||
while (queue.isNotEmpty()) {
|
||||
val c = queue.removeLast()
|
||||
if (c is TransferScanner) c.scanned()
|
||||
}
|
||||
|
||||
return if (continued.get()) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
|
||||
private fun createTransfer(
|
||||
source: Path,
|
||||
target: Path,
|
||||
isDirectory: Boolean,
|
||||
parentId: String,
|
||||
mode: TransferMode,
|
||||
action:TransferAction,
|
||||
permissions: Set<PosixFilePermission>? = null
|
||||
): Transfer {
|
||||
if (mode == TransferMode.Delete) {
|
||||
return DeleteTransfer(
|
||||
parentId,
|
||||
source,
|
||||
isDirectory,
|
||||
if (isDirectory) 1 else Files.size(source)
|
||||
)
|
||||
} else if (mode == TransferMode.ChangePermission) {
|
||||
if (permissions == null) throw IllegalStateException()
|
||||
return ChangePermissionTransfer(
|
||||
parentId,
|
||||
target,
|
||||
isDirectory = isDirectory,
|
||||
permissions = permissions,
|
||||
size = if (isDirectory) 1 else Files.size(target)
|
||||
)
|
||||
}
|
||||
|
||||
if (isDirectory) {
|
||||
return DirectoryTransfer(
|
||||
parentId = parentId,
|
||||
source = source,
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
|
||||
return FileTransfer(
|
||||
parentId = parentId,
|
||||
source = source,
|
||||
target = target,
|
||||
action = action,
|
||||
size = Files.size(source)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.sftp.internal.local
|
||||
package app.termora.transfer.internal.local
|
||||
|
||||
import app.termora.plugin.Extension
|
||||
import app.termora.plugin.InternalPlugin
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.sftp.internal.local
|
||||
package app.termora.transfer.internal.local
|
||||
|
||||
import app.termora.protocol.ProtocolProvider
|
||||
import app.termora.protocol.ProtocolProviderExtension
|
||||
@@ -1,41 +1,34 @@
|
||||
package app.termora.sftp.internal.local
|
||||
package app.termora.transfer.internal.local
|
||||
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.protocol.FileObjectHandler
|
||||
import app.termora.protocol.FileObjectRequest
|
||||
import app.termora.protocol.PathHandler
|
||||
import app.termora.protocol.PathHandlerRequest
|
||||
import app.termora.protocol.TransferProtocolProvider
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.lang3.SystemUtils
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.provider.FileProvider
|
||||
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
|
||||
import java.nio.file.FileSystems
|
||||
|
||||
internal class LocalTransferProtocolProvider : TransferProtocolProvider {
|
||||
companion object {
|
||||
val instance by lazy { LocalTransferProtocolProvider() }
|
||||
private val localFileProvider by lazy { DefaultLocalFileProvider() }
|
||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||
const val PROTOCOL = "file"
|
||||
}
|
||||
|
||||
override fun getFileProvider(): FileProvider {
|
||||
return localFileProvider
|
||||
}
|
||||
|
||||
override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler {
|
||||
var defaultDirectory = sftp.defaultDirectory
|
||||
if (StringUtils.isBlank(defaultDirectory)) {
|
||||
defaultDirectory = SystemUtils.USER_HOME
|
||||
}
|
||||
val file = VFS.getManager().resolveFile("file://${defaultDirectory}")
|
||||
return FileObjectHandler(file)
|
||||
}
|
||||
|
||||
override fun isTransient(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getProtocol(): String {
|
||||
return "file"
|
||||
return PROTOCOL
|
||||
}
|
||||
|
||||
override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
|
||||
var defaultDirectory = sftp.defaultDirectory
|
||||
if (StringUtils.isBlank(defaultDirectory)) {
|
||||
defaultDirectory = SystemUtils.USER_HOME
|
||||
}
|
||||
val fileSystem = FileSystems.getDefault()
|
||||
return PathHandler(fileSystem, fileSystem.getPath(defaultDirectory))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app.termora.transfer.internal.sftp
|
||||
|
||||
import app.termora.FrameExtension
|
||||
import app.termora.TermoraFrame
|
||||
import app.termora.actions.DataProviders
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.transfer.TransportTerminalTab
|
||||
|
||||
class SFTPFrameExtension private constructor() : FrameExtension {
|
||||
companion object {
|
||||
val instance = SFTPFrameExtension()
|
||||
}
|
||||
|
||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||
|
||||
override fun customize(frame: TermoraFrame) {
|
||||
val terminalTabbed = frame.getData(DataProviders.TerminalTabbed) ?: return
|
||||
if (sftp.pinTab) {
|
||||
terminalTabbed.addTerminalTab(TransportTerminalTab(), false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package app.termora.transfer.internal.sftp
|
||||
|
||||
import app.termora.Disposer
|
||||
import app.termora.protocol.PathHandler
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.common.future.CloseFuture
|
||||
import org.apache.sshd.common.future.SshFutureListener
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.Path
|
||||
|
||||
internal class SFTPPathHandler(
|
||||
fileSystem: FileSystem,
|
||||
path: Path,
|
||||
val client: SshClient,
|
||||
val session: ClientSession,
|
||||
) : PathHandler(fileSystem, path) {
|
||||
|
||||
private val listener = SshFutureListener<CloseFuture> { Disposer.dispose(this) }
|
||||
|
||||
init {
|
||||
session.addCloseFutureListener(listener)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
session.removeCloseFutureListener(listener)
|
||||
IOUtils.closeQuietly(fileSystem)
|
||||
IOUtils.closeQuietly(session)
|
||||
IOUtils.closeQuietly(client)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.termora.sftp.internal.sftp
|
||||
package app.termora.transfer.internal.sftp
|
||||
|
||||
import app.termora.FrameExtension
|
||||
import app.termora.plugin.Extension
|
||||
import app.termora.plugin.InternalPlugin
|
||||
import app.termora.protocol.ProtocolProviderExtension
|
||||
@@ -7,10 +8,11 @@ import app.termora.protocol.ProtocolProviderExtension
|
||||
internal class SFTPPlugin : InternalPlugin() {
|
||||
init {
|
||||
support.addExtension(ProtocolProviderExtension::class.java) { SFTPProtocolProviderExtension.instance }
|
||||
support.addExtension(FrameExtension::class.java) { SFTPFrameExtension.instance }
|
||||
}
|
||||
|
||||
override fun getName(): String {
|
||||
return "Transfer"
|
||||
return "Local Transfer"
|
||||
}
|
||||
|
||||
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.termora.sftp.internal.sftp
|
||||
package app.termora.transfer.internal.sftp
|
||||
|
||||
import app.termora.protocol.ProtocolProvider
|
||||
import app.termora.protocol.ProtocolProviderExtension
|
||||
@@ -1,32 +1,32 @@
|
||||
package app.termora.sftp.internal.sftp
|
||||
package app.termora.transfer.internal.sftp
|
||||
|
||||
import app.termora.SshClients
|
||||
import app.termora.protocol.FileObjectRequest
|
||||
import app.termora.database.DatabaseManager
|
||||
import app.termora.protocol.PathHandler
|
||||
import app.termora.protocol.PathHandlerRequest
|
||||
import app.termora.protocol.TransferProtocolProvider
|
||||
import app.termora.vfs2.sftp.MySftpFileProvider
|
||||
import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.vfs2.FileSystemOptions
|
||||
import org.apache.commons.vfs2.VFS
|
||||
import org.apache.commons.vfs2.provider.FileProvider
|
||||
import org.apache.sshd.client.SshClient
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.sftp.client.SftpClientFactory
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
internal class SFTPTransferProtocolProvider : TransferProtocolProvider {
|
||||
companion object {
|
||||
val instance by lazy { SFTPTransferProtocolProvider() }
|
||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||
const val PROTOCOL = "sftp"
|
||||
|
||||
}
|
||||
|
||||
override fun getFileProvider(): FileProvider {
|
||||
return MySftpFileProvider.instance
|
||||
override fun isTransient(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getRootFileObject(requester: FileObjectRequest): SFTPFileObjectHandler {
|
||||
override fun getProtocol(): String {
|
||||
return PROTOCOL
|
||||
}
|
||||
|
||||
override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
|
||||
var client: SshClient? = null
|
||||
var session: ClientSession? = null
|
||||
try {
|
||||
@@ -37,24 +37,16 @@ internal class SFTPTransferProtocolProvider : TransferProtocolProvider {
|
||||
val fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
|
||||
|
||||
val host = requester.host
|
||||
var defaultDirectory = host.options.sftpDefaultDirectory
|
||||
if (StringUtils.isBlank(defaultDirectory)) {
|
||||
defaultDirectory = fileSystem.defaultDir.absolutePathString()
|
||||
var path = fileSystem.defaultDir
|
||||
val defaultDirectory = host.options.sftpDefaultDirectory
|
||||
if (StringUtils.isNotBlank(defaultDirectory)) {
|
||||
path = fileSystem.getPath(defaultDirectory)
|
||||
}
|
||||
|
||||
val options = FileSystemOptions()
|
||||
MySftpFileSystemConfigBuilder.getInstance().setSftpFileSystem(options, fileSystem)
|
||||
val file = VFS.getManager().resolveFile("sftp://${defaultDirectory}", options)
|
||||
return SFTPFileObjectHandler(file, client, session, fileSystem)
|
||||
return SFTPPathHandler(fileSystem, path, client, session)
|
||||
} catch (e: Exception) {
|
||||
IOUtils.closeQuietly(session)
|
||||
IOUtils.closeQuietly(client)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
override fun getProtocol(): String {
|
||||
return "sftp"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import app.termora.database.DatabaseManager
|
||||
import app.termora.plugin.ExtensionManager
|
||||
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
||||
import app.termora.sftp.SFTPActionEvent
|
||||
import app.termora.tag.TagDialog
|
||||
import app.termora.tag.TagManager
|
||||
import app.termora.tag.TagSimpleTreeCellRendererExtension
|
||||
@@ -477,7 +476,7 @@ class NewHostTree : SimpleTree(), Disposable {
|
||||
if (nodes.isEmpty()) return
|
||||
|
||||
for (node in nodes) {
|
||||
sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
|
||||
// sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package app.termora.vfs2
|
||||
|
||||
import app.termora.DynamicIcon
|
||||
|
||||
/**
|
||||
* 文件描述
|
||||
*/
|
||||
interface FileObjectDescriptor {
|
||||
|
||||
/**
|
||||
* 图标
|
||||
*/
|
||||
fun getIcon(width: Int, height: Int): DynamicIcon? = null
|
||||
|
||||
/**
|
||||
* 获取类型描述
|
||||
*/
|
||||
fun getTypeDescription(): String? = null
|
||||
|
||||
/**
|
||||
* 最后修改时间,时间戳
|
||||
*/
|
||||
fun getLastModified(): Long? = null
|
||||
|
||||
/**
|
||||
* 获取所有者
|
||||
*/
|
||||
fun getOwner(): String? = null
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package app.termora.vfs2
|
||||
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.FileVisitor
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.nio.file.attribute.FileTime
|
||||
|
||||
object VFSWalker {
|
||||
fun walk(
|
||||
dir: FileObject,
|
||||
visitor: FileVisitor<FileObject>,
|
||||
): FileVisitResult {
|
||||
|
||||
// clear cache
|
||||
if (visitor.preVisitDirectory(dir, EmptyBasicFileAttributes.INSTANCE) == FileVisitResult.TERMINATE) {
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
for (e in dir.children) {
|
||||
if (e.name.baseName == ".." || e.name.baseName == ".") continue
|
||||
if (e.isFolder) {
|
||||
if (walk(dir.resolveFile(e.name.baseName), visitor) == FileVisitResult.TERMINATE) {
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
} else {
|
||||
val result = visitor.visitFile(
|
||||
dir.resolveFile(e.name.baseName),
|
||||
EmptyBasicFileAttributes.INSTANCE
|
||||
)
|
||||
if (result == FileVisitResult.TERMINATE) {
|
||||
return FileVisitResult.TERMINATE
|
||||
} else if (result == FileVisitResult.SKIP_SUBTREE) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) {
|
||||
return FileVisitResult.TERMINATE
|
||||
}
|
||||
|
||||
return FileVisitResult.CONTINUE
|
||||
}
|
||||
|
||||
private class EmptyBasicFileAttributes : BasicFileAttributes {
|
||||
companion object {
|
||||
val INSTANCE = EmptyBasicFileAttributes()
|
||||
}
|
||||
|
||||
override fun lastModifiedTime(): FileTime {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun lastAccessTime(): FileTime {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun creationTime(): FileTime {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun isRegularFile(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun isDirectory(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun isSymbolicLink(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun isOther(): Boolean {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun size(): Long {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun fileKey(): Any {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package app.termora.vfs2.s3
|
||||
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.apache.commons.vfs2.FileSystemConfigBuilder
|
||||
import org.apache.commons.vfs2.FileSystemOptions
|
||||
|
||||
abstract class AbstractS3FileSystemConfigBuilder : FileSystemConfigBuilder() {
|
||||
fun getEndpoint(options: FileSystemOptions): String {
|
||||
return getParam(options, "endpoint")
|
||||
}
|
||||
|
||||
fun setEndpoint(options: FileSystemOptions, endpoint: String) {
|
||||
setParam(options, "endpoint", endpoint)
|
||||
}
|
||||
|
||||
fun setAccessKey(options: FileSystemOptions, accessId: String) {
|
||||
setParam(options, "accessId", accessId)
|
||||
}
|
||||
|
||||
fun getAccessKey(options: FileSystemOptions): String {
|
||||
return getParam(options, "accessId")
|
||||
}
|
||||
|
||||
fun setSecretKey(options: FileSystemOptions, secretKey: String) {
|
||||
setParam(options, "secretKey", secretKey)
|
||||
}
|
||||
|
||||
fun getSecretKey(options: FileSystemOptions): String {
|
||||
return getParam(options, "secretKey")
|
||||
}
|
||||
|
||||
fun setRegion(options: FileSystemOptions, region: String) {
|
||||
setParam(options, "region", region)
|
||||
}
|
||||
|
||||
fun getRegion(options: FileSystemOptions): String {
|
||||
return getParam(options, "region")
|
||||
}
|
||||
|
||||
fun setDelimiter(options: FileSystemOptions, delimiter: String) {
|
||||
setParam(options, "delimiter", delimiter)
|
||||
}
|
||||
|
||||
fun getDelimiter(options: FileSystemOptions): String {
|
||||
return StringUtils.defaultIfBlank(getParam(options, "delimiter"), "/")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
package app.termora.vfs2.sftp
|
||||
|
||||
import app.termora.sftp.FileSystemViewTableModel
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.FileSystemException
|
||||
import org.apache.commons.vfs2.FileType
|
||||
import org.apache.commons.vfs2.provider.AbstractFileName
|
||||
import org.apache.commons.vfs2.provider.AbstractFileObject
|
||||
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.apache.sshd.sftp.client.fs.WithFileAttributes
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.nio.file.attribute.FileTime
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.io.path.*
|
||||
|
||||
internal class MySftpFileObject(
|
||||
private val sftpFileSystem: SftpFileSystem,
|
||||
fileName: AbstractFileName,
|
||||
fileSystem: MySftpFileSystem
|
||||
) : AbstractFileObject<MySftpFileSystem>(fileName, fileSystem) {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(MySftpFileObject::class.java)
|
||||
|
||||
const val POSIX_FILE_PERMISSIONS = "PosixFilePermissions"
|
||||
}
|
||||
|
||||
private var _attributes: SftpClient.Attributes? = null
|
||||
private val isInitialized = AtomicBoolean(false)
|
||||
private val path by lazy { sftpFileSystem.getPath(fileName.path) }
|
||||
private val attributes = mutableMapOf<String, Any>()
|
||||
|
||||
override fun doGetContentSize(): Long {
|
||||
val attributes = getAttributes()
|
||||
if (attributes == null || !attributes.flags.contains(SftpClient.Attribute.Size)) {
|
||||
throw FileSystemException("vfs.provider.sftp/unknown-size.error")
|
||||
}
|
||||
return attributes.size
|
||||
}
|
||||
|
||||
override fun doGetType(): FileType {
|
||||
val attributes = getAttributes() ?: return FileType.IMAGINARY
|
||||
return if (attributes.isDirectory)
|
||||
FileType.FOLDER
|
||||
else if (attributes.isRegularFile)
|
||||
FileType.FILE
|
||||
else if (attributes.isSymbolicLink) {
|
||||
val e = path.readSymbolicLink()
|
||||
if (e is SftpPath && e.attributes != null) {
|
||||
if (e.attributes.isDirectory) {
|
||||
FileType.FOLDER
|
||||
} else {
|
||||
FileType.FILE
|
||||
}
|
||||
} else if (e.isDirectory()) {
|
||||
FileType.FOLDER
|
||||
} else {
|
||||
FileType.FILE
|
||||
}
|
||||
} else FileType.IMAGINARY
|
||||
}
|
||||
|
||||
override fun doListChildren(): Array<String>? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun doListChildrenResolved(): Array<FileObject>? {
|
||||
if (isFile) return null
|
||||
|
||||
val children = mutableListOf<FileObject>()
|
||||
|
||||
Files.list(path).use { files ->
|
||||
for (file in files) {
|
||||
val fo = resolveFile(file.name)
|
||||
if (file is WithFileAttributes && fo is MySftpFileObject) {
|
||||
if (fo.isInitialized.compareAndSet(false, true)) {
|
||||
fo.setAttributes(file.attributes)
|
||||
}
|
||||
}
|
||||
children.add(fo)
|
||||
}
|
||||
}
|
||||
|
||||
return children.toTypedArray()
|
||||
}
|
||||
|
||||
override fun doGetOutputStream(bAppend: Boolean): OutputStream {
|
||||
if (bAppend) {
|
||||
return path.outputStream(StandardOpenOption.WRITE, StandardOpenOption.APPEND)
|
||||
}
|
||||
return path.outputStream()
|
||||
}
|
||||
|
||||
override fun doGetInputStream(bufferSize: Int): InputStream {
|
||||
return path.inputStream()
|
||||
}
|
||||
|
||||
override fun doCreateFolder() {
|
||||
Files.createDirectories(path)
|
||||
}
|
||||
|
||||
override fun doIsExecutable(): Boolean {
|
||||
val permissions = getPermissions()
|
||||
return permissions.contains(PosixFilePermission.GROUP_EXECUTE) ||
|
||||
permissions.contains(PosixFilePermission.OWNER_EXECUTE) ||
|
||||
permissions.contains(PosixFilePermission.GROUP_EXECUTE)
|
||||
}
|
||||
|
||||
override fun doIsReadable(): Boolean {
|
||||
val permissions = getPermissions()
|
||||
return permissions.contains(PosixFilePermission.GROUP_READ) ||
|
||||
permissions.contains(PosixFilePermission.OWNER_READ) ||
|
||||
permissions.contains(PosixFilePermission.OTHERS_READ)
|
||||
}
|
||||
|
||||
override fun doIsWriteable(): Boolean {
|
||||
val permissions = getPermissions()
|
||||
return permissions.contains(PosixFilePermission.GROUP_WRITE) ||
|
||||
permissions.contains(PosixFilePermission.OWNER_WRITE) ||
|
||||
permissions.contains(PosixFilePermission.OTHERS_WRITE)
|
||||
}
|
||||
|
||||
override fun doRename(newFile: FileObject) {
|
||||
if (newFile !is MySftpFileObject) {
|
||||
throw FileSystemException("vfs.provider/rename-not-supported.error")
|
||||
}
|
||||
Files.move(path, newFile.path, StandardCopyOption.ATOMIC_MOVE)
|
||||
}
|
||||
|
||||
override fun moveTo(destFile: FileObject) {
|
||||
if (canRenameTo(destFile)) {
|
||||
doRename(destFile)
|
||||
} else {
|
||||
throw FileSystemException("vfs.provider/rename-not-supported.error")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doDelete() {
|
||||
sftpFileSystem.client.use { deleteRecursivelySFTP(path, 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())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun doSetExecutable(executable: Boolean, ownerOnly: Boolean): Boolean {
|
||||
val permissions = getPermissions().toMutableSet()
|
||||
permissions.add(PosixFilePermission.OWNER_EXECUTE)
|
||||
if (ownerOnly) {
|
||||
permissions.remove(PosixFilePermission.OTHERS_EXECUTE)
|
||||
permissions.remove(PosixFilePermission.GROUP_EXECUTE)
|
||||
}
|
||||
Files.setPosixFilePermissions(path, permissions)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun doSetReadable(readable: Boolean, ownerOnly: Boolean): Boolean {
|
||||
val permissions = getPermissions().toMutableSet()
|
||||
permissions.add(PosixFilePermission.OWNER_READ)
|
||||
if (ownerOnly) {
|
||||
permissions.remove(PosixFilePermission.OTHERS_READ)
|
||||
permissions.remove(PosixFilePermission.GROUP_EXECUTE)
|
||||
}
|
||||
Files.setPosixFilePermissions(path, permissions)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun doSetWritable(writable: Boolean, ownerOnly: Boolean): Boolean {
|
||||
val permissions = getPermissions().toMutableSet()
|
||||
permissions.add(PosixFilePermission.OWNER_WRITE)
|
||||
if (ownerOnly) {
|
||||
permissions.remove(PosixFilePermission.OTHERS_WRITE)
|
||||
permissions.remove(PosixFilePermission.GROUP_WRITE)
|
||||
}
|
||||
Files.setPosixFilePermissions(path, permissions)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun doSetLastModifiedTime(modtime: Long): Boolean {
|
||||
Files.setLastModifiedTime(path, FileTime.fromMillis(modtime))
|
||||
return true
|
||||
}
|
||||
|
||||
override fun doDetach() {
|
||||
setAttributes(null)
|
||||
isInitialized.compareAndSet(true, false)
|
||||
}
|
||||
|
||||
override fun doIsHidden(): Boolean {
|
||||
return name.baseName.startsWith(".")
|
||||
}
|
||||
|
||||
override fun doGetAttributes(): MutableMap<String, Any> {
|
||||
return attributes
|
||||
}
|
||||
|
||||
override fun doGetLastModifiedTime(): Long {
|
||||
val attributes = getAttributes()
|
||||
if (attributes == null || !attributes.flags.contains(SftpClient.Attribute.ModifyTime)) {
|
||||
throw FileSystemException("vfs.provider.sftp/unknown-modtime.error")
|
||||
}
|
||||
return attributes.modifyTime.toMillis()
|
||||
}
|
||||
|
||||
override fun doSetAttribute(attrName: String, value: Any) {
|
||||
attributes[attrName] = value
|
||||
}
|
||||
|
||||
override fun doIsSymbolicLink(): Boolean {
|
||||
return getAttributes()?.isSymbolicLink == true
|
||||
}
|
||||
|
||||
fun setPosixFilePermissions(permissions: Set<PosixFilePermission>) {
|
||||
path.setPosixFilePermissions(permissions)
|
||||
}
|
||||
|
||||
private fun getAttributes(): SftpClient.Attributes? {
|
||||
if (isInitialized.compareAndSet(false, true)) {
|
||||
try {
|
||||
val attributes = sftpFileSystem.provider()
|
||||
.readRemoteAttributes(sftpFileSystem.provider().toSftpPath(path))
|
||||
setAttributes(attributes)
|
||||
} catch (e: Exception) {
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug(e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return _attributes
|
||||
}
|
||||
|
||||
private fun setAttributes(attributes: SftpClient.Attributes?) {
|
||||
if (attributes == null) {
|
||||
doGetAttributes().remove(POSIX_FILE_PERMISSIONS)
|
||||
} else {
|
||||
doSetAttribute(POSIX_FILE_PERMISSIONS, attributes.permissions)
|
||||
}
|
||||
this._attributes = attributes
|
||||
}
|
||||
|
||||
private fun getPermissions(): Set<PosixFilePermission> {
|
||||
return FileSystemViewTableModel.fromSftpPermissions(getAttributes()?.permissions ?: return setOf())
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package app.termora.vfs2.sftp
|
||||
|
||||
import org.apache.commons.vfs2.Capability
|
||||
import org.apache.commons.vfs2.FileName
|
||||
import org.apache.commons.vfs2.FileSystem
|
||||
import org.apache.commons.vfs2.FileSystemOptions
|
||||
import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider
|
||||
|
||||
internal class MySftpFileProvider private constructor() : AbstractOriginatingFileProvider() {
|
||||
|
||||
companion object {
|
||||
val instance by lazy { MySftpFileProvider() }
|
||||
val capabilities = listOf(
|
||||
Capability.CREATE,
|
||||
Capability.DELETE,
|
||||
Capability.RENAME,
|
||||
Capability.GET_TYPE,
|
||||
Capability.LIST_CHILDREN,
|
||||
Capability.READ_CONTENT,
|
||||
Capability.URI,
|
||||
Capability.WRITE_CONTENT,
|
||||
Capability.GET_LAST_MODIFIED,
|
||||
Capability.SET_LAST_MODIFIED_FILE,
|
||||
Capability.RANDOM_ACCESS_READ,
|
||||
Capability.APPEND_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
override fun getCapabilities(): Collection<Capability> {
|
||||
return MySftpFileProvider.capabilities
|
||||
}
|
||||
|
||||
override fun doCreateFileSystem(rootFileName: FileName, fileSystemOptions: FileSystemOptions): FileSystem {
|
||||
val sftpFileSystem = MySftpFileSystemConfigBuilder.getInstance()
|
||||
.getSftpFileSystem(fileSystemOptions)
|
||||
if (sftpFileSystem == null) {
|
||||
throw IllegalArgumentException("client session not found")
|
||||
}
|
||||
return MySftpFileSystem(
|
||||
sftpFileSystem,
|
||||
rootFileName,
|
||||
fileSystemOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package app.termora.vfs2.sftp
|
||||
|
||||
import org.apache.commons.vfs2.Capability
|
||||
import org.apache.commons.vfs2.FileName
|
||||
import org.apache.commons.vfs2.FileObject
|
||||
import org.apache.commons.vfs2.FileSystemOptions
|
||||
import org.apache.commons.vfs2.provider.AbstractFileName
|
||||
import org.apache.commons.vfs2.provider.AbstractFileSystem
|
||||
import org.apache.sshd.client.session.ClientSession
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
import kotlin.io.path.absolutePathString
|
||||
|
||||
internal class MySftpFileSystem(
|
||||
private val sftpFileSystem: SftpFileSystem,
|
||||
rootName: FileName,
|
||||
fileSystemOptions: FileSystemOptions
|
||||
) : AbstractFileSystem(rootName, null, fileSystemOptions) {
|
||||
|
||||
override fun addCapabilities(caps: MutableCollection<Capability>) {
|
||||
caps.addAll(MySftpFileProvider.capabilities)
|
||||
}
|
||||
|
||||
override fun createFile(name: AbstractFileName): FileObject {
|
||||
return MySftpFileObject(sftpFileSystem, name, this)
|
||||
}
|
||||
|
||||
fun getDefaultDir(): String {
|
||||
return sftpFileSystem.defaultDir.absolutePathString()
|
||||
}
|
||||
|
||||
fun getClientSession(): ClientSession {
|
||||
return sftpFileSystem.session
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package app.termora.vfs2.sftp
|
||||
|
||||
import org.apache.commons.vfs2.FileSystem
|
||||
import org.apache.commons.vfs2.FileSystemConfigBuilder
|
||||
import org.apache.commons.vfs2.FileSystemOptions
|
||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||
|
||||
internal class MySftpFileSystemConfigBuilder : FileSystemConfigBuilder() {
|
||||
|
||||
companion object {
|
||||
private val INSTANCE by lazy { MySftpFileSystemConfigBuilder() }
|
||||
fun getInstance(): MySftpFileSystemConfigBuilder {
|
||||
return INSTANCE
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConfigClass(): Class<out FileSystem> {
|
||||
return MySftpFileSystem::class.java
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun setSftpFileSystem(options: FileSystemOptions, sftpFileSystem: SftpFileSystem) {
|
||||
setParam(options, "sftpFileSystem", sftpFileSystem)
|
||||
}
|
||||
|
||||
fun getSftpFileSystem(options: FileSystemOptions): SftpFileSystem? {
|
||||
return getParam(options, "sftpFileSystem")
|
||||
}
|
||||
}
|
||||
@@ -312,7 +312,7 @@ termora.transport.table.contextmenu.transfer=Transfer
|
||||
termora.transport.table.contextmenu.edit=${termora.keymgr.edit}
|
||||
termora.transport.table.contextmenu.edit-command=You must configure the "Edit Command" in "Settings - SFTP" before you can edit the file
|
||||
termora.transport.table.contextmenu.copy-path=Copy Path
|
||||
termora.transport.table.contextmenu.open-in-folder=Open in {0}
|
||||
termora.transport.table.contextmenu.open-in-folder=Open in ${termora.finder}
|
||||
termora.transport.table.contextmenu.rename=${termora.welcome.contextmenu.rename}
|
||||
termora.transport.table.contextmenu.delete=${termora.remove}
|
||||
termora.transport.table.contextmenu.delete-warning=If the folder is too large, deleting it may take some time
|
||||
@@ -343,6 +343,7 @@ termora.transport.sftp.closed=The connection has been closed
|
||||
termora.transport.sftp.close-tab=Transfer is still in activated status. Are you sure you want to remove all jobs and close this session?
|
||||
termora.transport.sftp.close-tab-has-active-session=Session is still active. Do you want to close all sessions?
|
||||
termora.transport.sftp.status.transporting=In progress
|
||||
termora.transport.sftp.status.deleting=Deleting
|
||||
termora.transport.sftp.status.waiting=Waiting
|
||||
termora.transport.sftp.status.done=Done
|
||||
termora.transport.sftp.status.failed=Failed
|
||||
|
||||
@@ -304,7 +304,7 @@ termora.transport.table.owner=所有者
|
||||
termora.transport.table.contextmenu.transfer=传输
|
||||
termora.transport.table.contextmenu.copy-path=复制路径
|
||||
termora.transport.table.contextmenu.edit-command=你必须在 “设置 - SFTP” 中配置 “编辑命令” 后才能编辑文件
|
||||
termora.transport.table.contextmenu.open-in-folder=在{0}中打开
|
||||
termora.transport.table.contextmenu.open-in-folder=在${termora.finder}中打开
|
||||
termora.transport.table.contextmenu.change-permissions=更改权限...
|
||||
termora.transport.table.contextmenu.refresh=刷新
|
||||
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
|
||||
@@ -321,6 +321,7 @@ termora.transport.sftp.close-tab=传输还处于活动状态,是否删除所
|
||||
termora.transport.sftp.close-tab-has-active-session=会话还处于活动状态,是否关闭所有会话?
|
||||
|
||||
termora.transport.sftp.status.transporting=传输中
|
||||
termora.transport.sftp.status.deleting=删除中
|
||||
termora.transport.sftp.status.waiting=等待中
|
||||
termora.transport.sftp.status.done=已完成
|
||||
termora.transport.sftp.status.failed=已失败
|
||||
|
||||
@@ -302,7 +302,7 @@ termora.transport.table.owner=所有者
|
||||
termora.transport.table.contextmenu.transfer=傳輸
|
||||
termora.transport.table.contextmenu.copy-path=複製路徑
|
||||
termora.transport.table.contextmenu.edit-command=你必須在 “設定 - SFTP” 中設定 “編輯指令” 後才能編輯文件
|
||||
termora.transport.table.contextmenu.open-in-folder=在{0}中打開
|
||||
termora.transport.table.contextmenu.open-in-folder=在${termora.finder}中打開
|
||||
termora.transport.table.contextmenu.change-permissions=更改權限...
|
||||
termora.transport.table.contextmenu.refresh=刷新
|
||||
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
|
||||
@@ -318,6 +318,7 @@ termora.transport.sftp.closed=連線已經關閉
|
||||
termora.transport.sftp.close-tab=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話?
|
||||
termora.transport.sftp.close-tab-has-active-session=會話仍處於活動狀態,是否關閉所有會話?
|
||||
termora.transport.sftp.status.transporting=傳輸中
|
||||
termora.transport.sftp.status.deleting=刪除中
|
||||
termora.transport.sftp.status.waiting=等待中
|
||||
termora.transport.sftp.status.done=已完成
|
||||
termora.transport.sftp.status.failed=已失敗
|
||||
|
||||
4
src/main/resources/icons/cwmPermissions.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.96905 8C8.723 9.97316 7.03981 11.5 5 11.5C2.79086 11.5 1 9.70914 1 7.5C1 5.29086 2.79086 3.5 5 3.5C7.03981 3.5 8.723 5.02684 8.96905 7L14.5 7C14.7761 7 15 7.22386 15 7.5C15 7.77614 14.7761 8 14.5 8L14 8L14 10.5C14 10.7761 13.7761 11 13.5 11C13.2239 11 13 10.7761 13 10.5L13 8L12 8L12 10.5C12 10.7761 11.7761 11 11.5 11C11.2239 11 11 10.7761 11 10.5L11 8L8.96905 8ZM5 10.5C6.65685 10.5 8 9.15685 8 7.5C8 5.84315 6.65685 4.5 5 4.5C3.34315 4.5 2 5.84315 2 7.5C2 9.15685 3.34315 10.5 5 10.5Z" fill="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
4
src/main/resources/icons/cwmPermissions_dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.96905 8C8.723 9.97316 7.03981 11.5 5 11.5C2.79086 11.5 1 9.70914 1 7.5C1 5.29086 2.79086 3.5 5 3.5C7.03981 3.5 8.723 5.02684 8.96905 7L14.5 7C14.7761 7 15 7.22386 15 7.5C15 7.77614 14.7761 8 14.5 8L14 8L14 10.5C14 10.7761 13.7761 11 13.5 11C13.2239 11 13 10.7761 13 10.5L13 8L12 8L12 10.5C12 10.7761 11.7761 11 11.5 11C11.2239 11 11 10.7761 11 10.5L11 8L8.96905 8ZM5 10.5C6.65685 10.5 8 9.15685 8 7.5C8 5.84315 6.65685 4.5 5 4.5C3.34315 4.5 2 5.84315 2 7.5C2 9.15685 3.34315 10.5 5 10.5Z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 788 B |
5
src/main/resources/icons/desktop.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 13.5C2 13.2239 2.22386 13 2.5 13L13.5 13C13.7761 13 14 13.2239 14 13.5C14 13.7761 13.7761 14 13.5 14L2.5 14C2.22386 14 2 13.7761 2 13.5Z" fill="#6C707E"/>
|
||||
<path d="M13.5 4L13.5 10C13.5 10.8284 12.8284 11.5 12 11.5L4 11.5C3.17157 11.5 2.5 10.8284 2.5 10L2.5 4C2.5 3.17157 3.17157 2.5 4 2.5L12 2.5C12.8284 2.5 13.5 3.17157 13.5 4Z" stroke="#6C707E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 592 B |
5
src/main/resources/icons/desktop_dark.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 13.5C2 13.2239 2.22386 13 2.5 13L13.5 13C13.7761 13 14 13.2239 14 13.5C14 13.7761 13.7761 14 13.5 14L2.5 14C2.22386 14 2 13.7761 2 13.5Z" fill="#CED0D6"/>
|
||||
<path d="M13.5 4L13.5 10C13.5 10.8284 12.8284 11.5 12 11.5L4 11.5C3.17157 11.5 2.5 10.8284 2.5 10L2.5 4C2.5 3.17157 3.17157 2.5 4 2.5L12 2.5C12.8284 2.5 13.5 3.17157 13.5 4Z" stroke="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 592 B |
3
src/main/resources/icons/desktop_mac.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" viewBox="0 0 24 24" width="16">
|
||||
<path d="M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7l-1.63 2.45c-.44.66.03 1.55.83 1.55h5.6c.8 0 1.28-.89.83-1.55L14 18h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 12H3V5c0-.55.45-1 1-1h16c.55 0 1 .45 1 1v9z" fill="#6E6E6E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
3
src/main/resources/icons/desktop_mac_dark.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" viewBox="0 0 24 24" width="16">
|
||||
<path d="M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7l-1.63 2.45c-.44.66.03 1.55.83 1.55h5.6c.8 0 1.28-.89.83-1.55L14 18h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 12H3V5c0-.55.45-1 1-1h16c.55 0 1 .45 1 1v9z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
3
src/main/resources/icons/desktop_windows.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" viewBox="0 0 24 24" width="16">
|
||||
<path d="M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H9c-.55 0-1 .45-1 1s.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1h-1v-2h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 14H4c-.55 0-1-.45-1-1V5c0-.55.45-1 1-1h16c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1z" fill="#6E6E6E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
3
src/main/resources/icons/desktop_windows_dark.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="16" viewBox="0 0 24 24" width="16">
|
||||
<path d="M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H9c-.55 0-1 .45-1 1s.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1h-1v-2h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 14H4c-.55 0-1-.45-1-1V5c0-.55.45-1 1-1h16c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1z" fill="#CED0D6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 347 B |
8
src/main/resources/icons/moreHorizontal.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<!-- Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g fill="#6E6E6E" fill-rule="evenodd" transform="translate(2 6)">
|
||||
<circle cx="1.5" cy="2" r="1.5"/>
|
||||
<circle cx="6" cy="2" r="1.5"/>
|
||||
<circle cx="10.5" cy="2" r="1.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |