refactor: transfer

This commit is contained in:
hstyi
2025-06-21 16:52:55 +08:00
committed by hstyi
parent e6a45d25cd
commit e1eab9db06
113 changed files with 4592 additions and 4695 deletions

View File

@@ -75,7 +75,6 @@ dependencies {
api(libs.commons.csv)
api(libs.commons.net)
api(libs.commons.text)
api(libs.commons.vfs2) { exclude(group = "*", module = "*") }
api(libs.kotlinx.coroutines.swing)
api(libs.kotlinx.coroutines.core)
@@ -121,7 +120,8 @@ dependencies {
application {
val args = mutableListOf(
"-Xmx2048m",
"-Drelease-date=${DateFormatUtils.format(Date(), "yyyy-MM-dd")}"
"-Drelease-date=${DateFormatUtils.format(Date(), "yyyy-MM-dd")}",
"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
)
if (os.isMacOsX) {

View File

@@ -4,7 +4,7 @@ slf4j = "2.0.17"
pty4j = "0.13.6"
tinylog = "2.7.0"
kotlinx-coroutines = "1.10.2"
flatlaf = "3.7-SNAPSHOT"
flatlaf = "3.6"
kotlinx-serialization-json = "1.8.1"
commons-codec = "1.18.0"
commons-lang3 = "3.17.0"
@@ -46,7 +46,7 @@ h2 = "2.3.232"
sqlite = "3.50.1.0"
jug = "5.1.0"
semver4j = "5.7.1"
jsvg = "2.0.0"
jsvg = "1.4.0"
dom4j = "2.1.4"
[libraries]

View File

@@ -4,21 +4,22 @@ import app.termora.DialogWrapper
import app.termora.Disposable
import app.termora.Disposer
import app.termora.OptionPane
import app.termora.sftp.absolutePathString
import org.apache.commons.vfs2.FileObject
import java.awt.Dimension
import java.awt.Window
import java.awt.event.WindowAdapter
import java.awt.event.WindowEvent
import java.io.File
import java.nio.file.Path
import javax.swing.JComponent
import javax.swing.JOptionPane
import javax.swing.UIManager
import kotlin.io.path.absolutePathString
import kotlin.io.path.name
class EditorDialog(file: FileObject, owner: Window, myDisposable: Disposable) : DialogWrapper(null) {
class EditorDialog(file: Path, owner: Window, myDisposable: Disposable) : DialogWrapper(null) {
private val filename = file.name.baseName
private val filename = file.name
private val filepath = File(file.absolutePathString())
private val editorPanel = EditorPanel(this, filepath)

View File

@@ -3,13 +3,13 @@ package app.termora.plugins.editor
import app.termora.plugin.Extension
import app.termora.plugin.ExtensionSupport
import app.termora.plugin.Plugin
import app.termora.sftp.SFTPEditFileExtension
import app.termora.transfer.TransportEditFileExtension
class EditorPlugin : Plugin {
private val support = ExtensionSupport()
init {
support.addExtension(SFTPEditFileExtension::class.java) { MySFTPEditFileExtension.instance }
support.addExtension(TransportEditFileExtension::class.java) { MyTransportEditFileExtension.instance }
}
override fun getAuthor(): String {

View File

@@ -1,21 +0,0 @@
package app.termora.plugins.editor
import app.termora.Disposable
import app.termora.Disposer
import app.termora.sftp.SFTPEditFileExtension
import app.termora.sftp.absolutePathString
import org.apache.commons.vfs2.FileObject
import java.awt.Window
import javax.swing.SwingUtilities
class MySFTPEditFileExtension private constructor() : SFTPEditFileExtension {
companion object {
val instance = MySFTPEditFileExtension()
}
override fun edit(owner: Window, file: FileObject): Disposable {
val disposable = Disposer.newDisposable()
SwingUtilities.invokeLater { EditorDialog(file, owner, disposable).isVisible = true }
return disposable
}
}

View File

@@ -0,0 +1,20 @@
package app.termora.plugins.editor
import app.termora.Disposable
import app.termora.Disposer
import app.termora.transfer.TransportEditFileExtension
import java.awt.Window
import java.nio.file.Path
import javax.swing.SwingUtilities
class MyTransportEditFileExtension private constructor() : TransportEditFileExtension {
companion object {
val instance = MyTransportEditFileExtension()
}
override fun edit(owner: Window, path: Path): Disposable {
val disposable = Disposer.newDisposable()
SwingUtilities.invokeLater { EditorDialog(path, owner, disposable).isVisible = true }
return disposable
}
}

View File

@@ -2,8 +2,8 @@ package app.termora.plugins.s3
import app.termora.DynamicIcon
import app.termora.Icons
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 io.minio.MinioClient
import org.apache.commons.lang3.StringUtils
@@ -30,7 +30,7 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider {
return S3FileProvider.instance
}
override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler {
override fun getRootFileObject(requester: PathHandlerRequest): PathHandler {
val host = requester.host
val builder = MinioClient.builder()
.endpoint(host.host)
@@ -53,7 +53,7 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider {
"s3://${StringUtils.defaultIfBlank(defaultPath, "/")}",
options
)
return FileObjectHandler(file)
return PathHandler(file)
}
}

View File

@@ -3,7 +3,7 @@ package app.termora.plugins.s3
import app.termora.Authentication
import app.termora.AuthenticationType
import app.termora.Host
import app.termora.protocol.FileObjectRequest
import app.termora.protocol.PathHandlerRequest
import app.termora.vfs2.VFSWalker
import io.minio.MakeBucketArgs
import io.minio.MinioClient
@@ -66,7 +66,7 @@ class S3FileProviderTest {
)
}
val requester = FileObjectRequest(
val requester = PathHandlerRequest(
host = Host(
name = "test",
protocol = S3ProtocolProvider.PROTOCOL,

View File

@@ -3,7 +3,7 @@ plugins {
}
rootProject.name = "termora"
include("plugins:s3")
//include("plugins:s3")
//include("plugins:oss")
//include("plugins:cos")
//include("plugins:obs")

View File

@@ -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

View File

@@ -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)

View File

@@ -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") }
}

View 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()
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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 {
}

View 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 {
}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -1,9 +0,0 @@
package app.termora.sftp
import org.apache.commons.vfs2.FileSystem
interface FileSystemProvider {
fun getFileSystem(): FileSystem
fun setFileSystem(fileSystem: FileSystem)
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)) }
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -1,6 +0,0 @@
package app.termora.sftp
import app.termora.plugin.Extension
interface SFTPExtension : Extension {
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 }
}
}

View File

@@ -1,10 +0,0 @@
package app.termora.sftp
import java.util.*
interface TransportListener : EventListener {
/**
* 状态变化
*/
fun onTransportChanged(transport: Transport) {}
}

View File

@@ -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)
}

View File

@@ -1,3 +0,0 @@
package app.termora.sftp
class TransportStatusException(message: String) : RuntimeException(message)

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}

View 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
}
}

