mirror of
https://github.com/TermoraDev/termora.git
synced 2026-01-15 18:02:58 +08:00
refactor: transfer
This commit is contained in:
@@ -75,7 +75,6 @@ dependencies {
|
|||||||
api(libs.commons.csv)
|
api(libs.commons.csv)
|
||||||
api(libs.commons.net)
|
api(libs.commons.net)
|
||||||
api(libs.commons.text)
|
api(libs.commons.text)
|
||||||
api(libs.commons.vfs2) { exclude(group = "*", module = "*") }
|
|
||||||
api(libs.kotlinx.coroutines.swing)
|
api(libs.kotlinx.coroutines.swing)
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
@@ -121,7 +120,8 @@ dependencies {
|
|||||||
application {
|
application {
|
||||||
val args = mutableListOf(
|
val args = mutableListOf(
|
||||||
"-Xmx2048m",
|
"-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) {
|
if (os.isMacOsX) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ slf4j = "2.0.17"
|
|||||||
pty4j = "0.13.6"
|
pty4j = "0.13.6"
|
||||||
tinylog = "2.7.0"
|
tinylog = "2.7.0"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
flatlaf = "3.7-SNAPSHOT"
|
flatlaf = "3.6"
|
||||||
kotlinx-serialization-json = "1.8.1"
|
kotlinx-serialization-json = "1.8.1"
|
||||||
commons-codec = "1.18.0"
|
commons-codec = "1.18.0"
|
||||||
commons-lang3 = "3.17.0"
|
commons-lang3 = "3.17.0"
|
||||||
@@ -46,7 +46,7 @@ h2 = "2.3.232"
|
|||||||
sqlite = "3.50.1.0"
|
sqlite = "3.50.1.0"
|
||||||
jug = "5.1.0"
|
jug = "5.1.0"
|
||||||
semver4j = "5.7.1"
|
semver4j = "5.7.1"
|
||||||
jsvg = "2.0.0"
|
jsvg = "1.4.0"
|
||||||
dom4j = "2.1.4"
|
dom4j = "2.1.4"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import app.termora.DialogWrapper
|
|||||||
import app.termora.Disposable
|
import app.termora.Disposable
|
||||||
import app.termora.Disposer
|
import app.termora.Disposer
|
||||||
import app.termora.OptionPane
|
import app.termora.OptionPane
|
||||||
import app.termora.sftp.absolutePathString
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import java.awt.Dimension
|
import java.awt.Dimension
|
||||||
import java.awt.Window
|
import java.awt.Window
|
||||||
import java.awt.event.WindowAdapter
|
import java.awt.event.WindowAdapter
|
||||||
import java.awt.event.WindowEvent
|
import java.awt.event.WindowEvent
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.file.Path
|
||||||
import javax.swing.JComponent
|
import javax.swing.JComponent
|
||||||
import javax.swing.JOptionPane
|
import javax.swing.JOptionPane
|
||||||
import javax.swing.UIManager
|
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 filepath = File(file.absolutePathString())
|
||||||
private val editorPanel = EditorPanel(this, filepath)
|
private val editorPanel = EditorPanel(this, filepath)
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package app.termora.plugins.editor
|
|||||||
import app.termora.plugin.Extension
|
import app.termora.plugin.Extension
|
||||||
import app.termora.plugin.ExtensionSupport
|
import app.termora.plugin.ExtensionSupport
|
||||||
import app.termora.plugin.Plugin
|
import app.termora.plugin.Plugin
|
||||||
import app.termora.sftp.SFTPEditFileExtension
|
import app.termora.transfer.TransportEditFileExtension
|
||||||
|
|
||||||
class EditorPlugin : Plugin {
|
class EditorPlugin : Plugin {
|
||||||
private val support = ExtensionSupport()
|
private val support = ExtensionSupport()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
support.addExtension(SFTPEditFileExtension::class.java) { MySFTPEditFileExtension.instance }
|
support.addExtension(TransportEditFileExtension::class.java) { MyTransportEditFileExtension.instance }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAuthor(): String {
|
override fun getAuthor(): String {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ package app.termora.plugins.s3
|
|||||||
|
|
||||||
import app.termora.DynamicIcon
|
import app.termora.DynamicIcon
|
||||||
import app.termora.Icons
|
import app.termora.Icons
|
||||||
import app.termora.protocol.FileObjectHandler
|
import app.termora.protocol.PathHandler
|
||||||
import app.termora.protocol.FileObjectRequest
|
import app.termora.protocol.PathHandlerRequest
|
||||||
import app.termora.protocol.TransferProtocolProvider
|
import app.termora.protocol.TransferProtocolProvider
|
||||||
import io.minio.MinioClient
|
import io.minio.MinioClient
|
||||||
import org.apache.commons.lang3.StringUtils
|
import org.apache.commons.lang3.StringUtils
|
||||||
@@ -30,7 +30,7 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider {
|
|||||||
return S3FileProvider.instance
|
return S3FileProvider.instance
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler {
|
override fun getRootFileObject(requester: PathHandlerRequest): PathHandler {
|
||||||
val host = requester.host
|
val host = requester.host
|
||||||
val builder = MinioClient.builder()
|
val builder = MinioClient.builder()
|
||||||
.endpoint(host.host)
|
.endpoint(host.host)
|
||||||
@@ -53,7 +53,7 @@ class S3ProtocolProvider private constructor() : TransferProtocolProvider {
|
|||||||
"s3://${StringUtils.defaultIfBlank(defaultPath, "/")}",
|
"s3://${StringUtils.defaultIfBlank(defaultPath, "/")}",
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
return FileObjectHandler(file)
|
return PathHandler(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ package app.termora.plugins.s3
|
|||||||
import app.termora.Authentication
|
import app.termora.Authentication
|
||||||
import app.termora.AuthenticationType
|
import app.termora.AuthenticationType
|
||||||
import app.termora.Host
|
import app.termora.Host
|
||||||
import app.termora.protocol.FileObjectRequest
|
import app.termora.protocol.PathHandlerRequest
|
||||||
import app.termora.vfs2.VFSWalker
|
import app.termora.vfs2.VFSWalker
|
||||||
import io.minio.MakeBucketArgs
|
import io.minio.MakeBucketArgs
|
||||||
import io.minio.MinioClient
|
import io.minio.MinioClient
|
||||||
@@ -66,7 +66,7 @@ class S3FileProviderTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val requester = FileObjectRequest(
|
val requester = PathHandlerRequest(
|
||||||
host = Host(
|
host = Host(
|
||||||
name = "test",
|
name = "test",
|
||||||
protocol = S3ProtocolProvider.PROTOCOL,
|
protocol = S3ProtocolProvider.PROTOCOL,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
rootProject.name = "termora"
|
rootProject.name = "termora"
|
||||||
|
|
||||||
include("plugins:s3")
|
//include("plugins:s3")
|
||||||
//include("plugins:oss")
|
//include("plugins:oss")
|
||||||
//include("plugins:cos")
|
//include("plugins:cos")
|
||||||
//include("plugins:obs")
|
//include("plugins:obs")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import java.io.File
|
|||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.ln
|
import kotlin.math.ln
|
||||||
@@ -202,6 +203,42 @@ fun formatBytes(bytes: Long): String {
|
|||||||
return String.format("%.2f%s", value, units[exp])
|
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 {
|
fun formatSeconds(seconds: Long): String {
|
||||||
val days = seconds / 86400
|
val days = seconds / 86400
|
||||||
val hours = (seconds % 86400) / 3600
|
val hours = (seconds % 86400) / 3600
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import app.termora.database.DatabaseManager
|
|||||||
import app.termora.keymap.KeymapManager
|
import app.termora.keymap.KeymapManager
|
||||||
import app.termora.plugin.ExtensionManager
|
import app.termora.plugin.ExtensionManager
|
||||||
import app.termora.plugin.PluginManager
|
import app.termora.plugin.PluginManager
|
||||||
import app.termora.protocol.ProtocolProvider
|
|
||||||
import app.termora.protocol.TransferProtocolProvider
|
|
||||||
import com.formdev.flatlaf.FlatClientProperties
|
import com.formdev.flatlaf.FlatClientProperties
|
||||||
import com.formdev.flatlaf.FlatSystemProperties
|
import com.formdev.flatlaf.FlatSystemProperties
|
||||||
import com.formdev.flatlaf.extras.FlatDesktop
|
import com.formdev.flatlaf.extras.FlatDesktop
|
||||||
@@ -22,9 +20,6 @@ import kotlinx.coroutines.launch
|
|||||||
import org.apache.commons.io.FileUtils
|
import org.apache.commons.io.FileUtils
|
||||||
import org.apache.commons.lang3.LocaleUtils
|
import org.apache.commons.lang3.LocaleUtils
|
||||||
import org.apache.commons.lang3.SystemUtils
|
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.json.JSONObject
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import java.awt.MenuItem
|
import java.awt.MenuItem
|
||||||
@@ -77,15 +72,6 @@ class ApplicationRunner {
|
|||||||
// 等待插件加载完成
|
// 等待插件加载完成
|
||||||
loadPluginThread.join()
|
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)) {
|
for (extension in ExtensionManager.getInstance().getExtensions(ApplicationRunnerExtension::class.java)) {
|
||||||
extension.ready()
|
extension.ready()
|
||||||
@@ -206,11 +192,13 @@ class ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// init native icon
|
||||||
|
NativeIcons.folderIcon
|
||||||
|
|
||||||
themeManager.change(theme, true)
|
themeManager.change(theme, true)
|
||||||
|
|
||||||
|
|
||||||
if (Application.isUnknownVersion())
|
FlatInspector.install("ctrl shift X")
|
||||||
FlatInspector.install("ctrl shift alt X")
|
|
||||||
|
|
||||||
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
|
UIManager.put(FlatClientProperties.FULL_WINDOW_CONTENT, true)
|
||||||
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
|
UIManager.put(FlatClientProperties.USE_WINDOW_DECORATIONS, false)
|
||||||
@@ -218,7 +206,7 @@ class ApplicationRunner {
|
|||||||
|
|
||||||
UIManager.put("Component.arc", 5)
|
UIManager.put("Component.arc", 5)
|
||||||
UIManager.put("TextComponent.arc", UIManager.getInt("Component.arc"))
|
UIManager.put("TextComponent.arc", UIManager.getInt("Component.arc"))
|
||||||
UIManager.put("Component.hideMnemonics", false)
|
UIManager.put("Component.hideMnemonics", true)
|
||||||
|
|
||||||
UIManager.put("TitleBar.height", 36)
|
UIManager.put("TitleBar.height", 36)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ object Icons {
|
|||||||
val errorIntroduction by lazy { DynamicIcon("icons/errorIntroduction.svg", "icons/errorIntroduction_dark.svg") }
|
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 error by lazy { DynamicIcon("icons/error.svg", "icons/error_dark.svg") }
|
||||||
val cwmUsers by lazy { DynamicIcon("icons/cwmUsers.svg", "icons/cwmUsers_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 warningIntroduction by lazy { DynamicIcon("icons/warningIntroduction.svg", "icons/warningIntroduction_dark.svg") }
|
||||||
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
|
val networkPolicy by lazy { DynamicIcon("icons/networkPolicy.svg", "icons/networkPolicy_dark.svg") }
|
||||||
val clusterRole by lazy { DynamicIcon("icons/clusterRole.svg", "icons/clusterRole_dark.svg") }
|
val 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 chevronDown by lazy { DynamicIcon("icons/chevronDownLarge.svg", "icons/chevronDownLarge_dark.svg") }
|
||||||
val chevronRight by lazy { DynamicIcon("icons/chevronRight.svg", "icons/chevronRight_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 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 openNewTab by lazy { DynamicIcon("icons/openNewTab.svg", "icons/openNewTab_dark.svg") }
|
||||||
val import by lazy { DynamicIcon("icons/import.svg", "icons/import_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") }
|
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 forwardPorts by lazy { DynamicIcon("icons/forwardPorts.svg", "icons/forwardPorts_dark.svg") }
|
||||||
val showWriteAccess by lazy { DynamicIcon("icons/showWriteAccess.svg", "icons/showWriteAccess_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 nvidia by lazy { DynamicIcon("icons/nvidia.svg", "icons/nvidia_dark.svg") }
|
||||||
|
val desktop_windows by lazy { DynamicIcon("icons/desktop_windows.svg", "icons/desktop_windows_dark.svg") }
|
||||||
|
val desktop_mac by lazy { DynamicIcon("icons/desktop_mac.svg", "icons/desktop_mac_dark.svg") }
|
||||||
|
val desktop by lazy { DynamicIcon("icons/desktop.svg", "icons/desktop_dark.svg") }
|
||||||
|
val moreHorizontal by lazy { DynamicIcon("icons/moreHorizontal.svg", "icons/moreHorizontal_dark.svg") }
|
||||||
}
|
}
|
||||||
33
src/main/kotlin/app/termora/NativeIcons.kt
Normal file
33
src/main/kotlin/app/termora/NativeIcons.kt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package app.termora
|
||||||
|
|
||||||
|
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
|
||||||
|
import com.formdev.flatlaf.icons.FlatTreeLeafIcon
|
||||||
|
import org.apache.commons.lang3.SystemUtils
|
||||||
|
import java.nio.file.Files
|
||||||
|
import javax.swing.Icon
|
||||||
|
import javax.swing.UIManager
|
||||||
|
import javax.swing.filechooser.FileSystemView
|
||||||
|
import kotlin.io.path.createTempFile
|
||||||
|
|
||||||
|
object NativeIcons {
|
||||||
|
|
||||||
|
|
||||||
|
val folderIcon: Icon = if (SystemUtils.IS_OS_LINUX) FlatTreeClosedIcon()
|
||||||
|
else if (SystemUtils.IS_OS_MAC_OSX)
|
||||||
|
UIManager.getIcon("FileView.directoryIcon") ?: FlatTreeClosedIcon()
|
||||||
|
else if (SystemUtils.IS_OS_WINDOWS)
|
||||||
|
FileSystemView.getFileSystemView().getSystemIcon(SystemUtils.getUserDir()) ?: FlatTreeClosedIcon()
|
||||||
|
else FlatTreeClosedIcon()
|
||||||
|
|
||||||
|
|
||||||
|
val fileIcon: Icon = if (SystemUtils.IS_OS_LINUX) FlatTreeLeafIcon()
|
||||||
|
else if (SystemUtils.IS_OS_MAC_OSX)
|
||||||
|
UIManager.getIcon("FileView.fileIcon") ?: FlatTreeLeafIcon()
|
||||||
|
else if (SystemUtils.IS_OS_WINDOWS) {
|
||||||
|
val file = createTempFile()
|
||||||
|
val icon = FileSystemView.getFileSystemView().getSystemIcon(file.toFile()) ?: FlatTreeLeafIcon()
|
||||||
|
Files.deleteIfExists(file)
|
||||||
|
icon
|
||||||
|
} else FlatTreeLeafIcon()
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ import app.termora.actions.DataProviders
|
|||||||
import app.termora.database.DatabaseManager
|
import app.termora.database.DatabaseManager
|
||||||
import app.termora.keymap.KeymapPanel
|
import app.termora.keymap.KeymapPanel
|
||||||
import app.termora.plugin.ExtensionManager
|
import app.termora.plugin.ExtensionManager
|
||||||
import app.termora.sftp.SFTPTab
|
|
||||||
import app.termora.terminal.CursorStyle
|
import app.termora.terminal.CursorStyle
|
||||||
import app.termora.terminal.DataKey
|
import app.termora.terminal.DataKey
|
||||||
import app.termora.terminal.panel.FloatingToolbarPanel
|
import app.termora.terminal.panel.FloatingToolbarPanel
|
||||||
import app.termora.terminal.panel.TerminalPanel
|
import app.termora.terminal.panel.TerminalPanel
|
||||||
|
import app.termora.transfer.TransportTerminalTab
|
||||||
import com.formdev.flatlaf.FlatClientProperties
|
import com.formdev.flatlaf.FlatClientProperties
|
||||||
import com.formdev.flatlaf.extras.components.FlatComboBox
|
import com.formdev.flatlaf.extras.components.FlatComboBox
|
||||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||||
@@ -670,8 +670,8 @@ class SettingsOptionsPane : OptionsPane() {
|
|||||||
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
|
val manager = evt.getData(DataProviders.TerminalTabbedManager) ?: continue
|
||||||
|
|
||||||
if (sftp.pinTab) {
|
if (sftp.pinTab) {
|
||||||
if (manager.getTerminalTabs().none { it is SFTPTab }) {
|
if (manager.getTerminalTabs().none { it is TransportTerminalTab }) {
|
||||||
manager.addTerminalTab(1, SFTPTab(), false)
|
manager.addTerminalTab(1, TransportTerminalTab(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ package app.termora
|
|||||||
import app.termora.actions.DataProvider
|
import app.termora.actions.DataProvider
|
||||||
import app.termora.actions.DataProviderSupport
|
import app.termora.actions.DataProviderSupport
|
||||||
import app.termora.actions.DataProviders
|
import app.termora.actions.DataProviders
|
||||||
import app.termora.database.DatabaseManager
|
|
||||||
import app.termora.plugin.ExtensionManager
|
import app.termora.plugin.ExtensionManager
|
||||||
import app.termora.sftp.SFTPTab
|
|
||||||
import app.termora.terminal.DataKey
|
import app.termora.terminal.DataKey
|
||||||
import com.formdev.flatlaf.FlatClientProperties
|
import com.formdev.flatlaf.FlatClientProperties
|
||||||
import com.formdev.flatlaf.ui.FlatRootPaneUI
|
import com.formdev.flatlaf.ui.FlatRootPaneUI
|
||||||
@@ -23,7 +21,6 @@ import java.util.*
|
|||||||
import javax.imageio.ImageIO
|
import javax.imageio.ImageIO
|
||||||
import javax.swing.JComponent
|
import javax.swing.JComponent
|
||||||
import javax.swing.JFrame
|
import javax.swing.JFrame
|
||||||
import javax.swing.SwingUtilities
|
|
||||||
import javax.swing.SwingUtilities.isEventDispatchThread
|
import javax.swing.SwingUtilities.isEventDispatchThread
|
||||||
import javax.swing.UIManager
|
import javax.swing.UIManager
|
||||||
|
|
||||||
@@ -43,7 +40,6 @@ class TermoraFrame : JFrame(), DataProvider {
|
|||||||
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
|
private val terminalTabbed = TerminalTabbed(windowScope, toolbar, tabbedPane)
|
||||||
private val dataProviderSupport = DataProviderSupport()
|
private val dataProviderSupport = DataProviderSupport()
|
||||||
private val welcomePanel = WelcomePanel(windowScope)
|
private val welcomePanel = WelcomePanel(windowScope)
|
||||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
|
||||||
private var notifyListeners = emptyArray<NotifyListener>()
|
private var notifyListeners = emptyArray<NotifyListener>()
|
||||||
|
|
||||||
|
|
||||||
@@ -205,13 +201,6 @@ class TermoraFrame : JFrame(), DataProvider {
|
|||||||
minimumSize = Dimension(640, 400)
|
minimumSize = Dimension(640, 400)
|
||||||
terminalTabbed.addTerminalTab(welcomePanel)
|
terminalTabbed.addTerminalTab(welcomePanel)
|
||||||
|
|
||||||
// 下一次事件循环检测是否固定 SFTP
|
|
||||||
if (sftp.pinTab) {
|
|
||||||
SwingUtilities.invokeLater {
|
|
||||||
terminalTabbed.addTerminalTab(SFTPTab(), false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val glassPane = GlassPane()
|
val glassPane = GlassPane()
|
||||||
rootPane.glassPane = glassPane
|
rootPane.glassPane = glassPane
|
||||||
glassPane.isOpaque = false
|
glassPane.isOpaque = false
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import app.termora.findeverywhere.FindEverywhereAction
|
|||||||
import app.termora.highlight.KeywordHighlightAction
|
import app.termora.highlight.KeywordHighlightAction
|
||||||
import app.termora.keymgr.KeyManagerAction
|
import app.termora.keymgr.KeyManagerAction
|
||||||
import app.termora.macro.MacroAction
|
import app.termora.macro.MacroAction
|
||||||
import app.termora.sftp.SFTPAction
|
|
||||||
import app.termora.snippet.SnippetAction
|
import app.termora.snippet.SnippetAction
|
||||||
import app.termora.tlog.TerminalLoggerAction
|
import app.termora.tlog.TerminalLoggerAction
|
||||||
|
import app.termora.transfer.TransferAnAction
|
||||||
import javax.swing.Action
|
import javax.swing.Action
|
||||||
|
|
||||||
class ActionManager : org.jdesktop.swingx.action.ActionManager() {
|
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.APP_UPDATE, AppUpdateAction.getInstance())
|
||||||
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
|
addAction(Actions.KEYWORD_HIGHLIGHT, KeywordHighlightAction())
|
||||||
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
|
addAction(Actions.TERMINAL_LOGGER, TerminalLoggerAction())
|
||||||
addAction(Actions.SFTP, SFTPAction())
|
addAction(Actions.SFTP, TransferAnAction())
|
||||||
addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction())
|
addAction(SFTPCommandAction.SFTP_COMMAND, SFTPCommandAction())
|
||||||
addAction(SnippetAction.SNIPPET, SnippetAction.getInstance())
|
addAction(SnippetAction.SNIPPET, SnippetAction.getInstance())
|
||||||
addAction(Actions.MACRO, MacroAction())
|
addAction(Actions.MACRO, MacroAction())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package app.termora.actions
|
|||||||
import app.termora.*
|
import app.termora.*
|
||||||
import app.termora.protocol.GenericProtocolProvider
|
import app.termora.protocol.GenericProtocolProvider
|
||||||
import app.termora.protocol.ProtocolProvider
|
import app.termora.protocol.ProtocolProvider
|
||||||
import app.termora.sftp.SFTPActionEvent
|
import app.termora.transfer.TransferActionEvent
|
||||||
import org.apache.commons.lang3.StringUtils
|
import org.apache.commons.lang3.StringUtils
|
||||||
import javax.swing.JOptionPane
|
import javax.swing.JOptionPane
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ class OpenHostAction : AnAction() {
|
|||||||
if (providers.first { StringUtils.equalsIgnoreCase(it.getProtocol(), host.protocol) }
|
if (providers.first { StringUtils.equalsIgnoreCase(it.getProtocol(), host.protocol) }
|
||||||
.isTransfer()) {
|
.isTransfer()) {
|
||||||
ActionManager.getInstance().getAction(Actions.SFTP)
|
ActionManager.getInstance().getAction(Actions.SFTP)
|
||||||
.actionPerformed(SFTPActionEvent(evt.source, evt.host.id, evt.event))
|
.actionPerformed(TransferActionEvent(evt.source, evt.host.id, evt.event))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class KeymapManager private constructor() : Disposable {
|
|||||||
|
|
||||||
if (component is JComponent) {
|
if (component is JComponent) {
|
||||||
// 如果这个键已经被组件注册了,那么忽略
|
// 如果这个键已经被组件注册了,那么忽略
|
||||||
if (component.getConditionForKeyStroke(keyStroke) != JComponent.UNDEFINED_CONDITION) {
|
if (getConditionForKeyStroke(component, keyStroke) != JComponent.UNDEFINED_CONDITION) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,6 +182,21 @@ class KeymapManager private constructor() : Disposable {
|
|||||||
return false
|
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() {
|
override fun dispose() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package app.termora.plugin
|
|||||||
|
|
||||||
import app.termora.I18n
|
import app.termora.I18n
|
||||||
import app.termora.Icons
|
import app.termora.Icons
|
||||||
import com.formdev.flatlaf.extras.FlatSVGIcon
|
import app.termora.transfer.ScaleIcon
|
||||||
import org.semver4j.Semver
|
import org.semver4j.Semver
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -18,7 +18,7 @@ open class PluginDescriptor(
|
|||||||
val path: File? = null,
|
val path: File? = null,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val defaultIcon: Icon = FlatSVGIcon(Icons.plugin.name, 32, 32)
|
val defaultIcon: Icon = ScaleIcon(Icons.plugin, 32)
|
||||||
}
|
}
|
||||||
|
|
||||||
val description: String get() = getBestDescription()
|
val description: String get() = getBestDescription()
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import app.termora.plugin.internal.rdp.RDPInternalPlugin
|
|||||||
import app.termora.plugin.internal.serial.SerialInternalPlugin
|
import app.termora.plugin.internal.serial.SerialInternalPlugin
|
||||||
import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin
|
import app.termora.plugin.internal.sftppty.SFTPPtyInternalPlugin
|
||||||
import app.termora.plugin.internal.ssh.SSHInternalPlugin
|
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.swingCoroutineScope
|
||||||
|
import app.termora.transfer.internal.local.LocalPlugin
|
||||||
|
import app.termora.transfer.internal.sftp.SFTPPlugin
|
||||||
import com.formdev.flatlaf.util.SystemInfo
|
import com.formdev.flatlaf.util.SystemInfo
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import app.termora.setupAntialiasing
|
|||||||
import com.formdev.flatlaf.FlatLaf
|
import com.formdev.flatlaf.FlatLaf
|
||||||
import com.formdev.flatlaf.util.UIScale
|
import com.formdev.flatlaf.util.UIScale
|
||||||
import com.github.weisj.jsvg.SVGDocument
|
import com.github.weisj.jsvg.SVGDocument
|
||||||
import com.github.weisj.jsvg.parser.LoaderContext
|
|
||||||
import com.github.weisj.jsvg.parser.SVGLoader
|
import com.github.weisj.jsvg.parser.SVGLoader
|
||||||
import java.awt.Component
|
import java.awt.Component
|
||||||
import java.awt.Graphics
|
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 document = svgLoader.load(input)
|
||||||
private val darkDocument = dark?.let { svgLoader.load(it, null, LoaderContext.createDefault()) }
|
private val darkDocument = dark?.let { svgLoader.load(it) }
|
||||||
|
|
||||||
override fun getIconHeight(): Int {
|
override fun getIconHeight(): Int {
|
||||||
return 32
|
return 32
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package app.termora.protocol
|
|
||||||
|
|
||||||
import app.termora.Disposable
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
|
|
||||||
open class FileObjectHandler(val file: FileObject) : Disposable {
|
|
||||||
|
|
||||||
}
|
|
||||||
9
src/main/kotlin/app/termora/protocol/PathHandler.kt
Normal file
9
src/main/kotlin/app/termora/protocol/PathHandler.kt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package app.termora.protocol
|
||||||
|
|
||||||
|
import app.termora.Disposable
|
||||||
|
import java.nio.file.FileSystem
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
open class PathHandler(val fileSystem: FileSystem, val path: Path) : Disposable {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package app.termora.protocol
|
|||||||
import app.termora.Host
|
import app.termora.Host
|
||||||
import java.awt.Window
|
import java.awt.Window
|
||||||
|
|
||||||
class FileObjectRequest(
|
class PathHandlerRequest(
|
||||||
val host: Host,
|
val host: Host,
|
||||||
val owner: Window? = null,
|
val owner: Window? = null,
|
||||||
)
|
)
|
||||||
@@ -4,10 +4,9 @@ import app.termora.plugin.internal.local.LocalProtocolProvider
|
|||||||
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
||||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
||||||
import app.termora.protocol.ProtocolProvider.Companion.providers
|
import app.termora.protocol.ProtocolProvider.Companion.providers
|
||||||
import app.termora.sftp.internal.local.LocalTransferProtocolProvider
|
import app.termora.transfer.internal.local.LocalTransferProtocolProvider
|
||||||
import app.termora.sftp.internal.sftp.SFTPTransferProtocolProvider
|
import app.termora.transfer.internal.sftp.SFTPTransferProtocolProvider
|
||||||
import org.apache.commons.lang3.StringUtils
|
import org.apache.commons.lang3.StringUtils
|
||||||
import org.apache.commons.vfs2.provider.FileProvider
|
|
||||||
|
|
||||||
interface TransferProtocolProvider : ProtocolProvider {
|
interface TransferProtocolProvider : ProtocolProvider {
|
||||||
|
|
||||||
@@ -32,14 +31,9 @@ interface TransferProtocolProvider : ProtocolProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取文件提供者
|
* 创建一个文件
|
||||||
*/
|
*/
|
||||||
fun getFileProvider(): FileProvider
|
fun createPathHandler(requester: PathHandlerRequest): PathHandler
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取根文件
|
|
||||||
*/
|
|
||||||
fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler
|
|
||||||
|
|
||||||
override fun isTransfer(): Boolean {
|
override fun isTransfer(): Boolean {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import org.apache.commons.vfs2.FileSystem
|
|
||||||
|
|
||||||
|
|
||||||
interface FileSystemProvider {
|
|
||||||
fun getFileSystem(): FileSystem
|
|
||||||
fun setFileSystem(fileSystem: FileSystem)
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.Icons
|
|
||||||
import app.termora.assertEventDispatchThread
|
|
||||||
import com.formdev.flatlaf.FlatClientProperties
|
|
||||||
import com.formdev.flatlaf.extras.components.FlatTextField
|
|
||||||
import com.formdev.flatlaf.util.SystemInfo
|
|
||||||
import org.apache.commons.lang3.StringUtils
|
|
||||||
import org.apache.commons.lang3.SystemUtils
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.apache.commons.vfs2.VFS
|
|
||||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import java.awt.BorderLayout
|
|
||||||
import java.awt.Component
|
|
||||||
import java.awt.Graphics
|
|
||||||
import java.awt.Point
|
|
||||||
import java.awt.event.ActionEvent
|
|
||||||
import java.awt.event.ActionListener
|
|
||||||
import java.awt.event.ItemEvent
|
|
||||||
import java.awt.event.ItemListener
|
|
||||||
import java.nio.file.FileSystems
|
|
||||||
import javax.swing.*
|
|
||||||
import javax.swing.event.PopupMenuEvent
|
|
||||||
import javax.swing.event.PopupMenuListener
|
|
||||||
import javax.swing.filechooser.FileSystemView
|
|
||||||
import kotlin.io.path.absolutePathString
|
|
||||||
|
|
||||||
class FileSystemViewNav(
|
|
||||||
private val fileSystemProvider: FileSystemProvider,
|
|
||||||
private val homeDirectory: FileObject
|
|
||||||
) : JPanel(BorderLayout()) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PATH = "path"
|
|
||||||
private val log = LoggerFactory.getLogger(FileSystemViewNav::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val fileSystemView = FileSystemView.getFileSystemView()
|
|
||||||
private val textField = MyFlatTextField()
|
|
||||||
private var popupLastTime = 0L
|
|
||||||
private val history = linkedSetOf<String>()
|
|
||||||
private val layeredPane = LayeredPane()
|
|
||||||
private val downBtn = JButton(Icons.chevronDown)
|
|
||||||
private val comboBox = object : JComboBox<FileObject>() {
|
|
||||||
override fun getLocationOnScreen(): Point {
|
|
||||||
val point = super.getLocationOnScreen()
|
|
||||||
point.y -= 1
|
|
||||||
return point
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
initViews()
|
|
||||||
initEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initViews() {
|
|
||||||
|
|
||||||
comboBox.isEnabled = false
|
|
||||||
comboBox.putClientProperty("JComboBox.isTableCellEditor", true)
|
|
||||||
|
|
||||||
textField.leadingIcon = NativeFileIcons.getFolderIcon()
|
|
||||||
textField.trailingComponent = downBtn
|
|
||||||
textField.text = homeDirectory.absolutePathString()
|
|
||||||
textField.putClientProperty(PATH, homeDirectory)
|
|
||||||
|
|
||||||
downBtn.putClientProperty(
|
|
||||||
FlatClientProperties.STYLE,
|
|
||||||
mapOf(
|
|
||||||
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
|
|
||||||
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
comboBox.renderer = object : DefaultListCellRenderer() {
|
|
||||||
private val indentIcon = IndentIcon()
|
|
||||||
override fun getListCellRendererComponent(
|
|
||||||
list: JList<*>?,
|
|
||||||
value: Any?,
|
|
||||||
index: Int,
|
|
||||||
isSelected: Boolean,
|
|
||||||
cellHasFocus: Boolean
|
|
||||||
): Component {
|
|
||||||
val c = super.getListCellRendererComponent(
|
|
||||||
list,
|
|
||||||
if (value is FileObject) formatDisplayPath(value) else value.toString(),
|
|
||||||
index,
|
|
||||||
isSelected,
|
|
||||||
cellHasFocus
|
|
||||||
)
|
|
||||||
|
|
||||||
indentIcon.depth = 0
|
|
||||||
indentIcon.icon = NativeFileIcons.getFolderIcon()
|
|
||||||
|
|
||||||
icon = indentIcon
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layeredPane.add(comboBox, JLayeredPane.DEFAULT_LAYER as Any)
|
|
||||||
layeredPane.add(textField, JLayeredPane.PALETTE_LAYER as Any)
|
|
||||||
add(layeredPane, BorderLayout.CENTER)
|
|
||||||
|
|
||||||
|
|
||||||
if (SystemInfo.isWindows && fileSystemProvider.getFileSystem() is LocalFileSystem) {
|
|
||||||
try {
|
|
||||||
for (root in fileSystemView.roots) {
|
|
||||||
history.add(root.absolutePath)
|
|
||||||
}
|
|
||||||
for (rootDirectory in FileSystems.getDefault().rootDirectories) {
|
|
||||||
history.add(rootDirectory.absolutePathString())
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (log.isErrorEnabled) {
|
|
||||||
log.error(e.message, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatDisplayPath(file: FileObject): String {
|
|
||||||
return file.absolutePathString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initEvents() {
|
|
||||||
|
|
||||||
val itemListener = ItemListener { e ->
|
|
||||||
if (e.stateChange == ItemEvent.SELECTED) {
|
|
||||||
val item = comboBox.selectedItem
|
|
||||||
if (item is FileObject) {
|
|
||||||
changeSelectedPath(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
comboBox.addPopupMenuListener(object : PopupMenuListener {
|
|
||||||
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent) {
|
|
||||||
comboBox.addItemListener(itemListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
|
|
||||||
popupLastTime = System.currentTimeMillis()
|
|
||||||
comboBox.removeItemListener(itemListener)
|
|
||||||
comboBox.isEnabled = false
|
|
||||||
textField.requestFocusInWindow()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popupMenuCanceled(e: PopupMenuEvent?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听 Action
|
|
||||||
addActionListener(object : AbstractAction() {
|
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
|
||||||
val text = textField.text.trim()
|
|
||||||
if (text.isBlank()) return
|
|
||||||
if (history.contains(text)) return
|
|
||||||
history.add(text)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
downBtn.addActionListener(object : AbstractAction() {
|
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
|
||||||
if (System.currentTimeMillis() - popupLastTime < 250) return
|
|
||||||
comboBox.isEnabled = true
|
|
||||||
comboBox.requestFocusInWindow()
|
|
||||||
showComboBoxPopup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
textField.addActionListener(object : AbstractAction() {
|
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
|
||||||
val name = textField.text.trim()
|
|
||||||
if (name.isBlank()) return
|
|
||||||
val fileSystem = fileSystemProvider.getFileSystem()
|
|
||||||
try {
|
|
||||||
if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
|
|
||||||
val file = VFS.getManager().resolveFile("file://${name}")
|
|
||||||
if (!StringUtils.equals(file.fileSystem.rootURI, fileSystemProvider.getFileSystem().rootURI)) {
|
|
||||||
fileSystemProvider.setFileSystem(file.fileSystem)
|
|
||||||
}
|
|
||||||
changeSelectedPath(file)
|
|
||||||
} else {
|
|
||||||
changeSelectedPath(fileSystem.resolveFile(name))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (log.isErrorEnabled) {
|
|
||||||
log.error(e.message, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showComboBoxPopup() {
|
|
||||||
|
|
||||||
comboBox.removeAllItems()
|
|
||||||
val fileSystem = fileSystemProvider.getFileSystem()
|
|
||||||
|
|
||||||
for (text in history) {
|
|
||||||
val path = if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
|
|
||||||
VFS.getManager().resolveFile("file://${text}")
|
|
||||||
} else {
|
|
||||||
fileSystem.resolveFile(text)
|
|
||||||
}
|
|
||||||
comboBox.addItem(path)
|
|
||||||
if (text == textField.text) {
|
|
||||||
comboBox.selectedItem = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
comboBox.showPopup()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addActionListener(l: ActionListener) {
|
|
||||||
listenerList.add(ActionListener::class.java, l)
|
|
||||||
}
|
|
||||||
|
|
||||||
class IndentIcon : Icon {
|
|
||||||
val space = 10
|
|
||||||
var depth: Int = 0
|
|
||||||
var icon = NativeFileIcons.getFolderIcon()
|
|
||||||
|
|
||||||
override fun paintIcon(c: Component, g: Graphics, x: Int, y: Int) {
|
|
||||||
if (c.componentOrientation.isLeftToRight) {
|
|
||||||
icon.paintIcon(c, g, x + depth * space, y)
|
|
||||||
} else {
|
|
||||||
icon.paintIcon(c, g, x, y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getIconWidth(): Int {
|
|
||||||
return icon.iconWidth + depth * space
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getIconHeight(): Int {
|
|
||||||
return icon.iconHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSelectedPath(): FileObject {
|
|
||||||
return textField.getClientProperty(PATH) as FileObject
|
|
||||||
}
|
|
||||||
|
|
||||||
fun changeSelectedPath(file: FileObject) {
|
|
||||||
assertEventDispatchThread()
|
|
||||||
|
|
||||||
textField.text = formatDisplayPath(file)
|
|
||||||
textField.putClientProperty(PATH, file)
|
|
||||||
|
|
||||||
val fileSystem = fileSystemProvider.getFileSystem()
|
|
||||||
if (SystemInfo.isWindows && fileSystem is LocalFileSystem) {
|
|
||||||
if (!StringUtils.equals(fileSystem.rootURI, file.fileSystem.rootURI)) {
|
|
||||||
fileSystemProvider.setFileSystem(file.fileSystem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (listener in listenerList.getListeners(ActionListener::class.java)) {
|
|
||||||
listener.actionPerformed(ActionEvent(this, ActionEvent.ACTION_PERFORMED, StringUtils.EMPTY))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNNECESSARY_SAFE_CALL")
|
|
||||||
override fun updateUI() {
|
|
||||||
super.updateUI()
|
|
||||||
downBtn?.putClientProperty(
|
|
||||||
FlatClientProperties.STYLE,
|
|
||||||
mapOf(
|
|
||||||
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
|
|
||||||
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class MyFlatTextField : FlatTextField() {
|
|
||||||
public override fun fireActionPerformed() {
|
|
||||||
super.fireActionPerformed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private class LayeredPane : JLayeredPane() {
|
|
||||||
override fun doLayout() {
|
|
||||||
synchronized(treeLock) {
|
|
||||||
for (c in components) {
|
|
||||||
c.setBounds(0, 0, width, height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,466 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.*
|
|
||||||
import app.termora.actions.DataProvider
|
|
||||||
import app.termora.database.DatabaseManager
|
|
||||||
import app.termora.terminal.DataKey
|
|
||||||
import app.termora.vfs2.sftp.MySftpFileSystem
|
|
||||||
import com.formdev.flatlaf.extras.components.FlatToolBar
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.swing.Swing
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.apache.commons.lang3.StringUtils
|
|
||||||
import org.apache.commons.lang3.SystemUtils
|
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.apache.commons.vfs2.FileSystem
|
|
||||||
import org.apache.commons.vfs2.VFS
|
|
||||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
|
||||||
import org.jdesktop.swingx.JXBusyLabel
|
|
||||||
import java.awt.BorderLayout
|
|
||||||
import java.awt.event.*
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import java.util.function.Consumer
|
|
||||||
import javax.swing.*
|
|
||||||
|
|
||||||
class FileSystemViewPanel(
|
|
||||||
val host: Host,
|
|
||||||
private val homeDirectory: FileObject,
|
|
||||||
private val transportManager: TransportManager,
|
|
||||||
private val coroutineScope: CoroutineScope,
|
|
||||||
) : JPanel(BorderLayout()), Disposable, DataProvider, FileSystemProvider {
|
|
||||||
|
|
||||||
private var fileSystem: FileSystem = homeDirectory.fileSystem
|
|
||||||
private val properties get() = DatabaseManager.getInstance().properties
|
|
||||||
private val table = FileSystemViewTable(this, transportManager, coroutineScope)
|
|
||||||
private val disposed = AtomicBoolean(false)
|
|
||||||
private var nextReloadTicks = emptyArray<Consumer<Unit>>()
|
|
||||||
private val isLoading = AtomicBoolean(false)
|
|
||||||
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
|
||||||
private val loadingPanel = LoadingPanel()
|
|
||||||
private val layeredPane = LayeredPane()
|
|
||||||
private val nav = FileSystemViewNav(this, homeDirectory)
|
|
||||||
private var workdir = homeDirectory
|
|
||||||
private val model get() = table.model as FileSystemViewTableModel
|
|
||||||
private val showHiddenFilesKey = "termora.transport.host.${host.id}.show-hidden-files"
|
|
||||||
private var useFileHiding: Boolean
|
|
||||||
get() = properties.getString(showHiddenFilesKey, "true").toBoolean()
|
|
||||||
set(value) = properties.putString(showHiddenFilesKey, value.toString())
|
|
||||||
|
|
||||||
val isDisposed get() = disposed.get()
|
|
||||||
|
|
||||||
init {
|
|
||||||
initViews()
|
|
||||||
initEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initViews() {
|
|
||||||
|
|
||||||
val toolbar = FlatToolBar()
|
|
||||||
toolbar.add(createHomeFolderButton())
|
|
||||||
toolbar.add(Box.createHorizontalStrut(2))
|
|
||||||
toolbar.add(nav)
|
|
||||||
toolbar.add(createBookmarkButton())
|
|
||||||
toolbar.add(createParentFolderButton())
|
|
||||||
toolbar.add(createHiddenFilesButton())
|
|
||||||
toolbar.add(createRefreshButton())
|
|
||||||
|
|
||||||
add(toolbar, BorderLayout.NORTH)
|
|
||||||
add(layeredPane, BorderLayout.CENTER)
|
|
||||||
|
|
||||||
val scrollPane = JScrollPane(table)
|
|
||||||
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
|
||||||
layeredPane.add(scrollPane, JLayeredPane.DEFAULT_LAYER as Any)
|
|
||||||
layeredPane.add(loadingPanel, JLayeredPane.PALETTE_LAYER as Any)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initEvents() {
|
|
||||||
|
|
||||||
Disposer.register(this, table)
|
|
||||||
|
|
||||||
nav.addActionListener { reload() }
|
|
||||||
|
|
||||||
table.addMouseListener(object : MouseAdapter() {
|
|
||||||
override fun mouseClicked(e: MouseEvent) {
|
|
||||||
if (SwingUtilities.isLeftMouseButton(e) && e.clickCount % 2 == 0) {
|
|
||||||
enterTableSelectionFolder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
table.addKeyListener(object : KeyAdapter() {
|
|
||||||
override fun keyPressed(e: KeyEvent) {
|
|
||||||
if (e.keyCode == KeyEvent.VK_ENTER) {
|
|
||||||
enterTableSelectionFolder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
val listener = object : TransportListener, Disposable {
|
|
||||||
override fun onTransportChanged(transport: Transport) {
|
|
||||||
val path = transport.target.parent ?: return
|
|
||||||
if (path.fileSystem != fileSystem) return
|
|
||||||
if (path.name.path != workdir.name.path) return
|
|
||||||
// 立即刷新
|
|
||||||
reload(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispose() {
|
|
||||||
transportManager.removeTransportListener(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transportManager.addTransportListener(listener)
|
|
||||||
Disposer.register(this, listener)
|
|
||||||
|
|
||||||
// 变更工作目录
|
|
||||||
if (SwingUtilities.isEventDispatchThread()) {
|
|
||||||
changeWorkdir(homeDirectory)
|
|
||||||
} else {
|
|
||||||
SwingUtilities.invokeLater { changeWorkdir(homeDirectory) }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun enterTableSelectionFolder(row: Int = table.selectedRow) {
|
|
||||||
if (row < 0 || isLoading.get()) return
|
|
||||||
val file = model.getFileObject(row)
|
|
||||||
if (file.isFile) return
|
|
||||||
|
|
||||||
// 当前工作目录
|
|
||||||
val workdir = getWorkdir()
|
|
||||||
|
|
||||||
// 返回上级之后,选中上级目录
|
|
||||||
if (row == 0 && model.hasParent) {
|
|
||||||
val workdirName = workdir.name
|
|
||||||
nextReloadTickSelection(workdirName.baseName)
|
|
||||||
}
|
|
||||||
|
|
||||||
changeWorkdir(file)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createRefreshButton(): JButton {
|
|
||||||
val button = JButton(Icons.refresh)
|
|
||||||
button.addActionListener { reload(true) }
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createHiddenFilesButton(): JButton {
|
|
||||||
val button = JButton(if (useFileHiding) Icons.eyeClose else Icons.eye)
|
|
||||||
button.addActionListener(object : AbstractAction() {
|
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
|
||||||
useFileHiding = !useFileHiding
|
|
||||||
button.icon = if (useFileHiding) Icons.eyeClose else Icons.eye
|
|
||||||
reload(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createHomeFolderButton(): JButton {
|
|
||||||
val button = JButton(Icons.homeFolder)
|
|
||||||
button.addActionListener { nav.changeSelectedPath(homeDirectory) }
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createBookmarkButton(): JButton {
|
|
||||||
val bookmarkBtn = BookmarkButton()
|
|
||||||
bookmarkBtn.name = "Host.${host.id}.Bookmarks"
|
|
||||||
bookmarkBtn.addActionListener { e ->
|
|
||||||
if (e.actionCommand.isNullOrBlank()) {
|
|
||||||
if (bookmarkBtn.isBookmark) {
|
|
||||||
bookmarkBtn.deleteBookmark(workdir.absolutePathString())
|
|
||||||
} else {
|
|
||||||
bookmarkBtn.addBookmark(workdir.absolutePathString())
|
|
||||||
}
|
|
||||||
bookmarkBtn.isBookmark = !bookmarkBtn.isBookmark
|
|
||||||
} else {
|
|
||||||
if (fileSystem is LocalFileSystem && SystemUtils.IS_OS_WINDOWS) {
|
|
||||||
val file = VFS.getManager().resolveFile("file://${e.actionCommand}")
|
|
||||||
if (!StringUtils.equals(file.fileSystem.rootURI, fileSystem.rootURI)) {
|
|
||||||
fileSystem = file.fileSystem
|
|
||||||
}
|
|
||||||
changeWorkdir(file)
|
|
||||||
} else {
|
|
||||||
changeWorkdir(fileSystem.resolveFile(e.actionCommand))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nav.addActionListener(object : AbstractAction() {
|
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
|
||||||
bookmarkBtn.isBookmark = bookmarkBtn.getBookmarks().contains(nav.getSelectedPath().absolutePathString())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return bookmarkBtn
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun createParentFolderButton(): AbstractButton {
|
|
||||||
val button = JButton(Icons.up)
|
|
||||||
button.addActionListener(object : AbstractAction() {
|
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
|
||||||
if (model.rowCount < 1) return
|
|
||||||
if (model.hasParent) enterTableSelectionFolder(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
addPropertyChangeListener("workdir") {
|
|
||||||
button.isEnabled = model.rowCount > 0 && model.hasParent
|
|
||||||
}
|
|
||||||
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nextReloadTickSelection(name: String, consumer: Consumer<Int> = Consumer { }) {
|
|
||||||
// 创建成功之后需要修改和选中
|
|
||||||
registerNextReloadTick {
|
|
||||||
for (i in 0 until table.rowCount) {
|
|
||||||
if (model.getFileObject(i).name.baseName == name) {
|
|
||||||
table.addRowSelectionInterval(i, i)
|
|
||||||
table.scrollRectToVisible(table.getCellRect(i, 0, true))
|
|
||||||
consumer.accept(i)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun changeWorkdir(workdir: FileObject) {
|
|
||||||
assertEventDispatchThread()
|
|
||||||
nav.changeSelectedPath(workdir)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun renameTo(oldPath: FileObject, newPath: FileObject) {
|
|
||||||
|
|
||||||
// 新建文件夹
|
|
||||||
coroutineScope.launch {
|
|
||||||
|
|
||||||
if (requestLoading()) {
|
|
||||||
try {
|
|
||||||
oldPath.moveTo(newPath)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
OptionPane.showMessageDialog(
|
|
||||||
SwingUtilities.getWindowAncestor(owner),
|
|
||||||
ExceptionUtils.getMessage(e),
|
|
||||||
messageType = JOptionPane.ERROR_MESSAGE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
stopLoading()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建成功之后需要选中
|
|
||||||
nextReloadTickSelection(newPath.name.baseName)
|
|
||||||
|
|
||||||
// 立即刷新
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun newFolderOrFile(name: String, isFile: Boolean) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
if (requestLoading()) {
|
|
||||||
try {
|
|
||||||
doNewFolderOrFile(getWorkdir().resolveFile(name), isFile)
|
|
||||||
} finally {
|
|
||||||
stopLoading()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建成功之后需要修改和选中
|
|
||||||
nextReloadTickSelection(name)
|
|
||||||
|
|
||||||
// 立即刷新
|
|
||||||
reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private suspend fun doNewFolderOrFile(path: FileObject, isFile: Boolean) {
|
|
||||||
|
|
||||||
if (path.exists()) {
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
OptionPane.showMessageDialog(
|
|
||||||
owner,
|
|
||||||
I18n.getString("termora.transport.file-already-exists", path.name),
|
|
||||||
messageType = JOptionPane.ERROR_MESSAGE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建文件夹
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
runCatching { if (isFile) path.createFile() else path.createFolder() }.onFailure {
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
if (it is Exception) {
|
|
||||||
OptionPane.showMessageDialog(
|
|
||||||
owner,
|
|
||||||
ExceptionUtils.getMessage(it),
|
|
||||||
messageType = JOptionPane.ERROR_MESSAGE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun requestLoading(): Boolean {
|
|
||||||
if (isLoading.compareAndSet(false, true)) {
|
|
||||||
if (SwingUtilities.isEventDispatchThread()) {
|
|
||||||
loadingPanel.start()
|
|
||||||
} else {
|
|
||||||
SwingUtilities.invokeLater { loadingPanel.start() }
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopLoading() {
|
|
||||||
if (isLoading.compareAndSet(true, false)) {
|
|
||||||
if (SwingUtilities.isEventDispatchThread()) {
|
|
||||||
loadingPanel.stop()
|
|
||||||
} else {
|
|
||||||
SwingUtilities.invokeLater { loadingPanel.stop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reload(rememberSelection: Boolean = false) {
|
|
||||||
if (!requestLoading()) return
|
|
||||||
if (fileSystem is MySftpFileSystem) loadingPanel.start()
|
|
||||||
val oldWorkdir = workdir
|
|
||||||
val path = nav.getSelectedPath()
|
|
||||||
|
|
||||||
coroutineScope.launch {
|
|
||||||
try {
|
|
||||||
|
|
||||||
if (rememberSelection) {
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
table.selectedRows.sortedDescending().map { model.getFileObject(it).name.baseName }
|
|
||||||
.forEach { nextReloadTickSelection(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching { model.reload(path, useFileHiding) }.onFailure {
|
|
||||||
if (it is Exception) {
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
OptionPane.showMessageDialog(
|
|
||||||
owner, ExceptionUtils.getRootCauseMessage(it),
|
|
||||||
messageType = JOptionPane.ERROR_MESSAGE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onSuccess {
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
workdir = path
|
|
||||||
// 触发工作目录变动
|
|
||||||
firePropertyChange("workdir", oldWorkdir, workdir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
// 触发
|
|
||||||
triggerNextReloadTicks()
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
stopLoading()
|
|
||||||
if (fileSystem is MySftpFileSystem) {
|
|
||||||
withContext(Dispatchers.Swing) { loadingPanel.stop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getWorkdir(): FileObject {
|
|
||||||
return workdir
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun registerNextReloadTick(consumer: Consumer<Unit>) {
|
|
||||||
nextReloadTicks += Consumer<Unit> { t ->
|
|
||||||
assertEventDispatchThread()
|
|
||||||
consumer.accept(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun triggerNextReloadTicks() {
|
|
||||||
for (nextReloadTick in nextReloadTicks) {
|
|
||||||
nextReloadTick.accept(Unit)
|
|
||||||
}
|
|
||||||
nextReloadTicks = emptyArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispose() {
|
|
||||||
if (disposed.compareAndSet(false, true)) {
|
|
||||||
val rootChildren = transportManager.getTransports(0L)
|
|
||||||
for (child in rootChildren) {
|
|
||||||
if (child.source.fileSystem == fileSystem ||
|
|
||||||
child.target.fileSystem == fileSystem
|
|
||||||
) {
|
|
||||||
child.changeStatus(TransportStatus.Failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fileSystem.fileSystemManager.filesCache.clear(fileSystem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
|
||||||
return if (dataKey == SFTPDataProviders.FileSystemViewTable) table as T else null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getFileSystem(): FileSystem {
|
|
||||||
return fileSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setFileSystem(fileSystem: FileSystem) {
|
|
||||||
this.fileSystem = fileSystem
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LoadingPanel : JPanel() {
|
|
||||||
private val busyLabel = JXBusyLabel()
|
|
||||||
|
|
||||||
init {
|
|
||||||
isOpaque = false
|
|
||||||
border = BorderFactory.createEmptyBorder(50, 0, 0, 0)
|
|
||||||
|
|
||||||
add(busyLabel, BorderLayout.CENTER)
|
|
||||||
addMouseListener(object : MouseAdapter() {})
|
|
||||||
isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
busyLabel.isBusy = true
|
|
||||||
isVisible = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
busyLabel.isBusy = false
|
|
||||||
isVisible = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LayeredPane : JLayeredPane() {
|
|
||||||
override fun doLayout() {
|
|
||||||
synchronized(treeLock) {
|
|
||||||
val w = width
|
|
||||||
val h = height
|
|
||||||
for (c in components) {
|
|
||||||
c.setBounds(0, 0, w, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,239 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.I18n
|
|
||||||
import app.termora.NativeStringComparator
|
|
||||||
import app.termora.formatBytes
|
|
||||||
import app.termora.vfs2.FileObjectDescriptor
|
|
||||||
import app.termora.vfs2.sftp.MySftpFileObject
|
|
||||||
import com.formdev.flatlaf.util.SystemInfo
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.swing.Swing
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.apache.commons.lang3.StringUtils
|
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
|
||||||
import org.apache.commons.lang3.time.DateFormatUtils
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.apache.commons.vfs2.FileType
|
|
||||||
import org.apache.commons.vfs2.provider.local.LocalFileSystem
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import java.nio.file.attribute.PosixFilePermission
|
|
||||||
import java.nio.file.attribute.PosixFilePermissions
|
|
||||||
import java.util.*
|
|
||||||
import javax.swing.Icon
|
|
||||||
import javax.swing.SwingUtilities
|
|
||||||
import javax.swing.table.DefaultTableModel
|
|
||||||
|
|
||||||
class FileSystemViewTableModel : DefaultTableModel() {
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val COLUMN_NAME = 0
|
|
||||||
const val COLUMN_TYPE = 1
|
|
||||||
const val COLUMN_FILE_SIZE = 2
|
|
||||||
const val COLUMN_LAST_MODIFIED_TIME = 3
|
|
||||||
const val COLUMN_ATTRS = 4
|
|
||||||
const val COLUMN_OWNER = 5
|
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger(FileSystemViewTableModel::class.java)
|
|
||||||
|
|
||||||
fun fromSftpPermissions(sftpPermissions: Int): Set<PosixFilePermission> {
|
|
||||||
val result = mutableSetOf<PosixFilePermission>()
|
|
||||||
|
|
||||||
// 将十进制权限转换为八进制字符串
|
|
||||||
val octalPermissions = sftpPermissions.toString(8)
|
|
||||||
|
|
||||||
// 仅取后三位权限部分
|
|
||||||
if (octalPermissions.length < 3) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
val permissionBits = octalPermissions.takeLast(3)
|
|
||||||
|
|
||||||
// 解析每一部分的权限
|
|
||||||
val owner = permissionBits[0].digitToInt()
|
|
||||||
val group = permissionBits[1].digitToInt()
|
|
||||||
val others = permissionBits[2].digitToInt()
|
|
||||||
|
|
||||||
// 处理所有者权限
|
|
||||||
if ((owner and 4) != 0) result.add(PosixFilePermission.OWNER_READ)
|
|
||||||
if ((owner and 2) != 0) result.add(PosixFilePermission.OWNER_WRITE)
|
|
||||||
if ((owner and 1) != 0) result.add(PosixFilePermission.OWNER_EXECUTE)
|
|
||||||
|
|
||||||
// 处理组权限
|
|
||||||
if ((group and 4) != 0) result.add(PosixFilePermission.GROUP_READ)
|
|
||||||
if ((group and 2) != 0) result.add(PosixFilePermission.GROUP_WRITE)
|
|
||||||
if ((group and 1) != 0) result.add(PosixFilePermission.GROUP_EXECUTE)
|
|
||||||
|
|
||||||
// 处理其他用户权限
|
|
||||||
if ((others and 4) != 0) result.add(PosixFilePermission.OTHERS_READ)
|
|
||||||
if ((others and 2) != 0) result.add(PosixFilePermission.OTHERS_WRITE)
|
|
||||||
if ((others and 1) != 0) result.add(PosixFilePermission.OTHERS_EXECUTE)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasParent: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun getValueAt(row: Int, column: Int): Any {
|
|
||||||
val file = getFileObject(row)
|
|
||||||
val isParentRow = hasParent && row == 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (file.type == FileType.IMAGINARY) return StringUtils.EMPTY
|
|
||||||
return when (column) {
|
|
||||||
COLUMN_NAME -> if (isParentRow) ".." else file.name.baseName
|
|
||||||
COLUMN_FILE_SIZE -> if (isParentRow || file.isFolder) StringUtils.EMPTY else formatBytes(file.content.size)
|
|
||||||
COLUMN_TYPE -> if (isParentRow) StringUtils.EMPTY else getFileType(file)
|
|
||||||
COLUMN_LAST_MODIFIED_TIME -> if (isParentRow) StringUtils.EMPTY else getLastModifiedTime(file)
|
|
||||||
COLUMN_ATTRS -> if (isParentRow) StringUtils.EMPTY else getAttrs(file)
|
|
||||||
COLUMN_OWNER -> StringUtils.EMPTY
|
|
||||||
else -> StringUtils.EMPTY
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (file.fileSystem is LocalFileSystem) {
|
|
||||||
if (ExceptionUtils.getRootCause(e) is java.nio.file.NoSuchFileException) {
|
|
||||||
SwingUtilities.invokeLater { removeRow(row) }
|
|
||||||
return StringUtils.EMPTY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (log.isWarnEnabled) {
|
|
||||||
log.warn(e.message, e)
|
|
||||||
}
|
|
||||||
return StringUtils.EMPTY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getFileType(file: FileObject): String {
|
|
||||||
if (file is FileObjectDescriptor) {
|
|
||||||
val type = file.getTypeDescription()
|
|
||||||
if (type != null) return type
|
|
||||||
}
|
|
||||||
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
|
|
||||||
else if (file.isSymbolicLink) I18n.getString("termora.transport.table.type.symbolic-link")
|
|
||||||
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).second
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFileIcon(file: FileObject, width: Int = 16, height: Int = 16): Icon {
|
|
||||||
if (file is FileObjectDescriptor) {
|
|
||||||
val icon = file.getIcon(width, height)
|
|
||||||
if (icon != null) return icon
|
|
||||||
}
|
|
||||||
return if (SystemInfo.isWindows) NativeFileIcons.getIcon(file.name.baseName, file.isFile, width, height).first
|
|
||||||
else NativeFileIcons.getIcon(file.name.baseName, file.isFile).first
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFileIcon(row: Int): Icon {
|
|
||||||
return getFileIcon(getFileObject(row))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLastModifiedTime(file: FileObject): String {
|
|
||||||
var lastModified: Long = 0
|
|
||||||
if (file is FileObjectDescriptor) {
|
|
||||||
val time = file.getLastModified()
|
|
||||||
if (time != null) lastModified = time
|
|
||||||
} else {
|
|
||||||
lastModified = file.content.lastModifiedTime
|
|
||||||
}
|
|
||||||
if (lastModified < 1) return "-"
|
|
||||||
return DateFormatUtils.format(Date(lastModified), "yyyy/MM/dd HH:mm")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getAttrs(file: FileObject): String {
|
|
||||||
if (file.fileSystem is LocalFileSystem) return StringUtils.EMPTY
|
|
||||||
return PosixFilePermissions.toString(getFilePermissions(file))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFilePermissions(file: FileObject): Set<PosixFilePermission> {
|
|
||||||
val permissions = file.content.getAttribute(MySftpFileObject.POSIX_FILE_PERMISSIONS)
|
|
||||||
as Int? ?: return emptySet()
|
|
||||||
return fromSftpPermissions(permissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDataVector(): Vector<Vector<Any>> {
|
|
||||||
return super.getDataVector()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getColumnCount(): Int {
|
|
||||||
return 6
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getColumnClass(columnIndex: Int): Class<*> {
|
|
||||||
return when (columnIndex) {
|
|
||||||
COLUMN_NAME -> String::class.java
|
|
||||||
else -> super.getColumnClass(columnIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFileObject(row: Int): FileObject {
|
|
||||||
return super.getValueAt(row, 0) as FileObject
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPathNames(): Set<String> {
|
|
||||||
val names = linkedSetOf<String>()
|
|
||||||
for (i in 0 until rowCount) {
|
|
||||||
if (hasParent && i == 0) {
|
|
||||||
names.add("..")
|
|
||||||
} else {
|
|
||||||
names.add(getFileObject(i).name.baseName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun getColumnName(column: Int): String {
|
|
||||||
return when (column) {
|
|
||||||
COLUMN_NAME -> I18n.getString("termora.transport.table.filename")
|
|
||||||
COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size")
|
|
||||||
COLUMN_TYPE -> I18n.getString("termora.transport.table.type")
|
|
||||||
COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time")
|
|
||||||
COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions")
|
|
||||||
COLUMN_OWNER -> I18n.getString("termora.transport.table.owner")
|
|
||||||
else -> StringUtils.EMPTY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isCellEditable(row: Int, column: Int): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun reload(dir: FileObject, useFileHiding: Boolean) {
|
|
||||||
|
|
||||||
if (log.isDebugEnabled) {
|
|
||||||
log.debug("Reloading {} , useFileHiding {}", dir, useFileHiding)
|
|
||||||
}
|
|
||||||
|
|
||||||
val files = mutableListOf<FileObject>()
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
dir.refresh()
|
|
||||||
for (file in dir.children) {
|
|
||||||
if (useFileHiding && file.isHidden) continue
|
|
||||||
files.add(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
files.sortWith(compareBy<FileObject> { !it.isFolder }.thenComparing { a, b ->
|
|
||||||
NativeStringComparator.getInstance().compare(
|
|
||||||
a.name.baseName,
|
|
||||||
b.name.baseName
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
hasParent = dir.parent != null
|
|
||||||
if (hasParent) {
|
|
||||||
files.addFirst(dir.parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
while (rowCount > 0) removeRow(0)
|
|
||||||
files.forEach { addRow(arrayOf(it)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.Application
|
|
||||||
import app.termora.I18n
|
|
||||||
import com.formdev.flatlaf.icons.FlatTreeClosedIcon
|
|
||||||
import com.formdev.flatlaf.icons.FlatTreeLeafIcon
|
|
||||||
import com.formdev.flatlaf.util.SystemInfo
|
|
||||||
import org.apache.commons.io.FileUtils
|
|
||||||
import org.apache.commons.io.FilenameUtils
|
|
||||||
import org.apache.commons.lang3.StringUtils
|
|
||||||
import org.apache.commons.lang3.SystemUtils
|
|
||||||
import org.eclipse.jgit.util.LRUMap
|
|
||||||
import java.util.*
|
|
||||||
import javax.swing.Icon
|
|
||||||
import javax.swing.filechooser.FileSystemView.getFileSystemView
|
|
||||||
|
|
||||||
object NativeFileIcons {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* key: filename , value: <icon,description>
|
|
||||||
*/
|
|
||||||
private val cache = LRUMap<String, Pair<Icon, String>>(16, 512)
|
|
||||||
private val folderIcon = FlatTreeClosedIcon()
|
|
||||||
private val fileIcon = FlatTreeLeafIcon()
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (SystemUtils.IS_OS_UNIX) {
|
|
||||||
cache[SystemUtils.USER_HOME] = Pair(folderIcon, I18n.getString("termora.folder"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFolderIcon(): Icon {
|
|
||||||
return getIcon(UUID.randomUUID().toString(), false).first
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFileIcon(filename: String): Icon {
|
|
||||||
return getIcon(filename, true).first
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getIcon(filename: String, isFile: Boolean = true, width: Int = 16, height: Int = 16): Pair<Icon, String> {
|
|
||||||
val key = if (isFile) FilenameUtils.getExtension(filename) + "." + width + "@" + height
|
|
||||||
else SystemUtils.USER_HOME + "." + width + "@" + height
|
|
||||||
|
|
||||||
if (cache.containsKey(key)) {
|
|
||||||
return cache.getValue(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDirectory = !isFile
|
|
||||||
|
|
||||||
if (SystemInfo.isWindows) {
|
|
||||||
|
|
||||||
val file = if (isDirectory) FileUtils.getFile(SystemUtils.USER_HOME) else
|
|
||||||
FileUtils.getFile(Application.getTemporaryDir(), "${UUID.randomUUID()}.${filename}")
|
|
||||||
if (isFile && !file.exists()) {
|
|
||||||
file.createNewFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
val icon = getFileSystemView().getSystemIcon(file, width, height) ?: if (isFile) fileIcon else folderIcon
|
|
||||||
val description = getFileSystemView().getSystemTypeDescription(file)
|
|
||||||
?: StringUtils.defaultString(file.extension)
|
|
||||||
val pair = icon to description
|
|
||||||
|
|
||||||
cache[key] = pair
|
|
||||||
|
|
||||||
if (isFile) FileUtils.deleteQuietly(file)
|
|
||||||
|
|
||||||
return pair
|
|
||||||
}
|
|
||||||
|
|
||||||
return Pair(
|
|
||||||
if (isDirectory) folderIcon else fileIcon,
|
|
||||||
if (isDirectory) I18n.getString("termora.folder") else FilenameUtils.getExtension(filename)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.terminal.DataKey
|
|
||||||
|
|
||||||
object SFTPDataProviders {
|
|
||||||
val TransportManager = DataKey(app.termora.sftp.TransportManager::class)
|
|
||||||
val FileSystemViewPanel = DataKey(app.termora.sftp.FileSystemViewPanel::class)
|
|
||||||
val CoroutineScope = DataKey(kotlinx.coroutines.CoroutineScope::class)
|
|
||||||
val FileSystemViewTable = DataKey(app.termora.sftp.FileSystemViewTable::class)
|
|
||||||
val LeftSFTPTabbed = DataKey(SFTPTabbed::class)
|
|
||||||
val RightSFTPTabbed = DataKey(SFTPTabbed::class)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.Disposable
|
|
||||||
import app.termora.plugin.DispatchThread
|
|
||||||
import app.termora.plugin.Extension
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import java.awt.Window
|
|
||||||
|
|
||||||
interface SFTPEditFileExtension : Extension {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return 当停止编辑后请销毁
|
|
||||||
*/
|
|
||||||
fun edit(owner: Window, file: FileObject): Disposable
|
|
||||||
|
|
||||||
override fun getDispatchThread(): DispatchThread {
|
|
||||||
return DispatchThread.BGT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.plugin.Extension
|
|
||||||
|
|
||||||
interface SFTPExtension : Extension {
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils
|
|
||||||
import org.apache.commons.lang3.SystemUtils
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.apache.commons.vfs2.provider.local.LocalFile
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
|
|
||||||
fun FileObject.absolutePathString(): String {
|
|
||||||
var text = name.path
|
|
||||||
if (this is LocalFile && SystemUtils.IS_OS_WINDOWS) {
|
|
||||||
text = this.name.toString()
|
|
||||||
text = StringUtils.removeStart(text, "file:///")
|
|
||||||
text = StringUtils.replace(text, "/", File.separator)
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.*
|
|
||||||
import app.termora.actions.DataProvider
|
|
||||||
import app.termora.actions.DataProviderSupport
|
|
||||||
import app.termora.findeverywhere.FindEverywhereProvider
|
|
||||||
import app.termora.protocol.FileObjectRequest
|
|
||||||
import app.termora.protocol.TransferProtocolProvider
|
|
||||||
import app.termora.sftp.internal.local.LocalTransferProtocolProvider
|
|
||||||
import app.termora.terminal.DataKey
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import okio.withLock
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import java.awt.BorderLayout
|
|
||||||
import java.awt.event.ComponentAdapter
|
|
||||||
import java.awt.event.ComponentEvent
|
|
||||||
import java.nio.file.FileSystems
|
|
||||||
import javax.swing.*
|
|
||||||
|
|
||||||
|
|
||||||
class SFTPPanel : JPanel(BorderLayout()), DataProvider, Disposable {
|
|
||||||
|
|
||||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
private val transportTable = TransportTable()
|
|
||||||
private val transportManager get() = transportTable.model
|
|
||||||
private val dataProviderSupport = DataProviderSupport()
|
|
||||||
private val leftComponent = SFTPTabbed(transportManager)
|
|
||||||
private val rightComponent = SFTPTabbed(transportManager)
|
|
||||||
private val localHost = Host(
|
|
||||||
id = "local",
|
|
||||||
name = I18n.getString("termora.transport.local"),
|
|
||||||
protocol = "Local",
|
|
||||||
)
|
|
||||||
|
|
||||||
init {
|
|
||||||
initViews()
|
|
||||||
initEvents()
|
|
||||||
FileSystems.getDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initViews() {
|
|
||||||
|
|
||||||
putClientProperty(FindEverywhereProvider.SKIP_FIND_EVERYWHERE, true)
|
|
||||||
|
|
||||||
val splitPane = JSplitPane()
|
|
||||||
splitPane.resizeWeight = 0.5
|
|
||||||
splitPane.leftComponent = leftComponent
|
|
||||||
splitPane.rightComponent = rightComponent
|
|
||||||
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
|
|
||||||
splitPane.addComponentListener(object : ComponentAdapter() {
|
|
||||||
override fun componentResized(e: ComponentEvent) {
|
|
||||||
removeComponentListener(this)
|
|
||||||
splitPane.setDividerLocation(splitPane.resizeWeight)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
leftComponent.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
|
|
||||||
rightComponent.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
|
|
||||||
|
|
||||||
val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT)
|
|
||||||
val scrollPane = JScrollPane(transportTable)
|
|
||||||
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
|
||||||
|
|
||||||
rootSplitPane.resizeWeight = 0.7
|
|
||||||
rootSplitPane.topComponent = splitPane
|
|
||||||
rootSplitPane.bottomComponent = scrollPane
|
|
||||||
rootSplitPane.addComponentListener(object : ComponentAdapter() {
|
|
||||||
override fun componentResized(e: ComponentEvent) {
|
|
||||||
removeComponentListener(this)
|
|
||||||
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
add(rootSplitPane, BorderLayout.CENTER)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initEvents() {
|
|
||||||
Disposer.register(this, leftComponent)
|
|
||||||
Disposer.register(this, rightComponent)
|
|
||||||
Disposer.register(this, transportTable)
|
|
||||||
|
|
||||||
dataProviderSupport.addData(SFTPDataProviders.TransportManager, transportManager)
|
|
||||||
dataProviderSupport.addData(SFTPDataProviders.LeftSFTPTabbed, leftComponent)
|
|
||||||
dataProviderSupport.addData(SFTPDataProviders.RightSFTPTabbed, rightComponent)
|
|
||||||
|
|
||||||
|
|
||||||
// default tab
|
|
||||||
leftComponent.addTab(
|
|
||||||
I18n.getString("termora.transport.local"),
|
|
||||||
FileSystemViewPanel(
|
|
||||||
localHost,
|
|
||||||
TransferProtocolProvider.valueOf(LocalTransferProtocolProvider.PROTOCOL)!!
|
|
||||||
.getRootFileObject(FileObjectRequest(localHost)).file,
|
|
||||||
transportManager,
|
|
||||||
coroutineScope
|
|
||||||
)
|
|
||||||
)
|
|
||||||
leftComponent.setTabClosable(0, false)
|
|
||||||
|
|
||||||
|
|
||||||
// default tab
|
|
||||||
rightComponent.addTab(
|
|
||||||
I18n.getString("termora.transport.sftp.select-host"),
|
|
||||||
SFTPFileSystemViewPanel(transportManager = transportManager)
|
|
||||||
)
|
|
||||||
|
|
||||||
rightComponent.addChangeListener {
|
|
||||||
if (rightComponent.tabCount == 0 && !rightComponent.isDisposed) {
|
|
||||||
rightComponent.addTab(
|
|
||||||
I18n.getString("termora.transport.sftp.select-host"),
|
|
||||||
SFTPFileSystemViewPanel(transportManager = transportManager)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
leftComponent.setTabCloseCallback { _, index -> tabCloseCallback(leftComponent, index) }
|
|
||||||
rightComponent.setTabCloseCallback { _, index -> tabCloseCallback(rightComponent, index) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tabCloseCallback(tabbed: SFTPTabbed, index: Int) {
|
|
||||||
assertEventDispatchThread()
|
|
||||||
|
|
||||||
val c = tabbed.getFileSystemViewPanel(index)
|
|
||||||
if (c == null) {
|
|
||||||
tabbed.removeTabAt(index)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val fs = c.getFileSystem()
|
|
||||||
val root = transportManager.root
|
|
||||||
|
|
||||||
transportManager.lock.withLock {
|
|
||||||
val deletedIds = mutableListOf<Long>()
|
|
||||||
for (i in 0 until root.childCount) {
|
|
||||||
val child = root.getChildAt(i) as? TransportTreeTableNode ?: continue
|
|
||||||
if (child.transport.source.fileSystem == fs ||
|
|
||||||
child.transport.target.fileSystem == fs
|
|
||||||
) {
|
|
||||||
deletedIds.add(child.transport.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletedIds.isNotEmpty()) {
|
|
||||||
if (OptionPane.showConfirmDialog(
|
|
||||||
SwingUtilities.getWindowAncestor(this),
|
|
||||||
I18n.getString("termora.transport.sftp.close-tab"),
|
|
||||||
messageType = JOptionPane.QUESTION_MESSAGE,
|
|
||||||
optionType = JOptionPane.OK_CANCEL_OPTION
|
|
||||||
) != JOptionPane.OK_OPTION
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deletedIds.forEach { transportManager.removeTransport(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 删除并销毁
|
|
||||||
tabbed.removeTabAt(index)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返回失败表示没有创建成功
|
|
||||||
*/
|
|
||||||
fun addTransport(
|
|
||||||
source: JComponent,
|
|
||||||
sourceWorkdir: FileObject?,
|
|
||||||
target: FileSystemViewPanel,
|
|
||||||
targetWorkdir: FileObject?,
|
|
||||||
transport: Transport
|
|
||||||
): Boolean {
|
|
||||||
|
|
||||||
val sourcePanel = SwingUtilities.getAncestorOfClass(FileSystemViewPanel::class.java, source)
|
|
||||||
as? FileSystemViewPanel ?: return false
|
|
||||||
val targetPanel = target
|
|
||||||
if (sourcePanel.isDisposed || targetPanel.isDisposed) return false
|
|
||||||
val myTargetWorkdir = (targetWorkdir ?: targetPanel.getWorkdir())
|
|
||||||
val mySourceWorkdir = (sourceWorkdir ?: sourcePanel.getWorkdir())
|
|
||||||
val sourcePath = transport.source
|
|
||||||
|
|
||||||
val relativeName = mySourceWorkdir.name.getRelativeName(sourcePath.name)
|
|
||||||
transport.target = myTargetWorkdir.resolveFile(relativeName)
|
|
||||||
|
|
||||||
return transportManager.addTransport(transport)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canTransfer(source: JComponent): Boolean {
|
|
||||||
return getTarget(source) != null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getTarget(source: JComponent): FileSystemViewPanel? {
|
|
||||||
val sourceTabbed = SwingUtilities.getAncestorOfClass(SFTPTabbed::class.java, source)
|
|
||||||
as? SFTPTabbed ?: return null
|
|
||||||
val isLeft = sourceTabbed == leftComponent
|
|
||||||
val targetTabbed = if (isLeft) rightComponent else leftComponent
|
|
||||||
return targetTabbed.getSelectedFileSystemViewPanel()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取本地文件系统面板
|
|
||||||
*/
|
|
||||||
fun getLocalTarget(): FileSystemViewPanel {
|
|
||||||
return leftComponent.getFileSystemViewPanel(0) as FileSystemViewPanel
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
|
||||||
return dataProviderSupport.getData(dataKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispose() {
|
|
||||||
coroutineScope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.*
|
|
||||||
import app.termora.actions.AnAction
|
|
||||||
import app.termora.actions.AnActionEvent
|
|
||||||
import app.termora.database.DatabaseChangedExtension
|
|
||||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
|
||||||
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
|
||||||
import java.awt.event.MouseAdapter
|
|
||||||
import java.awt.event.MouseEvent
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import javax.swing.JButton
|
|
||||||
import javax.swing.JToolBar
|
|
||||||
import javax.swing.SwingUtilities
|
|
||||||
|
|
||||||
@Suppress("DuplicatedCode")
|
|
||||||
class SFTPTabbed(private val transportManager: TransportManager) : FlatTabbedPane(), Disposable {
|
|
||||||
private val addBtn = JButton(Icons.add)
|
|
||||||
private val tabbed = this
|
|
||||||
private val disposed = AtomicBoolean(false)
|
|
||||||
private val hostManager get() = HostManager.getInstance()
|
|
||||||
|
|
||||||
val isDisposed get() = disposed.get()
|
|
||||||
|
|
||||||
init {
|
|
||||||
initViews()
|
|
||||||
initEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initViews() {
|
|
||||||
super.setTabLayoutPolicy(SCROLL_TAB_LAYOUT)
|
|
||||||
super.setTabsClosable(true)
|
|
||||||
super.setTabType(TabType.underlined)
|
|
||||||
|
|
||||||
val toolbar = JToolBar()
|
|
||||||
toolbar.add(addBtn)
|
|
||||||
super.setTrailingComponent(toolbar)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initEvents() {
|
|
||||||
addBtn.addActionListener(object : AnAction() {
|
|
||||||
override fun actionPerformed(evt: AnActionEvent) {
|
|
||||||
for (i in 0 until tabCount) {
|
|
||||||
val c = getComponentAt(i)
|
|
||||||
if (c !is SFTPFileSystemViewPanel) continue
|
|
||||||
if (c.state != SFTPFileSystemViewPanel.State.Initialized) continue
|
|
||||||
selectedIndex = i
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加一个新的
|
|
||||||
addTab(
|
|
||||||
I18n.getString("termora.transport.sftp.select-host"),
|
|
||||||
SFTPFileSystemViewPanel(transportManager = transportManager)
|
|
||||||
)
|
|
||||||
selectedIndex = tabCount - 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 右键菜单
|
|
||||||
addMouseListener(object : MouseAdapter() {
|
|
||||||
override fun mouseClicked(e: MouseEvent) {
|
|
||||||
if (!SwingUtilities.isRightMouseButton(e)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val index = indexAtLocation(e.x, e.y)
|
|
||||||
if (index < 0) return
|
|
||||||
|
|
||||||
showContextMenu(index, e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
|
|
||||||
val panel = getFileSystemViewPanel(tabIndex) ?: return
|
|
||||||
val popupMenu = FlatPopupMenu()
|
|
||||||
// 克隆
|
|
||||||
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
|
|
||||||
clone.addActionListener(object : AnAction() {
|
|
||||||
override fun actionPerformed(evt: AnActionEvent) {
|
|
||||||
val host = hostManager.getHost(panel.host.id) ?: return
|
|
||||||
addSFTPFileSystemViewPanelTab(
|
|
||||||
host.copy(
|
|
||||||
options = host.options.copy(
|
|
||||||
sftpDefaultDirectory = panel.getWorkdir().absolutePathString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 编辑
|
|
||||||
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
|
|
||||||
edit.addActionListener(object : AnAction() {
|
|
||||||
override fun actionPerformed(evt: AnActionEvent) {
|
|
||||||
if (panel.host.id == "local") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val host = hostManager.getHost(panel.host.id) ?: return
|
|
||||||
val dialog = NewHostDialogV2(evt.window, host)
|
|
||||||
dialog.setLocationRelativeTo(evt.window)
|
|
||||||
dialog.isVisible = true
|
|
||||||
hostManager.addHost(dialog.host ?: return, DatabaseChangedExtension.Source.Sync)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
clone.isEnabled = panel.host.id != "local"
|
|
||||||
edit.isEnabled = clone.isEnabled
|
|
||||||
|
|
||||||
popupMenu.show(this, e.x, e.y)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addSFTPFileSystemViewPanelTab(host: Host) {
|
|
||||||
val panel = SFTPFileSystemViewPanel(host, transportManager)
|
|
||||||
addTab(host.name, panel)
|
|
||||||
panel.connect()
|
|
||||||
selectedIndex = tabCount - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前的 FileSystemViewPanel
|
|
||||||
*/
|
|
||||||
fun getSelectedFileSystemViewPanel(): FileSystemViewPanel? {
|
|
||||||
return getFileSystemViewPanel(selectedIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun getFileSystemViewPanel(index: Int): FileSystemViewPanel? {
|
|
||||||
if (tabCount < 1 || index < 0) return null
|
|
||||||
|
|
||||||
val c = getComponentAt(index)
|
|
||||||
if (c is FileSystemViewPanel) {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c is SFTPFileSystemViewPanel) {
|
|
||||||
return c.getData(SFTPDataProviders.FileSystemViewPanel)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun updateUI() {
|
|
||||||
styleMap = mapOf(
|
|
||||||
"focusColor" to DynamicColor("TabbedPane.background"),
|
|
||||||
"hoverColor" to DynamicColor("TabbedPane.background"),
|
|
||||||
"tabHeight" to 30
|
|
||||||
)
|
|
||||||
super.updateUI()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeTabAt(index: Int) {
|
|
||||||
val c = getComponentAt(index)
|
|
||||||
if (c is Disposable) {
|
|
||||||
Disposer.dispose(c)
|
|
||||||
}
|
|
||||||
super.removeTabAt(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispose() {
|
|
||||||
if (disposed.compareAndSet(false, true)) {
|
|
||||||
while (tabCount > 0) removeTabAt(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
class SpeedReporter(private val coroutineScope: CoroutineScope) {
|
|
||||||
companion object {
|
|
||||||
val millis = TimeUnit.MILLISECONDS.toMillis(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val events = ConcurrentLinkedQueue<Triple<Transport, Long, Long>>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun report(transport: Transport, bytes: Long, time: Long) {
|
|
||||||
events.add(Triple(transport, bytes, time))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun collect() {
|
|
||||||
// 异步上报数据
|
|
||||||
coroutineScope.launch {
|
|
||||||
while (coroutineScope.isActive) {
|
|
||||||
val time = System.currentTimeMillis()
|
|
||||||
val map = linkedMapOf<Transport, Long>()
|
|
||||||
|
|
||||||
// 收集
|
|
||||||
while (events.isNotEmpty() && events.peek().second < time) {
|
|
||||||
val (a, b) = events.poll()
|
|
||||||
map[a] = map.computeIfAbsent(a) { 0 } + b
|
|
||||||
}
|
|
||||||
|
|
||||||
if (map.isNotEmpty()) {
|
|
||||||
for ((a, b) in map) {
|
|
||||||
if (b > 0) {
|
|
||||||
reportTransferredFilesize(a, b, time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(millis.milliseconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun reportTransferredFilesize(transport: Transport, bytes: Long, time: Long) {
|
|
||||||
transport.reportTransferredFilesize(bytes, time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.database.DatabaseManager
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.apache.commons.io.IOUtils
|
|
||||||
import org.apache.commons.net.io.Util
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import java.nio.file.StandardOpenOption
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
enum class TransportStatus {
|
|
||||||
Ready,
|
|
||||||
Processing,
|
|
||||||
Failed,
|
|
||||||
Done,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 传输单位:单个文件
|
|
||||||
*/
|
|
||||||
class Transport(
|
|
||||||
/**
|
|
||||||
* 唯一 ID
|
|
||||||
*/
|
|
||||||
val id: Long = idGenerator.incrementAndGet(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否是文件夹
|
|
||||||
*/
|
|
||||||
val isDirectory: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 父
|
|
||||||
*/
|
|
||||||
val parentId: Long = 0,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 源
|
|
||||||
*/
|
|
||||||
val source: FileObject,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 目标
|
|
||||||
*/
|
|
||||||
var target: FileObject,
|
|
||||||
/**
|
|
||||||
* 仅对文件生效,切只有两个选项
|
|
||||||
*
|
|
||||||
* 1. [StandardOpenOption.APPEND]
|
|
||||||
* 2. [StandardOpenOption.TRUNCATE_EXISTING]
|
|
||||||
*/
|
|
||||||
var mode: StandardOpenOption = StandardOpenOption.TRUNCATE_EXISTING
|
|
||||||
) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val idGenerator = AtomicLong(0)
|
|
||||||
private val exception = RuntimeException("Nothing")
|
|
||||||
private val log = LoggerFactory.getLogger(Transport::class.java)
|
|
||||||
private val isPreserveModificationTime get() = DatabaseManager.getInstance().sftp.preserveModificationTime
|
|
||||||
}
|
|
||||||
|
|
||||||
private val scanned by lazy { AtomicBoolean(false) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计数器
|
|
||||||
*/
|
|
||||||
private val counter by lazy { SlidingWindowByteCounter() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 父
|
|
||||||
*/
|
|
||||||
var parent: Transport? = null
|
|
||||||
set(value) {
|
|
||||||
if (field != null) throw IllegalStateException("parent already exists")
|
|
||||||
field = value
|
|
||||||
// 上报大小
|
|
||||||
reportFilesize(filesize.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件大小,对于文件夹来说,文件大小是不确定的,它取决于文件夹下的文件
|
|
||||||
*/
|
|
||||||
val filesize = AtomicLong(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 已经传输完成的文件大小
|
|
||||||
*/
|
|
||||||
val transferredFilesize = AtomicLong(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 如果是文件夹,是否已经扫描完毕。如果已经扫描完毕,那么该文件夹传输完成后可以立即删除
|
|
||||||
*/
|
|
||||||
val isScanned get() = scanned.get()
|
|
||||||
|
|
||||||
val isFile = !isDirectory
|
|
||||||
val isRoot = parentId == 0L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取最近一秒内的速度
|
|
||||||
*/
|
|
||||||
val speed get() = counter.getLastSecondBytes()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 状态
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
var status: TransportStatus = TransportStatus.Ready
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 失败异常
|
|
||||||
*/
|
|
||||||
var exception: Throwable = Transport.exception
|
|
||||||
|
|
||||||
|
|
||||||
fun scanned() {
|
|
||||||
scanned.compareAndSet(false, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun changeStatus(status: TransportStatus): Boolean {
|
|
||||||
synchronized(this) {
|
|
||||||
if (status == TransportStatus.Processing) {
|
|
||||||
if (this.status != TransportStatus.Ready) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else if (status == TransportStatus.Failed || status == TransportStatus.Done) {
|
|
||||||
if (this.status != TransportStatus.Ready && this.status != TransportStatus.Processing) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else if (status == TransportStatus.Ready) {
|
|
||||||
if (this.status != TransportStatus.Ready) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.status = status
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val c = AtomicLong(0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始传输
|
|
||||||
*/
|
|
||||||
suspend fun transport(reporter: SpeedReporter) {
|
|
||||||
|
|
||||||
if (isDirectory) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
if (!target.exists()) {
|
|
||||||
target.createFolder()
|
|
||||||
}
|
|
||||||
} catch (_: FileAlreadyExistsException) {
|
|
||||||
if (log.isWarnEnabled) {
|
|
||||||
log.warn("Directory ${target.name} already exists")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
exception = e
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val input = source.content.inputStream
|
|
||||||
val output = target.content.getOutputStream(mode == StandardOpenOption.APPEND)
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
val buff = ByteArray(Util.DEFAULT_COPY_BUFFER_SIZE)
|
|
||||||
var len: Int
|
|
||||||
while (input.read(buff).also { len = it } != -1 && this.isActive) {
|
|
||||||
|
|
||||||
// 写入
|
|
||||||
output.write(buff, 0, len)
|
|
||||||
|
|
||||||
val size = len.toLong()
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// 上报传输的字节数量
|
|
||||||
reporter.report(this@Transport, size, now)
|
|
||||||
|
|
||||||
// 如果状态错误,那么可能已经取消了
|
|
||||||
if (status != TransportStatus.Processing) {
|
|
||||||
throw TransportStatusException("status is $status")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
IOUtils.closeQuietly(input, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试修改时间
|
|
||||||
preserveModificationTime()
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun preserveModificationTime() {
|
|
||||||
// 设置修改时间
|
|
||||||
if (isPreserveModificationTime) {
|
|
||||||
target.content.lastModifiedTime = source.content.lastModifiedTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 一层层上报文件大小
|
|
||||||
*/
|
|
||||||
fun reportFilesize(bytes: Long) {
|
|
||||||
val p = parent ?: return
|
|
||||||
if (isRoot) return
|
|
||||||
// 父状态不正常
|
|
||||||
if (p.status == TransportStatus.Failed) return
|
|
||||||
// 父的文件大小就是自己的文件大小
|
|
||||||
p.filesize.addAndGet(bytes)
|
|
||||||
// 递归上报
|
|
||||||
p.reportFilesize(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 一层层上报传输大小
|
|
||||||
*/
|
|
||||||
fun reportTransferredFilesize(bytes: Long, time: Long) {
|
|
||||||
var p = this as Transport?
|
|
||||||
while (p != null) {
|
|
||||||
// 记录上报的数量,用于统计速度
|
|
||||||
if (bytes > 0) p.counter.addBytes(bytes, time)
|
|
||||||
// 状态不正常
|
|
||||||
if (p.status == TransportStatus.Failed) return
|
|
||||||
// 父的传输文件大小就是自己的传输文件大小
|
|
||||||
p.transferredFilesize.addAndGet(bytes)
|
|
||||||
p = p.parent
|
|
||||||
c.incrementAndGet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private class SlidingWindowByteCounter {
|
|
||||||
private val events = ConcurrentLinkedQueue<Pair<Long, Long>>()
|
|
||||||
private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1)
|
|
||||||
|
|
||||||
fun addBytes(bytes: Long, time: Long) {
|
|
||||||
|
|
||||||
// 添加当前事件
|
|
||||||
events.add(time to bytes)
|
|
||||||
|
|
||||||
// 移除过期事件(超过 1 秒的记录)
|
|
||||||
while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) {
|
|
||||||
events.poll()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLastSecondBytes(): Long {
|
|
||||||
val currentTime = System.currentTimeMillis()
|
|
||||||
// 累加最近 1 秒内的字节数
|
|
||||||
return events.filter { it.first >= currentTime - oneSecondInMillis }
|
|
||||||
.sumOf { it.second }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
interface TransportListener : EventListener {
|
|
||||||
/**
|
|
||||||
* 状态变化
|
|
||||||
*/
|
|
||||||
fun onTransportChanged(transport: Transport) {}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
interface TransportManager {
|
|
||||||
fun addTransport(transport: Transport): Boolean
|
|
||||||
fun getTransport(id: Long): Transport?
|
|
||||||
fun getTransports(pId: Long): List<Transport>
|
|
||||||
fun getTransportCount(): Int
|
|
||||||
fun removeTransport(id: Long)
|
|
||||||
fun addTransportListener(listener: TransportListener)
|
|
||||||
fun removeTransportListener(listener: TransportListener)
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
class TransportStatusException(message: String) : RuntimeException(message)
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.Disposable
|
|
||||||
import app.termora.Disposer
|
|
||||||
import app.termora.I18n
|
|
||||||
import app.termora.OptionPane
|
|
||||||
import com.formdev.flatlaf.FlatClientProperties
|
|
||||||
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
|
||||||
import com.formdev.flatlaf.util.SystemInfo
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.swing.Swing
|
|
||||||
import org.jdesktop.swingx.JXTreeTable
|
|
||||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
|
||||||
import java.awt.Component
|
|
||||||
import java.awt.Graphics
|
|
||||||
import java.awt.Insets
|
|
||||||
import java.awt.event.KeyAdapter
|
|
||||||
import java.awt.event.KeyEvent
|
|
||||||
import java.awt.event.MouseAdapter
|
|
||||||
import java.awt.event.MouseEvent
|
|
||||||
import java.util.*
|
|
||||||
import javax.swing.*
|
|
||||||
import javax.swing.table.DefaultTableCellRenderer
|
|
||||||
import javax.swing.tree.DefaultTreeCellRenderer
|
|
||||||
import kotlin.math.floor
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
@Suppress("DuplicatedCode")
|
|
||||||
class TransportTable : JXTreeTable(), Disposable {
|
|
||||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
val model = TransportTableModel(coroutineScope)
|
|
||||||
|
|
||||||
|
|
||||||
private val table = this
|
|
||||||
private val transportManager = model as TransportManager
|
|
||||||
|
|
||||||
init {
|
|
||||||
initViews()
|
|
||||||
initEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initViews() {
|
|
||||||
super.getTableHeader().setReorderingAllowed(false)
|
|
||||||
super.setTreeTableModel(model)
|
|
||||||
super.setRowHeight(UIManager.getInt("Table.rowHeight"))
|
|
||||||
super.setAutoResizeMode(JTable.AUTO_RESIZE_OFF)
|
|
||||||
super.setFillsViewportHeight(true)
|
|
||||||
super.putClientProperty(
|
|
||||||
FlatClientProperties.STYLE, mapOf(
|
|
||||||
"cellMargins" to Insets(0, 4, 0, 4),
|
|
||||||
"selectionArc" to 0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
super.setTreeCellRenderer(object : DefaultTreeCellRenderer() {
|
|
||||||
override fun getTreeCellRendererComponent(
|
|
||||||
tree: JTree?,
|
|
||||||
value: Any?,
|
|
||||||
sel: Boolean,
|
|
||||||
expanded: Boolean,
|
|
||||||
leaf: Boolean,
|
|
||||||
row: Int,
|
|
||||||
hasFocus: Boolean
|
|
||||||
): Component {
|
|
||||||
val node = value as DefaultMutableTreeTableNode
|
|
||||||
val transport = node.userObject as? Transport
|
|
||||||
val text = Objects.toString(node.getValueAt(TransportTableModel.COLUMN_NAME))
|
|
||||||
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
|
|
||||||
icon = if (transport?.isDirectory == true) NativeFileIcons.getFolderIcon()
|
|
||||||
else NativeFileIcons.getFileIcon(text)
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_NAME).preferredWidth = 300
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
|
|
||||||
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_STATUS).preferredWidth = 100
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).preferredWidth = 150
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_SIZE).preferredWidth = 140
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_SPEED).preferredWidth = 80
|
|
||||||
|
|
||||||
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = centerTableCellRenderer
|
|
||||||
columnModel.getColumn(TransportTableModel.COLUMN_PROGRESS).cellRenderer =
|
|
||||||
object : DefaultTableCellRenderer() {
|
|
||||||
private var progress = 0.0
|
|
||||||
private var progressInt = 0
|
|
||||||
private val padding = 4
|
|
||||||
|
|
||||||
init {
|
|
||||||
horizontalAlignment = SwingConstants.CENTER
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTableCellRendererComponent(
|
|
||||||
table: JTable?,
|
|
||||||
value: Any?,
|
|
||||||
isSelected: Boolean,
|
|
||||||
hasFocus: Boolean,
|
|
||||||
row: Int,
|
|
||||||
column: Int
|
|
||||||
): Component {
|
|
||||||
|
|
||||||
this.progress = 0.0
|
|
||||||
this.progressInt = 0
|
|
||||||
|
|
||||||
if (value is Transport) {
|
|
||||||
if (value.status == TransportStatus.Processing) {
|
|
||||||
this.progress = value.transferredFilesize.get() * 1.0 / value.filesize.get()
|
|
||||||
this.progressInt = floor(progress * 100.0).toInt()
|
|
||||||
// 因为有一些 0B 大小的文件,所以如果在进行中,那么最大就是99
|
|
||||||
if (this.progress >= 1 && value.status == TransportStatus.Processing) {
|
|
||||||
this.progress = 0.99
|
|
||||||
this.progressInt = floor(progress * 100.0).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.getTableCellRendererComponent(
|
|
||||||
table,
|
|
||||||
"${progressInt}%",
|
|
||||||
isSelected,
|
|
||||||
hasFocus,
|
|
||||||
row,
|
|
||||||
column
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun paintComponent(g: Graphics) {
|
|
||||||
// 原始背景
|
|
||||||
g.color = background
|
|
||||||
g.fillRect(0, 0, width, height)
|
|
||||||
|
|
||||||
// 进度条背景
|
|
||||||
g.color = UIManager.getColor("Table.selectionInactiveBackground")
|
|
||||||
g.fillRect(0, padding, width, height - padding * 2)
|
|
||||||
|
|
||||||
// 进度条颜色
|
|
||||||
g.color = UIManager.getColor("ProgressBar.foreground")
|
|
||||||
g.fillRect(0, padding, (width * progress).toInt(), height - padding * 2)
|
|
||||||
|
|
||||||
// 大于某个阀值的时候,就要改变颜色
|
|
||||||
if (progress >= 0.45) {
|
|
||||||
foreground = selectionForeground
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绘制文字
|
|
||||||
ui.paint(g, this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun initEvents() {
|
|
||||||
// contextmenu
|
|
||||||
table.addMouseListener(object : MouseAdapter() {
|
|
||||||
override fun mouseClicked(e: MouseEvent) {
|
|
||||||
if (SwingUtilities.isRightMouseButton(e)) {
|
|
||||||
val r = table.rowAtPoint(e.point)
|
|
||||||
if (r >= 0 && r < table.rowCount) {
|
|
||||||
if (!table.isRowSelected(r)) {
|
|
||||||
table.setRowSelectionInterval(r, r)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
table.clearSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
val rows = table.selectedRows
|
|
||||||
|
|
||||||
if (!table.hasFocus()) {
|
|
||||||
table.requestFocusInWindow()
|
|
||||||
}
|
|
||||||
|
|
||||||
showContextMenu(rows, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 刷新状态
|
|
||||||
coroutineScope.launch(Dispatchers.Swing) { refreshView() }
|
|
||||||
|
|
||||||
|
|
||||||
// Delete key
|
|
||||||
table.addKeyListener(object : KeyAdapter() {
|
|
||||||
override fun keyPressed(e: KeyEvent) {
|
|
||||||
if ((SystemInfo.isMacOS && e.keyCode == KeyEvent.VK_BACK_SPACE) || (e.keyCode == KeyEvent.VK_DELETE)) {
|
|
||||||
val transports = selectedRows.map { getPathForRow(it).lastPathComponent }
|
|
||||||
.filterIsInstance<TransportTreeTableNode>().map { it.transport }
|
|
||||||
if (transports.isEmpty()) return
|
|
||||||
if (OptionPane.showConfirmDialog(
|
|
||||||
SwingUtilities.getWindowAncestor(table),
|
|
||||||
I18n.getString("termora.keymgr.delete-warning"),
|
|
||||||
messageType = JOptionPane.WARNING_MESSAGE
|
|
||||||
) == JOptionPane.YES_OPTION
|
|
||||||
) {
|
|
||||||
transports.forEach { transportManager.removeTransport(it.id) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
Disposer.register(this, model)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showContextMenu(rows: IntArray, e: MouseEvent) {
|
|
||||||
val transports = rows.map { getPathForRow(it).lastPathComponent }
|
|
||||||
.filterIsInstance<TransportTreeTableNode>().map { it.transport }
|
|
||||||
val popupMenu = FlatPopupMenu()
|
|
||||||
|
|
||||||
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete"))
|
|
||||||
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
|
|
||||||
delete.addActionListener {
|
|
||||||
if (OptionPane.showConfirmDialog(
|
|
||||||
SwingUtilities.getWindowAncestor(this),
|
|
||||||
I18n.getString("termora.keymgr.delete-warning"),
|
|
||||||
messageType = JOptionPane.WARNING_MESSAGE
|
|
||||||
) == JOptionPane.YES_OPTION
|
|
||||||
) {
|
|
||||||
for (transport in transports) {
|
|
||||||
transportManager.removeTransport(transport.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deleteAll.addActionListener {
|
|
||||||
if (OptionPane.showConfirmDialog(
|
|
||||||
SwingUtilities.getWindowAncestor(this),
|
|
||||||
I18n.getString("termora.keymgr.delete-warning"),
|
|
||||||
messageType = JOptionPane.WARNING_MESSAGE
|
|
||||||
) == JOptionPane.YES_OPTION
|
|
||||||
) {
|
|
||||||
transportManager.removeTransport(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delete.isEnabled = transports.isNotEmpty()
|
|
||||||
|
|
||||||
popupMenu.show(this, e.x, e.y)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshView() {
|
|
||||||
while (coroutineScope.isActive) {
|
|
||||||
for (row in 0 until rowCount) {
|
|
||||||
val treePath = getPathForRow(row) ?: continue
|
|
||||||
val node = treePath.lastPathComponent as? TransportTreeTableNode ?: continue
|
|
||||||
model.valueForPathChanged(treePath, node.transport)
|
|
||||||
}
|
|
||||||
delay(SpeedReporter.millis.milliseconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispose() {
|
|
||||||
coroutineScope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.Disposable
|
|
||||||
import app.termora.I18n
|
|
||||||
import app.termora.assertEventDispatchThread
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.swing.Swing
|
|
||||||
import okio.withLock
|
|
||||||
import org.apache.commons.lang3.ArrayUtils
|
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
|
||||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
|
||||||
import org.jdesktop.swingx.treetable.DefaultTreeTableModel
|
|
||||||
import org.jdesktop.swingx.treetable.MutableTreeTableNode
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import javax.swing.SwingUtilities
|
|
||||||
import kotlin.collections.ArrayDeque
|
|
||||||
import kotlin.math.abs
|
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
|
|
||||||
class TransportTableModel(private val coroutineScope: CoroutineScope) :
|
|
||||||
DefaultTreeTableModel(DefaultMutableTreeTableNode()), TransportManager, Disposable {
|
|
||||||
|
|
||||||
val lock = ReentrantLock()
|
|
||||||
|
|
||||||
private val transports = Collections.synchronizedMap(linkedMapOf<Long, TransportTreeTableNode>())
|
|
||||||
private val reporter = SpeedReporter(coroutineScope)
|
|
||||||
private var listeners = emptyArray<TransportListener>()
|
|
||||||
private val activeTransports = linkedMapOf<Long, Job>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 最多的平行任务
|
|
||||||
*/
|
|
||||||
private val maxParallels = max(min(Runtime.getRuntime().availableProcessors(), 4), 1)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val log = LoggerFactory.getLogger(TransportTableModel::class.java)
|
|
||||||
|
|
||||||
const val COLUMN_COUNT = 8
|
|
||||||
|
|
||||||
const val COLUMN_NAME = 0
|
|
||||||
const val COLUMN_STATUS = 1
|
|
||||||
const val COLUMN_PROGRESS = 2
|
|
||||||
const val COLUMN_SIZE = 3
|
|
||||||
const val COLUMN_SOURCE_PATH = 4
|
|
||||||
const val COLUMN_TARGET_PATH = 5
|
|
||||||
const val COLUMN_SPEED = 6
|
|
||||||
const val COLUMN_ESTIMATED_TIME = 7
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
setColumnIdentifiers(
|
|
||||||
listOf(
|
|
||||||
I18n.getString("termora.transport.jobs.table.name"),
|
|
||||||
I18n.getString("termora.transport.jobs.table.status"),
|
|
||||||
I18n.getString("termora.transport.jobs.table.progress"),
|
|
||||||
I18n.getString("termora.transport.jobs.table.size"),
|
|
||||||
I18n.getString("termora.transport.jobs.table.source-path"),
|
|
||||||
I18n.getString("termora.transport.jobs.table.target-path"),
|
|
||||||
I18n.getString("termora.transport.jobs.table.speed"),
|
|
||||||
I18n.getString("termora.transport.jobs.table.estimated-time")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
coroutineScope.launch { run() }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun getRoot(): DefaultMutableTreeTableNode {
|
|
||||||
return super.getRoot() as DefaultMutableTreeTableNode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isCellEditable(node: Any?, column: Int): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addTransport(transport: Transport): Boolean {
|
|
||||||
return lock.withLock {
|
|
||||||
if (!transport.isRoot) {
|
|
||||||
|
|
||||||
// 判断父是否存在
|
|
||||||
if (!transports.containsKey(transport.parentId)) {
|
|
||||||
return@withLock false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检测状态
|
|
||||||
if (!validGrandfatherStatus(transport)) {
|
|
||||||
changeStatus(transport, TransportStatus.Failed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val newNode = TransportTreeTableNode(transport)
|
|
||||||
val parentId = transport.parentId
|
|
||||||
val root = getRoot()
|
|
||||||
val p = if (parentId == 0L || !transports.contains(parentId)) {
|
|
||||||
root
|
|
||||||
} else {
|
|
||||||
transports.getValue(transport.parentId).apply { transport.parent = this.transport }
|
|
||||||
}
|
|
||||||
|
|
||||||
transports[transport.id] = newNode
|
|
||||||
|
|
||||||
if ((transports.containsKey(parentId) || p == root) && transports.containsKey(transport.id)) {
|
|
||||||
// 主线程加入节点
|
|
||||||
SwingUtilities.invokeLater {
|
|
||||||
// 因为是异步的,父节点此时可能已经被移除了
|
|
||||||
if (p == root || transports.containsKey(parentId)) {
|
|
||||||
insertNodeInto(newNode, p, p.childCount)
|
|
||||||
} else {
|
|
||||||
removeTransport(transport.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return@withLock true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTransport(id: Long): Transport? {
|
|
||||||
return transports[id]?.transport
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTransports(pId: Long): List<Transport> {
|
|
||||||
lock.withLock {
|
|
||||||
if (pId == 0L) {
|
|
||||||
return getRoot().children().toList().filterIsInstance<TransportTreeTableNode>()
|
|
||||||
.map { it.transport }
|
|
||||||
}
|
|
||||||
val p = transports[pId] ?: return emptyList()
|
|
||||||
return p.children().toList().filterIsInstance<TransportTreeTableNode>()
|
|
||||||
.map { it.transport }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTransportCount(): Int {
|
|
||||||
return transports.size
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取祖先的状态,如果祖先状态不正常,那么子直接定义为失败
|
|
||||||
*
|
|
||||||
* @return true 正常
|
|
||||||
*/
|
|
||||||
private fun validGrandfatherStatus(transport: Transport): Boolean {
|
|
||||||
lock.withLock {
|
|
||||||
// 如果自己/父不正常,那么失败
|
|
||||||
if (transport.isRoot) return transport.status != TransportStatus.Failed
|
|
||||||
|
|
||||||
// 父不存在,那么直接定义失败
|
|
||||||
val p = transports[transport.parentId] ?: return false
|
|
||||||
|
|
||||||
// 父状态不正常,那么失败
|
|
||||||
if (p.transport.status == TransportStatus.Failed) return false
|
|
||||||
|
|
||||||
return validGrandfatherStatus(p.transport)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeTransport(id: Long) {
|
|
||||||
assertEventDispatchThread()
|
|
||||||
|
|
||||||
lock.withLock {
|
|
||||||
|
|
||||||
// ID 为空就是清空
|
|
||||||
if (id <= 0) {
|
|
||||||
|
|
||||||
// 定义为失败
|
|
||||||
transports.forEach { changeStatus(it.value.transport, TransportStatus.Failed) }
|
|
||||||
// 清除所有任务
|
|
||||||
transports.clear()
|
|
||||||
|
|
||||||
// 取消任务
|
|
||||||
activeTransports.forEach { it.value.cancel() }
|
|
||||||
activeTransports.clear()
|
|
||||||
|
|
||||||
val root = getRoot()
|
|
||||||
while (root.childCount > 0) {
|
|
||||||
val c = root.getChildAt(0)
|
|
||||||
if (c is MutableTreeTableNode) {
|
|
||||||
removeNodeFromParent(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val n = transports[id] ?: return
|
|
||||||
val deletedIds = mutableListOf<Long>()
|
|
||||||
n.visit { deletedIds.add(it.transport.id) }
|
|
||||||
deletedIds.add(id)
|
|
||||||
|
|
||||||
for (deletedId in deletedIds) {
|
|
||||||
val node = transports[deletedId] ?: continue
|
|
||||||
|
|
||||||
// 定义为失败
|
|
||||||
changeStatus(node.transport, TransportStatus.Failed)
|
|
||||||
if (deletedId == id) {
|
|
||||||
val p = if (node.transport.isRoot) root else transports[node.transport.parentId]
|
|
||||||
if (p != null) {
|
|
||||||
removeNodeFromParent(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试取消
|
|
||||||
activeTransports[deletedId]?.cancel()
|
|
||||||
|
|
||||||
transports.remove(deletedId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果不是成功,那么就是人工手动删除
|
|
||||||
if (n.transport.status != TransportStatus.Done) {
|
|
||||||
// 文件大小减去尚未传输的
|
|
||||||
n.transport.reportFilesize(-abs((n.transport.filesize.get() - n.transport.transferredFilesize.get())))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addTransportListener(listener: TransportListener) {
|
|
||||||
listeners += listener
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeTransportListener(listener: TransportListener) {
|
|
||||||
listeners = ArrayUtils.removeElement(listeners, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun run() {
|
|
||||||
while (coroutineScope.isActive) {
|
|
||||||
val nodes = getReadyTransport()
|
|
||||||
if (nodes.isEmpty()) {
|
|
||||||
delay((Random.nextInt(100, 250)).milliseconds)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// pre process
|
|
||||||
val readyNodes = mutableListOf<TransportTreeTableNode>()
|
|
||||||
for (node in nodes) {
|
|
||||||
val transport = node.transport
|
|
||||||
|
|
||||||
// 因为有可能返回刚刚清理的 Transport,如果不返回清理的 Transport 那么就只能返回 null,返回null就要等待 N 毫秒
|
|
||||||
if (transport.status != TransportStatus.Ready) continue
|
|
||||||
|
|
||||||
// 如果祖先状态异常,那么直接定义为失败
|
|
||||||
if (!validGrandfatherStatus(transport)) {
|
|
||||||
changeStatus(transport, TransportStatus.Failed)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进行中
|
|
||||||
if (!changeStatus(transport, TransportStatus.Processing)) continue
|
|
||||||
|
|
||||||
// 能走到这里表示准备好的任务
|
|
||||||
readyNodes.add(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有准备好的节点,那么跳过
|
|
||||||
if (readyNodes.isEmpty()) continue
|
|
||||||
|
|
||||||
// 激活中的任务
|
|
||||||
val activeTransports = mutableMapOf<Long, Job>()
|
|
||||||
|
|
||||||
// 同步传输
|
|
||||||
for (node in readyNodes) {
|
|
||||||
val transport = node.transport
|
|
||||||
activeTransports[transport.id] = coroutineScope.launch { doTransport(node) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置为全局的
|
|
||||||
lock.withLock {
|
|
||||||
this.activeTransports.forEach { it.value.cancel() }
|
|
||||||
this.activeTransports.clear()
|
|
||||||
this.activeTransports.putAll(activeTransports)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 等待所有任务
|
|
||||||
activeTransports.values.joinAll()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (log.isErrorEnabled) {
|
|
||||||
log.error(e.message, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun doTransport(node: TransportTreeTableNode) {
|
|
||||||
val transport = node.transport
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 传输
|
|
||||||
transport.transport(reporter)
|
|
||||||
// 变更状态,文件夹不需要变更状态,因为当文件夹下所有文件都成功时,文件夹自然会成功
|
|
||||||
if (transport.isFile) {
|
|
||||||
changeStatus(transport, TransportStatus.Done)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
|
|
||||||
// 记录异常
|
|
||||||
transport.exception = ExceptionUtils.getRootCause(e)
|
|
||||||
|
|
||||||
if (e is TransportStatusException) {
|
|
||||||
if (log.isWarnEnabled) {
|
|
||||||
log.warn("{}: {}", transport.source.name, e.message)
|
|
||||||
}
|
|
||||||
} else if (log.isErrorEnabled) {
|
|
||||||
log.error(e.message, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义为失败
|
|
||||||
changeStatus(transport, TransportStatus.Failed)
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
|
|
||||||
// 从激活中移除
|
|
||||||
if (lock.tryLock()) {
|
|
||||||
try {
|
|
||||||
activeTransports.remove(transport.id)
|
|
||||||
} finally {
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 安全删除
|
|
||||||
if (transport.status == TransportStatus.Done) {
|
|
||||||
safeRemoveTransport(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fireTransportEvent(transport: Transport) {
|
|
||||||
for (listener in listeners) {
|
|
||||||
listener.onTransportChanged(transport)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private suspend fun safeRemoveTransport(node: TransportTreeTableNode) {
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
lock.withLock {
|
|
||||||
var n = node as TransportTreeTableNode?
|
|
||||||
while (n != null) {
|
|
||||||
// 如果还有子,跳过
|
|
||||||
if (n.childCount != 0) break
|
|
||||||
// 如果文件夹还没扫描完,那么不处理
|
|
||||||
if (n.transport.isDirectory && !n.transport.isScanned) break
|
|
||||||
// 提前保存一下父
|
|
||||||
val p = n.parent as? TransportTreeTableNode
|
|
||||||
// 设置成功
|
|
||||||
changeStatus(n.transport, TransportStatus.Done)
|
|
||||||
// 删除
|
|
||||||
removeTransport(n.transport.id)
|
|
||||||
// 继续向上查找
|
|
||||||
n = p
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getReadyTransport(): List<TransportTreeTableNode> {
|
|
||||||
val nodes = mutableListOf<TransportTreeTableNode>()
|
|
||||||
val removeNodes = mutableListOf<TransportTreeTableNode>()
|
|
||||||
|
|
||||||
lock.withLock {
|
|
||||||
|
|
||||||
val stack = ArrayDeque<TransportTreeTableNode>()
|
|
||||||
val root = getRoot()
|
|
||||||
for (i in root.childCount - 1 downTo 0) {
|
|
||||||
val child = root.getChildAt(i)
|
|
||||||
if (child is TransportTreeTableNode) {
|
|
||||||
stack.addLast(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (stack.isNotEmpty()) {
|
|
||||||
val node = stack.removeLast()
|
|
||||||
val transport = node.transport
|
|
||||||
|
|
||||||
// 如果父已经失败,那么自己也定义为失败,之所以定义失败要走下去是因为它的子也要定义为失败
|
|
||||||
if (transport.parent?.status == TransportStatus.Failed) {
|
|
||||||
changeStatus(transport, TransportStatus.Failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 这是一个比较特殊的情况,因为传输任务和文件夹扫描并不是一个线程。
|
|
||||||
// 如果该文件夹最后一个任务传输任务完成后(已经尝试清理)这时候
|
|
||||||
// 因为还没有“定义为扫描完毕”那么清理任务就会认为还在扫描,但是已经
|
|
||||||
// 扫描完了,所以这里要执行一次清理。
|
|
||||||
if (transport.isDirectory && transport.status == TransportStatus.Processing) {
|
|
||||||
if (node.childCount == 0 && transport.isScanned) {
|
|
||||||
removeNodes.add(node)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transport.status == TransportStatus.Ready) {
|
|
||||||
if (transport.isDirectory) {
|
|
||||||
// 文件夹不允许和文件作为并行任务
|
|
||||||
if (nodes.isNotEmpty()) break
|
|
||||||
// 加入任务立即退出
|
|
||||||
nodes.add(node)
|
|
||||||
break
|
|
||||||
} else if (transport.isFile) {
|
|
||||||
// 如果要准备加入的并行任务不是一个父,那么不允许
|
|
||||||
if (nodes.isNotEmpty() && nodes.last().transport.parentId != transport.parentId) break
|
|
||||||
// 加入任务
|
|
||||||
nodes.add(node)
|
|
||||||
// 如果超出了最大
|
|
||||||
if (nodes.size >= maxParallels) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 文件不可能有子
|
|
||||||
if (transport.isFile) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
for (i in node.childCount - 1 downTo 0) {
|
|
||||||
val child = node.getChildAt(i)
|
|
||||||
if (child is TransportTreeTableNode) {
|
|
||||||
stack.addLast(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有要清理的节点,那么直接返回清理的节点
|
|
||||||
if (removeNodes.isNotEmpty()) {
|
|
||||||
removeNodes.forEach { safeRemoveTransport(it) }
|
|
||||||
return removeNodes
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun changeStatus(transport: Transport, status: TransportStatus): Boolean {
|
|
||||||
return transport.changeStatus(status).apply { if (this) fireTransportEvent(transport) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispose() {
|
|
||||||
lock.withLock {
|
|
||||||
// remove all
|
|
||||||
removeTransport(0L)
|
|
||||||
coroutineScope.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package app.termora.sftp
|
|
||||||
|
|
||||||
import app.termora.I18n
|
|
||||||
import app.termora.formatBytes
|
|
||||||
import app.termora.formatSeconds
|
|
||||||
import app.termora.vfs2.sftp.MySftpFileSystem
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.eclipse.jgit.internal.transport.sshd.JGitClientSession
|
|
||||||
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
|
||||||
|
|
||||||
class TransportTreeTableNode(transport: Transport) : DefaultMutableTreeTableNode(transport) {
|
|
||||||
val transport get() = userObject as Transport
|
|
||||||
|
|
||||||
override fun getValueAt(column: Int): Any {
|
|
||||||
val isProcessing = transport.status == TransportStatus.Processing
|
|
||||||
val speed = if (isProcessing) transport.speed else 0
|
|
||||||
val estimatedTime = if (isProcessing && speed > 0)
|
|
||||||
(transport.filesize.get() - transport.transferredFilesize.get()) / speed else 0
|
|
||||||
|
|
||||||
return when (column) {
|
|
||||||
TransportTableModel.COLUMN_NAME -> transport.source.name.baseName
|
|
||||||
TransportTableModel.COLUMN_STATUS -> formatStatus(transport)
|
|
||||||
TransportTableModel.COLUMN_SIZE -> size()
|
|
||||||
TransportTableModel.COLUMN_SPEED -> if (isProcessing) formatBytes(speed) + "/s" else "-"
|
|
||||||
TransportTableModel.COLUMN_ESTIMATED_TIME -> if (isProcessing) formatSeconds(estimatedTime) else "-"
|
|
||||||
TransportTableModel.COLUMN_SOURCE_PATH -> formatPath(transport.source)
|
|
||||||
TransportTableModel.COLUMN_TARGET_PATH -> formatPath(transport.target)
|
|
||||||
else -> super.getValueAt(column)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatPath(file: FileObject): String {
|
|
||||||
val fileSystem = file.fileSystem
|
|
||||||
if (fileSystem is MySftpFileSystem) {
|
|
||||||
val session = fileSystem.getClientSession() as JGitClientSession
|
|
||||||
val hostname = session.hostConfigEntry.hostName
|
|
||||||
return hostname + ":" + file.name.path
|
|
||||||
}
|
|
||||||
return file.name.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatStatus(transport: Transport): String {
|
|
||||||
return when (transport.status) {
|
|
||||||
TransportStatus.Processing -> I18n.getString("termora.transport.sftp.status.transporting")
|
|
||||||
TransportStatus.Ready -> I18n.getString("termora.transport.sftp.status.waiting")
|
|
||||||
TransportStatus.Done -> I18n.getString("termora.transport.sftp.status.done")
|
|
||||||
TransportStatus.Failed -> I18n.getString("termora.transport.sftp.status.failed") + ": " + transport.exception.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun size(): String {
|
|
||||||
val transferredFilesize = transport.transferredFilesize.get()
|
|
||||||
val filesize = transport.filesize.get()
|
|
||||||
if (transferredFilesize <= 0) return formatBytes(filesize)
|
|
||||||
return "${formatBytes(transferredFilesize)}/${formatBytes(filesize)}"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getColumnCount(): Int {
|
|
||||||
return TransportTableModel.COLUMN_COUNT
|
|
||||||
}
|
|
||||||
|
|
||||||
fun visit(consumer: (TransportTreeTableNode) -> Unit) {
|
|
||||||
if (childCount == 0) return
|
|
||||||
for (child in children()) {
|
|
||||||
if (child is TransportTreeTableNode) {
|
|
||||||
child.visit(consumer)
|
|
||||||
consumer.invoke(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package app.termora.sftp.internal.sftp
|
|
||||||
|
|
||||||
import app.termora.Disposer
|
|
||||||
import app.termora.protocol.FileObjectHandler
|
|
||||||
import org.apache.commons.io.IOUtils
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.apache.sshd.client.SshClient
|
|
||||||
import org.apache.sshd.client.session.ClientSession
|
|
||||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
|
||||||
|
|
||||||
class SFTPFileObjectHandler(
|
|
||||||
file: FileObject,
|
|
||||||
val client: SshClient,
|
|
||||||
val session: ClientSession,
|
|
||||||
val sftpFileSystem: SftpFileSystem,
|
|
||||||
) : FileObjectHandler(file) {
|
|
||||||
init {
|
|
||||||
session.addCloseFutureListener { Disposer.dispose(this) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dispose() {
|
|
||||||
IOUtils.closeQuietly(sftpFileSystem)
|
|
||||||
IOUtils.closeQuietly(session)
|
|
||||||
IOUtils.closeQuietly(client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
56
src/main/kotlin/app/termora/transfer/AbstractTransfer.kt
Normal file
56
src/main/kotlin/app/termora/transfer/AbstractTransfer.kt
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
abstract class AbstractTransfer(
|
||||||
|
private val parentId: String,
|
||||||
|
private val source: Path,
|
||||||
|
private val target: Path,
|
||||||
|
private val isDirectory: Boolean,
|
||||||
|
private val priority: Transfer.Priority = Transfer.Priority.Normal,
|
||||||
|
) : Transfer {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val ID = AtomicLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val id = ID.incrementAndGet().toString()
|
||||||
|
private val handler: TransferHandler = object : TransferHandler {
|
||||||
|
override fun isDisposed(): Boolean {
|
||||||
|
return source.fileSystem.isOpen.not() || target.fileSystem.isOpen.not()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun source(): Path {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun target(): Path {
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isDirectory(): Boolean {
|
||||||
|
return isDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parentId(): String {
|
||||||
|
return parentId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun id(): String {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scanning(): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handler(): TransferHandler {
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun priority(): Transfer.Priority {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app.termora.sftp
|
package app.termora.transfer
|
||||||
|
|
||||||
import app.termora.Application.ohMyJson
|
import app.termora.Application.ohMyJson
|
||||||
import app.termora.DynamicColor
|
import app.termora.DynamicColor
|
||||||
@@ -28,7 +28,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
|||||||
* 为 true 表示在书签内
|
* 为 true 表示在书签内
|
||||||
*/
|
*/
|
||||||
var isBookmark = false
|
var isBookmark = false
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
icon = if (value) {
|
icon = if (value) {
|
||||||
Icons.bookmarksOff
|
Icons.bookmarksOff
|
||||||
@@ -42,7 +42,7 @@ class BookmarkButton : JButton(Icons.bookmarks) {
|
|||||||
val oldWidth = preferredSize.width
|
val oldWidth = preferredSize.width
|
||||||
|
|
||||||
preferredSize = Dimension(oldWidth + arrowWidth, preferredSize.height)
|
preferredSize = Dimension(oldWidth + arrowWidth, preferredSize.height)
|
||||||
horizontalAlignment = SwingConstants.LEFT
|
horizontalAlignment = LEFT
|
||||||
|
|
||||||
|
|
||||||
addMouseListener(object : MouseAdapter() {
|
addMouseListener(object : MouseAdapter() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app.termora.sftp
|
package app.termora.transfer
|
||||||
|
|
||||||
import app.termora.DialogWrapper
|
import app.termora.DialogWrapper
|
||||||
import app.termora.DynamicColor
|
import app.termora.DynamicColor
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class ChangePermissionTransfer(
|
||||||
|
parentId: String, path: Path, val permissions: Set<PosixFilePermission>,
|
||||||
|
isDirectory: Boolean, private val size: Long,
|
||||||
|
) : AbstractTransfer(parentId, path, path, isDirectory, Transfer.Priority.Normal), TransferScanner {
|
||||||
|
|
||||||
|
private var changed = false
|
||||||
|
private var scanned = false
|
||||||
|
|
||||||
|
override suspend fun transfer(bufferSize: Int): Long {
|
||||||
|
if (changed) return 0
|
||||||
|
Files.setPosixFilePermissions(source(), permissions)
|
||||||
|
changed = true
|
||||||
|
return size()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scanning(): Boolean {
|
||||||
|
return if (isDirectory()) scanned.not() else false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scanned() {
|
||||||
|
scanned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun size(): Long {
|
||||||
|
return max(size, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
36
src/main/kotlin/app/termora/transfer/DeleteTransfer.kt
Normal file
36
src/main/kotlin/app/termora/transfer/DeleteTransfer.kt
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class DeleteTransfer(
|
||||||
|
parentId: String,
|
||||||
|
path: Path,
|
||||||
|
isDirectory: Boolean,
|
||||||
|
private val size: Long,
|
||||||
|
) : AbstractTransfer(parentId, path, path, isDirectory), TransferScanner {
|
||||||
|
|
||||||
|
private var scanned = false
|
||||||
|
private var deleted = false
|
||||||
|
|
||||||
|
override suspend fun transfer(bufferSize: Int): Long {
|
||||||
|
if (deleted) return 0
|
||||||
|
Files.deleteIfExists(source())
|
||||||
|
deleted = true
|
||||||
|
return this.size()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scanning(): Boolean {
|
||||||
|
return if (isDirectory()) scanned.not() else false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scanned() {
|
||||||
|
scanned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun size(): Long {
|
||||||
|
return max(size, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
31
src/main/kotlin/app/termora/transfer/DirectoryTransfer.kt
Normal file
31
src/main/kotlin/app/termora/transfer/DirectoryTransfer.kt
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.createDirectories
|
||||||
|
|
||||||
|
class DirectoryTransfer(
|
||||||
|
parentId: String,
|
||||||
|
source: Path,
|
||||||
|
target: Path,
|
||||||
|
) : AbstractTransfer(parentId, source, target, true), TransferScanner {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var scanned = false
|
||||||
|
|
||||||
|
override suspend fun transfer(bufferSize: Int): Long {
|
||||||
|
target().createDirectories()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun size(): Long {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scanning(): Boolean {
|
||||||
|
return scanned.not()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scanned() {
|
||||||
|
scanned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/main/kotlin/app/termora/transfer/FileTransfer.kt
Normal file
60
src/main/kotlin/app/termora/transfer/FileTransfer.kt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import org.apache.commons.io.IOUtils
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
|
import kotlin.io.path.inputStream
|
||||||
|
import kotlin.io.path.outputStream
|
||||||
|
|
||||||
|
class FileTransfer(
|
||||||
|
parentId: String, source: Path, target: Path, private val size: Long,
|
||||||
|
private val action: TransferAction,
|
||||||
|
priority: Transfer.Priority = Transfer.Priority.Normal,
|
||||||
|
) : AbstractTransfer(parentId, source, target, false, priority), Closeable {
|
||||||
|
|
||||||
|
private lateinit var input: InputStream
|
||||||
|
private lateinit var output: OutputStream
|
||||||
|
|
||||||
|
override suspend fun transfer(bufferSize: Int): Long {
|
||||||
|
|
||||||
|
if (::input.isInitialized.not()) {
|
||||||
|
input = source().inputStream(StandardOpenOption.READ)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (::output.isInitialized.not()) {
|
||||||
|
output = if (action == TransferAction.Overwrite) {
|
||||||
|
target().outputStream(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)
|
||||||
|
} else {
|
||||||
|
target().outputStream(StandardOpenOption.WRITE, StandardOpenOption.APPEND)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val buffer = ByteArray(bufferSize)
|
||||||
|
val len = input.read(buffer)
|
||||||
|
if (len <= 0) return 0
|
||||||
|
output.write(buffer, 0, len)
|
||||||
|
return len.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scanning(): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun size(): Long {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (::input.isInitialized) {
|
||||||
|
IOUtils.closeQuietly(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (::output.isInitialized) {
|
||||||
|
IOUtils.closeQuietly(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.Disposable
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
|
||||||
|
interface InternalTransferManager {
|
||||||
|
enum class TransferMode {
|
||||||
|
Delete,
|
||||||
|
Transfer,
|
||||||
|
ChangePermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许传输,添加任务之前请调用
|
||||||
|
*/
|
||||||
|
fun canTransfer(paths: List<Path>): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加任务,如果是文件夹会递归查询子然后传递
|
||||||
|
*/
|
||||||
|
fun addTransfer(
|
||||||
|
paths: List<Pair<Path, TransportTableModel.Attributes>>,
|
||||||
|
mode: TransferMode
|
||||||
|
): CompletableFuture<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动指定传输到哪个目录
|
||||||
|
*/
|
||||||
|
fun addTransfer(
|
||||||
|
paths: List<Pair<Path, TransportTableModel.Attributes>>,
|
||||||
|
targetWorkdir: Path,
|
||||||
|
mode: TransferMode
|
||||||
|
): CompletableFuture<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加高优先级的传输,当有多个高优先级起的时候则有序传输,该方法通常用于编辑目的
|
||||||
|
*
|
||||||
|
* @return id
|
||||||
|
*/
|
||||||
|
fun addHighTransfer(source: Path, target: Path): String
|
||||||
|
|
||||||
|
fun addTransferListener(listener: TransferListener): Disposable
|
||||||
|
}
|
||||||
91
src/main/kotlin/app/termora/transfer/PathWalker.kt
Normal file
91
src/main/kotlin/app/termora/transfer/PathWalker.kt
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.transfer.PathWalker.EmptyBasicFileAttributes.Companion.INSTANCE
|
||||||
|
import org.apache.sshd.sftp.client.SftpClient
|
||||||
|
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||||
|
import java.nio.file.FileVisitResult
|
||||||
|
import java.nio.file.FileVisitor
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
|
import java.nio.file.attribute.FileTime
|
||||||
|
import kotlin.io.path.absolutePathString
|
||||||
|
|
||||||
|
object PathWalker {
|
||||||
|
|
||||||
|
fun walkFileTree(path: Path, visitor: FileVisitor<Path>) {
|
||||||
|
if (path.fileSystem is SftpFileSystem) {
|
||||||
|
val fileSystem = path.fileSystem as SftpFileSystem
|
||||||
|
fileSystem.client.use { walkFileTree(path, it, visitor) }
|
||||||
|
} else {
|
||||||
|
Files.walkFileTree(path, visitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun walkFileTree(path: Path, sftpClient: SftpClient, visitor: FileVisitor<Path>): Boolean {
|
||||||
|
if (visitor.preVisitDirectory(path, INSTANCE) == FileVisitResult.TERMINATE) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (e in sftpClient.readDir(path.absolutePathString())) {
|
||||||
|
if (e.filename == ".." || e.filename == ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (e.attributes.isDirectory) {
|
||||||
|
if (walkFileTree(path.resolve(e.filename), sftpClient, visitor).not()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (visitor.visitFile(path.resolve(e.filename), INSTANCE) == FileVisitResult.TERMINATE) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visitor.postVisitDirectory(path, null) == FileVisitResult.CONTINUE
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private class EmptyBasicFileAttributes : BasicFileAttributes {
|
||||||
|
companion object {
|
||||||
|
val INSTANCE = EmptyBasicFileAttributes()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun lastModifiedTime(): FileTime {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun lastAccessTime(): FileTime {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun creationTime(): FileTime {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isRegularFile(): Boolean {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isDirectory(): Boolean {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSymbolicLink(): Boolean {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isOther(): Boolean {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun size(): Long {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fileKey(): Any {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
package app.termora.sftp
|
package app.termora.transfer
|
||||||
|
|
||||||
import app.termora.DialogWrapper
|
|
||||||
import app.termora.I18n
|
import app.termora.I18n
|
||||||
|
import app.termora.OptionsPane.Companion.FORM_MARGIN
|
||||||
import com.jgoodies.forms.builder.FormBuilder
|
import com.jgoodies.forms.builder.FormBuilder
|
||||||
import com.jgoodies.forms.layout.FormLayout
|
import com.jgoodies.forms.layout.FormLayout
|
||||||
|
import java.awt.BorderLayout
|
||||||
import java.awt.Dimension
|
import java.awt.Dimension
|
||||||
import java.awt.Window
|
|
||||||
import java.nio.file.attribute.PosixFilePermission
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
import javax.swing.*
|
import javax.swing.*
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
class PosixFilePermissionDialog(
|
class PosixFilePermissionPanel(private val permissions: Set<PosixFilePermission>) : JPanel(BorderLayout()) {
|
||||||
owner: Window,
|
|
||||||
private val permissions: Set<PosixFilePermission>
|
|
||||||
) : DialogWrapper(owner) {
|
|
||||||
|
|
||||||
|
|
||||||
private val ownerRead = JCheckBox(I18n.getString("termora.transport.permissions.read"))
|
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 otherExecute = JCheckBox(I18n.getString("termora.transport.permissions.execute"))
|
||||||
private val includeSubFolder = JCheckBox(I18n.getString("termora.transport.permissions.include-subfolder"))
|
private val includeSubFolder = JCheckBox(I18n.getString("termora.transport.permissions.include-subfolder"))
|
||||||
|
|
||||||
private var isCancelled = false
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
isModal = true
|
|
||||||
isResizable = false
|
|
||||||
controlsVisible = false
|
|
||||||
title = I18n.getString("termora.transport.permissions")
|
|
||||||
initView()
|
initView()
|
||||||
init()
|
|
||||||
pack()
|
|
||||||
size = Dimension(max(size.width, UIManager.getInt("Dialog.width") - 300), size.height)
|
|
||||||
setLocationRelativeTo(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initView() {
|
private fun initView() {
|
||||||
@@ -62,17 +50,24 @@ class PosixFilePermissionDialog(
|
|||||||
otherWrite.isFocusable = false
|
otherWrite.isFocusable = false
|
||||||
otherExecute.isFocusable = false
|
otherExecute.isFocusable = false
|
||||||
includeSubFolder.isFocusable = false
|
includeSubFolder.isFocusable = false
|
||||||
}
|
|
||||||
|
|
||||||
override fun createCenterPanel(): JComponent {
|
add(createCenterPanel(), BorderLayout.CENTER)
|
||||||
val formMargin = "7dlu"
|
|
||||||
val layout = FormLayout(
|
preferredSize = Dimension(
|
||||||
"default:grow, $formMargin, default:grow, $formMargin, default:grow",
|
max(preferredSize.width, UIManager.getInt("Dialog.width") - 350),
|
||||||
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
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)
|
builder.add("${I18n.getString("termora.transport.permissions.file-folder-permissions")}:").xyw(1, 1, 5)
|
||||||
|
|
||||||
@@ -102,25 +97,12 @@ class PosixFilePermissionDialog(
|
|||||||
return builder.build()
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun doCancelAction() {
|
|
||||||
this.isCancelled = true
|
|
||||||
super.doCancelAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isIncludeSubdirectories(): Boolean {
|
fun isIncludeSubdirectories(): Boolean {
|
||||||
return includeSubFolder.isSelected
|
return includeSubFolder.isSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun getPermissions(): Set<PosixFilePermission> {
|
||||||
* @return 返回空表示取消了
|
|
||||||
*/
|
|
||||||
fun open(): Set<PosixFilePermission>? {
|
|
||||||
isModal = true
|
|
||||||
isVisible = true
|
|
||||||
|
|
||||||
if (isCancelled) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val permissions = mutableSetOf<PosixFilePermission>()
|
val permissions = mutableSetOf<PosixFilePermission>()
|
||||||
if (ownerRead.isSelected) {
|
if (ownerRead.isSelected) {
|
||||||
29
src/main/kotlin/app/termora/transfer/ScaleIcon.kt
Normal file
29
src/main/kotlin/app/termora/transfer/ScaleIcon.kt
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.restore
|
||||||
|
import app.termora.save
|
||||||
|
import java.awt.Component
|
||||||
|
import java.awt.Graphics
|
||||||
|
import java.awt.Graphics2D
|
||||||
|
import javax.swing.Icon
|
||||||
|
|
||||||
|
class ScaleIcon(private val icon: Icon, private val size: Int) : Icon {
|
||||||
|
override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
|
||||||
|
if (g is Graphics2D) {
|
||||||
|
g.save()
|
||||||
|
val iconWidth = icon.iconWidth.toDouble()
|
||||||
|
val iconHeight = icon.iconHeight.toDouble()
|
||||||
|
g.scale(getIconWidth() / iconWidth, getIconHeight() / iconHeight)
|
||||||
|
icon.paintIcon(c, g, x, y)
|
||||||
|
g.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIconWidth(): Int {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIconHeight(): Int {
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/main/kotlin/app/termora/transfer/Transfer.kt
Normal file
60
src/main/kotlin/app/termora/transfer/Transfer.kt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import org.apache.commons.net.io.Util
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
interface Transfer {
|
||||||
|
enum class Priority {
|
||||||
|
High,
|
||||||
|
Normal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每调用一次,传输一次
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
suspend fun transfer(bufferSize: Int = Util.DEFAULT_COPY_BUFFER_SIZE): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 源
|
||||||
|
*/
|
||||||
|
fun source(): Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 目标
|
||||||
|
*/
|
||||||
|
fun target(): Path
|
||||||
|
|
||||||
|
fun size(): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否是文件夹
|
||||||
|
*/
|
||||||
|
fun isDirectory(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 如果是文件夹,可能正在扫描中
|
||||||
|
*/
|
||||||
|
fun scanning(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务 ID
|
||||||
|
*/
|
||||||
|
fun id(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父任务 ID,为空则没有
|
||||||
|
*/
|
||||||
|
fun parentId(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持有者
|
||||||
|
*/
|
||||||
|
fun handler(): TransferHandler = TransferHandler.EMPTY
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优先级,此优先级只对根目录下的文件有效
|
||||||
|
*/
|
||||||
|
fun priority(): Priority = Priority.Normal
|
||||||
|
|
||||||
|
}
|
||||||
7
src/main/kotlin/app/termora/transfer/TransferAction.kt
Normal file
7
src/main/kotlin/app/termora/transfer/TransferAction.kt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
enum class TransferAction {
|
||||||
|
Overwrite,
|
||||||
|
Append,
|
||||||
|
Skip,
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package app.termora.sftp
|
package app.termora.transfer
|
||||||
|
|
||||||
import app.termora.actions.AnActionEvent
|
import app.termora.actions.AnActionEvent
|
||||||
import org.apache.commons.lang3.StringUtils
|
import org.apache.commons.lang3.StringUtils
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class SFTPActionEvent(
|
class TransferActionEvent(
|
||||||
source: Any,
|
source: Any,
|
||||||
val hostId: String,
|
val hostId: String,
|
||||||
event: EventObject
|
event: EventObject
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app.termora.sftp
|
package app.termora.transfer
|
||||||
|
|
||||||
import app.termora.HostManager
|
import app.termora.HostManager
|
||||||
import app.termora.HostTerminalTab
|
import app.termora.HostTerminalTab
|
||||||
@@ -7,19 +7,17 @@ import app.termora.Icons
|
|||||||
import app.termora.actions.AnAction
|
import app.termora.actions.AnAction
|
||||||
import app.termora.actions.AnActionEvent
|
import app.termora.actions.AnActionEvent
|
||||||
import app.termora.actions.DataProviders
|
import app.termora.actions.DataProviders
|
||||||
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
import app.termora.protocol.TransferProtocolProvider
|
||||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
|
||||||
import org.apache.commons.lang3.StringUtils
|
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()
|
private val hostManager get() = HostManager.getInstance()
|
||||||
|
|
||||||
override fun actionPerformed(evt: AnActionEvent) {
|
override fun actionPerformed(evt: AnActionEvent) {
|
||||||
|
|
||||||
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
|
||||||
var sftpTab: SFTPTab? = null
|
|
||||||
|
var sftpTab: TransportTerminalTab? = null
|
||||||
for (tab in terminalTabbedManager.getTerminalTabs()) {
|
for (tab in terminalTabbedManager.getTerminalTabs()) {
|
||||||
if (tab is SFTPTab) {
|
if (tab is TransportTerminalTab) {
|
||||||
sftpTab = tab
|
sftpTab = tab
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -27,17 +25,17 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold
|
|||||||
|
|
||||||
// 创建一个新的
|
// 创建一个新的
|
||||||
if (sftpTab == null) {
|
if (sftpTab == null) {
|
||||||
sftpTab = SFTPTab()
|
sftpTab = TransportTerminalTab()
|
||||||
terminalTabbedManager.addTerminalTab(sftpTab, false)
|
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 协议那么直接打开
|
// 如果不是特定事件,那么尝试获取选中的Tab,如果是一个 Host 并且是 SSH 协议那么直接打开
|
||||||
if (hostId.isBlank()) {
|
if (hostId.isBlank()) {
|
||||||
val tab = terminalTabbedManager.getSelectedTerminalTab()
|
val tab = terminalTabbedManager.getSelectedTerminalTab()
|
||||||
if (tab is HostTerminalTab) {
|
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
|
hostId = tab.host.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,11 +45,11 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold
|
|||||||
|
|
||||||
if (hostId.isBlank()) return
|
if (hostId.isBlank()) return
|
||||||
|
|
||||||
val tabbed = sftpTab.getData(SFTPDataProviders.RightSFTPTabbed) ?: return
|
val tabbed = sftpTab.rightTabbed
|
||||||
// 如果已经打开了 那么直接选中
|
// 如果已经打开了 那么直接选中
|
||||||
for (i in 0 until tabbed.tabCount) {
|
for (i in 0 until tabbed.tabCount) {
|
||||||
val fileSystemViewPanel = tabbed.getFileSystemViewPanel(i) ?: continue
|
val panel = tabbed.getTransportPanel(i) ?: continue
|
||||||
if (fileSystemViewPanel.host.id == hostId) {
|
if (panel.host.id == hostId) {
|
||||||
tabbed.selectedIndex = i
|
tabbed.selectedIndex = i
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -60,15 +58,15 @@ class SFTPAction : AnAction(I18n.getString("termora.transport.sftp"), Icons.fold
|
|||||||
val host = hostManager.getHost(hostId) ?: return
|
val host = hostManager.getHost(hostId) ?: return
|
||||||
for (i in 0 until tabbed.tabCount) {
|
for (i in 0 until tabbed.tabCount) {
|
||||||
val c = tabbed.getComponentAt(i)
|
val c = tabbed.getComponentAt(i)
|
||||||
if (c is SFTPFileSystemViewPanel) {
|
if (c is TransportSelectionPanel) {
|
||||||
if (c.state == SFTPFileSystemViewPanel.State.Initialized) {
|
if (c.state == TransportSelectionPanel.State.Initialized) {
|
||||||
c.selectHost(host)
|
c.connect(host)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tabbed.addSFTPFileSystemViewPanelTab(host)
|
tabbed.addSelectionTab()
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.Disposable
|
||||||
|
|
||||||
|
class TransferDisposable(val id: String) : Disposable {
|
||||||
|
}
|
||||||
14
src/main/kotlin/app/termora/transfer/TransferHandler.kt
Normal file
14
src/main/kotlin/app/termora/transfer/TransferHandler.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
interface TransferHandler {
|
||||||
|
companion object {
|
||||||
|
val EMPTY: TransferHandler = object : TransferHandler {
|
||||||
|
override fun isDisposed() = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持有者已经销毁
|
||||||
|
*/
|
||||||
|
fun isDisposed(): Boolean
|
||||||
|
}
|
||||||
10
src/main/kotlin/app/termora/transfer/TransferListener.kt
Normal file
10
src/main/kotlin/app/termora/transfer/TransferListener.kt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
interface TransferListener : EventListener {
|
||||||
|
/**
|
||||||
|
* 状态变化
|
||||||
|
*/
|
||||||
|
fun onTransferChanged(transfer: Transfer, state: TransferTreeTableNode.State)
|
||||||
|
}
|
||||||
33
src/main/kotlin/app/termora/transfer/TransferManager.kt
Normal file
33
src/main/kotlin/app/termora/transfer/TransferManager.kt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.Disposable
|
||||||
|
|
||||||
|
interface TransferManager {
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加传输任务
|
||||||
|
*/
|
||||||
|
fun addTransfer(transfer: Transfer): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除传输任务
|
||||||
|
*/
|
||||||
|
fun removeTransfer(id: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务
|
||||||
|
*/
|
||||||
|
fun getTransfers(): Collection<Transfer>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务数量
|
||||||
|
*/
|
||||||
|
fun getTransferCount(): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 传输监听器
|
||||||
|
*/
|
||||||
|
fun addTransferListener(listener: TransferListener): Disposable
|
||||||
|
|
||||||
|
}
|
||||||
6
src/main/kotlin/app/termora/transfer/TransferScanner.kt
Normal file
6
src/main/kotlin/app/termora/transfer/TransferScanner.kt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
interface TransferScanner {
|
||||||
|
fun scanning(): Boolean
|
||||||
|
fun scanned()
|
||||||
|
}
|
||||||
233
src/main/kotlin/app/termora/transfer/TransferTable.kt
Normal file
233
src/main/kotlin/app/termora/transfer/TransferTable.kt
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.Disposable
|
||||||
|
import app.termora.I18n
|
||||||
|
import app.termora.NativeIcons
|
||||||
|
import app.termora.OptionPane
|
||||||
|
import com.formdev.flatlaf.FlatClientProperties
|
||||||
|
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.swing.Swing
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
import org.jdesktop.swingx.JXTreeTable
|
||||||
|
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||||
|
import java.awt.Component
|
||||||
|
import java.awt.Graphics
|
||||||
|
import java.awt.Insets
|
||||||
|
import java.awt.event.ActionEvent
|
||||||
|
import java.awt.event.MouseAdapter
|
||||||
|
import java.awt.event.MouseEvent
|
||||||
|
import javax.swing.*
|
||||||
|
import javax.swing.table.DefaultTableCellRenderer
|
||||||
|
import javax.swing.tree.DefaultTreeCellRenderer
|
||||||
|
import kotlin.io.path.name
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
||||||
|
class TransferTable(private val coroutineScope: CoroutineScope, private val tableModel: TransferTableModel) :
|
||||||
|
JXTreeTable(), Disposable {
|
||||||
|
|
||||||
|
private val table get() = this
|
||||||
|
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||||
|
|
||||||
|
|
||||||
|
init {
|
||||||
|
initView()
|
||||||
|
initEvents()
|
||||||
|
refreshView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initView() {
|
||||||
|
super.setTreeTableModel(tableModel)
|
||||||
|
super.getTableHeader().setReorderingAllowed(false)
|
||||||
|
super.setRowHeight(UIManager.getInt("Table.rowHeight"))
|
||||||
|
super.setAutoResizeMode(AUTO_RESIZE_OFF)
|
||||||
|
super.setFillsViewportHeight(true)
|
||||||
|
super.putClientProperty(
|
||||||
|
FlatClientProperties.STYLE, mapOf(
|
||||||
|
"cellMargins" to Insets(0, 4, 0, 4),
|
||||||
|
"selectionArc" to 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
super.setTreeCellRenderer(object : DefaultTreeCellRenderer() {
|
||||||
|
override fun getTreeCellRendererComponent(
|
||||||
|
tree: JTree?,
|
||||||
|
value: Any?,
|
||||||
|
sel: Boolean,
|
||||||
|
expanded: Boolean,
|
||||||
|
leaf: Boolean,
|
||||||
|
row: Int,
|
||||||
|
hasFocus: Boolean
|
||||||
|
): Component {
|
||||||
|
val node = value as DefaultMutableTreeTableNode
|
||||||
|
val transfer = node.userObject as? Transfer
|
||||||
|
val text = transfer?.source()?.name ?: StringUtils.EMPTY
|
||||||
|
val c = super.getTreeCellRendererComponent(tree, text, sel, expanded, leaf, row, hasFocus)
|
||||||
|
icon = if (transfer?.isDirectory() == true) NativeIcons.folderIcon else NativeIcons.fileIcon
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_NAME).preferredWidth = 300
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_SOURCE_PATH).preferredWidth = 200
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_TARGET_PATH).preferredWidth = 200
|
||||||
|
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_STATUS).preferredWidth = 100
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_PROGRESS).preferredWidth = 150
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_SIZE).preferredWidth = 140
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_SPEED).preferredWidth = 80
|
||||||
|
|
||||||
|
val centerTableCellRenderer = DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_STATUS).cellRenderer = centerTableCellRenderer
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_SIZE).cellRenderer = centerTableCellRenderer
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_SPEED).cellRenderer = centerTableCellRenderer
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_ESTIMATED_TIME).cellRenderer = centerTableCellRenderer
|
||||||
|
columnModel.getColumn(TransferTableModel.COLUMN_PROGRESS).cellRenderer = ProgressTableCellRenderer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initEvents() {
|
||||||
|
// contextmenu
|
||||||
|
addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseClicked(e: MouseEvent) {
|
||||||
|
if (SwingUtilities.isRightMouseButton(e)) {
|
||||||
|
val r = table.rowAtPoint(e.point)
|
||||||
|
if (r >= 0 && r < table.rowCount) {
|
||||||
|
if (!table.isRowSelected(r)) {
|
||||||
|
table.setRowSelectionInterval(r, r)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
table.clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
val rows = table.selectedRows
|
||||||
|
|
||||||
|
if (!table.hasFocus()) {
|
||||||
|
table.requestFocusInWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
showContextmenu(rows, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showContextmenu(rows: IntArray, e: MouseEvent) {
|
||||||
|
val transfers = rows.map { getPathForRow(it).lastPathComponent }
|
||||||
|
.filterIsInstance<DefaultMutableTreeTableNode>().map { it.userObject }
|
||||||
|
.filterIsInstance<Transfer>()
|
||||||
|
if (transfers.isEmpty()) return
|
||||||
|
|
||||||
|
val popupMenu = FlatPopupMenu()
|
||||||
|
val delete = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete"))
|
||||||
|
val deleteAll = popupMenu.add(I18n.getString("termora.transport.jobs.contextmenu.delete-all"))
|
||||||
|
delete.addActionListener(object : AbstractAction() {
|
||||||
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
|
if (OptionPane.showConfirmDialog(
|
||||||
|
owner,
|
||||||
|
I18n.getString("termora.keymgr.delete-warning"),
|
||||||
|
messageType = JOptionPane.WARNING_MESSAGE
|
||||||
|
) != JOptionPane.YES_OPTION
|
||||||
|
) return
|
||||||
|
for (transfer in transfers) {
|
||||||
|
tableModel.removeTransfer(transfer.id())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
deleteAll.addActionListener {
|
||||||
|
if (OptionPane.showConfirmDialog(
|
||||||
|
SwingUtilities.getWindowAncestor(this),
|
||||||
|
I18n.getString("termora.keymgr.delete-warning"),
|
||||||
|
messageType = JOptionPane.WARNING_MESSAGE
|
||||||
|
) == JOptionPane.YES_OPTION
|
||||||
|
) {
|
||||||
|
tableModel.removeTransfer(StringUtils.EMPTY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete.isEnabled = transfers.isNotEmpty()
|
||||||
|
|
||||||
|
popupMenu.show(this, e.x, e.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshView() {
|
||||||
|
coroutineScope.launch(Dispatchers.Swing) {
|
||||||
|
val timeout = 500
|
||||||
|
while (coroutineScope.isActive) {
|
||||||
|
for (row in 0 until rowCount) {
|
||||||
|
val treePath = getPathForRow(row) ?: continue
|
||||||
|
val node = treePath.lastPathComponent as? DefaultMutableTreeTableNode ?: continue
|
||||||
|
tableModel.valueForPathChanged(treePath, node.userObject)
|
||||||
|
}
|
||||||
|
delay(timeout.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ProgressTableCellRenderer : DefaultTableCellRenderer() {
|
||||||
|
private var progress = 0.0
|
||||||
|
private var progressInt = 0
|
||||||
|
private val padding = 4
|
||||||
|
|
||||||
|
init {
|
||||||
|
horizontalAlignment = CENTER
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getTableCellRendererComponent(
|
||||||
|
table: JTable?,
|
||||||
|
value: Any?,
|
||||||
|
isSelected: Boolean,
|
||||||
|
hasFocus: Boolean,
|
||||||
|
row: Int,
|
||||||
|
column: Int
|
||||||
|
): Component {
|
||||||
|
|
||||||
|
this.progress = 0.0
|
||||||
|
this.progressInt = 0
|
||||||
|
|
||||||
|
if (value is TransferTreeTableNode) {
|
||||||
|
if (value.state() == TransferTreeTableNode.State.Processing || value.waitingChildrenCompleted() || value.transfer is DeleteTransfer) {
|
||||||
|
this.progress = value.transferred.get() * 1.0 / value.filesize.get()
|
||||||
|
this.progressInt = floor(progress * 100.0).toInt()
|
||||||
|
// 因为有一些 0B 大小的文件,所以如果在进行中,那么最大就是99
|
||||||
|
if (this.progress >= 1 && value.state() == TransferTreeTableNode.State.Processing) {
|
||||||
|
this.progress = 0.99
|
||||||
|
this.progressInt = floor(progress * 100.0).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.getTableCellRendererComponent(
|
||||||
|
table,
|
||||||
|
"${progressInt}%",
|
||||||
|
isSelected,
|
||||||
|
hasFocus,
|
||||||
|
row,
|
||||||
|
column
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun paintComponent(g: Graphics) {
|
||||||
|
// 原始背景
|
||||||
|
g.color = background
|
||||||
|
g.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
// 进度条背景
|
||||||
|
g.color = UIManager.getColor("Table.selectionInactiveBackground")
|
||||||
|
g.fillRect(0, padding, width, height - padding * 2)
|
||||||
|
|
||||||
|
// 进度条颜色
|
||||||
|
g.color = UIManager.getColor("ProgressBar.foreground")
|
||||||
|
g.fillRect(0, padding, (width * progress).toInt(), height - padding * 2)
|
||||||
|
|
||||||
|
// 大于某个阀值的时候,就要改变颜色
|
||||||
|
if (progress >= 0.45) {
|
||||||
|
foreground = selectionForeground
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制文字
|
||||||
|
ui.paint(g, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
543
src/main/kotlin/app/termora/transfer/TransferTableModel.kt
Normal file
543
src/main/kotlin/app/termora/transfer/TransferTableModel.kt
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.Disposable
|
||||||
|
import app.termora.I18n
|
||||||
|
import app.termora.assertEventDispatchThread
|
||||||
|
import app.termora.transfer.TransferTreeTableNode.State
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.swing.Swing
|
||||||
|
import okio.withLock
|
||||||
|
import org.apache.commons.io.IOUtils
|
||||||
|
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||||
|
import org.jdesktop.swingx.treetable.DefaultTreeTableModel
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.locks.Condition
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
import javax.swing.event.EventListenerList
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
|
||||||
|
class TransferTableModel(private val coroutineScope: CoroutineScope) :
|
||||||
|
DefaultTreeTableModel(DefaultMutableTreeTableNode()), Disposable, TransferManager {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val log = LoggerFactory.getLogger(TransferTableModel::class.java)
|
||||||
|
|
||||||
|
const val COLUMN_COUNT = 8
|
||||||
|
|
||||||
|
const val COLUMN_NAME = 0
|
||||||
|
const val COLUMN_STATUS = 1
|
||||||
|
const val COLUMN_PROGRESS = 2
|
||||||
|
const val COLUMN_SIZE = 3
|
||||||
|
const val COLUMN_SOURCE_PATH = 4
|
||||||
|
const val COLUMN_TARGET_PATH = 5
|
||||||
|
const val COLUMN_SPEED = 6
|
||||||
|
const val COLUMN_ESTIMATED_TIME = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
private val maxParallels = max(min(Runtime.getRuntime().availableProcessors(), 6), 1)
|
||||||
|
private val map = ConcurrentHashMap<String, TransferTreeTableNode>()
|
||||||
|
private val reporter = SizeReporter(coroutineScope)
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
private val normalCondition = lock.newCondition()
|
||||||
|
private val highCondition = lock.newCondition()
|
||||||
|
private val eventListener = EventListenerList()
|
||||||
|
|
||||||
|
init {
|
||||||
|
setColumnIdentifiers(
|
||||||
|
listOf(
|
||||||
|
I18n.getString("termora.transport.jobs.table.name"),
|
||||||
|
I18n.getString("termora.transport.jobs.table.status"),
|
||||||
|
I18n.getString("termora.transport.jobs.table.progress"),
|
||||||
|
I18n.getString("termora.transport.jobs.table.size"),
|
||||||
|
I18n.getString("termora.transport.jobs.table.source-path"),
|
||||||
|
I18n.getString("termora.transport.jobs.table.target-path"),
|
||||||
|
I18n.getString("termora.transport.jobs.table.speed"),
|
||||||
|
I18n.getString("termora.transport.jobs.table.estimated-time")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun getRoot(): DefaultMutableTreeTableNode {
|
||||||
|
return super.getRoot() as DefaultMutableTreeTableNode
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isCellEditable(node: Any?, column: Int): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getColumnCount(): Int {
|
||||||
|
return COLUMN_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addTransferListener(listener: TransferListener): Disposable {
|
||||||
|
eventListener.add(TransferListener::class.java, listener)
|
||||||
|
return object : Disposable {
|
||||||
|
override fun dispose() {
|
||||||
|
eventListener.remove(TransferListener::class.java, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addTransfer(transfer: Transfer): Boolean {
|
||||||
|
val node = TransferTreeTableNode(transfer)
|
||||||
|
val parent = if (transfer.parentId().isBlank()) getRoot() else map[transfer.parentId()] ?: return false
|
||||||
|
|
||||||
|
// EDT 线程操作
|
||||||
|
if (insertNode(node, parent).not()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件立即计算大小
|
||||||
|
if (transfer.isDirectory().not() || transfer is DeleteTransfer) {
|
||||||
|
computeFilesize(node, transfer.size(), 0, setOf(ComputeField.Filesize))
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.withLock { normalCondition.signalAll();highCondition.signalAll() }
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertNode(node: TransferTreeTableNode, parent: DefaultMutableTreeTableNode): Boolean {
|
||||||
|
val result = AtomicBoolean(false)
|
||||||
|
if (SwingUtilities.isEventDispatchThread()) {
|
||||||
|
if (validGrandfather(node.transfer.parentId())) {
|
||||||
|
map[node.transfer.id()] = node
|
||||||
|
insertNodeInto(node, parent, parent.childCount)
|
||||||
|
result.set(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SwingUtilities.invokeAndWait { result.set(insertNode(node, parent)) }
|
||||||
|
}
|
||||||
|
return result.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取祖先的状态,如果祖先状态不正常,那么子直接定义为失败
|
||||||
|
*
|
||||||
|
* @return true 正常
|
||||||
|
*/
|
||||||
|
private fun validGrandfather(parentId: String): Boolean {
|
||||||
|
if (parentId.isBlank()) return true
|
||||||
|
|
||||||
|
var parent = map[parentId]
|
||||||
|
if (parent == null) return false
|
||||||
|
|
||||||
|
while (parent != null) {
|
||||||
|
if (map.containsKey(parent.transfer.id()).not()) return false
|
||||||
|
if (parent.state() == State.Failed) return false
|
||||||
|
if (parent == getRoot()) return true
|
||||||
|
if (parent.transfer.parentId().isBlank()) return true
|
||||||
|
parent = parent.parent as? TransferTreeTableNode
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getTransferCount(): Int {
|
||||||
|
return map.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getTransfers(): Collection<Transfer> {
|
||||||
|
return map.values.map { it.transfer }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeTransfer(id: String) {
|
||||||
|
assertEventDispatchThread()
|
||||||
|
|
||||||
|
val stack = ArrayDeque<Pair<TransferTreeTableNode, Boolean>>()
|
||||||
|
if (id.isNotBlank()) {
|
||||||
|
val rootNode = map[id] ?: return
|
||||||
|
stack.addLast(rootNode to false)
|
||||||
|
} else {
|
||||||
|
for (i in 0 until getRoot().childCount) {
|
||||||
|
val child = getRoot().getChildAt(i)
|
||||||
|
if (child is TransferTreeTableNode) {
|
||||||
|
stack.addLast(child to false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (stack.isNotEmpty()) {
|
||||||
|
val (node, visitedChildren) = stack.removeLast()
|
||||||
|
if (visitedChildren || node.childCount == 0) {
|
||||||
|
val failed = node.state() != State.Done
|
||||||
|
val transfer = node.transfer
|
||||||
|
|
||||||
|
// 定义为失败
|
||||||
|
node.tryChangeState(State.Failed)
|
||||||
|
// 移除
|
||||||
|
map.remove(node.transfer.id())
|
||||||
|
removeNodeFromParent(node)
|
||||||
|
|
||||||
|
// 如果删除时还在传输,那么需要减去大小
|
||||||
|
// 如果是传输任务,文件夹是不处理的,因为文件夹的大小来自文件
|
||||||
|
// 如果是删除任务,需要减去大小,删除任务的文件大小最小的:1
|
||||||
|
if ((failed && transfer.isDirectory().not()) || (failed && transfer is DeleteTransfer)) {
|
||||||
|
// 收集一次,确保数据实时
|
||||||
|
reporter.collect()
|
||||||
|
// 该文件已传输的大小
|
||||||
|
val transferred = node.transferred.get()
|
||||||
|
// 减去总大小,总大小就是减去尚未传输的数量
|
||||||
|
computeFilesize(node, -abs(node.transfer.size() - transferred), 0, setOf(ComputeField.Filesize))
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.addLast(node to true)
|
||||||
|
for (i in node.childCount - 1 downTo 0) {
|
||||||
|
val child = node.getChildAt(i)
|
||||||
|
if (child is TransferTreeTableNode) {
|
||||||
|
stack.addLast(child to false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeFilesize(
|
||||||
|
node: TransferTreeTableNode,
|
||||||
|
size: Long,
|
||||||
|
time: Long,
|
||||||
|
fields: Set<ComputeField>
|
||||||
|
) {
|
||||||
|
if (fields.contains(ComputeField.Counter)) {
|
||||||
|
node.counter.addBytes(size, time)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.contains(ComputeField.Transferred)) {
|
||||||
|
node.transferred.addAndGet(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
var p = map[node.transfer.parentId()]
|
||||||
|
while (p != null) {
|
||||||
|
for (field in fields) {
|
||||||
|
when (field) {
|
||||||
|
ComputeField.Filesize -> p.filesize.addAndGet(size)
|
||||||
|
ComputeField.Transferred -> p.transferred.addAndGet(size)
|
||||||
|
ComputeField.Counter -> p.counter.addBytes(size, time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p = map[p.transfer.parentId()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canTransfer(node: TransferTreeTableNode): Boolean {
|
||||||
|
var p: TransferTreeTableNode? = node
|
||||||
|
while (p != null) {
|
||||||
|
if (map.containsKey(p.transfer.id()).not()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p = map[p.transfer.parentId()]
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun consume() {
|
||||||
|
// 普通级别的,如果空闲时也会传输高优先级的
|
||||||
|
repeat(maxParallels) { coroutineScope.launch { transfer(Transfer.Priority.Normal, normalCondition) } }
|
||||||
|
// 专门用户高优先级下载,高优先级的单独一个线程去处理
|
||||||
|
coroutineScope.launch { transfer(Transfer.Priority.High, highCondition) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun getReadyTransfer(priority: Transfer.Priority): TransferTreeTableNode? {
|
||||||
|
assertEventDispatchThread()
|
||||||
|
|
||||||
|
val stack = ArrayDeque<TransferTreeTableNode>()
|
||||||
|
val root = getRoot()
|
||||||
|
|
||||||
|
for (i in root.childCount - 1 downTo 0) {
|
||||||
|
val child = root.getChildAt(i)
|
||||||
|
if (child is TransferTreeTableNode) {
|
||||||
|
if (priority == Transfer.Priority.High) {
|
||||||
|
if (child.transfer.priority() == Transfer.Priority.High) {
|
||||||
|
if (child.state() == State.Ready) {
|
||||||
|
changeState(child, State.Processing)
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.addLast(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority == Transfer.Priority.High) return null
|
||||||
|
|
||||||
|
while (stack.isNotEmpty()) {
|
||||||
|
val node = stack.removeLast()
|
||||||
|
val transfer = node.transfer
|
||||||
|
val parent = node.parent as? TransferTreeTableNode
|
||||||
|
|
||||||
|
// 删除文件和传输文件完全相反,传输文件是先创建文件夹后传输文件
|
||||||
|
// 删除文件,是先删除文件后删除文件夹
|
||||||
|
if (transfer is DeleteTransfer) {
|
||||||
|
if (node.state() != State.Failed) {
|
||||||
|
val c = getReadyDeleteTransfer(node)
|
||||||
|
if (c != null) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果父文件夹正在创建,那么等待创建完毕
|
||||||
|
// 顺序一定是先创建文件夹后传输文件
|
||||||
|
if (parent != null) {
|
||||||
|
if (parent.state() == State.Processing) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 父亲失败则子失败
|
||||||
|
if (parent.state() == State.Failed && node.state() != State.Failed) {
|
||||||
|
changeState(node, State.Failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是文件夹并且已经创建,那么尝试去删除
|
||||||
|
if (transfer.isDirectory() && node.state() == State.Done && node.waitingChildrenCompleted().not()) {
|
||||||
|
removeCompleted(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.state() == State.Ready) {
|
||||||
|
changeState(node, State.Processing)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in node.childCount - 1 downTo 0) {
|
||||||
|
val child = node.getChildAt(i)
|
||||||
|
if (child is TransferTreeTableNode) {
|
||||||
|
stack.addLast(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深度优先
|
||||||
|
*/
|
||||||
|
private fun getReadyDeleteTransfer(
|
||||||
|
treeNode: TransferTreeTableNode,
|
||||||
|
): TransferTreeTableNode? {
|
||||||
|
val stack = ArrayDeque<TransferTreeTableNode>()
|
||||||
|
stack.addLast(treeNode)
|
||||||
|
|
||||||
|
while (stack.isNotEmpty()) {
|
||||||
|
val node = stack.removeLast()
|
||||||
|
val transfer = node.transfer
|
||||||
|
if (transfer.isDirectory().not()) {
|
||||||
|
if (node.state() == State.Ready) {
|
||||||
|
changeState(node, State.Processing)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是文件夹并且已经扫描完毕
|
||||||
|
if (transfer.isDirectory() && transfer.scanning().not() && node.childCount < 1) {
|
||||||
|
if (node.state() == State.Ready) {
|
||||||
|
changeState(node, State.Processing)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in node.childCount - 1 downTo 0) {
|
||||||
|
val child = node.getChildAt(i)
|
||||||
|
if (child is TransferTreeTableNode) {
|
||||||
|
stack.addLast(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun transfer(priority: Transfer.Priority, condition: Condition) {
|
||||||
|
while (coroutineScope.isActive) {
|
||||||
|
try {
|
||||||
|
val node = withContext(Dispatchers.Swing) { getReadyTransfer(priority) }
|
||||||
|
if (node == null) {
|
||||||
|
if (map.isEmpty()) {
|
||||||
|
lock.withLock { condition.await() }
|
||||||
|
} else {
|
||||||
|
lock.withLock { condition.await(1, TimeUnit.SECONDS) }
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
} else if (canTransfer(node)) {
|
||||||
|
doTransfer(node)
|
||||||
|
}
|
||||||
|
lock.withLock { condition.signalAll() }
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
break
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (log.isErrorEnabled) log.error(e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doTransfer(node: TransferTreeTableNode) {
|
||||||
|
|
||||||
|
val transfer = node.transfer
|
||||||
|
|
||||||
|
try {
|
||||||
|
var len = 0L
|
||||||
|
while (continueTransfer(node) && transfer.transfer().also { len = it } > 0) {
|
||||||
|
// 异步上报,因为数据量非常大,所以采用异步
|
||||||
|
reporter.report(node, len, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Swing) {
|
||||||
|
if (continueTransfer(node)) {
|
||||||
|
changeState(node, State.Done)
|
||||||
|
removeCompleted(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
node.tryChangeState(State.Failed)
|
||||||
|
if (e !is UserCanceledException) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (transfer is Closeable) IOUtils.closeQuietly(transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun continueTransfer(node: TransferTreeTableNode, throws: Boolean = true): Boolean {
|
||||||
|
val transfer = node.transfer
|
||||||
|
// 如果不存在则表示已经被删除了
|
||||||
|
if (map.containsKey(transfer.id()).not()) if (throws) throw UserCanceledException() else return false
|
||||||
|
// 状态突然变更
|
||||||
|
if (node.state() != State.Processing) if (throws) throw UserCanceledException() else return false
|
||||||
|
// 持有者已经销毁,和平结束
|
||||||
|
if (transfer.handler().isDisposed()) if (throws) throw UserCanceledException() else return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fireTransferChanged(node: TransferTreeTableNode) {
|
||||||
|
try {
|
||||||
|
for (listener in eventListener.getListeners(TransferListener::class.java)) {
|
||||||
|
listener.onTransferChanged(node.transfer, node.state())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (log.isWarnEnabled) {
|
||||||
|
log.warn(e.message, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changeState(node: TransferTreeTableNode, state: State) {
|
||||||
|
node.changeState(state)
|
||||||
|
fireTransferChanged(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeCompleted(node: TransferTreeTableNode) {
|
||||||
|
|
||||||
|
if (node == getRoot()) return
|
||||||
|
if (node.transfer.isDirectory() && node.childCount > 0) return
|
||||||
|
if (node.transfer.scanning()) return
|
||||||
|
if (node.parent == null) return
|
||||||
|
if (node.state() != State.Done) return
|
||||||
|
|
||||||
|
assertEventDispatchThread()
|
||||||
|
|
||||||
|
removeTransfer(node.transfer.id())
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class UserCanceledException : RuntimeException()
|
||||||
|
|
||||||
|
|
||||||
|
private enum class ComputeField {
|
||||||
|
Filesize,
|
||||||
|
Transferred,
|
||||||
|
Counter
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private inner class SizeReporter(private val coroutineScope: CoroutineScope) {
|
||||||
|
|
||||||
|
private val events = ConcurrentLinkedQueue<Triple<TransferTreeTableNode, Long, Long>>()
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scheduleCollect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun report(node: TransferTreeTableNode, bytes: Long, time: Long) {
|
||||||
|
events.add(Triple(node, bytes, time))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleCollect() {
|
||||||
|
// 异步上报数据
|
||||||
|
coroutineScope.launch {
|
||||||
|
while (coroutineScope.isActive) {
|
||||||
|
collect()
|
||||||
|
delay(500.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collect() {
|
||||||
|
lock.withLock {
|
||||||
|
val time = System.currentTimeMillis()
|
||||||
|
val map = linkedMapOf<TransferTreeTableNode, Long>()
|
||||||
|
|
||||||
|
// 收集
|
||||||
|
while (events.isNotEmpty() && events.peek().second < time) {
|
||||||
|
val (a, b) = events.poll()
|
||||||
|
map[a] = map.computeIfAbsent(a) { 0 } + b
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.isNotEmpty()) {
|
||||||
|
for ((a, b) in map) {
|
||||||
|
if (b > 0) {
|
||||||
|
computeFilesize(a, b, time, setOf(ComputeField.Counter, ComputeField.Transferred))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class SlidingWindowByteCounter {
|
||||||
|
private val events = ConcurrentLinkedQueue<Pair<Long, Long>>()
|
||||||
|
private val oneSecondInMillis = TimeUnit.SECONDS.toMillis(1)
|
||||||
|
|
||||||
|
fun addBytes(bytes: Long, time: Long) {
|
||||||
|
|
||||||
|
// 添加当前事件
|
||||||
|
events.add(time to bytes)
|
||||||
|
|
||||||
|
// 移除过期事件(超过 1 秒的记录)
|
||||||
|
while (events.isNotEmpty() && events.peek().first < time - oneSecondInMillis) {
|
||||||
|
events.poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLastSecondBytes(): Long {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
// 累加最近 1 秒内的字节数
|
||||||
|
return events.filter { it.first >= currentTime - oneSecondInMillis }
|
||||||
|
.sumOf { it.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
148
src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt
Normal file
148
src/main/kotlin/app/termora/transfer/TransferTreeTableNode.kt
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.I18n
|
||||||
|
import app.termora.formatBytes
|
||||||
|
import app.termora.formatSeconds
|
||||||
|
import app.termora.transfer.TransferTableModel.Companion.COLUMN_COUNT
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
import org.jdesktop.swingx.treetable.DefaultMutableTreeTableNode
|
||||||
|
import java.nio.file.FileSystems
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.io.path.absolutePathString
|
||||||
|
import kotlin.io.path.name
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class TransferTreeTableNode(transfer: Transfer) : DefaultMutableTreeTableNode(transfer) {
|
||||||
|
|
||||||
|
enum class State {
|
||||||
|
Ready,
|
||||||
|
Processing,
|
||||||
|
Failed,
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件总大小,删除时文件夹也算数量
|
||||||
|
*/
|
||||||
|
val filesize = AtomicLong(transfer.size())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件传输的大小
|
||||||
|
*/
|
||||||
|
val transferred = AtomicLong(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 速率计数
|
||||||
|
*/
|
||||||
|
val counter = TransferTableModel.SlidingWindowByteCounter()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态
|
||||||
|
*/
|
||||||
|
private var state = State.Ready
|
||||||
|
|
||||||
|
|
||||||
|
override fun getColumnCount(): Int {
|
||||||
|
return COLUMN_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
val transfer get() = getUserObject() as Transfer
|
||||||
|
|
||||||
|
override fun getValueAt(column: Int): Any? {
|
||||||
|
val filesize = if (transfer.isDirectory()) filesize.get() else transfer.size()
|
||||||
|
val totalBytesTransferred = transferred.get()
|
||||||
|
val state = if (waitingChildrenCompleted()) State.Processing else state()
|
||||||
|
val isProcessing = state == State.Processing ||
|
||||||
|
(transfer is DeleteTransfer && transfer.isDirectory() && (state() == State.Processing || state() == State.Ready))
|
||||||
|
val speed = counter.getLastSecondBytes()
|
||||||
|
val estimatedTime = max(if (isProcessing && speed > 0) (filesize - totalBytesTransferred) / speed else 0, 0)
|
||||||
|
|
||||||
|
return when (column) {
|
||||||
|
TransferTableModel.COLUMN_NAME -> transfer.source().name
|
||||||
|
TransferTableModel.COLUMN_STATUS -> formatStatus(state)
|
||||||
|
TransferTableModel.COLUMN_SOURCE_PATH -> formatPath(transfer.source(), false)
|
||||||
|
TransferTableModel.COLUMN_TARGET_PATH -> formatPath(transfer.target(), true)
|
||||||
|
TransferTableModel.COLUMN_SIZE -> "${formatBytes(totalBytesTransferred)} / ${formatBytes(filesize)}"
|
||||||
|
TransferTableModel.COLUMN_SPEED -> if (isProcessing) "${formatBytes(speed)}/s" else "-"
|
||||||
|
TransferTableModel.COLUMN_ESTIMATED_TIME -> if (isProcessing) formatSeconds(estimatedTime) else "-"
|
||||||
|
TransferTableModel.COLUMN_PROGRESS -> this
|
||||||
|
else -> StringUtils.EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatPath(path: Path, target: Boolean): String {
|
||||||
|
if (target) {
|
||||||
|
if (transfer is DeleteTransfer) {
|
||||||
|
return I18n.getString("termora.transport.sftp.status.deleting")
|
||||||
|
} else if (transfer is ChangePermissionTransfer) {
|
||||||
|
val permissions = (transfer as ChangePermissionTransfer).permissions
|
||||||
|
// @formatter:off
|
||||||
|
return "${I18n.getString("termora.transport.table.permissions")} -> ${PosixFilePermissions.toString(permissions)}"
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.fileSystem == FileSystems.getDefault()) {
|
||||||
|
return path.absolutePathString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.absolutePathString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatStatus(state: State): String {
|
||||||
|
if (transfer is DeleteTransfer && state == State.Processing) {
|
||||||
|
return I18n.getString("termora.transport.sftp.status.deleting")
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (state) {
|
||||||
|
State.Processing -> I18n.getString("termora.transport.sftp.status.transporting")
|
||||||
|
State.Ready -> I18n.getString("termora.transport.sftp.status.waiting")
|
||||||
|
State.Done -> I18n.getString("termora.transport.sftp.status.done")
|
||||||
|
State.Failed -> I18n.getString("termora.transport.sftp.status.failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun state(): State {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待子完成
|
||||||
|
*/
|
||||||
|
fun waitingChildrenCompleted(): Boolean {
|
||||||
|
if (transfer.isDirectory().not()) return false
|
||||||
|
if (state == State.Processing) return true
|
||||||
|
return state == State.Done && (transfer.scanning() || childCount > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun changeState(state: State) {
|
||||||
|
if (this.state == State.Done || this.state == State.Failed) {
|
||||||
|
throw IllegalStateException()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state == State.Processing && state == State.Ready) {
|
||||||
|
throw IllegalStateException()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = state
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun tryChangeState(state: State): Boolean {
|
||||||
|
if (this.state == State.Done || this.state == State.Failed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state == State.Processing && state == State.Ready) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = state
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.Disposable
|
||||||
|
import app.termora.plugin.Extension
|
||||||
|
import java.awt.Window
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
interface TransportEditFileExtension : Extension {
|
||||||
|
fun edit(owner: Window, path: Path): Disposable
|
||||||
|
}
|
||||||
390
src/main/kotlin/app/termora/transfer/TransportNavigationPanel.kt
Normal file
390
src/main/kotlin/app/termora/transfer/TransportNavigationPanel.kt
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.DynamicColor
|
||||||
|
import app.termora.Icons
|
||||||
|
import app.termora.OptionPane
|
||||||
|
import app.termora.transfer.TransportPanel.Companion.isWindowsFileSystem
|
||||||
|
import com.formdev.flatlaf.FlatClientProperties
|
||||||
|
import com.formdev.flatlaf.extras.FlatSVGIcon
|
||||||
|
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||||
|
import com.formdev.flatlaf.extras.components.FlatTextField
|
||||||
|
import com.formdev.flatlaf.extras.components.FlatToolBar
|
||||||
|
import com.formdev.flatlaf.ui.FlatLineBorder
|
||||||
|
import com.formdev.flatlaf.util.SystemInfo
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
import org.apache.commons.lang3.SystemUtils
|
||||||
|
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.awt.CardLayout
|
||||||
|
import java.awt.Dimension
|
||||||
|
import java.awt.Insets
|
||||||
|
import java.awt.Point
|
||||||
|
import java.awt.event.*
|
||||||
|
import java.beans.PropertyChangeEvent
|
||||||
|
import java.beans.PropertyChangeListener
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.function.Supplier
|
||||||
|
import javax.swing.*
|
||||||
|
import javax.swing.event.PopupMenuEvent
|
||||||
|
import javax.swing.event.PopupMenuListener
|
||||||
|
import kotlin.io.path.absolutePathString
|
||||||
|
import kotlin.io.path.name
|
||||||
|
import kotlin.io.path.pathString
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
class TransportNavigationPanel(
|
||||||
|
private val support: Supplier<TransportSupport>,
|
||||||
|
private val navigator: TransportNavigator
|
||||||
|
) : JPanel() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val log = LoggerFactory.getLogger(TransportNavigationPanel::class.java)
|
||||||
|
private const val TEXT_FIELD = "TextField"
|
||||||
|
private const val SEGMENTS = "Segments"
|
||||||
|
|
||||||
|
private val ICON_SIZE = if (SystemInfo.isMacOS) 14 else 16
|
||||||
|
private val icon = FlatSVGIcon(Icons.playForward.name, ICON_SIZE, ICON_SIZE)
|
||||||
|
private val moreHorizontal = FlatSVGIcon(Icons.moreHorizontal.name, ICON_SIZE, ICON_SIZE)
|
||||||
|
private val computerIcon = FlatSVGIcon(Icons.desktop.name, ICON_SIZE, ICON_SIZE)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||||
|
private val layeredPane = LayeredPane()
|
||||||
|
private val textField = FlatTextField()
|
||||||
|
private val downBtn = JButton(Icons.chevronDown)
|
||||||
|
private val comboBox = object : JComboBox<Path>() {
|
||||||
|
override fun getLocationOnScreen(): Point {
|
||||||
|
val point = super.getLocationOnScreen()
|
||||||
|
point.y -= 1
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val segmentPanel = object : FlatToolBar() {
|
||||||
|
override fun updateUI() {
|
||||||
|
super.updateUI()
|
||||||
|
border = FlatLineBorder(
|
||||||
|
Insets(1, 0, 1, 0), DynamicColor.BorderColor,
|
||||||
|
1f, UIManager.getInt("TextComponent.arc")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val cardLayout = CardLayout()
|
||||||
|
private val that get() = this
|
||||||
|
|
||||||
|
init {
|
||||||
|
initView()
|
||||||
|
initEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initView() {
|
||||||
|
super.setLayout(cardLayout)
|
||||||
|
comboBox.isEnabled = false
|
||||||
|
comboBox.putClientProperty("JComboBox.isTableCellEditor", true)
|
||||||
|
comboBox.border = BorderFactory.createEmptyBorder()
|
||||||
|
|
||||||
|
textField.trailingComponent = downBtn
|
||||||
|
downBtn.putClientProperty(
|
||||||
|
FlatClientProperties.STYLE,
|
||||||
|
mapOf(
|
||||||
|
"toolbar.hoverBackground" to UIManager.getColor("Button.background"),
|
||||||
|
"toolbar.pressedBackground" to UIManager.getColor("Button.background"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
segmentPanel.layout = BoxLayout(segmentPanel, BoxLayout.X_AXIS)
|
||||||
|
segmentPanel.putClientProperty(
|
||||||
|
FlatClientProperties.STYLE,
|
||||||
|
mapOf("background" to DynamicColor("TextField.background"))
|
||||||
|
)
|
||||||
|
segmentPanel.isFocusable = true
|
||||||
|
|
||||||
|
layeredPane.add(comboBox, JLayeredPane.DEFAULT_LAYER as Any)
|
||||||
|
layeredPane.add(textField, JLayeredPane.PALETTE_LAYER as Any)
|
||||||
|
|
||||||
|
add(layeredPane, TEXT_FIELD)
|
||||||
|
add(segmentPanel, SEGMENTS)
|
||||||
|
|
||||||
|
cardLayout.show(this, SEGMENTS)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initEvents() {
|
||||||
|
|
||||||
|
val itemListener = object : ItemListener {
|
||||||
|
override fun itemStateChanged(e: ItemEvent) {
|
||||||
|
val path = comboBox.selectedItem as Path? ?: return
|
||||||
|
if (navigator.loading) return
|
||||||
|
navigator.navigateTo(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentPanel.addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseClicked(e: MouseEvent) {
|
||||||
|
cardLayout.show(that, TEXT_FIELD)
|
||||||
|
textField.requestFocusInWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
segmentPanel.addComponentListener(object : ComponentAdapter() {
|
||||||
|
override fun componentResized(e: ComponentEvent) {
|
||||||
|
val workdir = navigator.workdir ?: return
|
||||||
|
repack(workdir)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
textField.addFocusListener(object : FocusAdapter() {
|
||||||
|
override fun focusLost(e: FocusEvent) {
|
||||||
|
if (comboBox.isPopupVisible) return
|
||||||
|
cardLayout.show(that, SEGMENTS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
downBtn.addActionListener(object : AbstractAction() {
|
||||||
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
|
if (comboBox.isPopupVisible) return
|
||||||
|
comboBox.isEnabled = true
|
||||||
|
comboBox.removeAllItems()
|
||||||
|
for (path in navigator.getHistory()) {
|
||||||
|
comboBox.addItem(path)
|
||||||
|
}
|
||||||
|
comboBox.selectedItem = navigator.workdir
|
||||||
|
comboBox.requestFocusInWindow()
|
||||||
|
comboBox.showPopup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
comboBox.addPopupMenuListener(object : PopupMenuListener {
|
||||||
|
override fun popupMenuWillBecomeVisible(e: PopupMenuEvent?) {
|
||||||
|
comboBox.addItemListener(itemListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popupMenuWillBecomeInvisible(e: PopupMenuEvent) {
|
||||||
|
textField.requestFocusInWindow()
|
||||||
|
comboBox.isEnabled = false
|
||||||
|
comboBox.removeItemListener(itemListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popupMenuCanceled(e: PopupMenuEvent?) {
|
||||||
|
textField.requestFocusInWindow()
|
||||||
|
comboBox.isEnabled = false
|
||||||
|
comboBox.removeItemListener(itemListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
textField.addActionListener(object : AbstractAction() {
|
||||||
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
|
if (navigator.loading) return
|
||||||
|
if (textField.text.isBlank()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val path = support.get().fileSystem.getPath(textField.text)
|
||||||
|
navigator.navigateTo(path)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (log.isErrorEnabled) log.error(e.message, e)
|
||||||
|
OptionPane.showMessageDialog(
|
||||||
|
owner, ExceptionUtils.getRootCauseMessage(e),
|
||||||
|
messageType = JOptionPane.ERROR_MESSAGE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.addPropertyChangeListener("workdir", object : PropertyChangeListener {
|
||||||
|
override fun propertyChange(evt: PropertyChangeEvent) {
|
||||||
|
val path = evt.newValue as? Path ?: return
|
||||||
|
setTextFieldText(path)
|
||||||
|
repack(path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setTextFieldText(path: Path) {
|
||||||
|
if (path.fileSystem.isWindowsFileSystem() && path.pathString == path.fileSystem.separator) {
|
||||||
|
textField.text = StringUtils.EMPTY
|
||||||
|
} else {
|
||||||
|
textField.text = path.absolutePathString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun repack(workdir: Path) {
|
||||||
|
segmentPanel.removeAll()
|
||||||
|
|
||||||
|
var parent: Path? = workdir
|
||||||
|
val fileSystem = workdir.fileSystem
|
||||||
|
val parents = mutableListOf<Path>()
|
||||||
|
|
||||||
|
while (parent != null) {
|
||||||
|
parents.addFirst(parent)
|
||||||
|
parent = parent.parent
|
||||||
|
// Windows 比较特殊,因为它有盘符
|
||||||
|
if (parent == null && fileSystem.isWindowsFileSystem()) {
|
||||||
|
parents.addFirst(fileSystem.getPath(fileSystem.separator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预留点击空间
|
||||||
|
val width = segmentPanel.width - 100
|
||||||
|
val children = mutableListOf<JComponent>()
|
||||||
|
|
||||||
|
for (i in 0 until parents.size) {
|
||||||
|
val path = parents[i]
|
||||||
|
val button = if (i == 0) JLabel(computerIcon)
|
||||||
|
else if (fileSystem.isWindowsFileSystem() && path.root == path)
|
||||||
|
JButton(path.toString().replace(fileSystem.separator, StringUtils.EMPTY))
|
||||||
|
else JButton(path.name)
|
||||||
|
// JLabel 与 JButton 对齐
|
||||||
|
if (SystemUtils.IS_OS_MAC_OSX) {
|
||||||
|
if (button is JLabel)
|
||||||
|
button.border = BorderFactory.createEmptyBorder(2, 4, 2, 4)
|
||||||
|
else
|
||||||
|
button.putClientProperty(FlatClientProperties.STYLE, mapOf("margin" to Insets(1, 2, 1, 2)))
|
||||||
|
} else if (SystemUtils.IS_OS_LINUX) {
|
||||||
|
if (button is JLabel)
|
||||||
|
button.border = BorderFactory.createEmptyBorder(0, 4, 0, 4)
|
||||||
|
else
|
||||||
|
button.putClientProperty(FlatClientProperties.STYLE, mapOf("margin" to Insets(0, 0, 0, 0)))
|
||||||
|
} else {
|
||||||
|
if (button is JLabel)
|
||||||
|
button.border = BorderFactory.createEmptyBorder(3, 4, 3, 4)
|
||||||
|
else
|
||||||
|
button.putClientProperty(FlatClientProperties.STYLE, mapOf("margin" to Insets(1, 2, 1, 2)))
|
||||||
|
}
|
||||||
|
button.isFocusable = false
|
||||||
|
button.putClientProperty(FlatClientProperties.BUTTON_TYPE, FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON)
|
||||||
|
button.addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseClicked(e: MouseEvent) {
|
||||||
|
if (navigator.loading) return
|
||||||
|
if (path == navigator.workdir) {
|
||||||
|
setTextFieldText(path)
|
||||||
|
} else {
|
||||||
|
navigator.navigateTo(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
button.putClientProperty("Path", path)
|
||||||
|
children.add(button)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.isEmpty()) {
|
||||||
|
revalidate()
|
||||||
|
repaint()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val moreChildren = mutableListOf<Path>()
|
||||||
|
val rightBtnWidth = createRightLabel().preferredSize.width
|
||||||
|
var childrenWidth = children.first().preferredSize.width - rightBtnWidth
|
||||||
|
|
||||||
|
var i = 1
|
||||||
|
while (i < children.size) {
|
||||||
|
val child = children[i]
|
||||||
|
if (child.preferredSize.width + childrenWidth <= width) {
|
||||||
|
childrenWidth += (child.preferredSize.width + rightBtnWidth)
|
||||||
|
} else {
|
||||||
|
i--
|
||||||
|
if (children.size < 2 || i < 0) break
|
||||||
|
val c = children.removeAt(1)
|
||||||
|
val path = c.getClientProperty("Path") as Path
|
||||||
|
moreChildren.add(path)
|
||||||
|
childrenWidth -= (c.preferredSize.width + rightBtnWidth)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
for (n in 0 until children.size) {
|
||||||
|
val child = children[n]
|
||||||
|
segmentPanel.add(child)
|
||||||
|
if (n != children.size - 1 || (moreChildren.isNotEmpty() && n == 0)) {
|
||||||
|
segmentPanel.add(createRightLabel())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moreChildren.isNotEmpty()) {
|
||||||
|
val button = JButton(moreHorizontal)
|
||||||
|
// JLabel 与 JButton 对齐
|
||||||
|
button.putClientProperty(FlatClientProperties.STYLE, mapOf("margin" to Insets(1, 2, 1, 2)))
|
||||||
|
button.isFocusable = false
|
||||||
|
button.putClientProperty(
|
||||||
|
FlatClientProperties.BUTTON_TYPE,
|
||||||
|
FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON
|
||||||
|
)
|
||||||
|
val paths = moreChildren.toTypedArray()
|
||||||
|
button.addActionListener { showMoreContextmenu(button, paths) }
|
||||||
|
segmentPanel.add(button)
|
||||||
|
|
||||||
|
if (n != children.size - 1) {
|
||||||
|
segmentPanel.add(createRightLabel())
|
||||||
|
}
|
||||||
|
|
||||||
|
moreChildren.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentPanel.add(Box.createHorizontalGlue())
|
||||||
|
val downBtn = JLabel(Icons.chevronDown)
|
||||||
|
downBtn.border = BorderFactory.createEmptyBorder(2, 2, 2, 3)
|
||||||
|
downBtn.addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseClicked(e: MouseEvent) {
|
||||||
|
if (SwingUtilities.isLeftMouseButton(e)) {
|
||||||
|
cardLayout.show(that, TEXT_FIELD)
|
||||||
|
SwingUtilities.invokeLater { that.downBtn.doClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
segmentPanel.add(downBtn)
|
||||||
|
|
||||||
|
revalidate()
|
||||||
|
repaint()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMoreContextmenu(button: JButton, paths: Array<Path>) {
|
||||||
|
val popupMenu = FlatPopupMenu()
|
||||||
|
for (item in paths) {
|
||||||
|
var text = item.name
|
||||||
|
if (item.fileSystem.isWindowsFileSystem()) {
|
||||||
|
if (item.root == item) {
|
||||||
|
text = item.pathString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
popupMenu.add(text).addActionListener { navigator.navigateTo(item) }
|
||||||
|
}
|
||||||
|
popupMenu.show(
|
||||||
|
button,
|
||||||
|
button.x - button.width / 2 - popupMenu.preferredSize.width / 2,
|
||||||
|
button.y + button.height + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRightLabel(): JLabel {
|
||||||
|
val rightBtn = JLabel(icon)
|
||||||
|
rightBtn.preferredSize = Dimension(
|
||||||
|
round(rightBtn.preferredSize.width / 1.5).toInt(),
|
||||||
|
rightBtn.preferredSize.height
|
||||||
|
)
|
||||||
|
rightBtn.maximumSize = rightBtn.preferredSize
|
||||||
|
rightBtn.isFocusable = false
|
||||||
|
rightBtn.putClientProperty(
|
||||||
|
FlatClientProperties.BUTTON_TYPE,
|
||||||
|
FlatClientProperties.BUTTON_TYPE_TOOLBAR_BUTTON
|
||||||
|
)
|
||||||
|
rightBtn.addMouseListener(object : MouseAdapter() {})
|
||||||
|
return rightBtn
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LayeredPane : JLayeredPane() {
|
||||||
|
override fun doLayout() {
|
||||||
|
synchronized(treeLock) {
|
||||||
|
for (c in components) {
|
||||||
|
c.setBounds(0, 0, width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
22
src/main/kotlin/app/termora/transfer/TransportNavigator.kt
Normal file
22
src/main/kotlin/app/termora/transfer/TransportNavigator.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import java.beans.PropertyChangeListener
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
interface TransportNavigator {
|
||||||
|
|
||||||
|
val loading: Boolean
|
||||||
|
val workdir: Path?
|
||||||
|
|
||||||
|
fun navigateTo(destination: Path): Boolean
|
||||||
|
|
||||||
|
fun addPropertyChangeListener(propertyName: String, listener: PropertyChangeListener)
|
||||||
|
fun removePropertyChangeListener(propertyName: String, listener: PropertyChangeListener)
|
||||||
|
|
||||||
|
fun getHistory(): List<Path>
|
||||||
|
|
||||||
|
fun canRedo(): Boolean
|
||||||
|
fun canUndo(): Boolean
|
||||||
|
fun back()
|
||||||
|
fun forward()
|
||||||
|
}
|
||||||
1220
src/main/kotlin/app/termora/transfer/TransportPanel.kt
Normal file
1220
src/main/kotlin/app/termora/transfer/TransportPanel.kt
Normal file
File diff suppressed because it is too large
Load Diff
217
src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt
Normal file
217
src/main/kotlin/app/termora/transfer/TransportPopupMenu.kt
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.Application
|
||||||
|
import app.termora.I18n
|
||||||
|
import app.termora.Icons
|
||||||
|
import app.termora.OptionPane
|
||||||
|
import app.termora.transfer.TransportPanel.Companion.isLocallyFileSystem
|
||||||
|
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
||||||
|
import java.awt.Window
|
||||||
|
import java.awt.datatransfer.StringSelection
|
||||||
|
import java.awt.event.ActionEvent
|
||||||
|
import java.awt.event.ActionListener
|
||||||
|
import java.awt.event.KeyEvent
|
||||||
|
import java.nio.file.FileSystem
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
|
import java.util.*
|
||||||
|
import javax.swing.JMenu
|
||||||
|
import javax.swing.JMenuItem
|
||||||
|
import javax.swing.JOptionPane
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
import javax.swing.event.EventListenerList
|
||||||
|
import kotlin.io.path.absolutePathString
|
||||||
|
import kotlin.io.path.name
|
||||||
|
|
||||||
|
|
||||||
|
class TransportPopupMenu(
|
||||||
|
private val owner: Window,
|
||||||
|
private val model: TransportTableModel,
|
||||||
|
private val transferManager: InternalTransferManager,
|
||||||
|
private val fileSystem: FileSystem,
|
||||||
|
private val files: List<Pair<Path, TransportTableModel.Attributes>>
|
||||||
|
) : FlatPopupMenu() {
|
||||||
|
private val paths = files.map { it.first }
|
||||||
|
private val hasParent = files.any { it.second.isParent }
|
||||||
|
|
||||||
|
private val transferMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.transfer"))
|
||||||
|
private val editMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.edit"))
|
||||||
|
private val copyPathMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.copy-path"))
|
||||||
|
private val openInFinderMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.open-in-folder"))
|
||||||
|
private val renameMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.rename"))
|
||||||
|
private val deleteMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.delete"))
|
||||||
|
private val rmrfMenu = JMenuItem("rm -rf", Icons.warningIntroduction)
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
private val changePermissionsMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.change-permissions"))
|
||||||
|
// @formatter:on
|
||||||
|
private val refreshMenu = JMenuItem(I18n.getString("termora.transport.table.contextmenu.refresh"))
|
||||||
|
private val newMenu = JMenu(I18n.getString("termora.transport.table.contextmenu.new"))
|
||||||
|
private val newFolderMenu = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.folder"))
|
||||||
|
private val newFileMenu = newMenu.add(I18n.getString("termora.transport.table.contextmenu.new.file"))
|
||||||
|
|
||||||
|
private val eventListeners = EventListenerList()
|
||||||
|
private val mnemonics = mapOf(
|
||||||
|
refreshMenu to KeyEvent.VK_R,
|
||||||
|
newMenu to KeyEvent.VK_W,
|
||||||
|
newFolderMenu to KeyEvent.VK_F,
|
||||||
|
renameMenu to KeyEvent.VK_M,
|
||||||
|
deleteMenu to KeyEvent.VK_D,
|
||||||
|
editMenu to KeyEvent.VK_E,
|
||||||
|
transferMenu to KeyEvent.VK_T,
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
initView()
|
||||||
|
initEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initView() {
|
||||||
|
inheritsPopupMenu = false
|
||||||
|
|
||||||
|
add(transferMenu)
|
||||||
|
add(editMenu)
|
||||||
|
addSeparator()
|
||||||
|
add(copyPathMenu)
|
||||||
|
if (fileSystem.isLocallyFileSystem()) add(openInFinderMenu)
|
||||||
|
addSeparator()
|
||||||
|
add(renameMenu)
|
||||||
|
add(deleteMenu)
|
||||||
|
if (fileSystem is SftpFileSystem) add(rmrfMenu)
|
||||||
|
add(changePermissionsMenu)
|
||||||
|
addSeparator()
|
||||||
|
add(refreshMenu)
|
||||||
|
addSeparator()
|
||||||
|
add(newMenu)
|
||||||
|
|
||||||
|
transferMenu.isEnabled = hasParent.not() && files.isNotEmpty() && transferManager.canTransfer(paths)
|
||||||
|
copyPathMenu.isEnabled = files.isNotEmpty()
|
||||||
|
openInFinderMenu.isEnabled = files.isNotEmpty() && fileSystem.isLocallyFileSystem()
|
||||||
|
editMenu.isEnabled = files.isNotEmpty() && fileSystem.isLocallyFileSystem().not()
|
||||||
|
&& files.all { it.second.isFile && it.second.isSymbolicLink.not() }
|
||||||
|
renameMenu.isEnabled = hasParent.not() && files.size == 1
|
||||||
|
deleteMenu.isEnabled = hasParent.not() && files.isNotEmpty()
|
||||||
|
rmrfMenu.isEnabled = hasParent.not() && files.isNotEmpty()
|
||||||
|
changePermissionsMenu.isEnabled = hasParent.not() && fileSystem is SftpFileSystem && files.size == 1
|
||||||
|
|
||||||
|
for ((item, mnemonic) in mnemonics) {
|
||||||
|
item.text = "${item.text}(${KeyEvent.getKeyText(mnemonic)})"
|
||||||
|
item.setMnemonic(mnemonic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initEvents() {
|
||||||
|
transferMenu.addActionListener { fireActionPerformed(it, ActionCommand.Transfer) }
|
||||||
|
deleteMenu.addActionListener {
|
||||||
|
if (OptionPane.showConfirmDialog(
|
||||||
|
owner,
|
||||||
|
I18n.getString("termora.keymgr.delete-warning"),
|
||||||
|
messageType = JOptionPane.WARNING_MESSAGE
|
||||||
|
) == JOptionPane.YES_OPTION
|
||||||
|
) {
|
||||||
|
fireActionPerformed(it, ActionCommand.Delete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmrfMenu.addActionListener {
|
||||||
|
if (OptionPane.showConfirmDialog(
|
||||||
|
SwingUtilities.getWindowAncestor(this),
|
||||||
|
I18n.getString("termora.transport.table.contextmenu.rm-warning"),
|
||||||
|
messageType = JOptionPane.ERROR_MESSAGE
|
||||||
|
) == JOptionPane.YES_OPTION
|
||||||
|
) {
|
||||||
|
fireActionPerformed(it, ActionCommand.Rmrf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renameMenu.addActionListener { newFolderOrNewFile(it, ActionCommand.Rename) }
|
||||||
|
editMenu.addActionListener { fireActionPerformed(it, ActionCommand.Edit) }
|
||||||
|
newFolderMenu.addActionListener { newFolderOrNewFile(it, ActionCommand.NewFolder) }
|
||||||
|
newFileMenu.addActionListener { newFolderOrNewFile(it, ActionCommand.NewFile) }
|
||||||
|
refreshMenu.addActionListener { fireActionPerformed(it, ActionCommand.Refresh) }
|
||||||
|
openInFinderMenu.addActionListener { for (path in paths) Application.browseInFolder(path.toFile()) }
|
||||||
|
changePermissionsMenu.addActionListener { changePosixFilePermission(it) }
|
||||||
|
copyPathMenu.addActionListener {
|
||||||
|
val sb = StringBuilder()
|
||||||
|
paths.forEach { sb.append(it.absolutePathString()).appendLine() }
|
||||||
|
sb.deleteCharAt(sb.length - 1)
|
||||||
|
toolkit.systemClipboard.setContents(StringSelection(sb.toString()), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fireActionPerformed(evt: ActionEvent, command: ActionCommand) {
|
||||||
|
for (listener in eventListeners.getListeners(ActionListener::class.java)) {
|
||||||
|
listener.actionPerformed(ActionEvent(evt.source, evt.id, command.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun changePosixFilePermission(evt: ActionEvent) {
|
||||||
|
val panel = PosixFilePermissionPanel(files.first().second.permissions)
|
||||||
|
if (OptionPane.showConfirmDialog(
|
||||||
|
owner, panel,
|
||||||
|
messageType = JOptionPane.PLAIN_MESSAGE,
|
||||||
|
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||||
|
) != JOptionPane.OK_OPTION
|
||||||
|
) return
|
||||||
|
|
||||||
|
if (panel.isIncludeSubdirectories().not()) {
|
||||||
|
if (Objects.deepEquals(panel.getPermissions(), files.first().second.permissions)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fireActionPerformed(
|
||||||
|
ActionEvent(
|
||||||
|
ChangePermission(panel.getPermissions(), panel.isIncludeSubdirectories()),
|
||||||
|
evt.id,
|
||||||
|
evt.actionCommand
|
||||||
|
),
|
||||||
|
ActionCommand.ChangePermissions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newFolderOrNewFile(evt: ActionEvent, actionCommand: ActionCommand) {
|
||||||
|
val title = when (actionCommand) {
|
||||||
|
ActionCommand.NewFile -> I18n.getString("termora.transport.table.contextmenu.new.file")
|
||||||
|
ActionCommand.NewFolder -> I18n.getString("termora.welcome.contextmenu.new.folder.name")
|
||||||
|
ActionCommand.Rename -> I18n.getString("termora.transport.table.contextmenu.rename")
|
||||||
|
else -> StringUtils.EMPTY
|
||||||
|
}
|
||||||
|
val defaultValue = if (actionCommand == ActionCommand.Rename) paths.first().name else title
|
||||||
|
val text = OptionPane.showInputDialog(owner, title = title, value = defaultValue) ?: return
|
||||||
|
if (text.isBlank()) return
|
||||||
|
for (i in 0 until model.rowCount) {
|
||||||
|
if (model.getPath(i).name == text) {
|
||||||
|
OptionPane.showMessageDialog(
|
||||||
|
owner,
|
||||||
|
I18n.getString("termora.transport.file-already-exists", text),
|
||||||
|
messageType = JOptionPane.ERROR_MESSAGE
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fireActionPerformed(ActionEvent(text, evt.id, evt.actionCommand), actionCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addActionListener(listener: ActionListener) {
|
||||||
|
eventListeners.add(ActionListener::class.java, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeActionListener(listener: ActionListener) {
|
||||||
|
eventListeners.remove(ActionListener::class.java, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ActionCommand {
|
||||||
|
Transfer,
|
||||||
|
Delete,
|
||||||
|
Edit,
|
||||||
|
Rename,
|
||||||
|
NewFolder,
|
||||||
|
NewFile,
|
||||||
|
Refresh,
|
||||||
|
ChangePermissions,
|
||||||
|
Rmrf,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ChangePermission(val permissions: Set<PosixFilePermission>, val includeSubFolder: Boolean)
|
||||||
|
}
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
package app.termora.sftp
|
package app.termora.transfer
|
||||||
|
|
||||||
import app.termora.Disposable
|
import app.termora.*
|
||||||
import app.termora.Disposer
|
|
||||||
import app.termora.Host
|
|
||||||
import app.termora.I18n
|
|
||||||
import app.termora.actions.DataProvider
|
|
||||||
import app.termora.database.DatabaseManager
|
import app.termora.database.DatabaseManager
|
||||||
import app.termora.protocol.FileObjectHandler
|
import app.termora.protocol.PathHandlerRequest
|
||||||
import app.termora.protocol.FileObjectRequest
|
|
||||||
import app.termora.protocol.TransferProtocolProvider
|
import app.termora.protocol.TransferProtocolProvider
|
||||||
import app.termora.terminal.DataKey
|
|
||||||
import app.termora.tree.*
|
import app.termora.tree.*
|
||||||
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
|
import com.formdev.flatlaf.icons.FlatOptionPaneErrorIcon
|
||||||
import com.jgoodies.forms.builder.FormBuilder
|
import com.jgoodies.forms.builder.FormBuilder
|
||||||
@@ -18,7 +12,6 @@ import kotlinx.coroutines.*
|
|||||||
import kotlinx.coroutines.swing.Swing
|
import kotlinx.coroutines.swing.Swing
|
||||||
import org.apache.commons.lang3.StringUtils
|
import org.apache.commons.lang3.StringUtils
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.jdesktop.swingx.JXBusyLabel
|
import org.jdesktop.swingx.JXBusyLabel
|
||||||
import org.jdesktop.swingx.JXHyperlink
|
import org.jdesktop.swingx.JXHyperlink
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
@@ -27,43 +20,42 @@ import java.awt.CardLayout
|
|||||||
import java.awt.event.ActionEvent
|
import java.awt.event.ActionEvent
|
||||||
import java.awt.event.MouseAdapter
|
import java.awt.event.MouseAdapter
|
||||||
import java.awt.event.MouseEvent
|
import java.awt.event.MouseEvent
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.Executors
|
||||||
import javax.swing.*
|
import javax.swing.*
|
||||||
import javax.swing.event.TreeExpansionEvent
|
import javax.swing.event.TreeExpansionEvent
|
||||||
import javax.swing.event.TreeExpansionListener
|
import javax.swing.event.TreeExpansionListener
|
||||||
|
import kotlin.io.path.absolutePathString
|
||||||
|
|
||||||
class SFTPFileSystemViewPanel(
|
class TransportSelectionPanel(
|
||||||
var host: Host? = null,
|
private val tabbed: TransportTabbed,
|
||||||
private val transportManager: TransportManager,
|
private val transferManager: InternalTransferManager,
|
||||||
) : JPanel(BorderLayout()), Disposable, DataProvider {
|
) : JPanel(BorderLayout()), Disposable {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val log = LoggerFactory.getLogger(SFTPFileSystemViewPanel::class.java)
|
private val log = LoggerFactory.getLogger(TransportSelectionPanel::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class State {
|
enum class State {
|
||||||
Initialized,
|
Initialized,
|
||||||
Connecting,
|
Connecting,
|
||||||
Connected,
|
|
||||||
ConnectFailed,
|
ConnectFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile
|
|
||||||
var state = State.Initialized
|
var state = State.Initialized
|
||||||
private set
|
private set
|
||||||
private val cardLayout = CardLayout()
|
private val cardLayout = CardLayout()
|
||||||
private val cardPanel = JPanel(cardLayout)
|
private val cardPanel = JPanel(cardLayout)
|
||||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
private val connectingPanel = ConnectingPanel()
|
private val connectingPanel = ConnectingPanel()
|
||||||
private val selectHostPanel = SelectHostPanel()
|
private val selectHostPanel = SelectHostPanel()
|
||||||
private val connectFailedPanel = ConnectFailedPanel()
|
private val connectFailedPanel = ConnectFailedPanel()
|
||||||
private val isDisposed = AtomicBoolean(false)
|
|
||||||
private val that = this
|
|
||||||
private val properties get() = DatabaseManager.getInstance().properties
|
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 val that get() = this
|
||||||
private var fileSystemPanel: FileSystemViewPanel? = null
|
private var host: Host? = null
|
||||||
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
initView()
|
initView()
|
||||||
@@ -71,6 +63,7 @@ class SFTPFileSystemViewPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initView() {
|
private fun initView() {
|
||||||
|
isFocusable = false
|
||||||
cardPanel.add(selectHostPanel, State.Initialized.name)
|
cardPanel.add(selectHostPanel, State.Initialized.name)
|
||||||
cardPanel.add(connectingPanel, State.Connecting.name)
|
cardPanel.add(connectingPanel, State.Connecting.name)
|
||||||
cardPanel.add(connectFailedPanel, State.ConnectFailed.name)
|
cardPanel.add(connectFailedPanel, State.ConnectFailed.name)
|
||||||
@@ -82,110 +75,67 @@ class SFTPFileSystemViewPanel(
|
|||||||
Disposer.register(this, selectHostPanel)
|
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 {
|
coroutineScope.launch {
|
||||||
if (state != State.Connecting) {
|
try {
|
||||||
state = State.Connecting
|
doConnect(host)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (log.isErrorEnabled) log.error(e.message, e)
|
||||||
withContext(Dispatchers.Swing) {
|
withContext(Dispatchers.Swing) {
|
||||||
connectingPanel.start()
|
state = State.ConnectFailed
|
||||||
cardLayout.show(cardPanel, State.Connecting.name)
|
connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(e)
|
||||||
|
cardLayout.show(cardPanel, State.ConnectFailed.name)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}.invokeOnCompletion { swingCoroutineScope.launch { connectingPanel.busyLabel.isBusy = false } }
|
||||||
|
}
|
||||||
|
|
||||||
runCatching { doConnect() }.onFailure {
|
private suspend fun doConnect(host: Host) {
|
||||||
if (log.isErrorEnabled) {
|
|
||||||
log.error(it.message, it)
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Swing) {
|
|
||||||
state = State.ConnectFailed
|
|
||||||
connectFailedPanel.errorLabel.text = ExceptionUtils.getRootCauseMessage(it)
|
|
||||||
cardLayout.show(cardPanel, State.ConnectFailed.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.Swing) {
|
val provider = TransferProtocolProvider.valueOf(host.protocol)
|
||||||
connectingPanel.stop()
|
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() {
|
override fun requestFocusInWindow(): Boolean {
|
||||||
val thisHost = this.host ?: return
|
return selectHostPanel.tree.requestFocusInWindow()
|
||||||
|
|
||||||
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 dispose() {
|
override fun dispose() {
|
||||||
if (isDisposed.compareAndSet(false, true)) {
|
coroutineScope.cancel()
|
||||||
closeIO()
|
coroutineDispatcher.close()
|
||||||
coroutineScope.cancel()
|
executorService.shutdownNow()
|
||||||
}
|
connectingPanel.busyLabel.isBusy = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private class ConnectingPanel : JPanel(BorderLayout()) {
|
private class ConnectingPanel : JPanel(BorderLayout()) {
|
||||||
private val busyLabel = JXBusyLabel()
|
val busyLabel = JXBusyLabel()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
initView()
|
initView()
|
||||||
@@ -210,13 +160,6 @@ class SFTPFileSystemViewPanel(
|
|||||||
add(builder.build(), BorderLayout.CENTER)
|
add(builder.build(), BorderLayout.CENTER)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start() {
|
|
||||||
busyLabel.isBusy = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
busyLabel.isBusy = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class ConnectFailedPanel : JPanel(BorderLayout()) {
|
private inner class ConnectFailedPanel : JPanel(BorderLayout()) {
|
||||||
@@ -240,7 +183,7 @@ class SFTPFileSystemViewPanel(
|
|||||||
builder.add(errorLabel).xyw(1, 4, 3, "fill, center")
|
builder.add(errorLabel).xyw(1, 4, 3, "fill, center")
|
||||||
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.retry")) {
|
builder.add(JXHyperlink(object : AbstractAction(I18n.getString("termora.transport.sftp.retry")) {
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
connect()
|
host?.let { connect(it) }
|
||||||
}
|
}
|
||||||
}).apply {
|
}).apply {
|
||||||
horizontalAlignment = SwingConstants.CENTER
|
horizontalAlignment = SwingConstants.CENTER
|
||||||
@@ -251,8 +194,8 @@ class SFTPFileSystemViewPanel(
|
|||||||
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
|
AbstractAction(I18n.getString("termora.transport.sftp.select-another-host")) {
|
||||||
override fun actionPerformed(e: ActionEvent) {
|
override fun actionPerformed(e: ActionEvent) {
|
||||||
state = State.Initialized
|
state = State.Initialized
|
||||||
that.setTabTitle(I18n.getString("termora.transport.sftp.select-host"))
|
|
||||||
cardLayout.show(cardPanel, State.Initialized.name)
|
cardLayout.show(cardPanel, State.Initialized.name)
|
||||||
|
selectHostPanel.tree.requestFocusInWindow()
|
||||||
}
|
}
|
||||||
}).apply {
|
}).apply {
|
||||||
horizontalAlignment = SwingConstants.CENTER
|
horizontalAlignment = SwingConstants.CENTER
|
||||||
@@ -264,7 +207,7 @@ class SFTPFileSystemViewPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private inner class SelectHostPanel : JPanel(BorderLayout()), Disposable {
|
private inner class SelectHostPanel : JPanel(BorderLayout()), Disposable {
|
||||||
private val tree = NewHostTree()
|
val tree = NewHostTree()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
initView()
|
initView()
|
||||||
@@ -306,7 +249,7 @@ class SFTPFileSystemViewPanel(
|
|||||||
val node = tree.getLastSelectedPathNode() ?: return
|
val node = tree.getLastSelectedPathNode() ?: return
|
||||||
if (node.isFolder) return
|
if (node.isFolder) return
|
||||||
val host = node.data as Host
|
val host = node.data as Host
|
||||||
selectHost(host)
|
connect(host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -324,31 +267,5 @@ class SFTPFileSystemViewPanel(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
|
||||||
return when (dataKey) {
|
|
||||||
SFTPDataProviders.FileSystemViewPanel -> fileSystemPanel as T?
|
|
||||||
SFTPDataProviders.CoroutineScope -> coroutineScope as T?
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectHost(host: Host) {
|
|
||||||
that.setTabTitle(host.name)
|
|
||||||
that.host = host
|
|
||||||
that.connect()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setTabTitle(title: String) {
|
|
||||||
val tabbed = SwingUtilities.getAncestorOfClass(JTabbedPane::class.java, that)
|
|
||||||
if (tabbed is JTabbedPane) {
|
|
||||||
for (i in 0 until tabbed.tabCount) {
|
|
||||||
if (tabbed.getComponentAt(i) == that) {
|
|
||||||
tabbed.setTitleAt(i, title)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
9
src/main/kotlin/app/termora/transfer/TransportSupport.kt
Normal file
9
src/main/kotlin/app/termora/transfer/TransportSupport.kt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import java.nio.file.FileSystem
|
||||||
|
|
||||||
|
|
||||||
|
class TransportSupport(
|
||||||
|
val fileSystem: FileSystem,
|
||||||
|
val path: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import okio.withLock
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
|
import java.util.function.Supplier
|
||||||
|
|
||||||
|
class TransportSupportLoader(private val support: Supplier<TransportSupport>) : Supplier<TransportSupport> {
|
||||||
|
private val loading = AtomicBoolean(false)
|
||||||
|
private lateinit var mySupport: TransportSupport
|
||||||
|
private val lock = ReentrantLock()
|
||||||
|
private val condition = lock.newCondition()
|
||||||
|
private val exceptionReference = AtomicReference<Exception>(null)
|
||||||
|
|
||||||
|
val isLoaded get() = ::mySupport.isInitialized
|
||||||
|
|
||||||
|
|
||||||
|
override fun get(): TransportSupport {
|
||||||
|
if (isLoaded) return mySupport
|
||||||
|
|
||||||
|
if (loading.compareAndSet(false, true)) {
|
||||||
|
try {
|
||||||
|
mySupport = support.get()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
exceptionReference.set(e)
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
lock.withLock {
|
||||||
|
loading.set(false)
|
||||||
|
condition.signalAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lock.lock()
|
||||||
|
try {
|
||||||
|
condition.await()
|
||||||
|
} finally {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val exception = exceptionReference.get()
|
||||||
|
if (exception != null) {
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
|
||||||
|
return get()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
197
src/main/kotlin/app/termora/transfer/TransportTabbed.kt
Normal file
197
src/main/kotlin/app/termora/transfer/TransportTabbed.kt
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.*
|
||||||
|
import app.termora.actions.AnAction
|
||||||
|
import app.termora.actions.AnActionEvent
|
||||||
|
import app.termora.plugin.internal.local.LocalProtocolProvider
|
||||||
|
import com.formdev.flatlaf.extras.components.FlatPopupMenu
|
||||||
|
import com.formdev.flatlaf.extras.components.FlatTabbedPane
|
||||||
|
import org.apache.commons.lang3.SystemUtils
|
||||||
|
import java.awt.event.MouseAdapter
|
||||||
|
import java.awt.event.MouseEvent
|
||||||
|
import java.nio.file.FileSystems
|
||||||
|
import javax.swing.JButton
|
||||||
|
import javax.swing.JOptionPane
|
||||||
|
import javax.swing.JToolBar
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
|
||||||
|
@Suppress("DuplicatedCode")
|
||||||
|
class TransportTabbed(
|
||||||
|
private val transferManager: TransferManager,
|
||||||
|
private val internalTransferManager: InternalTransferManager
|
||||||
|
) : FlatTabbedPane(), Disposable {
|
||||||
|
private val addBtn = JButton(Icons.add)
|
||||||
|
private val tabbed get() = this
|
||||||
|
|
||||||
|
init {
|
||||||
|
initViews()
|
||||||
|
initEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initViews() {
|
||||||
|
super.setTabLayoutPolicy(SCROLL_TAB_LAYOUT)
|
||||||
|
super.setTabsClosable(true)
|
||||||
|
super.setTabType(TabType.underlined)
|
||||||
|
super.setFocusable(false)
|
||||||
|
|
||||||
|
|
||||||
|
val toolbar = JToolBar()
|
||||||
|
toolbar.add(addBtn)
|
||||||
|
super.setTrailingComponent(toolbar)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initEvents() {
|
||||||
|
addBtn.addActionListener(object : AnAction() {
|
||||||
|
override fun actionPerformed(evt: AnActionEvent) {
|
||||||
|
for (i in 0 until tabCount) {
|
||||||
|
val c = getComponentAt(i)
|
||||||
|
if (c !is TransportSelectionPanel) continue
|
||||||
|
if (c.state != TransportSelectionPanel.State.Initialized) continue
|
||||||
|
selectedIndex = i
|
||||||
|
SwingUtilities.invokeLater { c.requestFocusInWindow() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加一个新的
|
||||||
|
addSelectionTab()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 右键菜单
|
||||||
|
addMouseListener(object : MouseAdapter() {
|
||||||
|
override fun mouseClicked(e: MouseEvent) {
|
||||||
|
if (!SwingUtilities.isRightMouseButton(e)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val index = indexAtLocation(e.x, e.y)
|
||||||
|
if (index < 0) return
|
||||||
|
|
||||||
|
showContextMenu(index, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// 关闭 tab
|
||||||
|
setTabCloseCallback { _, i -> tabCloseCallback(i) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tabCloseCallback(index: Int) {
|
||||||
|
assertEventDispatchThread()
|
||||||
|
|
||||||
|
if (isTabClosable(index).not()) return
|
||||||
|
|
||||||
|
val c = tabbed.getComponentAt(index)
|
||||||
|
if (c == null) {
|
||||||
|
tabbed.removeTabAt(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c is TransportPanel) {
|
||||||
|
if (tabClose(c).not()) return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除并销毁
|
||||||
|
tabbed.removeTabAt(index)
|
||||||
|
|
||||||
|
if (tabbed.tabCount < 1) {
|
||||||
|
addSelectionTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tabClose(c: TransportPanel): Boolean {
|
||||||
|
if (transferManager.getTransferCount() < 1) return true
|
||||||
|
if (c.loader.isLoaded.not()) return false
|
||||||
|
val fileSystem = c.getFileSystem()
|
||||||
|
val transfers = transferManager.getTransfers()
|
||||||
|
.filter { it.source().fileSystem == fileSystem || it.target().fileSystem == fileSystem }
|
||||||
|
if (transfers.isEmpty()) return true
|
||||||
|
|
||||||
|
if (OptionPane.showConfirmDialog(
|
||||||
|
SwingUtilities.getWindowAncestor(this),
|
||||||
|
I18n.getString("termora.transport.sftp.close-tab"),
|
||||||
|
messageType = JOptionPane.QUESTION_MESSAGE,
|
||||||
|
optionType = JOptionPane.OK_CANCEL_OPTION
|
||||||
|
) != JOptionPane.OK_OPTION
|
||||||
|
) return false
|
||||||
|
|
||||||
|
// 删除所有关联任务
|
||||||
|
for (transfer in transfers) {
|
||||||
|
transferManager.removeTransfer(transfer.id())
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSelectionTab() {
|
||||||
|
val c = TransportSelectionPanel(tabbed, internalTransferManager)
|
||||||
|
addTab(I18n.getString("termora.transport.sftp.select-host"), c)
|
||||||
|
selectedIndex = tabCount - 1
|
||||||
|
SwingUtilities.invokeLater { c.requestFocusInWindow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addLocalTab() {
|
||||||
|
val host = Host(name = "Local", protocol = LocalProtocolProvider.PROTOCOL)
|
||||||
|
val support = TransportSupport(FileSystems.getDefault(), SystemUtils.USER_HOME)
|
||||||
|
val panel = TransportPanel(internalTransferManager, host, TransportSupportLoader { support })
|
||||||
|
addTab(I18n.getString("termora.transport.local"), panel)
|
||||||
|
super.setTabClosable(0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
|
||||||
|
val popupMenu = FlatPopupMenu()
|
||||||
|
// 克隆
|
||||||
|
val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone"))
|
||||||
|
clone.addActionListener(object : AnAction() {
|
||||||
|
override fun actionPerformed(evt: AnActionEvent) {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
val edit = popupMenu.add(I18n.getString("termora.keymgr.edit"))
|
||||||
|
edit.addActionListener(object : AnAction() {
|
||||||
|
override fun actionPerformed(evt: AnActionEvent) {
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
edit.isEnabled = clone.isEnabled
|
||||||
|
|
||||||
|
popupMenu.show(this, e.x, e.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSelectedTransportPanel(): TransportPanel? {
|
||||||
|
val index = selectedIndex
|
||||||
|
if (index < 0) return null
|
||||||
|
return getTransportPanel(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTransportPanel(index: Int): TransportPanel? {
|
||||||
|
return getComponentAt(index) as? TransportPanel
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateUI() {
|
||||||
|
styleMap = mapOf(
|
||||||
|
"focusColor" to DynamicColor("TabbedPane.background"),
|
||||||
|
"hoverColor" to DynamicColor("TabbedPane.background"),
|
||||||
|
"tabHeight" to 30,
|
||||||
|
"showTabSeparators" to true,
|
||||||
|
"tabSeparatorsFullHeight" to true,
|
||||||
|
)
|
||||||
|
super.updateUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeTabAt(index: Int) {
|
||||||
|
val c = getComponentAt(index)
|
||||||
|
if (c is Disposable) {
|
||||||
|
Disposer.dispose(c)
|
||||||
|
}
|
||||||
|
super.removeTabAt(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispose() {
|
||||||
|
while (tabCount > 0) removeTabAt(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/main/kotlin/app/termora/transfer/TransportTableModel.kt
Normal file
89
src/main/kotlin/app/termora/transfer/TransportTableModel.kt
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.I18n
|
||||||
|
import org.apache.commons.io.FilenameUtils
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
|
import javax.swing.table.DefaultTableModel
|
||||||
|
|
||||||
|
class TransportTableModel() : DefaultTableModel() {
|
||||||
|
companion object {
|
||||||
|
const val COLUMN_NAME = 0
|
||||||
|
const val COLUMN_TYPE = 1
|
||||||
|
const val COLUMN_FILE_SIZE = 2
|
||||||
|
const val COLUMN_LAST_MODIFIED_TIME = 3
|
||||||
|
const val COLUMN_ATTRS = 4
|
||||||
|
const val COLUMN_OWNER = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getColumnCount(): Int {
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPath(row: Int): Path {
|
||||||
|
return super.getValueAt(row, 0) as Path
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAttributes(row: Int): Attributes {
|
||||||
|
return super.getValueAt(row, 1) as Attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getColumnClass(columnIndex: Int): Class<*> {
|
||||||
|
return Attributes::class.java
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValueAt(row: Int, column: Int): Any? {
|
||||||
|
return getAttributes(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getColumnName(column: Int): String {
|
||||||
|
return when (column) {
|
||||||
|
COLUMN_NAME -> I18n.getString("termora.transport.table.filename")
|
||||||
|
COLUMN_FILE_SIZE -> I18n.getString("termora.transport.table.size")
|
||||||
|
COLUMN_TYPE -> I18n.getString("termora.transport.table.type")
|
||||||
|
COLUMN_LAST_MODIFIED_TIME -> I18n.getString("termora.transport.table.modified-time")
|
||||||
|
COLUMN_ATTRS -> I18n.getString("termora.transport.table.permissions")
|
||||||
|
COLUMN_OWNER -> I18n.getString("termora.transport.table.owner")
|
||||||
|
else -> StringUtils.EMPTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isCellEditable(row: Int, column: Int): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
while (rowCount > 0) {
|
||||||
|
removeRow(rowCount - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class Attributes(
|
||||||
|
val name: String,
|
||||||
|
val type: String,
|
||||||
|
val isDirectory: Boolean,
|
||||||
|
val isFile: Boolean,
|
||||||
|
val isSymbolicLink: Boolean,
|
||||||
|
val fileSize: Long,
|
||||||
|
val permissions: Set<PosixFilePermission>,
|
||||||
|
val owner: String,
|
||||||
|
val lastModifiedTime: Long
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun computeType(isSymbolicLink: Boolean, isDirectory: Boolean, name: String): String {
|
||||||
|
if (isSymbolicLink) {
|
||||||
|
return I18n.getString("termora.transport.table.type.symbolic-link")
|
||||||
|
} else if (isDirectory) {
|
||||||
|
return I18n.getString("termora.folder")
|
||||||
|
}
|
||||||
|
if (name == "..") return StringUtils.EMPTY
|
||||||
|
return FilenameUtils.getExtension(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isParent get() = name == ".."
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
package app.termora.sftp
|
package app.termora.transfer
|
||||||
|
|
||||||
import app.termora.*
|
import app.termora.*
|
||||||
import app.termora.database.DatabaseManager
|
import app.termora.database.DatabaseManager
|
||||||
import app.termora.terminal.DataKey
|
import app.termora.terminal.DataKey
|
||||||
import java.beans.PropertyChangeListener
|
import java.beans.PropertyChangeListener
|
||||||
|
import java.nio.file.FileSystems
|
||||||
import javax.swing.Icon
|
import javax.swing.Icon
|
||||||
import javax.swing.JComponent
|
import javax.swing.JComponent
|
||||||
import javax.swing.JOptionPane
|
import javax.swing.JOptionPane
|
||||||
import javax.swing.SwingUtilities
|
import javax.swing.SwingUtilities
|
||||||
|
|
||||||
class SFTPTab : RememberFocusTerminalTab() {
|
class TransportTerminalTab : RememberFocusTerminalTab() {
|
||||||
private val sftpPanel = SFTPPanel()
|
private val transportViewer = TransportViewer()
|
||||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||||
|
private val transferManager get() = transportViewer.getTransferManager()
|
||||||
|
val leftTabbed get() = transportViewer.getLeftTabbed()
|
||||||
|
val rightTabbed get() = transportViewer.getRightTabbed()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Disposer.register(this, sftpPanel)
|
Disposer.register(this, transportViewer)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getTitle(): String {
|
override fun getTitle(): String {
|
||||||
@@ -32,14 +36,13 @@ class SFTPTab : RememberFocusTerminalTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun canClose(): Boolean {
|
override fun canClose(): Boolean {
|
||||||
return !sftp.pinTab
|
return sftp.pinTab.not()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun willBeClose(): Boolean {
|
override fun willBeClose(): Boolean {
|
||||||
if (!canClose()) return false
|
if (canClose().not()) return false
|
||||||
|
|
||||||
val transportManager = sftpPanel.getData(SFTPDataProviders.TransportManager) ?: return true
|
if (transferManager.getTransferCount() > 0) {
|
||||||
if (transportManager.getTransportCount() > 0) {
|
|
||||||
return OptionPane.showConfirmDialog(
|
return OptionPane.showConfirmDialog(
|
||||||
SwingUtilities.getWindowAncestor(getJComponent()),
|
SwingUtilities.getWindowAncestor(getJComponent()),
|
||||||
I18n.getString("termora.transport.sftp.close-tab"),
|
I18n.getString("termora.transport.sftp.close-tab"),
|
||||||
@@ -48,8 +51,6 @@ class SFTPTab : RememberFocusTerminalTab() {
|
|||||||
) == JOptionPane.OK_OPTION
|
) == JOptionPane.OK_OPTION
|
||||||
}
|
}
|
||||||
|
|
||||||
val leftTabbed = sftpPanel.getData(SFTPDataProviders.LeftSFTPTabbed) ?: return true
|
|
||||||
val rightTabbed = sftpPanel.getData(SFTPDataProviders.RightSFTPTabbed) ?: return true
|
|
||||||
if (hasActiveTab(leftTabbed) || hasActiveTab(rightTabbed)) {
|
if (hasActiveTab(leftTabbed) || hasActiveTab(rightTabbed)) {
|
||||||
return OptionPane.showConfirmDialog(
|
return OptionPane.showConfirmDialog(
|
||||||
SwingUtilities.getWindowAncestor(getJComponent()),
|
SwingUtilities.getWindowAncestor(getJComponent()),
|
||||||
@@ -59,25 +60,26 @@ class SFTPTab : RememberFocusTerminalTab() {
|
|||||||
) == JOptionPane.OK_OPTION
|
) == JOptionPane.OK_OPTION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasActiveTab(tabbed: SFTPTabbed): Boolean {
|
private fun hasActiveTab(tabbed: TransportTabbed): Boolean {
|
||||||
for (i in 0 until tabbed.tabCount) {
|
for (i in 0 until tabbed.tabCount) {
|
||||||
val c = tabbed.getFileSystemViewPanel(i) ?: continue
|
val c = tabbed.getComponentAt(i) ?: continue
|
||||||
if (c.host.id != "local") {
|
if (c is TransportPanel && c.loader.isLoaded) {
|
||||||
return true
|
if (c.getFileSystem() != FileSystems.getDefault()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJComponent(): JComponent {
|
override fun getJComponent(): JComponent {
|
||||||
return sftpPanel
|
return transportViewer
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
override fun <T : Any> getData(dataKey: DataKey<T>): T? {
|
||||||
return sftpPanel.getData(dataKey)
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
489
src/main/kotlin/app/termora/transfer/TransportViewer.kt
Normal file
489
src/main/kotlin/app/termora/transfer/TransportViewer.kt
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
package app.termora.transfer
|
||||||
|
|
||||||
|
import app.termora.*
|
||||||
|
import app.termora.actions.DataProvider
|
||||||
|
import app.termora.transfer.InternalTransferManager.TransferMode
|
||||||
|
import com.jgoodies.forms.builder.FormBuilder
|
||||||
|
import com.jgoodies.forms.layout.FormLayout
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.swing.Swing
|
||||||
|
import org.apache.commons.lang3.StringUtils
|
||||||
|
import org.apache.commons.lang3.time.DateFormatUtils
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.awt.BorderLayout
|
||||||
|
import java.awt.Component
|
||||||
|
import java.awt.Dimension
|
||||||
|
import java.awt.event.ComponentAdapter
|
||||||
|
import java.awt.event.ComponentEvent
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.file.*
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.concurrent.CompletableFuture
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import javax.swing.*
|
||||||
|
import kotlin.collections.ArrayDeque
|
||||||
|
import kotlin.collections.List
|
||||||
|
import kotlin.collections.Set
|
||||||
|
import kotlin.collections.isNotEmpty
|
||||||
|
import kotlin.io.path.name
|
||||||
|
import kotlin.io.path.pathString
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
|
||||||
|
class TransportViewer : JPanel(BorderLayout()), DataProvider, Disposable {
|
||||||
|
companion object {
|
||||||
|
private val log = LoggerFactory.getLogger(TransportViewer::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val splitPane = JSplitPane()
|
||||||
|
private val transferManager = TransferTableModel(coroutineScope)
|
||||||
|
private val transferTable = TransferTable(coroutineScope, transferManager)
|
||||||
|
private val leftTransferManager = MyInternalTransferManager()
|
||||||
|
private val rightTransferManager = MyInternalTransferManager()
|
||||||
|
private val leftTabbed = TransportTabbed(transferManager, leftTransferManager)
|
||||||
|
private val rightTabbed = TransportTabbed(transferManager, rightTransferManager)
|
||||||
|
private val rootSplitPane = JSplitPane(JSplitPane.VERTICAL_SPLIT)
|
||||||
|
private val owner get() = SwingUtilities.getWindowAncestor(this)
|
||||||
|
|
||||||
|
init {
|
||||||
|
initView()
|
||||||
|
initEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initView() {
|
||||||
|
isFocusable = false
|
||||||
|
|
||||||
|
leftTabbed.addLocalTab()
|
||||||
|
rightTabbed.addSelectionTab()
|
||||||
|
|
||||||
|
leftTransferManager.source = leftTabbed
|
||||||
|
leftTransferManager.target = rightTabbed
|
||||||
|
|
||||||
|
rightTransferManager.source = rightTabbed
|
||||||
|
rightTransferManager.target = leftTabbed
|
||||||
|
|
||||||
|
|
||||||
|
val scrollPane = JScrollPane(transferTable)
|
||||||
|
scrollPane.border = BorderFactory.createMatteBorder(1, 0, 0, 0, DynamicColor.BorderColor)
|
||||||
|
|
||||||
|
leftTabbed.border = BorderFactory.createMatteBorder(0, 0, 0, 1, DynamicColor.BorderColor)
|
||||||
|
rightTabbed.border = BorderFactory.createMatteBorder(0, 1, 0, 0, DynamicColor.BorderColor)
|
||||||
|
|
||||||
|
splitPane.resizeWeight = 0.5
|
||||||
|
splitPane.leftComponent = leftTabbed
|
||||||
|
splitPane.rightComponent = rightTabbed
|
||||||
|
splitPane.border = BorderFactory.createMatteBorder(0, 0, 1, 0, DynamicColor.BorderColor)
|
||||||
|
|
||||||
|
rootSplitPane.resizeWeight = 0.7
|
||||||
|
rootSplitPane.topComponent = splitPane
|
||||||
|
rootSplitPane.bottomComponent = scrollPane
|
||||||
|
|
||||||
|
add(rootSplitPane, BorderLayout.CENTER)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initEvents() {
|
||||||
|
splitPane.addComponentListener(object : ComponentAdapter() {
|
||||||
|
override fun componentResized(e: ComponentEvent) {
|
||||||
|
removeComponentListener(this)
|
||||||
|
splitPane.setDividerLocation(splitPane.resizeWeight)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
rootSplitPane.addComponentListener(object : ComponentAdapter() {
|
||||||
|
override fun componentResized(e: ComponentEvent) {
|
||||||
|
removeComponentListener(this)
|
||||||
|
rootSplitPane.setDividerLocation(rootSplitPane.resizeWeight)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Disposer.register(this, leftTabbed)
|
||||||
|
Disposer.register(this, rightTabbed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTransferManager(): TransferManager {
|
||||||
|
return transferManager
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLeftTabbed(): TransportTabbed {
|
||||||
|
return leftTabbed
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRightTabbed(): TransportTabbed {
|
||||||
|
return rightTabbed
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AskTransfer(
|
||||||
|
val option: Int,
|
||||||
|
val action: TransferAction,
|
||||||
|
val applyAll: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class AskTransferContext(var action: TransferAction, var applyAll: Boolean)
|
||||||
|
|
||||||
|
private inner class MyInternalTransferManager() : InternalTransferManager {
|
||||||
|
lateinit var source: TransportTabbed
|
||||||
|
lateinit var target: TransportTabbed
|
||||||
|
|
||||||
|
override fun canTransfer(paths: List<Path>): Boolean {
|
||||||
|
return target.getSelectedTransportPanel()?.workdir != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addTransfer(
|
||||||
|
paths: List<Pair<Path, TransportTableModel.Attributes>>,
|
||||||
|
mode: TransferMode
|
||||||
|
): CompletableFuture<Unit> {
|
||||||
|
val workdir = (if (mode == TransferMode.Delete || mode == TransferMode.ChangePermission)
|
||||||
|
source.getSelectedTransportPanel()?.workdir else target.getSelectedTransportPanel()?.workdir)
|
||||||
|
?: throw IllegalStateException()
|
||||||
|
return addTransfer(paths, workdir, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addTransfer(
|
||||||
|
paths: List<Pair<Path, TransportTableModel.Attributes>>,
|
||||||
|
targetWorkdir: Path,
|
||||||
|
mode: TransferMode
|
||||||
|
): CompletableFuture<Unit> {
|
||||||
|
assertEventDispatchThread()
|
||||||
|
|
||||||
|
if (paths.isEmpty()) return CompletableFuture.completedFuture(Unit)
|
||||||
|
|
||||||
|
val future = CompletableFuture<Unit>()
|
||||||
|
val panel = getTransportPanel(targetWorkdir.fileSystem, leftTabbed)
|
||||||
|
?: getTransportPanel(targetWorkdir.fileSystem, rightTabbed)
|
||||||
|
|
||||||
|
coroutineScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val context = AskTransferContext(TransferAction.Overwrite, false)
|
||||||
|
for (pair in paths) {
|
||||||
|
if (mode == TransferMode.Transfer && panel != null) {
|
||||||
|
val action = withContext(Dispatchers.Swing) {
|
||||||
|
getTransferAction(context, panel, pair.second)
|
||||||
|
}
|
||||||
|
if (action == null) {
|
||||||
|
break
|
||||||
|
} else if (context.applyAll) {
|
||||||
|
if (action == TransferAction.Skip) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else if (action == TransferAction.Skip) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val flag = doAddTransfer(targetWorkdir, pair, mode, context.action, future)
|
||||||
|
if (flag != FileVisitResult.CONTINUE) break
|
||||||
|
}
|
||||||
|
future.complete(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (log.isErrorEnabled) log.error(e.message, e)
|
||||||
|
future.completeExceptionally(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return future
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addHighTransfer(source: Path, target: Path): String {
|
||||||
|
val transfer = FileTransfer(
|
||||||
|
parentId = StringUtils.EMPTY,
|
||||||
|
source = source,
|
||||||
|
target = target,
|
||||||
|
size = Files.size(source),
|
||||||
|
action = TransferAction.Overwrite,
|
||||||
|
priority = Transfer.Priority.High
|
||||||
|
)
|
||||||
|
if (transferManager.addTransfer(transfer)) {
|
||||||
|
return transfer.id()
|
||||||
|
} else {
|
||||||
|
throw IllegalStateException("Cannot add high transfer.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addTransferListener(listener: TransferListener): Disposable {
|
||||||
|
return transferManager.addTransferListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTransferAction(
|
||||||
|
context: AskTransferContext,
|
||||||
|
panel: TransportPanel,
|
||||||
|
source: TransportTableModel.Attributes
|
||||||
|
): TransferAction? {
|
||||||
|
if (context.applyAll) return context.action
|
||||||
|
|
||||||
|
val model = panel.getTableModel()
|
||||||
|
for (i in 0 until model.rowCount) {
|
||||||
|
val c = model.getAttributes(i)
|
||||||
|
if (c.name != source.name) continue
|
||||||
|
val transfer = askTransfer(source, c)
|
||||||
|
context.action = transfer.action
|
||||||
|
context.applyAll = transfer.applyAll
|
||||||
|
if (transfer.option != JOptionPane.OK_OPTION) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.action
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun getTransportPanel(fileSystem: FileSystem, tabbed: TransportTabbed): TransportPanel? {
|
||||||
|
for (i in 0 until tabbed.tabCount) {
|
||||||
|
val c = tabbed.getComponentAt(i)
|
||||||
|
if (c is TransportPanel) {
|
||||||
|
if (c.loader.isLoaded) {
|
||||||
|
if (c.loader.get().fileSystem == fileSystem) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun askTransfer(
|
||||||
|
source: TransportTableModel.Attributes,
|
||||||
|
target: TransportTableModel.Attributes
|
||||||
|
): AskTransfer {
|
||||||
|
val formMargin = "7dlu"
|
||||||
|
val layout = FormLayout(
|
||||||
|
"left:pref, $formMargin, default:grow, 2dlu, left:pref",
|
||||||
|
"pref, 12dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, 16dlu, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
|
||||||
|
)
|
||||||
|
|
||||||
|
val iconSize = 36
|
||||||
|
// @formatter:off
|
||||||
|
val targetIcon = ScaleIcon(if(target.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize)
|
||||||
|
val sourceIcon = ScaleIcon(if(source.isDirectory) NativeIcons.folderIcon else NativeIcons.fileIcon, iconSize)
|
||||||
|
val sourceModified= DateFormatUtils.format(Date(source.lastModifiedTime), I18n.getString("termora.date-format"))
|
||||||
|
val targetModified= DateFormatUtils.format(Date(target.lastModifiedTime), I18n.getString("termora.date-format"))
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
|
||||||
|
val actionsComBoBox = JComboBox<TransferAction>()
|
||||||
|
actionsComBoBox.addItem(TransferAction.Overwrite)
|
||||||
|
actionsComBoBox.addItem(TransferAction.Append)
|
||||||
|
actionsComBoBox.addItem(TransferAction.Skip)
|
||||||
|
actionsComBoBox.renderer = object : DefaultListCellRenderer() {
|
||||||
|
override fun getListCellRendererComponent(
|
||||||
|
list: JList<*>?,
|
||||||
|
value: Any?,
|
||||||
|
index: Int,
|
||||||
|
isSelected: Boolean,
|
||||||
|
cellHasFocus: Boolean
|
||||||
|
): Component {
|
||||||
|
var text = value?.toString() ?: StringUtils.EMPTY
|
||||||
|
if (value == TransferAction.Overwrite) {
|
||||||
|
text = I18n.getString("termora.transport.sftp.already-exists.overwrite")
|
||||||
|
} else if (value == TransferAction.Skip) {
|
||||||
|
text = I18n.getString("termora.transport.sftp.already-exists.skip")
|
||||||
|
} else if (value == TransferAction.Append) {
|
||||||
|
text = I18n.getString("termora.transport.sftp.already-exists.append")
|
||||||
|
}
|
||||||
|
return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val applyAllCheckbox = JCheckBox(I18n.getString("termora.transport.sftp.already-exists.apply-all"))
|
||||||
|
val box = Box.createHorizontalBox()
|
||||||
|
box.add(actionsComBoBox)
|
||||||
|
box.add(Box.createHorizontalStrut(8))
|
||||||
|
box.add(applyAllCheckbox)
|
||||||
|
box.add(Box.createHorizontalGlue())
|
||||||
|
|
||||||
|
val ttBox = Box.createVerticalBox()
|
||||||
|
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message1")))
|
||||||
|
ttBox.add(JLabel(I18n.getString("termora.transport.sftp.already-exists.message2")))
|
||||||
|
|
||||||
|
val warningIcon = ScaleIcon(Icons.warningIntroduction, iconSize)
|
||||||
|
|
||||||
|
var rows = 1
|
||||||
|
val step = 2
|
||||||
|
val panel = FormBuilder.create().layout(layout)
|
||||||
|
// tip
|
||||||
|
.add(JLabel(warningIcon)).xy(1, rows)
|
||||||
|
.add(ttBox).xyw(3, rows, 3).apply { rows += step }
|
||||||
|
// name
|
||||||
|
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.name")}:")).xy(1, rows)
|
||||||
|
.add(source.name).xyw(3, rows, 3).apply { rows += step }
|
||||||
|
// separator
|
||||||
|
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
|
||||||
|
// Destination
|
||||||
|
.add("${I18n.getString("termora.transport.sftp.already-exists.destination")}:").xy(1, rows)
|
||||||
|
.apply { rows += step }
|
||||||
|
// Folder
|
||||||
|
.add(JLabel(targetIcon)).xy(1, rows, "center, fill")
|
||||||
|
.add(targetModified).xyw(3, rows, 3).apply { rows += step }
|
||||||
|
// Source
|
||||||
|
.add("${I18n.getString("termora.transport.sftp.already-exists.source")}:").xy(1, rows)
|
||||||
|
.apply { rows += step }
|
||||||
|
// Folder
|
||||||
|
.add(JLabel(sourceIcon)).xy(1, rows, "center, fill")
|
||||||
|
.add(sourceModified).xyw(3, rows, 3).apply { rows += step }
|
||||||
|
// separator
|
||||||
|
.addSeparator(StringUtils.EMPTY).xyw(1, rows, 5).apply { rows += step }
|
||||||
|
// name
|
||||||
|
.add(JLabel("${I18n.getString("termora.transport.sftp.already-exists.actions")}:")).xy(1, rows)
|
||||||
|
.add(box).xyw(3, rows, 3).apply { rows += step }
|
||||||
|
.build()
|
||||||
|
panel.putClientProperty("SKIP_requestFocusInWindow", true)
|
||||||
|
|
||||||
|
return AskTransfer(
|
||||||
|
option = OptionPane.showConfirmDialog(
|
||||||
|
owner, panel,
|
||||||
|
messageType = JOptionPane.PLAIN_MESSAGE,
|
||||||
|
optionType = JOptionPane.OK_CANCEL_OPTION,
|
||||||
|
title = source.name,
|
||||||
|
initialValue = JOptionPane.OK_OPTION,
|
||||||
|
) {
|
||||||
|
it.size = Dimension(max(UIManager.getInt("Dialog.width") - 220, it.width), it.height)
|
||||||
|
it.setLocationRelativeTo(it.owner)
|
||||||
|
},
|
||||||
|
action = actionsComBoBox.selectedItem as TransferAction,
|
||||||
|
applyAll = applyAllCheckbox.isSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doAddTransfer(
|
||||||
|
workdir: Path,
|
||||||
|
pair: Pair<Path, TransportTableModel.Attributes>,
|
||||||
|
mode: TransferMode,
|
||||||
|
action: TransferAction,
|
||||||
|
future: CompletableFuture<Unit>
|
||||||
|
): FileVisitResult {
|
||||||
|
|
||||||
|
val isDirectory = pair.second.isDirectory
|
||||||
|
val path = pair.first
|
||||||
|
if (isDirectory.not()) {
|
||||||
|
val transfer = createTransfer(path, workdir.resolve(path.name), false, StringUtils.EMPTY, mode, action)
|
||||||
|
return if (transferManager.addTransfer(transfer)) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
|
||||||
|
}
|
||||||
|
|
||||||
|
val continued = AtomicBoolean(true)
|
||||||
|
val queue = ArrayDeque<Transfer>()
|
||||||
|
val isCancelled =
|
||||||
|
{ (future.isCancelled || future.isCompletedExceptionally).apply { continued.set(this.not()) } }
|
||||||
|
val basedir = if (isDirectory) workdir.resolve(path.name) else workdir
|
||||||
|
val visitor = object : FileVisitor<Path> {
|
||||||
|
override fun preVisitDirectory(
|
||||||
|
dir: Path,
|
||||||
|
attrs: BasicFileAttributes
|
||||||
|
): FileVisitResult {
|
||||||
|
val parentId = queue.lastOrNull()?.id() ?: StringUtils.EMPTY
|
||||||
|
// @formatter:off
|
||||||
|
val transfer = when (mode) {
|
||||||
|
TransferMode.Delete -> createTransfer(dir, dir, true, parentId, mode, action)
|
||||||
|
TransferMode.ChangePermission -> createTransfer(path, dir, true, parentId, mode, action, pair.second.permissions)
|
||||||
|
else -> createTransfer(dir, basedir.resolve(path.relativize(dir).pathString), true, parentId, mode, action)
|
||||||
|
}
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
queue.addLast(transfer)
|
||||||
|
|
||||||
|
if (transferManager.addTransfer(transfer).not()) {
|
||||||
|
continued.set(false)
|
||||||
|
return FileVisitResult.TERMINATE
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun visitFile(
|
||||||
|
file: Path,
|
||||||
|
attrs: BasicFileAttributes
|
||||||
|
): FileVisitResult {
|
||||||
|
|
||||||
|
val parentId = queue.last().id()
|
||||||
|
// @formatter:off
|
||||||
|
val transfer = when (mode) {
|
||||||
|
TransferMode.Delete -> createTransfer(file, file, false, parentId, mode, action)
|
||||||
|
TransferMode.ChangePermission -> createTransfer(file, file, false, parentId, mode, action, pair.second.permissions)
|
||||||
|
else -> createTransfer(file, basedir.resolve(path.relativize(file).pathString), false, parentId, mode, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transferManager.addTransfer(transfer).not()) {
|
||||||
|
continued.set(false)
|
||||||
|
return FileVisitResult.TERMINATE
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun visitFileFailed(
|
||||||
|
file: Path?,
|
||||||
|
exc: IOException
|
||||||
|
): FileVisitResult {
|
||||||
|
if (log.isErrorEnabled) log.error(exc.message, exc)
|
||||||
|
future.completeExceptionally(exc)
|
||||||
|
return FileVisitResult.TERMINATE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun postVisitDirectory(
|
||||||
|
dir: Path?,
|
||||||
|
exc: IOException?
|
||||||
|
): FileVisitResult {
|
||||||
|
val c = queue.removeLast()
|
||||||
|
if (c is TransferScanner) c.scanned()
|
||||||
|
return if (isCancelled.invoke()) FileVisitResult.TERMINATE else FileVisitResult.CONTINUE
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
PathWalker.walkFileTree(path, visitor)
|
||||||
|
|
||||||
|
// 已经添加的则继续传输
|
||||||
|
while (queue.isNotEmpty()) {
|
||||||
|
val c = queue.removeLast()
|
||||||
|
if (c is TransferScanner) c.scanned()
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (continued.get()) FileVisitResult.CONTINUE else FileVisitResult.TERMINATE
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun createTransfer(
|
||||||
|
source: Path,
|
||||||
|
target: Path,
|
||||||
|
isDirectory: Boolean,
|
||||||
|
parentId: String,
|
||||||
|
mode: TransferMode,
|
||||||
|
action:TransferAction,
|
||||||
|
permissions: Set<PosixFilePermission>? = null
|
||||||
|
): Transfer {
|
||||||
|
if (mode == TransferMode.Delete) {
|
||||||
|
return DeleteTransfer(
|
||||||
|
parentId,
|
||||||
|
source,
|
||||||
|
isDirectory,
|
||||||
|
if (isDirectory) 1 else Files.size(source)
|
||||||
|
)
|
||||||
|
} else if (mode == TransferMode.ChangePermission) {
|
||||||
|
if (permissions == null) throw IllegalStateException()
|
||||||
|
return ChangePermissionTransfer(
|
||||||
|
parentId,
|
||||||
|
target,
|
||||||
|
isDirectory = isDirectory,
|
||||||
|
permissions = permissions,
|
||||||
|
size = if (isDirectory) 1 else Files.size(target)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectory) {
|
||||||
|
return DirectoryTransfer(
|
||||||
|
parentId = parentId,
|
||||||
|
source = source,
|
||||||
|
target = target,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileTransfer(
|
||||||
|
parentId = parentId,
|
||||||
|
source = source,
|
||||||
|
target = target,
|
||||||
|
action = action,
|
||||||
|
size = Files.size(source)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app.termora.sftp.internal.local
|
package app.termora.transfer.internal.local
|
||||||
|
|
||||||
import app.termora.plugin.Extension
|
import app.termora.plugin.Extension
|
||||||
import app.termora.plugin.InternalPlugin
|
import app.termora.plugin.InternalPlugin
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app.termora.sftp.internal.local
|
package app.termora.transfer.internal.local
|
||||||
|
|
||||||
import app.termora.protocol.ProtocolProvider
|
import app.termora.protocol.ProtocolProvider
|
||||||
import app.termora.protocol.ProtocolProviderExtension
|
import app.termora.protocol.ProtocolProviderExtension
|
||||||
@@ -1,41 +1,34 @@
|
|||||||
package app.termora.sftp.internal.local
|
package app.termora.transfer.internal.local
|
||||||
|
|
||||||
import app.termora.database.DatabaseManager
|
import app.termora.database.DatabaseManager
|
||||||
import app.termora.protocol.FileObjectHandler
|
import app.termora.protocol.PathHandler
|
||||||
import app.termora.protocol.FileObjectRequest
|
import app.termora.protocol.PathHandlerRequest
|
||||||
import app.termora.protocol.TransferProtocolProvider
|
import app.termora.protocol.TransferProtocolProvider
|
||||||
import org.apache.commons.lang3.StringUtils
|
import org.apache.commons.lang3.StringUtils
|
||||||
import org.apache.commons.lang3.SystemUtils
|
import org.apache.commons.lang3.SystemUtils
|
||||||
import org.apache.commons.vfs2.VFS
|
import java.nio.file.FileSystems
|
||||||
import org.apache.commons.vfs2.provider.FileProvider
|
|
||||||
import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider
|
|
||||||
|
|
||||||
internal class LocalTransferProtocolProvider : TransferProtocolProvider {
|
internal class LocalTransferProtocolProvider : TransferProtocolProvider {
|
||||||
companion object {
|
companion object {
|
||||||
val instance by lazy { LocalTransferProtocolProvider() }
|
val instance by lazy { LocalTransferProtocolProvider() }
|
||||||
private val localFileProvider by lazy { DefaultLocalFileProvider() }
|
|
||||||
private val sftp get() = DatabaseManager.getInstance().sftp
|
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||||
const val PROTOCOL = "file"
|
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 {
|
override fun isTransient(): Boolean {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getProtocol(): String {
|
override fun getProtocol(): String {
|
||||||
return "file"
|
return PROTOCOL
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
|
||||||
|
var defaultDirectory = sftp.defaultDirectory
|
||||||
|
if (StringUtils.isBlank(defaultDirectory)) {
|
||||||
|
defaultDirectory = SystemUtils.USER_HOME
|
||||||
|
}
|
||||||
|
val fileSystem = FileSystems.getDefault()
|
||||||
|
return PathHandler(fileSystem, fileSystem.getPath(defaultDirectory))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package app.termora.transfer.internal.sftp
|
||||||
|
|
||||||
|
import app.termora.FrameExtension
|
||||||
|
import app.termora.TermoraFrame
|
||||||
|
import app.termora.actions.DataProviders
|
||||||
|
import app.termora.database.DatabaseManager
|
||||||
|
import app.termora.transfer.TransportTerminalTab
|
||||||
|
|
||||||
|
class SFTPFrameExtension private constructor() : FrameExtension {
|
||||||
|
companion object {
|
||||||
|
val instance = SFTPFrameExtension()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||||
|
|
||||||
|
override fun customize(frame: TermoraFrame) {
|
||||||
|
val terminalTabbed = frame.getData(DataProviders.TerminalTabbed) ?: return
|
||||||
|
if (sftp.pinTab) {
|
||||||
|
terminalTabbed.addTerminalTab(TransportTerminalTab(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package app.termora.transfer.internal.sftp
|
||||||
|
|
||||||
|
import app.termora.Disposer
|
||||||
|
import app.termora.protocol.PathHandler
|
||||||
|
import org.apache.commons.io.IOUtils
|
||||||
|
import org.apache.sshd.client.SshClient
|
||||||
|
import org.apache.sshd.client.session.ClientSession
|
||||||
|
import org.apache.sshd.common.future.CloseFuture
|
||||||
|
import org.apache.sshd.common.future.SshFutureListener
|
||||||
|
import java.nio.file.FileSystem
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
internal class SFTPPathHandler(
|
||||||
|
fileSystem: FileSystem,
|
||||||
|
path: Path,
|
||||||
|
val client: SshClient,
|
||||||
|
val session: ClientSession,
|
||||||
|
) : PathHandler(fileSystem, path) {
|
||||||
|
|
||||||
|
private val listener = SshFutureListener<CloseFuture> { Disposer.dispose(this) }
|
||||||
|
|
||||||
|
init {
|
||||||
|
session.addCloseFutureListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispose() {
|
||||||
|
session.removeCloseFutureListener(listener)
|
||||||
|
IOUtils.closeQuietly(fileSystem)
|
||||||
|
IOUtils.closeQuietly(session)
|
||||||
|
IOUtils.closeQuietly(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.termora.sftp.internal.sftp
|
package app.termora.transfer.internal.sftp
|
||||||
|
|
||||||
|
import app.termora.FrameExtension
|
||||||
import app.termora.plugin.Extension
|
import app.termora.plugin.Extension
|
||||||
import app.termora.plugin.InternalPlugin
|
import app.termora.plugin.InternalPlugin
|
||||||
import app.termora.protocol.ProtocolProviderExtension
|
import app.termora.protocol.ProtocolProviderExtension
|
||||||
@@ -7,10 +8,11 @@ import app.termora.protocol.ProtocolProviderExtension
|
|||||||
internal class SFTPPlugin : InternalPlugin() {
|
internal class SFTPPlugin : InternalPlugin() {
|
||||||
init {
|
init {
|
||||||
support.addExtension(ProtocolProviderExtension::class.java) { SFTPProtocolProviderExtension.instance }
|
support.addExtension(ProtocolProviderExtension::class.java) { SFTPProtocolProviderExtension.instance }
|
||||||
|
support.addExtension(FrameExtension::class.java) { SFTPFrameExtension.instance }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getName(): String {
|
override fun getName(): String {
|
||||||
return "Transfer"
|
return "Local Transfer"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
|
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app.termora.sftp.internal.sftp
|
package app.termora.transfer.internal.sftp
|
||||||
|
|
||||||
import app.termora.protocol.ProtocolProvider
|
import app.termora.protocol.ProtocolProvider
|
||||||
import app.termora.protocol.ProtocolProviderExtension
|
import app.termora.protocol.ProtocolProviderExtension
|
||||||
@@ -1,32 +1,32 @@
|
|||||||
package app.termora.sftp.internal.sftp
|
package app.termora.transfer.internal.sftp
|
||||||
|
|
||||||
import app.termora.SshClients
|
import app.termora.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.protocol.TransferProtocolProvider
|
||||||
import app.termora.vfs2.sftp.MySftpFileProvider
|
|
||||||
import app.termora.vfs2.sftp.MySftpFileSystemConfigBuilder
|
|
||||||
import org.apache.commons.io.IOUtils
|
import org.apache.commons.io.IOUtils
|
||||||
import org.apache.commons.lang3.StringUtils
|
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.SshClient
|
||||||
import org.apache.sshd.client.session.ClientSession
|
import org.apache.sshd.client.session.ClientSession
|
||||||
import org.apache.sshd.sftp.client.SftpClientFactory
|
import org.apache.sshd.sftp.client.SftpClientFactory
|
||||||
import kotlin.io.path.absolutePathString
|
|
||||||
|
|
||||||
internal class SFTPTransferProtocolProvider : TransferProtocolProvider {
|
internal class SFTPTransferProtocolProvider : TransferProtocolProvider {
|
||||||
companion object {
|
companion object {
|
||||||
val instance by lazy { SFTPTransferProtocolProvider() }
|
val instance by lazy { SFTPTransferProtocolProvider() }
|
||||||
|
private val sftp get() = DatabaseManager.getInstance().sftp
|
||||||
const val PROTOCOL = "sftp"
|
const val PROTOCOL = "sftp"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getFileProvider(): FileProvider {
|
override fun isTransient(): Boolean {
|
||||||
return MySftpFileProvider.instance
|
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 client: SshClient? = null
|
||||||
var session: ClientSession? = null
|
var session: ClientSession? = null
|
||||||
try {
|
try {
|
||||||
@@ -37,24 +37,16 @@ internal class SFTPTransferProtocolProvider : TransferProtocolProvider {
|
|||||||
val fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
|
val fileSystem = SftpClientFactory.instance().createSftpFileSystem(session)
|
||||||
|
|
||||||
val host = requester.host
|
val host = requester.host
|
||||||
var defaultDirectory = host.options.sftpDefaultDirectory
|
var path = fileSystem.defaultDir
|
||||||
if (StringUtils.isBlank(defaultDirectory)) {
|
val defaultDirectory = host.options.sftpDefaultDirectory
|
||||||
defaultDirectory = fileSystem.defaultDir.absolutePathString()
|
if (StringUtils.isNotBlank(defaultDirectory)) {
|
||||||
|
path = fileSystem.getPath(defaultDirectory)
|
||||||
}
|
}
|
||||||
|
return SFTPPathHandler(fileSystem, path, client, session)
|
||||||
val options = FileSystemOptions()
|
|
||||||
MySftpFileSystemConfigBuilder.getInstance().setSftpFileSystem(options, fileSystem)
|
|
||||||
val file = VFS.getManager().resolveFile("sftp://${defaultDirectory}", options)
|
|
||||||
return SFTPFileObjectHandler(file, client, session, fileSystem)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
IOUtils.closeQuietly(session)
|
IOUtils.closeQuietly(session)
|
||||||
IOUtils.closeQuietly(client)
|
IOUtils.closeQuietly(client)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getProtocol(): String {
|
|
||||||
return "sftp"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ import app.termora.database.DatabaseManager
|
|||||||
import app.termora.plugin.ExtensionManager
|
import app.termora.plugin.ExtensionManager
|
||||||
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider
|
||||||
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
import app.termora.plugin.internal.ssh.SSHProtocolProvider
|
||||||
import app.termora.sftp.SFTPActionEvent
|
|
||||||
import app.termora.tag.TagDialog
|
import app.termora.tag.TagDialog
|
||||||
import app.termora.tag.TagManager
|
import app.termora.tag.TagManager
|
||||||
import app.termora.tag.TagSimpleTreeCellRendererExtension
|
import app.termora.tag.TagSimpleTreeCellRendererExtension
|
||||||
@@ -477,7 +476,7 @@ class NewHostTree : SimpleTree(), Disposable {
|
|||||||
if (nodes.isEmpty()) return
|
if (nodes.isEmpty()) return
|
||||||
|
|
||||||
for (node in nodes) {
|
for (node in nodes) {
|
||||||
sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
|
// sftpAction.actionPerformed(SFTPActionEvent(this, node.id, evt))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package app.termora.vfs2
|
|
||||||
|
|
||||||
import app.termora.DynamicIcon
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件描述
|
|
||||||
*/
|
|
||||||
interface FileObjectDescriptor {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图标
|
|
||||||
*/
|
|
||||||
fun getIcon(width: Int, height: Int): DynamicIcon? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取类型描述
|
|
||||||
*/
|
|
||||||
fun getTypeDescription(): String? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 最后修改时间,时间戳
|
|
||||||
*/
|
|
||||||
fun getLastModified(): Long? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取所有者
|
|
||||||
*/
|
|
||||||
fun getOwner(): String? = null
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package app.termora.vfs2
|
|
||||||
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import java.nio.file.FileVisitResult
|
|
||||||
import java.nio.file.FileVisitor
|
|
||||||
import java.nio.file.attribute.BasicFileAttributes
|
|
||||||
import java.nio.file.attribute.FileTime
|
|
||||||
|
|
||||||
object VFSWalker {
|
|
||||||
fun walk(
|
|
||||||
dir: FileObject,
|
|
||||||
visitor: FileVisitor<FileObject>,
|
|
||||||
): FileVisitResult {
|
|
||||||
|
|
||||||
// clear cache
|
|
||||||
if (visitor.preVisitDirectory(dir, EmptyBasicFileAttributes.INSTANCE) == FileVisitResult.TERMINATE) {
|
|
||||||
return FileVisitResult.TERMINATE
|
|
||||||
}
|
|
||||||
|
|
||||||
for (e in dir.children) {
|
|
||||||
if (e.name.baseName == ".." || e.name.baseName == ".") continue
|
|
||||||
if (e.isFolder) {
|
|
||||||
if (walk(dir.resolveFile(e.name.baseName), visitor) == FileVisitResult.TERMINATE) {
|
|
||||||
return FileVisitResult.TERMINATE
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val result = visitor.visitFile(
|
|
||||||
dir.resolveFile(e.name.baseName),
|
|
||||||
EmptyBasicFileAttributes.INSTANCE
|
|
||||||
)
|
|
||||||
if (result == FileVisitResult.TERMINATE) {
|
|
||||||
return FileVisitResult.TERMINATE
|
|
||||||
} else if (result == FileVisitResult.SKIP_SUBTREE) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visitor.postVisitDirectory(dir, null) == FileVisitResult.TERMINATE) {
|
|
||||||
return FileVisitResult.TERMINATE
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileVisitResult.CONTINUE
|
|
||||||
}
|
|
||||||
|
|
||||||
private class EmptyBasicFileAttributes : BasicFileAttributes {
|
|
||||||
companion object {
|
|
||||||
val INSTANCE = EmptyBasicFileAttributes()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun lastModifiedTime(): FileTime {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun lastAccessTime(): FileTime {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun creationTime(): FileTime {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isRegularFile(): Boolean {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isDirectory(): Boolean {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isSymbolicLink(): Boolean {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isOther(): Boolean {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun size(): Long {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun fileKey(): Any {
|
|
||||||
TODO("Not yet implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package app.termora.vfs2.s3
|
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils
|
|
||||||
import org.apache.commons.vfs2.FileSystemConfigBuilder
|
|
||||||
import org.apache.commons.vfs2.FileSystemOptions
|
|
||||||
|
|
||||||
abstract class AbstractS3FileSystemConfigBuilder : FileSystemConfigBuilder() {
|
|
||||||
fun getEndpoint(options: FileSystemOptions): String {
|
|
||||||
return getParam(options, "endpoint")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setEndpoint(options: FileSystemOptions, endpoint: String) {
|
|
||||||
setParam(options, "endpoint", endpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAccessKey(options: FileSystemOptions, accessId: String) {
|
|
||||||
setParam(options, "accessId", accessId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAccessKey(options: FileSystemOptions): String {
|
|
||||||
return getParam(options, "accessId")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSecretKey(options: FileSystemOptions, secretKey: String) {
|
|
||||||
setParam(options, "secretKey", secretKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSecretKey(options: FileSystemOptions): String {
|
|
||||||
return getParam(options, "secretKey")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setRegion(options: FileSystemOptions, region: String) {
|
|
||||||
setParam(options, "region", region)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getRegion(options: FileSystemOptions): String {
|
|
||||||
return getParam(options, "region")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDelimiter(options: FileSystemOptions, delimiter: String) {
|
|
||||||
setParam(options, "delimiter", delimiter)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDelimiter(options: FileSystemOptions): String {
|
|
||||||
return StringUtils.defaultIfBlank(getParam(options, "delimiter"), "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
package app.termora.vfs2.sftp
|
|
||||||
|
|
||||||
import app.termora.sftp.FileSystemViewTableModel
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.apache.commons.vfs2.FileSystemException
|
|
||||||
import org.apache.commons.vfs2.FileType
|
|
||||||
import org.apache.commons.vfs2.provider.AbstractFileName
|
|
||||||
import org.apache.commons.vfs2.provider.AbstractFileObject
|
|
||||||
import org.apache.sshd.sftp.client.SftpClient
|
|
||||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
|
||||||
import org.apache.sshd.sftp.client.fs.SftpPath
|
|
||||||
import org.apache.sshd.sftp.client.fs.WithFileAttributes
|
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.nio.file.StandardCopyOption
|
|
||||||
import java.nio.file.StandardOpenOption
|
|
||||||
import java.nio.file.attribute.FileTime
|
|
||||||
import java.nio.file.attribute.PosixFilePermission
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import kotlin.io.path.*
|
|
||||||
|
|
||||||
internal class MySftpFileObject(
|
|
||||||
private val sftpFileSystem: SftpFileSystem,
|
|
||||||
fileName: AbstractFileName,
|
|
||||||
fileSystem: MySftpFileSystem
|
|
||||||
) : AbstractFileObject<MySftpFileSystem>(fileName, fileSystem) {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val log = LoggerFactory.getLogger(MySftpFileObject::class.java)
|
|
||||||
|
|
||||||
const val POSIX_FILE_PERMISSIONS = "PosixFilePermissions"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var _attributes: SftpClient.Attributes? = null
|
|
||||||
private val isInitialized = AtomicBoolean(false)
|
|
||||||
private val path by lazy { sftpFileSystem.getPath(fileName.path) }
|
|
||||||
private val attributes = mutableMapOf<String, Any>()
|
|
||||||
|
|
||||||
override fun doGetContentSize(): Long {
|
|
||||||
val attributes = getAttributes()
|
|
||||||
if (attributes == null || !attributes.flags.contains(SftpClient.Attribute.Size)) {
|
|
||||||
throw FileSystemException("vfs.provider.sftp/unknown-size.error")
|
|
||||||
}
|
|
||||||
return attributes.size
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doGetType(): FileType {
|
|
||||||
val attributes = getAttributes() ?: return FileType.IMAGINARY
|
|
||||||
return if (attributes.isDirectory)
|
|
||||||
FileType.FOLDER
|
|
||||||
else if (attributes.isRegularFile)
|
|
||||||
FileType.FILE
|
|
||||||
else if (attributes.isSymbolicLink) {
|
|
||||||
val e = path.readSymbolicLink()
|
|
||||||
if (e is SftpPath && e.attributes != null) {
|
|
||||||
if (e.attributes.isDirectory) {
|
|
||||||
FileType.FOLDER
|
|
||||||
} else {
|
|
||||||
FileType.FILE
|
|
||||||
}
|
|
||||||
} else if (e.isDirectory()) {
|
|
||||||
FileType.FOLDER
|
|
||||||
} else {
|
|
||||||
FileType.FILE
|
|
||||||
}
|
|
||||||
} else FileType.IMAGINARY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doListChildren(): Array<String>? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doListChildrenResolved(): Array<FileObject>? {
|
|
||||||
if (isFile) return null
|
|
||||||
|
|
||||||
val children = mutableListOf<FileObject>()
|
|
||||||
|
|
||||||
Files.list(path).use { files ->
|
|
||||||
for (file in files) {
|
|
||||||
val fo = resolveFile(file.name)
|
|
||||||
if (file is WithFileAttributes && fo is MySftpFileObject) {
|
|
||||||
if (fo.isInitialized.compareAndSet(false, true)) {
|
|
||||||
fo.setAttributes(file.attributes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
children.add(fo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return children.toTypedArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doGetOutputStream(bAppend: Boolean): OutputStream {
|
|
||||||
if (bAppend) {
|
|
||||||
return path.outputStream(StandardOpenOption.WRITE, StandardOpenOption.APPEND)
|
|
||||||
}
|
|
||||||
return path.outputStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doGetInputStream(bufferSize: Int): InputStream {
|
|
||||||
return path.inputStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doCreateFolder() {
|
|
||||||
Files.createDirectories(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doIsExecutable(): Boolean {
|
|
||||||
val permissions = getPermissions()
|
|
||||||
return permissions.contains(PosixFilePermission.GROUP_EXECUTE) ||
|
|
||||||
permissions.contains(PosixFilePermission.OWNER_EXECUTE) ||
|
|
||||||
permissions.contains(PosixFilePermission.GROUP_EXECUTE)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doIsReadable(): Boolean {
|
|
||||||
val permissions = getPermissions()
|
|
||||||
return permissions.contains(PosixFilePermission.GROUP_READ) ||
|
|
||||||
permissions.contains(PosixFilePermission.OWNER_READ) ||
|
|
||||||
permissions.contains(PosixFilePermission.OTHERS_READ)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doIsWriteable(): Boolean {
|
|
||||||
val permissions = getPermissions()
|
|
||||||
return permissions.contains(PosixFilePermission.GROUP_WRITE) ||
|
|
||||||
permissions.contains(PosixFilePermission.OWNER_WRITE) ||
|
|
||||||
permissions.contains(PosixFilePermission.OTHERS_WRITE)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doRename(newFile: FileObject) {
|
|
||||||
if (newFile !is MySftpFileObject) {
|
|
||||||
throw FileSystemException("vfs.provider/rename-not-supported.error")
|
|
||||||
}
|
|
||||||
Files.move(path, newFile.path, StandardCopyOption.ATOMIC_MOVE)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun moveTo(destFile: FileObject) {
|
|
||||||
if (canRenameTo(destFile)) {
|
|
||||||
doRename(destFile)
|
|
||||||
} else {
|
|
||||||
throw FileSystemException("vfs.provider/rename-not-supported.error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doDelete() {
|
|
||||||
sftpFileSystem.client.use { deleteRecursivelySFTP(path, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 优化删除效率,采用一个连接
|
|
||||||
*/
|
|
||||||
private fun deleteRecursivelySFTP(path: SftpPath, sftpClient: SftpClient) {
|
|
||||||
val isDirectory = if (path.attributes != null) path.attributes.isDirectory else path.isDirectory()
|
|
||||||
if (isDirectory) {
|
|
||||||
for (e in sftpClient.readDir(path.toString())) {
|
|
||||||
if (e.filename == ".." || e.filename == ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (e.attributes.isDirectory) {
|
|
||||||
deleteRecursivelySFTP(path.resolve(e.filename), sftpClient)
|
|
||||||
} else {
|
|
||||||
sftpClient.remove(path.resolve(e.filename).toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sftpClient.rmdir(path.toString())
|
|
||||||
} else {
|
|
||||||
sftpClient.remove(path.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doSetExecutable(executable: Boolean, ownerOnly: Boolean): Boolean {
|
|
||||||
val permissions = getPermissions().toMutableSet()
|
|
||||||
permissions.add(PosixFilePermission.OWNER_EXECUTE)
|
|
||||||
if (ownerOnly) {
|
|
||||||
permissions.remove(PosixFilePermission.OTHERS_EXECUTE)
|
|
||||||
permissions.remove(PosixFilePermission.GROUP_EXECUTE)
|
|
||||||
}
|
|
||||||
Files.setPosixFilePermissions(path, permissions)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doSetReadable(readable: Boolean, ownerOnly: Boolean): Boolean {
|
|
||||||
val permissions = getPermissions().toMutableSet()
|
|
||||||
permissions.add(PosixFilePermission.OWNER_READ)
|
|
||||||
if (ownerOnly) {
|
|
||||||
permissions.remove(PosixFilePermission.OTHERS_READ)
|
|
||||||
permissions.remove(PosixFilePermission.GROUP_EXECUTE)
|
|
||||||
}
|
|
||||||
Files.setPosixFilePermissions(path, permissions)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doSetWritable(writable: Boolean, ownerOnly: Boolean): Boolean {
|
|
||||||
val permissions = getPermissions().toMutableSet()
|
|
||||||
permissions.add(PosixFilePermission.OWNER_WRITE)
|
|
||||||
if (ownerOnly) {
|
|
||||||
permissions.remove(PosixFilePermission.OTHERS_WRITE)
|
|
||||||
permissions.remove(PosixFilePermission.GROUP_WRITE)
|
|
||||||
}
|
|
||||||
Files.setPosixFilePermissions(path, permissions)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doSetLastModifiedTime(modtime: Long): Boolean {
|
|
||||||
Files.setLastModifiedTime(path, FileTime.fromMillis(modtime))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doDetach() {
|
|
||||||
setAttributes(null)
|
|
||||||
isInitialized.compareAndSet(true, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doIsHidden(): Boolean {
|
|
||||||
return name.baseName.startsWith(".")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doGetAttributes(): MutableMap<String, Any> {
|
|
||||||
return attributes
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doGetLastModifiedTime(): Long {
|
|
||||||
val attributes = getAttributes()
|
|
||||||
if (attributes == null || !attributes.flags.contains(SftpClient.Attribute.ModifyTime)) {
|
|
||||||
throw FileSystemException("vfs.provider.sftp/unknown-modtime.error")
|
|
||||||
}
|
|
||||||
return attributes.modifyTime.toMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doSetAttribute(attrName: String, value: Any) {
|
|
||||||
attributes[attrName] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doIsSymbolicLink(): Boolean {
|
|
||||||
return getAttributes()?.isSymbolicLink == true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPosixFilePermissions(permissions: Set<PosixFilePermission>) {
|
|
||||||
path.setPosixFilePermissions(permissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getAttributes(): SftpClient.Attributes? {
|
|
||||||
if (isInitialized.compareAndSet(false, true)) {
|
|
||||||
try {
|
|
||||||
val attributes = sftpFileSystem.provider()
|
|
||||||
.readRemoteAttributes(sftpFileSystem.provider().toSftpPath(path))
|
|
||||||
setAttributes(attributes)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (log.isDebugEnabled) {
|
|
||||||
log.debug(e.message, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _attributes
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setAttributes(attributes: SftpClient.Attributes?) {
|
|
||||||
if (attributes == null) {
|
|
||||||
doGetAttributes().remove(POSIX_FILE_PERMISSIONS)
|
|
||||||
} else {
|
|
||||||
doSetAttribute(POSIX_FILE_PERMISSIONS, attributes.permissions)
|
|
||||||
}
|
|
||||||
this._attributes = attributes
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPermissions(): Set<PosixFilePermission> {
|
|
||||||
return FileSystemViewTableModel.fromSftpPermissions(getAttributes()?.permissions ?: return setOf())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package app.termora.vfs2.sftp
|
|
||||||
|
|
||||||
import org.apache.commons.vfs2.Capability
|
|
||||||
import org.apache.commons.vfs2.FileName
|
|
||||||
import org.apache.commons.vfs2.FileSystem
|
|
||||||
import org.apache.commons.vfs2.FileSystemOptions
|
|
||||||
import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider
|
|
||||||
|
|
||||||
internal class MySftpFileProvider private constructor() : AbstractOriginatingFileProvider() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val instance by lazy { MySftpFileProvider() }
|
|
||||||
val capabilities = listOf(
|
|
||||||
Capability.CREATE,
|
|
||||||
Capability.DELETE,
|
|
||||||
Capability.RENAME,
|
|
||||||
Capability.GET_TYPE,
|
|
||||||
Capability.LIST_CHILDREN,
|
|
||||||
Capability.READ_CONTENT,
|
|
||||||
Capability.URI,
|
|
||||||
Capability.WRITE_CONTENT,
|
|
||||||
Capability.GET_LAST_MODIFIED,
|
|
||||||
Capability.SET_LAST_MODIFIED_FILE,
|
|
||||||
Capability.RANDOM_ACCESS_READ,
|
|
||||||
Capability.APPEND_CONTENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCapabilities(): Collection<Capability> {
|
|
||||||
return MySftpFileProvider.capabilities
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doCreateFileSystem(rootFileName: FileName, fileSystemOptions: FileSystemOptions): FileSystem {
|
|
||||||
val sftpFileSystem = MySftpFileSystemConfigBuilder.getInstance()
|
|
||||||
.getSftpFileSystem(fileSystemOptions)
|
|
||||||
if (sftpFileSystem == null) {
|
|
||||||
throw IllegalArgumentException("client session not found")
|
|
||||||
}
|
|
||||||
return MySftpFileSystem(
|
|
||||||
sftpFileSystem,
|
|
||||||
rootFileName,
|
|
||||||
fileSystemOptions
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package app.termora.vfs2.sftp
|
|
||||||
|
|
||||||
import org.apache.commons.vfs2.Capability
|
|
||||||
import org.apache.commons.vfs2.FileName
|
|
||||||
import org.apache.commons.vfs2.FileObject
|
|
||||||
import org.apache.commons.vfs2.FileSystemOptions
|
|
||||||
import org.apache.commons.vfs2.provider.AbstractFileName
|
|
||||||
import org.apache.commons.vfs2.provider.AbstractFileSystem
|
|
||||||
import org.apache.sshd.client.session.ClientSession
|
|
||||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
|
||||||
import kotlin.io.path.absolutePathString
|
|
||||||
|
|
||||||
internal class MySftpFileSystem(
|
|
||||||
private val sftpFileSystem: SftpFileSystem,
|
|
||||||
rootName: FileName,
|
|
||||||
fileSystemOptions: FileSystemOptions
|
|
||||||
) : AbstractFileSystem(rootName, null, fileSystemOptions) {
|
|
||||||
|
|
||||||
override fun addCapabilities(caps: MutableCollection<Capability>) {
|
|
||||||
caps.addAll(MySftpFileProvider.capabilities)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createFile(name: AbstractFileName): FileObject {
|
|
||||||
return MySftpFileObject(sftpFileSystem, name, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDefaultDir(): String {
|
|
||||||
return sftpFileSystem.defaultDir.absolutePathString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getClientSession(): ClientSession {
|
|
||||||
return sftpFileSystem.session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package app.termora.vfs2.sftp
|
|
||||||
|
|
||||||
import org.apache.commons.vfs2.FileSystem
|
|
||||||
import org.apache.commons.vfs2.FileSystemConfigBuilder
|
|
||||||
import org.apache.commons.vfs2.FileSystemOptions
|
|
||||||
import org.apache.sshd.sftp.client.fs.SftpFileSystem
|
|
||||||
|
|
||||||
internal class MySftpFileSystemConfigBuilder : FileSystemConfigBuilder() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val INSTANCE by lazy { MySftpFileSystemConfigBuilder() }
|
|
||||||
fun getInstance(): MySftpFileSystemConfigBuilder {
|
|
||||||
return INSTANCE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getConfigClass(): Class<out FileSystem> {
|
|
||||||
return MySftpFileSystem::class.java
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun setSftpFileSystem(options: FileSystemOptions, sftpFileSystem: SftpFileSystem) {
|
|
||||||
setParam(options, "sftpFileSystem", sftpFileSystem)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSftpFileSystem(options: FileSystemOptions): SftpFileSystem? {
|
|
||||||
return getParam(options, "sftpFileSystem")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -312,7 +312,7 @@ termora.transport.table.contextmenu.transfer=Transfer
|
|||||||
termora.transport.table.contextmenu.edit=${termora.keymgr.edit}
|
termora.transport.table.contextmenu.edit=${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.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.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.rename=${termora.welcome.contextmenu.rename}
|
||||||
termora.transport.table.contextmenu.delete=${termora.remove}
|
termora.transport.table.contextmenu.delete=${termora.remove}
|
||||||
termora.transport.table.contextmenu.delete-warning=If the folder is too large, deleting it may take some time
|
termora.transport.table.contextmenu.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=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.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.transporting=In progress
|
||||||
|
termora.transport.sftp.status.deleting=Deleting
|
||||||
termora.transport.sftp.status.waiting=Waiting
|
termora.transport.sftp.status.waiting=Waiting
|
||||||
termora.transport.sftp.status.done=Done
|
termora.transport.sftp.status.done=Done
|
||||||
termora.transport.sftp.status.failed=Failed
|
termora.transport.sftp.status.failed=Failed
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ termora.transport.table.owner=所有者
|
|||||||
termora.transport.table.contextmenu.transfer=传输
|
termora.transport.table.contextmenu.transfer=传输
|
||||||
termora.transport.table.contextmenu.copy-path=复制路径
|
termora.transport.table.contextmenu.copy-path=复制路径
|
||||||
termora.transport.table.contextmenu.edit-command=你必须在 “设置 - SFTP” 中配置 “编辑命令” 后才能编辑文件
|
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.change-permissions=更改权限...
|
||||||
termora.transport.table.contextmenu.refresh=刷新
|
termora.transport.table.contextmenu.refresh=刷新
|
||||||
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
|
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.close-tab-has-active-session=会话还处于活动状态,是否关闭所有会话?
|
||||||
|
|
||||||
termora.transport.sftp.status.transporting=传输中
|
termora.transport.sftp.status.transporting=传输中
|
||||||
|
termora.transport.sftp.status.deleting=删除中
|
||||||
termora.transport.sftp.status.waiting=等待中
|
termora.transport.sftp.status.waiting=等待中
|
||||||
termora.transport.sftp.status.done=已完成
|
termora.transport.sftp.status.done=已完成
|
||||||
termora.transport.sftp.status.failed=已失败
|
termora.transport.sftp.status.failed=已失败
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ termora.transport.table.owner=所有者
|
|||||||
termora.transport.table.contextmenu.transfer=傳輸
|
termora.transport.table.contextmenu.transfer=傳輸
|
||||||
termora.transport.table.contextmenu.copy-path=複製路徑
|
termora.transport.table.contextmenu.copy-path=複製路徑
|
||||||
termora.transport.table.contextmenu.edit-command=你必須在 “設定 - SFTP” 中設定 “編輯指令” 後才能編輯文件
|
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.change-permissions=更改權限...
|
||||||
termora.transport.table.contextmenu.refresh=刷新
|
termora.transport.table.contextmenu.refresh=刷新
|
||||||
termora.transport.table.contextmenu.new.file=${termora.transport.table.contextmenu.new}文件
|
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=傳輸仍處於活動狀態,是否刪除所有傳輸任務並關閉此會話?
|
||||||
termora.transport.sftp.close-tab-has-active-session=會話仍處於活動狀態,是否關閉所有會話?
|
termora.transport.sftp.close-tab-has-active-session=會話仍處於活動狀態,是否關閉所有會話?
|
||||||
termora.transport.sftp.status.transporting=傳輸中
|
termora.transport.sftp.status.transporting=傳輸中
|
||||||
|
termora.transport.sftp.status.deleting=刪除中
|
||||||
termora.transport.sftp.status.waiting=等待中
|
termora.transport.sftp.status.waiting=等待中
|
||||||
termora.transport.sftp.status.done=已完成
|
termora.transport.sftp.status.done=已完成
|
||||||
termora.transport.sftp.status.failed=已失敗
|
termora.transport.sftp.status.failed=已失敗
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user