View File

@@ -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() {

View File

@@ -1,4 +1,4 @@
package app.termora.sftp
package app.termora.transfer
import app.termora.DialogWrapper
import app.termora.DynamicColor

View File

@@ -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)
}
}

View 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)
}
}

View 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
}
}

View 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)
}
}
}

View File

@@ -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
}

View 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")
}
}
}

View File

@@ -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) {

View 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
}
}

View 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
}

View File

@@ -0,0 +1,7 @@
package app.termora.transfer
enum class TransferAction {
Overwrite,
Append,
Skip,
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -0,0 +1,6 @@
package app.termora.transfer
import app.termora.Disposable
class TransferDisposable(val id: String) : Disposable {
}

View 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
}

View File

@@ -0,0 +1,10 @@
package app.termora.transfer
import java.util.*
interface TransferListener : EventListener {
/**
* 状态变化
*/
fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State)
}

View 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
}

View File

@@ -0,0 +1,6 @@
package app.termora.transfer
interface TransferScanner {
fun scanning(): Boolean
fun scanned()
}

View 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)
}
}
}

View 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 }
}
}
}

View 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
}
}

View File

@@ -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
}

View 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)
}
}
}
}
}

View 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()
}

File diff suppressed because it is too large Load Diff

View 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)
}

View File

@@ -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
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
package app.termora.transfer
import java.nio.file.FileSystem
class TransportSupport(
val fileSystem: FileSystem,
val path: String
)

View File

@@ -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()
}
}

View 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)
}
}

View 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 == ".."
}
}

View File

@@ -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
}
}

View 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)
)
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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> {

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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")
}
}
}

View File

@@ -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"), "/")
}
}

View File

@@ -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())
}
}

View File

@@ -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
)
}
}

View File

@@ -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
}
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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=已失败

View File

@@ -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=已失敗

Some files were not shown because too many files have changed in this diff Show More